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