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