/* * Copyright (C) 2009 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.providers.applications; import com.android.internal.content.PackageMonitor; import com.android.internal.os.PkgUsageStats; import android.app.ActivityManager; import android.app.AlarmManager; import android.app.PendingIntent; import android.app.SearchManager; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.UriMatcher; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import android.provider.Applications; import android.text.TextUtils; import android.util.Log; import java.lang.Runnable; import java.util.HashMap; import java.util.List; import java.util.Map; import com.google.common.annotations.VisibleForTesting; /** * Fetches the list of applications installed on the phone to provide search suggestions. * If the functionality of this provider changes, the documentation at * {@link android.provider.Applications} should be updated. * * TODO: this provider should be moved to the Launcher, which contains similar logic to keep an up * to date list of installed applications. Alternatively, Launcher could be updated to use this * provider. */ public class ApplicationsProvider extends ContentProvider { private static final boolean DBG = false; private static final String TAG = "ApplicationsProvider"; private static final int SEARCH_SUGGEST = 0; private static final int SHORTCUT_REFRESH = 1; private static final int SEARCH = 2; private static final UriMatcher sURIMatcher = buildUriMatcher(); private static final int THREAD_PRIORITY = android.os.Process.THREAD_PRIORITY_BACKGROUND; // Messages for mHandler private static final int MSG_UPDATE_ALL = 0; private static final int MSG_UPDATE_PACKAGE = 1; public static final String _ID = "_id"; public static final String NAME = "name"; public static final String DESCRIPTION = "description"; public static final String PACKAGE = "package"; public static final String CLASS = "class"; public static final String ICON = "icon"; public static final String LAUNCH_COUNT = "launch_count"; public static final String LAST_RESUME_TIME = "last_resume_time"; // A query parameter to refresh application statistics. Used by QSB. public static final String REFRESH_STATS = "refresh"; private static final String APPLICATIONS_TABLE = "applications"; private static final String APPLICATIONS_LOOKUP_JOIN = "applicationsLookup JOIN " + APPLICATIONS_TABLE + " ON" + " applicationsLookup.source = " + APPLICATIONS_TABLE + "." + _ID; private static final HashMap sSearchSuggestionsProjectionMap = buildSuggestionsProjectionMap(false); private static final HashMap sGlobalSearchSuggestionsProjectionMap = buildSuggestionsProjectionMap(true); private static final HashMap sSearchProjectionMap = buildSearchProjectionMap(); /** * An in-memory database storing the details of applications installed on * the device. Populated when the ApplicationsProvider is launched. */ private SQLiteDatabase mDb; // Handler that runs DB updates. private Handler mHandler; /** * We delay application updates by this many millis to avoid doing more than one update to the * applications list within this window. */ private static final long UPDATE_DELAY_MILLIS = 1000L; private static UriMatcher buildUriMatcher() { UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH); matcher.addURI(Applications.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, SEARCH_SUGGEST); matcher.addURI(Applications.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY + "/*", SEARCH_SUGGEST); matcher.addURI(Applications.AUTHORITY, SearchManager.SUGGEST_URI_PATH_SHORTCUT, SHORTCUT_REFRESH); matcher.addURI(Applications.AUTHORITY, SearchManager.SUGGEST_URI_PATH_SHORTCUT + "/*", SHORTCUT_REFRESH); matcher.addURI(Applications.AUTHORITY, Applications.SEARCH_PATH, SEARCH); matcher.addURI(Applications.AUTHORITY, Applications.SEARCH_PATH + "/*", SEARCH); return matcher; } /** * Updates applications list when packages are added/removed. * * TODO: Maybe this should listen for changes to individual apps instead. */ private class MyPackageMonitor extends PackageMonitor { @Override public void onSomePackagesChanged() { postUpdateAll(); } @Override public void onPackageModified(String packageName) { postUpdatePackage(packageName); } } // Broadcast receiver for updating applications list when the locale changes. private BroadcastReceiver mLocaleChangeReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Intent.ACTION_LOCALE_CHANGED.equals(action)) { if (DBG) Log.d(TAG, "locale changed"); postUpdateAll(); } } }; @Override public boolean onCreate() { createDatabase(); // Start thread that runs app updates HandlerThread thread = new HandlerThread("ApplicationsProviderUpdater", THREAD_PRIORITY); thread.start(); mHandler = createHandler(thread.getLooper()); // Kick off first apps update postUpdateAll(); // Listen for package changes new MyPackageMonitor().register(getContext(), null, true); // Listen for locale changes IntentFilter localeFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); getContext().registerReceiver(mLocaleChangeReceiver, localeFilter); return true; } @VisibleForTesting Handler createHandler(Looper looper) { return new UpdateHandler(looper); } @VisibleForTesting class UpdateHandler extends Handler { public UpdateHandler(Looper looper) { super(looper); } @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_UPDATE_ALL: updateApplicationsList(null); break; case MSG_UPDATE_PACKAGE: updateApplicationsList((String) msg.obj); break; default: Log.e(TAG, "Unknown message: " + msg.what); break; } } } /** * Posts an update to run on the DB update thread. */ private void postUpdateAll() { // Clear pending updates mHandler.removeMessages(MSG_UPDATE_ALL); // Post a new update Message msg = Message.obtain(); msg.what = MSG_UPDATE_ALL; mHandler.sendMessageDelayed(msg, UPDATE_DELAY_MILLIS); } private void postUpdatePackage(String packageName) { Message msg = Message.obtain(); msg.what = MSG_UPDATE_PACKAGE; msg.obj = packageName; mHandler.sendMessageDelayed(msg, UPDATE_DELAY_MILLIS); } // ---------- // END ASYC UPDATE CODE // ---------- /** * Creates an in-memory database for storing application info. */ private void createDatabase() { mDb = SQLiteDatabase.create(null); mDb.execSQL("CREATE TABLE IF NOT EXISTS " + APPLICATIONS_TABLE + " ("+ _ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + NAME + " TEXT COLLATE LOCALIZED," + DESCRIPTION + " description TEXT," + PACKAGE + " TEXT," + CLASS + " TEXT," + ICON + " TEXT," + LAUNCH_COUNT + " INTEGER DEFAULT 0," + LAST_RESUME_TIME + " INTEGER DEFAULT 0" + ");"); // Needed for efficient update and remove mDb.execSQL("CREATE INDEX applicationsComponentIndex ON " + APPLICATIONS_TABLE + " (" + PACKAGE + "," + CLASS + ");"); // Maps token from the app name to records in the applications table mDb.execSQL("CREATE TABLE applicationsLookup (" + "token TEXT," + "source INTEGER REFERENCES " + APPLICATIONS_TABLE + "(" + _ID + ")," + "token_index INTEGER" + ");"); mDb.execSQL("CREATE INDEX applicationsLookupIndex ON applicationsLookup (" + "token," + "source" + ");"); // Triggers to keep the applicationsLookup table up to date mDb.execSQL("CREATE TRIGGER applicationsLookup_update UPDATE OF " + NAME + " ON " + APPLICATIONS_TABLE + " " + "BEGIN " + "DELETE FROM applicationsLookup WHERE source = new." + _ID + ";" + "SELECT _TOKENIZE('applicationsLookup', new." + _ID + ", new." + NAME + ", ' ', 1);" + "END"); mDb.execSQL("CREATE TRIGGER applicationsLookup_insert AFTER INSERT ON " + APPLICATIONS_TABLE + " " + "BEGIN " + "SELECT _TOKENIZE('applicationsLookup', new." + _ID + ", new." + NAME + ", ' ', 1);" + "END"); mDb.execSQL("CREATE TRIGGER applicationsLookup_delete DELETE ON " + APPLICATIONS_TABLE + " " + "BEGIN " + "DELETE FROM applicationsLookup WHERE source = old." + _ID + ";" + "END"); } /** * This will always return {@link SearchManager#SUGGEST_MIME_TYPE} as this * provider is purely to provide suggestions. */ @Override public String getType(Uri uri) { switch (sURIMatcher.match(uri)) { case SEARCH_SUGGEST: return SearchManager.SUGGEST_MIME_TYPE; case SHORTCUT_REFRESH: return SearchManager.SHORTCUT_MIME_TYPE; case SEARCH: return Applications.APPLICATION_DIR_TYPE; default: throw new IllegalArgumentException("URL " + uri + " doesn't support querying."); } } /** * Queries for a given search term and returns a cursor containing * suggestions ordered by best match. */ @Override public Cursor query(Uri uri, String[] projectionIn, String selection, String[] selectionArgs, String sortOrder) { if (DBG) Log.d(TAG, "query(" + uri + ")"); if (!TextUtils.isEmpty(selection)) { throw new IllegalArgumentException("selection not allowed for " + uri); } if (selectionArgs != null && selectionArgs.length != 0) { throw new IllegalArgumentException("selectionArgs not allowed for " + uri); } if (!TextUtils.isEmpty(sortOrder)) { throw new IllegalArgumentException("sortOrder not allowed for " + uri); } switch (sURIMatcher.match(uri)) { case SEARCH_SUGGEST: { String query = null; if (uri.getPathSegments().size() > 1) { query = uri.getLastPathSegment().toLowerCase(); } if (uri.getQueryParameter(REFRESH_STATS) != null) { updateUsageStats(); } return getSuggestions(query, projectionIn); } case SHORTCUT_REFRESH: { String shortcutId = null; if (uri.getPathSegments().size() > 1) { shortcutId = uri.getLastPathSegment(); } return refreshShortcut(shortcutId, projectionIn); } case SEARCH: { String query = null; if (uri.getPathSegments().size() > 1) { query = uri.getLastPathSegment().toLowerCase(); } return getSearchResults(query, projectionIn); } default: throw new IllegalArgumentException("URL " + uri + " doesn't support querying."); } } private Cursor getSuggestions(String query, String[] projectionIn) { Map projectionMap = sSearchSuggestionsProjectionMap; // No zero-query suggestions or launch times except for global search, // to avoid leaking info about apps that have been used. if (hasGlobalSearchPermission()) { projectionMap = sGlobalSearchSuggestionsProjectionMap; } else if (TextUtils.isEmpty(query)) { return null; } return searchApplications(query, projectionIn, projectionMap); } /** * Refreshes the shortcut of an application. * * @param shortcutId Flattened component name of an activity. */ private Cursor refreshShortcut(String shortcutId, String[] projectionIn) { ComponentName component = ComponentName.unflattenFromString(shortcutId); if (component == null) { Log.w(TAG, "Bad shortcut id: " + shortcutId); return null; } SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(APPLICATIONS_TABLE); qb.setProjectionMap(sSearchSuggestionsProjectionMap); qb.appendWhere("package = ? AND class = ?"); String[] selectionArgs = { component.getPackageName(), component.getClassName() }; Cursor cursor = qb.query(mDb, projectionIn, null, selectionArgs, null, null, null); if (DBG) Log.d(TAG, "Returning " + cursor.getCount() + " results for shortcut refresh."); return cursor; } private Cursor getSearchResults(String query, String[] projectionIn) { return searchApplications(query, projectionIn, sSearchProjectionMap); } private Cursor searchApplications(String query, String[] projectionIn, Map columnMap) { final boolean zeroQuery = TextUtils.isEmpty(query); SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables(APPLICATIONS_LOOKUP_JOIN); qb.setProjectionMap(columnMap); String orderBy = null; if (!zeroQuery) { qb.appendWhere(buildTokenFilter(query)); } else { if (hasGlobalSearchPermission()) { qb.appendWhere(LAST_RESUME_TIME + " > 0"); } } if (!hasGlobalSearchPermission()) { orderBy = getOrderBy(zeroQuery); } // don't return duplicates when there are two matching tokens for an app String groupBy = APPLICATIONS_TABLE + "." + _ID; Cursor cursor = qb.query(mDb, projectionIn, null, null, groupBy, null, orderBy); if (DBG) Log.d(TAG, "Returning " + cursor.getCount() + " results for " + query); return cursor; } private String getOrderBy(boolean zeroQuery) { // order first by whether it a full prefix match, then by launch // count (if allowed, frequently used apps rank higher), then name // MIN(token_index) != 0 is true for non-full prefix matches, // and since false (0) < true(1), this expression makes sure // that full prefix matches come first. StringBuilder orderBy = new StringBuilder(); if (!zeroQuery) { orderBy.append("MIN(token_index) != 0, "); } if (hasGlobalSearchPermission()) { orderBy.append(LAST_RESUME_TIME + " DESC, "); } orderBy.append(NAME); return orderBy.toString(); } @SuppressWarnings("deprecation") private String buildTokenFilter(String filterParam) { StringBuilder filter = new StringBuilder("token GLOB "); // NOTE: Query parameters won't work here since the SQL compiler // needs to parse the actual string to know that it can use the // index to do a prefix scan. DatabaseUtils.appendEscapedSQLString(filter, DatabaseUtils.getHexCollationKey(filterParam) + "*"); return filter.toString(); } private static HashMap buildSuggestionsProjectionMap(boolean forGlobalSearch) { HashMap map = new HashMap(); addProjection(map, Applications.ApplicationColumns._ID, _ID); addProjection(map, SearchManager.SUGGEST_COLUMN_TEXT_1, NAME); addProjection(map, SearchManager.SUGGEST_COLUMN_TEXT_2, DESCRIPTION); addProjection(map, SearchManager.SUGGEST_COLUMN_INTENT_DATA, "'content://" + Applications.AUTHORITY + "/applications/'" + " || " + PACKAGE + " || '/' || " + CLASS); addProjection(map, SearchManager.SUGGEST_COLUMN_ICON_1, ICON); addProjection(map, SearchManager.SUGGEST_COLUMN_ICON_2, "NULL"); addProjection(map, SearchManager.SUGGEST_COLUMN_SHORTCUT_ID, PACKAGE + " || '/' || " + CLASS); if (forGlobalSearch) { addProjection(map, SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT, LAST_RESUME_TIME); } return map; } private static HashMap buildSearchProjectionMap() { HashMap map = new HashMap(); addProjection(map, Applications.ApplicationColumns._ID, _ID); addProjection(map, Applications.ApplicationColumns.NAME, NAME); addProjection(map, Applications.ApplicationColumns.ICON, ICON); addProjection(map, Applications.ApplicationColumns.URI, "'content://" + Applications.AUTHORITY + "/applications/'" + " || " + PACKAGE + " || '/' || " + CLASS); return map; } private static void addProjection(HashMap map, String name, String value) { if (!value.equals(name)) { value = value + " AS " + name; } map.put(name, value); } /** * Updates the cached list of installed applications. * * @param packageName Name of package whose activities to update. * If {@code null}, all packages are updated. */ private synchronized void updateApplicationsList(String packageName) { if (DBG) Log.d(TAG, "Updating database (packageName = " + packageName + ")..."); DatabaseUtils.InsertHelper inserter = new DatabaseUtils.InsertHelper(mDb, APPLICATIONS_TABLE); int nameCol = inserter.getColumnIndex(NAME); int descriptionCol = inserter.getColumnIndex(DESCRIPTION); int packageCol = inserter.getColumnIndex(PACKAGE); int classCol = inserter.getColumnIndex(CLASS); int iconCol = inserter.getColumnIndex(ICON); int launchCountCol = inserter.getColumnIndex(LAUNCH_COUNT); int lastResumeTimeCol = inserter.getColumnIndex(LAST_RESUME_TIME); Map usageStats = fetchUsageStats(); mDb.beginTransaction(); try { removeApplications(packageName); String description = getContext().getString(R.string.application_desc); // Iterate and find all the activities which have the LAUNCHER category set. Intent mainIntent = new Intent(Intent.ACTION_MAIN, null); mainIntent.addCategory(Intent.CATEGORY_LAUNCHER); if (packageName != null) { // Limit to activities in the package, if given mainIntent.setPackage(packageName); } final PackageManager manager = getPackageManager(); List activities = manager.queryIntentActivities(mainIntent, 0); int activityCount = activities == null ? 0 : activities.size(); for (int i = 0; i < activityCount; i++) { ResolveInfo info = activities.get(i); String title = info.loadLabel(manager).toString(); String activityClassName = info.activityInfo.name; if (TextUtils.isEmpty(title)) { title = activityClassName; } String activityPackageName = info.activityInfo.applicationInfo.packageName; if (DBG) Log.d(TAG, "activity " + activityPackageName + "/" + activityClassName); PkgUsageStats stats = usageStats.get(activityPackageName); int launchCount = 0; long lastResumeTime = 0; if (stats != null) { launchCount = stats.launchCount; if (stats.componentResumeTimes.containsKey(activityClassName)) { lastResumeTime = stats.componentResumeTimes.get(activityClassName); } } String icon = getActivityIconUri(info.activityInfo); inserter.prepareForInsert(); inserter.bind(nameCol, title); inserter.bind(descriptionCol, description); inserter.bind(packageCol, activityPackageName); inserter.bind(classCol, activityClassName); inserter.bind(iconCol, icon); inserter.bind(launchCountCol, launchCount); inserter.bind(lastResumeTimeCol, lastResumeTime); inserter.execute(); } mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); inserter.close(); } if (DBG) Log.d(TAG, "Finished updating database."); } @VisibleForTesting protected synchronized void updateUsageStats() { if (DBG) Log.d(TAG, "Update application usage stats."); Map usageStats = fetchUsageStats(); mDb.beginTransaction(); try { for (Map.Entry statsEntry : usageStats.entrySet()) { ContentValues updatedLaunchCount = new ContentValues(); String packageName = statsEntry.getKey(); PkgUsageStats stats = statsEntry.getValue(); updatedLaunchCount.put(LAUNCH_COUNT, stats.launchCount); mDb.update(APPLICATIONS_TABLE, updatedLaunchCount, PACKAGE + " = ?", new String[] { packageName }); for (Map.Entry crtEntry: stats.componentResumeTimes.entrySet()) { ContentValues updatedLastResumeTime = new ContentValues(); String componentName = crtEntry.getKey(); updatedLastResumeTime.put(LAST_RESUME_TIME, crtEntry.getValue()); mDb.update(APPLICATIONS_TABLE, updatedLastResumeTime, PACKAGE + " = ? AND " + CLASS + " = ?", new String[] { packageName, componentName }); } } mDb.setTransactionSuccessful(); } finally { mDb.endTransaction(); } if (DBG) Log.d(TAG, "Finished updating application usage stats in database."); } private String getActivityIconUri(ActivityInfo activityInfo) { int icon = activityInfo.getIconResource(); if (icon == 0) return null; Uri uri = getResourceUri(activityInfo.applicationInfo, icon); return uri == null ? null : uri.toString(); } private void removeApplications(String packageName) { if (packageName == null) { mDb.delete(APPLICATIONS_TABLE, null, null); } else { mDb.delete(APPLICATIONS_TABLE, PACKAGE + " = ?", new String[] { packageName }); } } @Override public Uri insert(Uri uri, ContentValues values) { throw new UnsupportedOperationException(); } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { throw new UnsupportedOperationException(); } private Uri getResourceUri(ApplicationInfo appInfo, int res) { try { Resources resources = getPackageManager().getResourcesForApplication(appInfo); return getResourceUri(resources, appInfo.packageName, res); } catch (PackageManager.NameNotFoundException e) { return null; } catch (Resources.NotFoundException e) { return null; } } private static Uri getResourceUri(Resources resources, String appPkg, int res) throws Resources.NotFoundException { String resPkg = resources.getResourcePackageName(res); String type = resources.getResourceTypeName(res); String name = resources.getResourceEntryName(res); return makeResourceUri(appPkg, resPkg, type, name); } private static Uri makeResourceUri(String appPkg, String resPkg, String type, String name) throws Resources.NotFoundException { Uri.Builder uriBuilder = new Uri.Builder(); uriBuilder.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE); uriBuilder.encodedAuthority(appPkg); uriBuilder.appendEncodedPath(type); if (!appPkg.equals(resPkg)) { uriBuilder.appendEncodedPath(resPkg + ":" + name); } else { uriBuilder.appendEncodedPath(name); } return uriBuilder.build(); } @VisibleForTesting protected Map fetchUsageStats() { try { ActivityManager activityManager = (ActivityManager) getContext().getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { Map stats = new HashMap(); PkgUsageStats[] pkgUsageStats = activityManager.getAllPackageUsageStats(); if (pkgUsageStats != null) { for (PkgUsageStats pus : pkgUsageStats) { stats.put(pus.packageName, pus); } } return stats; } } catch (Exception e) { Log.w(TAG, "Could not fetch usage stats", e); } return new HashMap(); } @VisibleForTesting protected PackageManager getPackageManager() { return getContext().getPackageManager(); } @VisibleForTesting protected boolean hasGlobalSearchPermission() { // Only the global-search system is allowed to see the usage stats of // applications. Without this restriction the ApplicationsProvider // could leak information about the user's behavior to applications. return (PackageManager.PERMISSION_GRANTED == getContext().checkCallingPermission(android.Manifest.permission.GLOBAL_SEARCH)); } }