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