1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.common.model.account;
18
19import android.content.Context;
20import android.content.pm.PackageInfo;
21import android.content.pm.PackageManager;
22import android.content.pm.PackageManager.NameNotFoundException;
23import android.content.pm.ServiceInfo;
24import android.content.res.Resources;
25import android.content.res.TypedArray;
26import android.content.res.XmlResourceParser;
27import android.provider.ContactsContract.CommonDataKinds.Photo;
28import android.provider.ContactsContract.CommonDataKinds.StructuredName;
29import android.text.TextUtils;
30import android.util.AttributeSet;
31import android.util.Log;
32import android.util.Xml;
33
34import com.android.contacts.common.R;
35import com.android.contacts.common.model.dataitem.DataKind;
36import com.google.common.annotations.VisibleForTesting;
37
38import org.xmlpull.v1.XmlPullParser;
39import org.xmlpull.v1.XmlPullParserException;
40
41import java.io.IOException;
42import java.util.ArrayList;
43import java.util.List;
44
45/**
46 * A general contacts account type descriptor.
47 */
48public class ExternalAccountType extends BaseAccountType {
49    private static final String TAG = "ExternalAccountType";
50
51    /**
52     * The metadata name for so-called "contacts.xml".
53     *
54     * On LMP and later, we also accept the "alternate" name.
55     * This is to allow sync adapters to have a contacts.xml without making it visible on older
56     * platforms.
57     */
58    private static final String[] METADATA_CONTACTS_NAMES = new String[] {
59            "android.provider.ALTERNATE_CONTACTS_STRUCTURE",
60            "android.provider.CONTACTS_STRUCTURE"
61    };
62
63    private static final String TAG_CONTACTS_SOURCE_LEGACY = "ContactsSource";
64    private static final String TAG_CONTACTS_ACCOUNT_TYPE = "ContactsAccountType";
65    private static final String TAG_CONTACTS_DATA_KIND = "ContactsDataKind";
66    private static final String TAG_EDIT_SCHEMA = "EditSchema";
67
68    private static final String ATTR_EDIT_CONTACT_ACTIVITY = "editContactActivity";
69    private static final String ATTR_CREATE_CONTACT_ACTIVITY = "createContactActivity";
70    private static final String ATTR_INVITE_CONTACT_ACTIVITY = "inviteContactActivity";
71    private static final String ATTR_INVITE_CONTACT_ACTION_LABEL = "inviteContactActionLabel";
72    private static final String ATTR_VIEW_CONTACT_NOTIFY_SERVICE = "viewContactNotifyService";
73    private static final String ATTR_VIEW_GROUP_ACTIVITY = "viewGroupActivity";
74    private static final String ATTR_VIEW_GROUP_ACTION_LABEL = "viewGroupActionLabel";
75    private static final String ATTR_DATA_SET = "dataSet";
76    private static final String ATTR_EXTENSION_PACKAGE_NAMES = "extensionPackageNames";
77
78    // The following attributes should only be set in non-sync-adapter account types.  They allow
79    // for the account type and resource IDs to be specified without an associated authenticator.
80    private static final String ATTR_ACCOUNT_TYPE = "accountType";
81    private static final String ATTR_ACCOUNT_LABEL = "accountTypeLabel";
82    private static final String ATTR_ACCOUNT_ICON = "accountTypeIcon";
83
84    private final boolean mIsExtension;
85
86    private String mEditContactActivityClassName;
87    private String mCreateContactActivityClassName;
88    private String mInviteContactActivity;
89    private String mInviteActionLabelAttribute;
90    private int mInviteActionLabelResId;
91    private String mViewContactNotifyService;
92    private String mViewGroupActivity;
93    private String mViewGroupLabelAttribute;
94    private int mViewGroupLabelResId;
95    private List<String> mExtensionPackageNames;
96    private String mAccountTypeLabelAttribute;
97    private String mAccountTypeIconAttribute;
98    private boolean mHasContactsMetadata;
99    private boolean mHasEditSchema;
100
101    public ExternalAccountType(Context context, String resPackageName, boolean isExtension) {
102        this(context, resPackageName, isExtension, null);
103    }
104
105    /**
106     * Constructor used for testing to initialize with any arbitrary XML.
107     *
108     * @param injectedMetadata If non-null, it'll be used to initialize the type.  Only set by
109     *     tests.  If null, the metadata is loaded from the specified package.
110     */
111    ExternalAccountType(Context context, String packageName, boolean isExtension,
112            XmlResourceParser injectedMetadata) {
113        this.mIsExtension = isExtension;
114        this.resourcePackageName = packageName;
115        this.syncAdapterPackageName = packageName;
116
117        final PackageManager pm = context.getPackageManager();
118        final XmlResourceParser parser;
119        if (injectedMetadata == null) {
120            try {
121                parser = loadContactsXml(context, packageName);
122            } catch (NameNotFoundException e1) {
123                // If the package name is not found, we can't initialize this account type.
124                return;
125            }
126        } else {
127            parser = injectedMetadata;
128        }
129        boolean needLineNumberInErrorLog = true;
130        try {
131            if (parser != null) {
132                inflate(context, parser);
133            }
134
135            // Done parsing; line number no longer needed in error log.
136            needLineNumberInErrorLog = false;
137            if (mHasEditSchema) {
138                checkKindExists(StructuredName.CONTENT_ITEM_TYPE);
139                checkKindExists(DataKind.PSEUDO_MIME_TYPE_DISPLAY_NAME);
140                checkKindExists(DataKind.PSEUDO_MIME_TYPE_PHONETIC_NAME);
141                checkKindExists(Photo.CONTENT_ITEM_TYPE);
142            } else {
143                // Bring in name and photo from fallback source, which are non-optional
144                addDataKindStructuredName(context);
145                addDataKindDisplayName(context);
146                addDataKindPhoneticName(context);
147                addDataKindPhoto(context);
148            }
149        } catch (DefinitionException e) {
150            final StringBuilder error = new StringBuilder();
151            error.append("Problem reading XML");
152            if (needLineNumberInErrorLog && (parser != null)) {
153                error.append(" in line ");
154                error.append(parser.getLineNumber());
155            }
156            error.append(" for external package ");
157            error.append(packageName);
158
159            Log.e(TAG, error.toString(), e);
160            return;
161        } finally {
162            if (parser != null) {
163                parser.close();
164            }
165        }
166
167        mExtensionPackageNames = new ArrayList<String>();
168        mInviteActionLabelResId = resolveExternalResId(context, mInviteActionLabelAttribute,
169                syncAdapterPackageName, ATTR_INVITE_CONTACT_ACTION_LABEL);
170        mViewGroupLabelResId = resolveExternalResId(context, mViewGroupLabelAttribute,
171                syncAdapterPackageName, ATTR_VIEW_GROUP_ACTION_LABEL);
172        titleRes = resolveExternalResId(context, mAccountTypeLabelAttribute,
173                syncAdapterPackageName, ATTR_ACCOUNT_LABEL);
174        iconRes = resolveExternalResId(context, mAccountTypeIconAttribute,
175                syncAdapterPackageName, ATTR_ACCOUNT_ICON);
176
177        // If we reach this point, the account type has been successfully initialized.
178        mIsInitialized = true;
179    }
180
181    /**
182     * Returns the CONTACTS_STRUCTURE metadata (aka "contacts.xml") in the given apk package.
183     *
184     * Unfortunately, there's no public way to determine which service defines a sync service for
185     * which account type, so this method looks through all services in the package, and just
186     * returns the first CONTACTS_STRUCTURE metadata defined in any of them.
187     *
188     * Returns {@code null} if the package has no CONTACTS_STRUCTURE metadata.  In this case
189     * the account type *will* be initialized with minimal configuration.
190     *
191     * On the other hand, if the package is not found, it throws a {@link NameNotFoundException},
192     * in which case the account type will *not* be initialized.
193     */
194    private XmlResourceParser loadContactsXml(Context context, String resPackageName)
195            throws NameNotFoundException {
196        final PackageManager pm = context.getPackageManager();
197        PackageInfo packageInfo = pm.getPackageInfo(resPackageName,
198                PackageManager.GET_SERVICES|PackageManager.GET_META_DATA);
199        for (ServiceInfo serviceInfo : packageInfo.services) {
200            for (String metadataName : METADATA_CONTACTS_NAMES) {
201                final XmlResourceParser parser = serviceInfo.loadXmlMetaData(pm,
202                        metadataName);
203                if (parser != null) {
204                    if (Log.isLoggable(TAG, Log.DEBUG)) {
205                        Log.d(TAG, String.format("Metadata loaded from: %s, %s, %s",
206                                serviceInfo.packageName, serviceInfo.name,
207                                metadataName));
208                    }
209                    return parser;
210                }
211            }
212        }
213        // Package was found, but that doesn't contain the CONTACTS_STRUCTURE metadata.
214        return null;
215    }
216
217    private void checkKindExists(String mimeType) throws DefinitionException {
218        if (getKindForMimetype(mimeType) == null) {
219            throw new DefinitionException(mimeType + " must be supported");
220        }
221    }
222
223    @Override
224    public boolean isEmbedded() {
225        return false;
226    }
227
228    @Override
229    public boolean isExtension() {
230        return mIsExtension;
231    }
232
233    @Override
234    public boolean areContactsWritable() {
235        return mHasEditSchema;
236    }
237
238    /**
239     * Whether this account type has the android.provider.CONTACTS_STRUCTURE metadata xml.
240     */
241    public boolean hasContactsMetadata() {
242        return mHasContactsMetadata;
243    }
244
245    @Override
246    public String getEditContactActivityClassName() {
247        return mEditContactActivityClassName;
248    }
249
250    @Override
251    public String getCreateContactActivityClassName() {
252        return mCreateContactActivityClassName;
253    }
254
255    @Override
256    public String getInviteContactActivityClassName() {
257        return mInviteContactActivity;
258    }
259
260    @Override
261    protected int getInviteContactActionResId() {
262        return mInviteActionLabelResId;
263    }
264
265    @Override
266    public String getViewContactNotifyServiceClassName() {
267        return mViewContactNotifyService;
268    }
269
270    @Override
271    public String getViewGroupActivity() {
272        return mViewGroupActivity;
273    }
274
275    @Override
276    protected int getViewGroupLabelResId() {
277        return mViewGroupLabelResId;
278    }
279
280    @Override
281    public List<String> getExtensionPackageNames() {
282        return mExtensionPackageNames;
283    }
284
285    /**
286     * Inflate this {@link AccountType} from the given parser. This may only
287     * load details matching the publicly-defined schema.
288     */
289    protected void inflate(Context context, XmlPullParser parser) throws DefinitionException {
290        final AttributeSet attrs = Xml.asAttributeSet(parser);
291
292        try {
293            int type;
294            while ((type = parser.next()) != XmlPullParser.START_TAG
295                    && type != XmlPullParser.END_DOCUMENT) {
296                // Drain comments and whitespace
297            }
298
299            if (type != XmlPullParser.START_TAG) {
300                throw new IllegalStateException("No start tag found");
301            }
302
303            String rootTag = parser.getName();
304            if (!TAG_CONTACTS_ACCOUNT_TYPE.equals(rootTag) &&
305                    !TAG_CONTACTS_SOURCE_LEGACY.equals(rootTag)) {
306                throw new IllegalStateException("Top level element must be "
307                        + TAG_CONTACTS_ACCOUNT_TYPE + ", not " + rootTag);
308            }
309
310            mHasContactsMetadata = true;
311
312            int attributeCount = parser.getAttributeCount();
313            for (int i = 0; i < attributeCount; i++) {
314                String attr = parser.getAttributeName(i);
315                String value = parser.getAttributeValue(i);
316                if (Log.isLoggable(TAG, Log.DEBUG)) {
317                    Log.d(TAG, attr + "=" + value);
318                }
319                if (ATTR_EDIT_CONTACT_ACTIVITY.equals(attr)) {
320                    mEditContactActivityClassName = value;
321                } else if (ATTR_CREATE_CONTACT_ACTIVITY.equals(attr)) {
322                    mCreateContactActivityClassName = value;
323                } else if (ATTR_INVITE_CONTACT_ACTIVITY.equals(attr)) {
324                    mInviteContactActivity = value;
325                } else if (ATTR_INVITE_CONTACT_ACTION_LABEL.equals(attr)) {
326                    mInviteActionLabelAttribute = value;
327                } else if (ATTR_VIEW_CONTACT_NOTIFY_SERVICE.equals(attr)) {
328                    mViewContactNotifyService = value;
329                } else if (ATTR_VIEW_GROUP_ACTIVITY.equals(attr)) {
330                    mViewGroupActivity = value;
331                } else if (ATTR_VIEW_GROUP_ACTION_LABEL.equals(attr)) {
332                    mViewGroupLabelAttribute = value;
333                } else if (ATTR_DATA_SET.equals(attr)) {
334                    dataSet = value;
335                } else if (ATTR_EXTENSION_PACKAGE_NAMES.equals(attr)) {
336                    mExtensionPackageNames.add(value);
337                } else if (ATTR_ACCOUNT_TYPE.equals(attr)) {
338                    accountType = value;
339                } else if (ATTR_ACCOUNT_LABEL.equals(attr)) {
340                    mAccountTypeLabelAttribute = value;
341                } else if (ATTR_ACCOUNT_ICON.equals(attr)) {
342                    mAccountTypeIconAttribute = value;
343                } else {
344                    Log.e(TAG, "Unsupported attribute " + attr);
345                }
346            }
347
348            // Parse all children kinds
349            final int startDepth = parser.getDepth();
350            while (((type = parser.next()) != XmlPullParser.END_TAG
351                        || parser.getDepth() > startDepth)
352                    && type != XmlPullParser.END_DOCUMENT) {
353
354                if (type != XmlPullParser.START_TAG || parser.getDepth() != startDepth + 1) {
355                    continue; // Not a direct child tag
356                }
357
358                String tag = parser.getName();
359                if (TAG_EDIT_SCHEMA.equals(tag)) {
360                    mHasEditSchema = true;
361                    parseEditSchema(context, parser, attrs);
362                } else if (TAG_CONTACTS_DATA_KIND.equals(tag)) {
363                    final TypedArray a = context.obtainStyledAttributes(attrs,
364                            R.styleable.ContactsDataKind);
365                    final DataKind kind = new DataKind();
366
367                    kind.mimeType = a
368                            .getString(R.styleable.ContactsDataKind_android_mimeType);
369                    final String summaryColumn = a.getString(
370                            R.styleable.ContactsDataKind_android_summaryColumn);
371                    if (summaryColumn != null) {
372                        // Inflate a specific column as summary when requested
373                        kind.actionHeader = new SimpleInflater(summaryColumn);
374                    }
375                    final String detailColumn = a.getString(
376                            R.styleable.ContactsDataKind_android_detailColumn);
377                    if (detailColumn != null) {
378                        // Inflate specific column as summary
379                        kind.actionBody = new SimpleInflater(detailColumn);
380                    }
381
382                    a.recycle();
383
384                    addKind(kind);
385                }
386            }
387        } catch (XmlPullParserException e) {
388            throw new DefinitionException("Problem reading XML", e);
389        } catch (IOException e) {
390            throw new DefinitionException("Problem reading XML", e);
391        }
392    }
393
394    /**
395     * Takes a string in the "@xxx/yyy" format and return the resource ID for the resource in
396     * the resource package.
397     *
398     * If the argument is in the invalid format or isn't a resource name, it returns -1.
399     *
400     * @param context context
401     * @param resourceName Resource name in the "@xxx/yyy" format, e.g. "@string/invite_lavbel"
402     * @param packageName name of the package containing the resource.
403     * @param xmlAttributeName attribute name which the resource came from.  Used for logging.
404     */
405    @VisibleForTesting
406    static int resolveExternalResId(Context context, String resourceName,
407            String packageName, String xmlAttributeName) {
408        if (TextUtils.isEmpty(resourceName)) {
409            return -1; // Empty text is okay.
410        }
411        if (resourceName.charAt(0) != '@') {
412            Log.e(TAG, xmlAttributeName + " must be a resource name beginnig with '@'");
413            return -1;
414        }
415        final String name = resourceName.substring(1);
416        final Resources res;
417        try {
418             res = context.getPackageManager().getResourcesForApplication(packageName);
419        } catch (NameNotFoundException e) {
420            Log.e(TAG, "Unable to load package " + packageName);
421            return -1;
422        }
423        final int resId = res.getIdentifier(name, null, packageName);
424        if (resId == 0) {
425            Log.e(TAG, "Unable to load " + resourceName + " from package " + packageName);
426            return -1;
427        }
428        return resId;
429    }
430}
431