1/*
2 * Copyright (C) 2010 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.providers.contacts;
18
19import android.annotation.NonNull;
20import android.content.ContentValues;
21import android.content.Context;
22import android.content.pm.PackageInfo;
23import android.content.pm.PackageManager;
24import android.content.pm.PackageManager.NameNotFoundException;
25import android.content.pm.ProviderInfo;
26import android.content.res.Resources;
27import android.content.res.Resources.NotFoundException;
28import android.database.Cursor;
29import android.database.sqlite.SQLiteDatabase;
30import android.net.Uri;
31import android.os.Bundle;
32import android.os.SystemClock;
33import android.os.SystemProperties;
34import android.provider.ContactsContract;
35import android.provider.ContactsContract.Directory;
36import android.text.TextUtils;
37import android.util.Log;
38
39import com.android.providers.contacts.ContactsDatabaseHelper.DbProperties;
40import com.android.providers.contacts.ContactsDatabaseHelper.DirectoryColumns;
41import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
42import com.google.android.collect.Lists;
43import com.google.android.collect.Sets;
44import com.google.common.annotations.VisibleForTesting;
45
46import java.util.ArrayList;
47import java.util.List;
48import java.util.Objects;
49import java.util.Set;
50
51/**
52 * Manages the contents of the {@link Directory} table.
53 */
54public class ContactDirectoryManager {
55
56    private static final String TAG = "ContactDirectoryManager";
57    private static final boolean DEBUG = AbstractContactsProvider.VERBOSE_LOGGING;
58
59    public static final String CONTACT_DIRECTORY_META_DATA = "android.content.ContactDirectory";
60
61    public static class DirectoryInfo {
62        long id;
63        String packageName;
64        String authority;
65        String accountName;
66        String accountType;
67        String displayName;
68        int typeResourceId;
69        int exportSupport = Directory.EXPORT_SUPPORT_NONE;
70        int shortcutSupport = Directory.SHORTCUT_SUPPORT_NONE;
71        int photoSupport = Directory.PHOTO_SUPPORT_NONE;
72        @Override
73        public String toString() {
74            return "DirectoryInfo:"
75                    + "id=" + id
76                    + " packageName=" + accountType
77                    + " authority=" + authority
78                    + " accountName=***"
79                    + " accountType=" + accountType;
80        }
81    }
82
83    private final static class DirectoryQuery {
84        public static final String[] PROJECTION = {
85            Directory.ACCOUNT_NAME,
86            Directory.ACCOUNT_TYPE,
87            Directory.DISPLAY_NAME,
88            Directory.TYPE_RESOURCE_ID,
89            Directory.EXPORT_SUPPORT,
90            Directory.SHORTCUT_SUPPORT,
91            Directory.PHOTO_SUPPORT,
92        };
93
94        public static final int ACCOUNT_NAME = 0;
95        public static final int ACCOUNT_TYPE = 1;
96        public static final int DISPLAY_NAME = 2;
97        public static final int TYPE_RESOURCE_ID = 3;
98        public static final int EXPORT_SUPPORT = 4;
99        public static final int SHORTCUT_SUPPORT = 5;
100        public static final int PHOTO_SUPPORT = 6;
101    }
102
103    private final ContactsProvider2 mContactsProvider;
104    private final Context mContext;
105    private final PackageManager mPackageManager;
106
107    private volatile boolean mDirectoriesForceUpdated = false;
108
109    public ContactDirectoryManager(ContactsProvider2 contactsProvider) {
110        mContactsProvider = contactsProvider;
111        mContext = contactsProvider.getContext();
112        mPackageManager = mContext.getPackageManager();
113    }
114
115    public ContactsDatabaseHelper getDbHelper() {
116        return (ContactsDatabaseHelper) mContactsProvider.getDatabaseHelper();
117    }
118
119    public void setDirectoriesForceUpdated(boolean updated) {
120        mDirectoriesForceUpdated = updated;
121    }
122
123    /**
124     * Scans through existing directories to see if the cached resource IDs still
125     * match their original resource names.  If not - plays it safe by refreshing all directories.
126     *
127     * @return true if all resource IDs were found valid
128     */
129    private boolean areTypeResourceIdsValid() {
130        SQLiteDatabase db = getDbHelper().getReadableDatabase();
131
132        final Cursor cursor = db.rawQuery("SELECT DISTINCT "
133                + Directory.TYPE_RESOURCE_ID + ","
134                + Directory.PACKAGE_NAME + ","
135                + DirectoryColumns.TYPE_RESOURCE_NAME
136                + " FROM " + Tables.DIRECTORIES, null);
137        try {
138            while (cursor.moveToNext()) {
139                int resourceId = cursor.getInt(0);
140                if (resourceId != 0) {
141                    String packageName = cursor.getString(1);
142                    String storedResourceName = cursor.getString(2);
143                    String resourceName = getResourceNameById(packageName, resourceId);
144                    if (!TextUtils.equals(storedResourceName, resourceName)) {
145                        if (DEBUG) {
146                            Log.d(TAG, "areTypeResourceIdsValid:"
147                                    + " resourceId=" + resourceId
148                                    + " packageName=" + packageName
149                                    + " storedResourceName=" + storedResourceName
150                                    + " resourceName=" + resourceName);
151                        }
152                        return false;
153                    }
154                }
155            }
156        } finally {
157            cursor.close();
158        }
159
160        return true;
161    }
162
163    /**
164     * Given a resource ID, returns the corresponding resource name or null if the package name /
165     * resource ID combination is invalid.
166     */
167    private String getResourceNameById(String packageName, int resourceId) {
168        try {
169            Resources resources = mPackageManager.getResourcesForApplication(packageName);
170            return resources.getResourceName(resourceId);
171        } catch (NameNotFoundException e) {
172            return null;
173        } catch (NotFoundException e) {
174            return null;
175        }
176    }
177
178    private void saveKnownDirectoryProviders(Set<String> packages) {
179        getDbHelper().setProperty(DbProperties.KNOWN_DIRECTORY_PACKAGES,
180                TextUtils.join(",", packages));
181    }
182
183    private boolean haveKnownDirectoryProvidersChanged(Set<String> packages) {
184        final String directoryPackages = TextUtils.join(",", packages);
185        final String prev = getDbHelper().getProperty(DbProperties.KNOWN_DIRECTORY_PACKAGES, "");
186
187        final boolean changed = !Objects.equals(directoryPackages, prev);
188        if (DEBUG) {
189            Log.d(TAG, "haveKnownDirectoryProvidersChanged=" + changed + "\nprev=" + prev
190                    + " current=" + directoryPackages);
191        }
192        return changed;
193    }
194
195    @VisibleForTesting
196    boolean isRescanNeeded() {
197        if ("1".equals(SystemProperties.get("debug.cp2.scan_all_packages", "0"))) {
198            Log.w(TAG, "debug.cp2.scan_all_packages set to 1.");
199            return true; // For debugging.
200        }
201        final String scanComplete =
202                getDbHelper().getProperty(DbProperties.DIRECTORY_SCAN_COMPLETE, "0");
203        if (!"1".equals(scanComplete)) {
204            if (DEBUG) {
205                Log.d(TAG, "DIRECTORY_SCAN_COMPLETE is 0.");
206            }
207            return true;
208        }
209        if (haveKnownDirectoryProvidersChanged(getDirectoryProviderPackages(mPackageManager))) {
210            Log.i(TAG, "Directory provider packages have changed.");
211            return true;
212        }
213        return false;
214    }
215
216    /**
217     * Scans all packages for directory content providers.
218     */
219    public int scanAllPackages(boolean rescan) {
220        if (!areTypeResourceIdsValid()) {
221            rescan = true;
222            Log.i(TAG, "!areTypeResourceIdsValid.");
223        }
224        if (rescan) {
225            getDbHelper().forceDirectoryRescan();
226        }
227
228        return scanAllPackagesIfNeeded();
229    }
230
231    private int scanAllPackagesIfNeeded() {
232        if (!isRescanNeeded()) {
233            return 0;
234        }
235        if (DEBUG) {
236            Log.d(TAG, "scanAllPackagesIfNeeded()");
237        }
238        final long start = SystemClock.elapsedRealtime();
239        // Reset directory updated flag to false. If it's changed to true
240        // then we need to rescan directories.
241        mDirectoriesForceUpdated = false;
242        final int count = scanAllPackages();
243        getDbHelper().setProperty(DbProperties.DIRECTORY_SCAN_COMPLETE, "1");
244        final long end = SystemClock.elapsedRealtime();
245        Log.i(TAG, "Discovered " + count + " contact directories in " + (end - start) + "ms");
246
247        // Announce the change to listeners of the contacts authority
248        mContactsProvider.notifyChange(/* syncToNetwork =*/false,
249                /* syncToMetadataNetwork =*/false);
250
251        // We schedule a rescan if update(DIRECTORIES) is called while we're scanning all packages.
252        if (mDirectoriesForceUpdated) {
253            mDirectoriesForceUpdated = false;
254            mContactsProvider.scheduleRescanDirectories();
255        }
256
257        return count;
258    }
259
260    @VisibleForTesting
261    static boolean isDirectoryProvider(ProviderInfo provider) {
262        if (provider == null) return false;
263        Bundle metaData = provider.metaData;
264        if (metaData == null) return false;
265
266        Object trueFalse = metaData.get(CONTACT_DIRECTORY_META_DATA);
267        return trueFalse != null && Boolean.TRUE.equals(trueFalse);
268    }
269
270    @NonNull
271    static private List<ProviderInfo> getDirectoryProviderInfos(PackageManager pm) {
272        return pm.queryContentProviders(null, 0, 0, CONTACT_DIRECTORY_META_DATA);
273    }
274
275    /**
276     * @return List of packages that contain a directory provider.
277     */
278    @VisibleForTesting
279    @NonNull
280    static Set<String> getDirectoryProviderPackages(PackageManager pm) {
281        final Set<String> ret = Sets.newHashSet();
282
283        if (DEBUG) {
284            Log.d(TAG, "Listing directory provider packages...");
285        }
286
287        for (ProviderInfo provider : getDirectoryProviderInfos(pm)) {
288            ret.add(provider.packageName);
289        }
290        if (DEBUG) {
291            Log.d(TAG, "Found " + ret.size() + " directory provider packages");
292        }
293
294        return ret;
295    }
296
297    private int scanAllPackages() {
298        SQLiteDatabase db = getDbHelper().getWritableDatabase();
299        insertDefaultDirectory(db);
300        insertLocalInvisibleDirectory(db);
301
302        int count = 0;
303
304        // Prepare query strings for removing stale rows which don't correspond to existing
305        // directories.
306        StringBuilder deleteWhereBuilder = new StringBuilder();
307        ArrayList<String> deleteWhereArgs = new ArrayList<String>();
308        deleteWhereBuilder.append("NOT (" + Directory._ID + "=? OR " + Directory._ID + "=?");
309        deleteWhereArgs.add(String.valueOf(Directory.DEFAULT));
310        deleteWhereArgs.add(String.valueOf(Directory.LOCAL_INVISIBLE));
311        final String wherePart = "(" + Directory.PACKAGE_NAME + "=? AND "
312                + Directory.DIRECTORY_AUTHORITY + "=? AND "
313                + Directory.ACCOUNT_NAME + "=? AND "
314                + Directory.ACCOUNT_TYPE + "=?)";
315
316        final Set<String> directoryProviderPackages = getDirectoryProviderPackages(mPackageManager);
317        for (String packageName : directoryProviderPackages) {
318            if (DEBUG) Log.d(TAG, "package=" + packageName);
319
320            // getDirectoryProviderPackages() shouldn't return the contacts provider package
321            // because it doesn't have CONTACT_DIRECTORY_META_DATA, but just to make sure...
322            if (mContext.getPackageName().equals(packageName)) {
323                Log.w(TAG, "  skipping self");
324                continue;
325            }
326
327            final PackageInfo packageInfo;
328            try {
329                packageInfo = mPackageManager.getPackageInfo(packageName,
330                        PackageManager.GET_PROVIDERS | PackageManager.GET_META_DATA);
331                if (packageInfo == null) continue;  // Just in case...
332            } catch (NameNotFoundException nnfe) {
333                continue; // Application just removed?
334            }
335
336            List<DirectoryInfo> directories = updateDirectoriesForPackage(packageInfo, true);
337            if (directories != null && !directories.isEmpty()) {
338                count += directories.size();
339
340                // We shouldn't delete rows for existing directories.
341                for (DirectoryInfo info : directories) {
342                    if (DEBUG) Log.d(TAG, "  directory=" + info);
343                    deleteWhereBuilder.append(" OR ");
344                    deleteWhereBuilder.append(wherePart);
345                    deleteWhereArgs.add(info.packageName);
346                    deleteWhereArgs.add(info.authority);
347                    deleteWhereArgs.add(info.accountName);
348                    deleteWhereArgs.add(info.accountType);
349                }
350            }
351        }
352
353        deleteWhereBuilder.append(")");  // Close "NOT ("
354
355        int deletedRows = db.delete(Tables.DIRECTORIES, deleteWhereBuilder.toString(),
356                deleteWhereArgs.toArray(new String[0]));
357
358        saveKnownDirectoryProviders(directoryProviderPackages);
359
360        Log.i(TAG, "deleted " + deletedRows
361                + " stale rows which don't have any relevant directory");
362        return count;
363    }
364
365    private void insertDefaultDirectory(SQLiteDatabase db) {
366        ContentValues values = new ContentValues();
367        values.put(Directory._ID, Directory.DEFAULT);
368        values.put(Directory.PACKAGE_NAME, mContext.getPackageName());
369        values.put(Directory.DIRECTORY_AUTHORITY, ContactsContract.AUTHORITY);
370        values.put(Directory.TYPE_RESOURCE_ID, R.string.default_directory);
371        values.put(DirectoryColumns.TYPE_RESOURCE_NAME,
372                mContext.getResources().getResourceName(R.string.default_directory));
373        values.put(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_NONE);
374        values.put(Directory.SHORTCUT_SUPPORT, Directory.SHORTCUT_SUPPORT_FULL);
375        values.put(Directory.PHOTO_SUPPORT, Directory.PHOTO_SUPPORT_FULL);
376        db.replace(Tables.DIRECTORIES, null, values);
377    }
378
379    private void insertLocalInvisibleDirectory(SQLiteDatabase db) {
380        ContentValues values = new ContentValues();
381        values.put(Directory._ID, Directory.LOCAL_INVISIBLE);
382        values.put(Directory.PACKAGE_NAME, mContext.getPackageName());
383        values.put(Directory.DIRECTORY_AUTHORITY, ContactsContract.AUTHORITY);
384        values.put(Directory.TYPE_RESOURCE_ID, R.string.local_invisible_directory);
385        values.put(DirectoryColumns.TYPE_RESOURCE_NAME,
386                mContext.getResources().getResourceName(R.string.local_invisible_directory));
387        values.put(Directory.EXPORT_SUPPORT, Directory.EXPORT_SUPPORT_NONE);
388        values.put(Directory.SHORTCUT_SUPPORT, Directory.SHORTCUT_SUPPORT_FULL);
389        values.put(Directory.PHOTO_SUPPORT, Directory.PHOTO_SUPPORT_FULL);
390        db.replace(Tables.DIRECTORIES, null, values);
391    }
392
393    /**
394     * Scans the specified package for content directories.  The package may have
395     * already been removed, so packageName does not necessarily correspond to
396     * an installed package.
397     */
398    public void onPackageChanged(String packageName) {
399        PackageInfo packageInfo = null;
400
401        try {
402            packageInfo = mPackageManager.getPackageInfo(packageName,
403                    PackageManager.GET_PROVIDERS | PackageManager.GET_META_DATA);
404        } catch (NameNotFoundException e) {
405            // The package got removed
406            packageInfo = new PackageInfo();
407            packageInfo.packageName = packageName;
408        }
409
410        if (mContext.getPackageName().equals(packageInfo.packageName)) {
411            if (DEBUG) Log.d(TAG, "Ignoring onPackageChanged for self");
412            return;
413        }
414        updateDirectoriesForPackage(packageInfo, false);
415    }
416
417
418    /**
419     * Scans the specified package for content directories and updates the {@link Directory}
420     * table accordingly.
421     */
422    private List<DirectoryInfo> updateDirectoriesForPackage(
423            PackageInfo packageInfo, boolean initialScan) {
424        if (DEBUG) {
425            Log.d(TAG, "updateDirectoriesForPackage  packageName=" + packageInfo.packageName
426                    + " initialScan=" + initialScan);
427        }
428
429        ArrayList<DirectoryInfo> directories = Lists.newArrayList();
430
431        ProviderInfo[] providers = packageInfo.providers;
432        if (providers != null) {
433            for (ProviderInfo provider : providers) {
434                if (isDirectoryProvider(provider)) {
435                    queryDirectoriesForAuthority(directories, provider);
436                }
437            }
438        }
439
440        if (directories.size() == 0 && initialScan) {
441            return null;
442        }
443
444        SQLiteDatabase db = getDbHelper().getWritableDatabase();
445        db.beginTransaction();
446        try {
447            updateDirectories(db, directories);
448            // Clear out directories that are no longer present
449            StringBuilder sb = new StringBuilder(Directory.PACKAGE_NAME + "=?");
450            if (!directories.isEmpty()) {
451                sb.append(" AND " + Directory._ID + " NOT IN(");
452                for (DirectoryInfo info: directories) {
453                    sb.append(info.id).append(",");
454                }
455                sb.setLength(sb.length() - 1);  // Remove the extra comma
456                sb.append(")");
457            }
458            final int numDeleted = db.delete(Tables.DIRECTORIES, sb.toString(),
459                    new String[] { packageInfo.packageName });
460            if (DEBUG) {
461                Log.d(TAG, "  deleted " + numDeleted + " stale rows");
462            }
463            db.setTransactionSuccessful();
464        } finally {
465            db.endTransaction();
466        }
467
468        mContactsProvider.resetDirectoryCache();
469        return directories;
470    }
471
472    /**
473     * Sends a {@link Directory#CONTENT_URI} request to a specific contact directory
474     * provider and appends all discovered directories to the directoryInfo list.
475     */
476    protected void queryDirectoriesForAuthority(
477            ArrayList<DirectoryInfo> directoryInfo, ProviderInfo provider) {
478        Uri uri = new Uri.Builder().scheme("content")
479                .authority(provider.authority).appendPath("directories").build();
480        Cursor cursor = null;
481        try {
482            cursor = mContext.getContentResolver().query(
483                    uri, DirectoryQuery.PROJECTION, null, null, null);
484            if (cursor == null) {
485                Log.i(TAG, providerDescription(provider) + " returned a NULL cursor.");
486            } else {
487                while (cursor.moveToNext()) {
488                    DirectoryInfo info = new DirectoryInfo();
489                    info.packageName = provider.packageName;
490                    info.authority = provider.authority;
491                    info.accountName = cursor.getString(DirectoryQuery.ACCOUNT_NAME);
492                    info.accountType = cursor.getString(DirectoryQuery.ACCOUNT_TYPE);
493                    info.displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME);
494                    if (!cursor.isNull(DirectoryQuery.TYPE_RESOURCE_ID)) {
495                        info.typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID);
496                    }
497                    if (!cursor.isNull(DirectoryQuery.EXPORT_SUPPORT)) {
498                        int exportSupport = cursor.getInt(DirectoryQuery.EXPORT_SUPPORT);
499                        switch (exportSupport) {
500                            case Directory.EXPORT_SUPPORT_NONE:
501                            case Directory.EXPORT_SUPPORT_SAME_ACCOUNT_ONLY:
502                            case Directory.EXPORT_SUPPORT_ANY_ACCOUNT:
503                                info.exportSupport = exportSupport;
504                                break;
505                            default:
506                                Log.e(TAG, providerDescription(provider)
507                                        + " - invalid export support flag: " + exportSupport);
508                        }
509                    }
510                    if (!cursor.isNull(DirectoryQuery.SHORTCUT_SUPPORT)) {
511                        int shortcutSupport = cursor.getInt(DirectoryQuery.SHORTCUT_SUPPORT);
512                        switch (shortcutSupport) {
513                            case Directory.SHORTCUT_SUPPORT_NONE:
514                            case Directory.SHORTCUT_SUPPORT_DATA_ITEMS_ONLY:
515                            case Directory.SHORTCUT_SUPPORT_FULL:
516                                info.shortcutSupport = shortcutSupport;
517                                break;
518                            default:
519                                Log.e(TAG, providerDescription(provider)
520                                        + " - invalid shortcut support flag: " + shortcutSupport);
521                        }
522                    }
523                    if (!cursor.isNull(DirectoryQuery.PHOTO_SUPPORT)) {
524                        int photoSupport = cursor.getInt(DirectoryQuery.PHOTO_SUPPORT);
525                        switch (photoSupport) {
526                            case Directory.PHOTO_SUPPORT_NONE:
527                            case Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY:
528                            case Directory.PHOTO_SUPPORT_FULL_SIZE_ONLY:
529                            case Directory.PHOTO_SUPPORT_FULL:
530                                info.photoSupport = photoSupport;
531                                break;
532                            default:
533                                Log.e(TAG, providerDescription(provider)
534                                        + " - invalid photo support flag: " + photoSupport);
535                        }
536                    }
537                    directoryInfo.add(info);
538                }
539            }
540        } catch (Throwable t) {
541            Log.e(TAG, providerDescription(provider) + " exception", t);
542        } finally {
543            if (cursor != null) {
544                cursor.close();
545            }
546        }
547    }
548
549    /**
550     * Updates the directories tables in the database to match the info received
551     * from directory providers.
552     */
553    private void updateDirectories(SQLiteDatabase db, ArrayList<DirectoryInfo> directoryInfo) {
554        // Insert or replace existing directories.
555        // This happens so infrequently that we can use a less-then-optimal one-a-time approach
556        for (DirectoryInfo info : directoryInfo) {
557            ContentValues values = new ContentValues();
558            values.put(Directory.PACKAGE_NAME, info.packageName);
559            values.put(Directory.DIRECTORY_AUTHORITY, info.authority);
560            values.put(Directory.ACCOUNT_NAME, info.accountName);
561            values.put(Directory.ACCOUNT_TYPE, info.accountType);
562            values.put(Directory.TYPE_RESOURCE_ID, info.typeResourceId);
563            values.put(Directory.DISPLAY_NAME, info.displayName);
564            values.put(Directory.EXPORT_SUPPORT, info.exportSupport);
565            values.put(Directory.SHORTCUT_SUPPORT, info.shortcutSupport);
566            values.put(Directory.PHOTO_SUPPORT, info.photoSupport);
567
568            if (info.typeResourceId != 0) {
569                String resourceName = getResourceNameById(info.packageName, info.typeResourceId);
570                values.put(DirectoryColumns.TYPE_RESOURCE_NAME, resourceName);
571            }
572
573            Cursor cursor = db.query(Tables.DIRECTORIES, new String[] { Directory._ID },
574                    Directory.PACKAGE_NAME + "=? AND " + Directory.DIRECTORY_AUTHORITY + "=? AND "
575                            + Directory.ACCOUNT_NAME + "=? AND " + Directory.ACCOUNT_TYPE + "=?",
576                    new String[] {
577                            info.packageName, info.authority, info.accountName, info.accountType },
578                    null, null, null);
579            try {
580                long id;
581                if (cursor.moveToFirst()) {
582                    id = cursor.getLong(0);
583                    db.update(Tables.DIRECTORIES, values, Directory._ID + "=?",
584                            new String[] { String.valueOf(id) });
585                } else {
586                    id = db.insert(Tables.DIRECTORIES, null, values);
587                }
588                info.id = id;
589            } finally {
590                cursor.close();
591            }
592        }
593    }
594
595    protected String providerDescription(ProviderInfo provider) {
596        return "Directory provider " + provider.packageName + "(" + provider.authority + ")";
597    }
598}
599