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