package org.fdroid.fdroid.data; import android.app.IntentService; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.Signature; import android.net.Uri; import android.os.Process; import android.support.annotation.Nullable; import org.acra.ACRA; import org.fdroid.fdroid.Hasher; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.InstalledAppTable; import java.io.File; import java.io.FilenameFilter; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import rx.functions.Action1; import rx.schedulers.Schedulers; import rx.subjects.PublishSubject; /** * Handles all updates to {@link InstalledAppProvider}, whether checking the contents * versus what Android says is installed, or processing {@link Intent}s that come * from {@link android.content.BroadcastReceiver}s for {@link Intent#ACTION_PACKAGE_ADDED} * and {@link Intent#ACTION_PACKAGE_REMOVED} * <p/> * Since {@link android.content.ContentProvider#insert(Uri, ContentValues)} does not check * for duplicate records, it is entirely the job of this service to ensure that it is not * inserting duplicate versions of the same installed APK. On that note, * {@link #insertAppIntoDb(Context, PackageInfo, String, String)} and * {@link #deleteAppFromDb(Context, String)} are both static methods to enable easy testing * of this stuff. */ public class InstalledAppProviderService extends IntentService { private static final String TAG = "InstalledAppProviderSer"; private static final String ACTION_INSERT = "org.fdroid.fdroid.data.action.INSERT"; private static final String ACTION_DELETE = "org.fdroid.fdroid.data.action.DELETE"; private static final String EXTRA_PACKAGE_INFO = "org.fdroid.fdroid.data.extra.PACKAGE_INFO"; /** * This is for notifing the users of this {@link android.content.ContentProvider} * that the contents has changed. Since {@link Intent}s can come in slow * or fast, and this can trigger a lot of UI updates, the actual * notifications are rate limited to one per second. */ private PublishSubject<Void> notifyEvents; public InstalledAppProviderService() { super("InstalledAppProviderService"); } @Override public void onCreate() { super.onCreate(); notifyEvents = PublishSubject.create(); notifyEvents.debounce(1, TimeUnit.SECONDS) .subscribeOn(Schedulers.newThread()) .subscribe(new Action1<Void>() { @Override public void call(Void voidArg) { Utils.debugLog(TAG, "Notifying content providers (so they can update the relevant views)."); getContentResolver().notifyChange(AppProvider.getContentUri(), null); getContentResolver().notifyChange(ApkProvider.getContentUri(), null); } }); } /** * Inserts an app into {@link InstalledAppProvider} based on a {@code package:} {@link Uri}. * This has no checks for whether it is inserting an exact duplicate, whatever is provided * will be inserted. */ public static void insert(Context context, PackageInfo packageInfo) { insert(context, Utils.getPackageUri(packageInfo.packageName), packageInfo); } /** * Inserts an app into {@link InstalledAppProvider} based on a {@code package:} {@link Uri}. * This has no checks for whether it is inserting an exact duplicate, whatever is provided * will be inserted. */ public static void insert(Context context, Uri uri) { insert(context, uri, null); } private static void insert(Context context, Uri uri, PackageInfo packageInfo) { Intent intent = new Intent(context, InstalledAppProviderService.class); intent.setAction(ACTION_INSERT); intent.setData(uri); intent.putExtra(EXTRA_PACKAGE_INFO, packageInfo); context.startService(intent); } /** * Deletes an app from {@link InstalledAppProvider} based on a {@code package:} {@link Uri} */ public static void delete(Context context, String packageName) { delete(context, Utils.getPackageUri(packageName)); } /** * Deletes an app from {@link InstalledAppProvider} based on a {@code package:} {@link Uri} */ public static void delete(Context context, Uri uri) { Intent intent = new Intent(context, InstalledAppProviderService.class); intent.setAction(ACTION_DELETE); intent.setData(uri); context.startService(intent); } /** * Make sure that {@link InstalledAppProvider}, our database of installed apps, * is in sync with what the {@link PackageManager} tells us is installed. Once * completed, the relevant {@link android.content.ContentProvider}s will be * notified of any changes to installed statuses. * <p/> * The installed app cache could get out of sync, e.g. if F-Droid crashed/ or * ran out of battery half way through responding to {@link Intent#ACTION_PACKAGE_ADDED}. * This method returns immediately, and will continue to work in an * {@link IntentService}. It doesn't really matter where we put this in the * bootstrap process, because it runs in its own thread, at the lowest priority: * {@link Process#THREAD_PRIORITY_LOWEST}. */ public static void compareToPackageManager(Context context) { Map<String, Long> cachedInfo = InstalledAppProvider.Helper.all(context); List<PackageInfo> packageInfoList = context.getPackageManager() .getInstalledPackages(PackageManager.GET_SIGNATURES); for (PackageInfo packageInfo : packageInfoList) { if (cachedInfo.containsKey(packageInfo.packageName)) { if (packageInfo.lastUpdateTime > cachedInfo.get(packageInfo.packageName)) { insert(context, packageInfo); } cachedInfo.remove(packageInfo.packageName); } else { insert(context, packageInfo); } } for (String packageName : cachedInfo.keySet()) { delete(context, packageName); } } @Override protected void onHandleIntent(Intent intent) { Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); if (intent == null) { return; } String packageName = intent.getData().getSchemeSpecificPart(); final String action = intent.getAction(); if (ACTION_INSERT.equals(action)) { PackageInfo packageInfo = getPackageInfo(intent, packageName); if (packageInfo != null) { File apk = new File(packageInfo.applicationInfo.publicSourceDir); if (apk.isDirectory()) { FilenameFilter filter = new FilenameFilter() { @Override public boolean accept(File dir, String name) { return name.endsWith(".apk"); } }; File[] files = apk.listFiles(filter); if (files == null) { String msg = packageName + " sourceDir has no APKs: " + apk.getAbsolutePath(); Utils.debugLog(TAG, msg); ACRA.getErrorReporter().handleException(new IllegalArgumentException(msg), false); return; } apk = files[0]; } if (apk.exists() && apk.canRead()) { try { String hashType = "sha256"; String hash = Utils.getBinaryHash(apk, hashType); insertAppIntoDb(this, packageInfo, hashType, hash); } catch (IllegalArgumentException e) { Utils.debugLog(TAG, e.getMessage()); ACRA.getErrorReporter().handleException(e, false); return; } } } } else if (ACTION_DELETE.equals(action)) { deleteAppFromDb(this, packageName); } notifyEvents.onNext(null); } /** * This class will either have received an intent from the {@link InstalledAppProviderService} * itself, while iterating over installed apps, or from a {@link Intent#ACTION_PACKAGE_ADDED} * broadcast. In the first case, it will already have a {@link PackageInfo} for us. However if * it is from the later case, we'll need to query the {@link PackageManager} ourselves to get * this info. * * Can still return null, as there is potentially race conditions to do with uninstalling apps * such that querying the {@link PackageManager} for a given package may throw an exception. */ @Nullable private PackageInfo getPackageInfo(Intent intent, String packageName) { PackageInfo packageInfo = intent.getParcelableExtra(EXTRA_PACKAGE_INFO); if (packageInfo != null) { return packageInfo; } try { return getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES); } catch (PackageManager.NameNotFoundException e) { e.printStackTrace(); return null; } } /** * @param hash Although the has could be calculated within this function, it is helpful to inject * the hash so as to be able to use this method during testing. Otherwise, the * hashing method will try to hash a non-existent .apk file and try to insert NULL * into the database when under test. */ static void insertAppIntoDb(Context context, PackageInfo packageInfo, String hashType, String hash) { Uri uri = InstalledAppProvider.getContentUri(); ContentValues contentValues = new ContentValues(); contentValues.put(InstalledAppTable.Cols.PACKAGE_NAME, packageInfo.packageName); contentValues.put(InstalledAppTable.Cols.VERSION_CODE, packageInfo.versionCode); contentValues.put(InstalledAppTable.Cols.VERSION_NAME, packageInfo.versionName); contentValues.put(InstalledAppTable.Cols.APPLICATION_LABEL, InstalledAppProvider.getApplicationLabel(context, packageInfo.packageName)); contentValues.put(InstalledAppTable.Cols.SIGNATURE, getPackageSig(packageInfo)); contentValues.put(InstalledAppTable.Cols.LAST_UPDATE_TIME, packageInfo.lastUpdateTime); contentValues.put(InstalledAppTable.Cols.HASH_TYPE, hashType); contentValues.put(InstalledAppTable.Cols.HASH, hash); context.getContentResolver().insert(uri, contentValues); } static void deleteAppFromDb(Context context, String packageName) { Uri uri = InstalledAppProvider.getAppUri(packageName); context.getContentResolver().delete(uri, null, null); } private static String getPackageSig(PackageInfo info) { if (info == null || info.signatures == null || info.signatures.length < 1) { return ""; } Signature sig = info.signatures[0]; String sigHash = ""; try { Hasher hash = new Hasher("MD5", sig.toCharsString().getBytes()); sigHash = hash.getHash(); } catch (NoSuchAlgorithmException e) { // ignore } return sigHash; } }