diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index 39f45d5da..9ce22abb9 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -5,6 +5,7 @@ import android.app.PendingIntent; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -35,6 +36,8 @@ import java.util.Map; */ public final class AppUpdateStatusManager { + private static final String TAG = "AppUpdateStatusManager"; + /** * Broadcast when: * * The user clears the list of installed apps from notification manager. @@ -124,9 +127,13 @@ public final class AppUpdateStatusManager { private final HashMap appMapping = new HashMap<>(); private boolean isBatchUpdating; + /** @see #isPendingInstall(String) */ + private final SharedPreferences apksPendingInstall; + private AppUpdateStatusManager(Context context) { this.context = context; localBroadcastManager = LocalBroadcastManager.getInstance(context.getApplicationContext()); + apksPendingInstall = context.getSharedPreferences("apks-pending-install", Context.MODE_PRIVATE); } @Nullable @@ -419,4 +426,54 @@ public final class AppUpdateStatusManager { errorDialogIntent, PendingIntent.FLAG_UPDATE_CURRENT); } + + /** + * Note that this could technically be made private and automatically invoked when + * {@link #addApk(Apk, Status, PendingIntent)} is called, but that would greatly reduce + * the maintainability of this class. Right now it is used by two clients: the notification + * manager, and the Updates tab. They have different requirements, with the Updates information + * being more permanent than the notification info. As such, the different clients should be + * aware of their requirements when invoking general-sounding methods like "addApk()", rather + * than this class trying to second-guess why they added an apk. + * @see #isPendingInstall(String) + */ + public void markAsPendingInstall(String uniqueKey) { + AppUpdateStatus entry = get(uniqueKey); + if (entry != null) { + Utils.debugLog(TAG, "Marking " + entry.apk.packageName + " as pending install."); + apksPendingInstall.edit().putBoolean(entry.apk.hash, true).apply(); + } + } + + /** + * @see #markAsNoLongerPendingInstall(AppUpdateStatus) + * @see #isPendingInstall(String) + */ + public void markAsNoLongerPendingInstall(String uniqueKey) { + AppUpdateStatus entry = get(uniqueKey); + if (entry != null) { + markAsNoLongerPendingInstall(entry); + } + } + + /** + * @see #markAsNoLongerPendingInstall(AppUpdateStatus) + * @see #isPendingInstall(String) + */ + public void markAsNoLongerPendingInstall(@NonNull AppUpdateStatus entry) { + Utils.debugLog(TAG, "Marking " + entry.apk.packageName + " as NO LONGER pending install."); + apksPendingInstall.edit().remove(entry.apk.hash).apply(); + } + + /** + * Keep track of the list of apks for which an install was initiated (i.e. a download + install). + * This is used when F-Droid starts, so that it can look through the cached apks and decide whether + * the presence of a .apk file means we should tell the user to press "Install" to complete the + * process, or whether it is purely there because it was installed some time ago and is no longer + * needed. + */ + public boolean isPendingInstall(String apkHash) { + return apksPendingInstall.contains(apkHash); + } + } diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java index b3b43b54a..76e2eb2a4 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusService.java @@ -3,17 +3,18 @@ package org.fdroid.fdroid; import android.app.IntentService; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.net.Uri; import android.support.annotation.Nullable; +import android.util.Log; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.ApkProvider; -import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.AppProvider; -import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.installer.ApkCache; import org.fdroid.fdroid.installer.InstallManagerService; +import java.io.File; import java.util.ArrayList; import java.util.List; @@ -23,12 +24,11 @@ import java.util.List; * {@link AppUpdateStatusManager.Status#ReadyToInstall}. This is an {@link IntentService} so as to * run on a background thread, as it hits the disk a bit to figure out the hash of each downloaded * file. - * - * TODO: Deal with more than just the suggested version. It should also work for people downloading earlier versions (but still newer than their current) - * TODO: Identify new apps which have not been installed before, but which have been downloading. Currently only works for updates. */ public class AppUpdateStatusService extends IntentService { + private static final String TAG = "AppUpdateStatusService"; + /** * Queue up a background scan of all downloaded apk files to see if we should notify the user * that they are ready to install. @@ -43,17 +43,79 @@ public class AppUpdateStatusService extends IntentService { @Override protected void onHandleIntent(@Nullable Intent intent) { - List apps = AppProvider.Helper.findCanUpdate(this, Schema.AppMetadataTable.Cols.ALL); + Utils.debugLog(TAG, "Scanning apk cache to see if we need to prompt the user to install any apks."); List apksReadyToInstall = new ArrayList<>(); - for (App app : apps) { - Apk apk = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); - Uri downloadUri = Uri.parse(apk.getUrl()); - if (ApkCache.apkIsCached(ApkCache.getApkDownloadPath(this, downloadUri), apk)) { - apksReadyToInstall.add(apk); + File cacheDir = ApkCache.getApkCacheDir(this); + for (String repoDirName : cacheDir.list()) { + File repoDir = new File(cacheDir, repoDirName); + for (String apkFileName : repoDir.list()) { + Apk apk = processDownloadedApk(new File(repoDir, apkFileName)); + if (apk != null) { + Log.i(TAG, "Found downloaded apk " + apk.packageName + ". Notifying user that it should be installed."); + apksReadyToInstall.add(apk); + } } } - AppUpdateStatusManager.getInstance(this).addApks(apksReadyToInstall, AppUpdateStatusManager.Status.ReadyToInstall); - InstallManagerService.managePreviouslyDownloadedApks(this); + if (apksReadyToInstall.size() > 0) { + AppUpdateStatusManager.getInstance(this).addApks(apksReadyToInstall, AppUpdateStatusManager.Status.ReadyToInstall); + InstallManagerService.managePreviouslyDownloadedApks(this); + } + } + + @Nullable + private Apk processDownloadedApk(File apkPath) { + Utils.debugLog(TAG, "Checking " + apkPath); + PackageInfo downloadedInfo = getPackageManager().getPackageArchiveInfo(apkPath.getAbsolutePath(), PackageManager.GET_GIDS); + if (downloadedInfo == null) { + Utils.debugLog(TAG, "Skipping " + apkPath + " because PackageManager was unable to read it."); + return null; + } + + Utils.debugLog(TAG, "Found package for " + downloadedInfo.packageName + ", checking its hash to see if it downloaded correctly."); + Apk downloadedApk = findApkMatchingHash(apkPath); + if (downloadedApk == null) { + Utils.debugLog(TAG, "Either the apk wasn't downloaded fully, or the repo it came from has been disabled. Either way, not notifying the user about it."); + return null; + } + + if (AppUpdateStatusManager.getInstance(this).isPendingInstall(downloadedApk.hash)) { + Utils.debugLog(TAG, downloadedApk.packageName + " is pending install, so we need to notify the user about installing it."); + return downloadedApk; + } else { + Utils.debugLog(TAG, downloadedApk.packageName + " is NOT pending install, probably just left over from a previous install."); + return null; + } + } + + /** + * There could be multiple apks with the same hash, provided by different repositories. + * This method looks for all matching records in the database. It then asks each of these + * {@link Apk} instances where they expect to be downloaded. If they expect to be downloaded + * to {@param apkPath} then that instance is returned. + * + * If no files have a matching hash, or only those which don't belong to the correct repo, then + * this will return null. + */ + @Nullable + private Apk findApkMatchingHash(File apkPath) { + + // NOTE: This presumes SHA256 is the only supported hash. It seems like that is an assumption + // in more than one place in the F-Droid client. If this becomes a problem in the future, we + // can query the Apk table for `SELECT DISTINCT hashType FROM fdroid_apk` and then we can just + // try each of the hash types that have been specified in the metadata. Seems a bit overkill + // at the time of writing though. + String hash = Utils.getBinaryHash(apkPath, "sha256"); + + List apksMatchingHash = ApkProvider.Helper.findApksByHash(this, hash); + Utils.debugLog(TAG, "Found " + apksMatchingHash.size() + " apk(s) matching the hash " + hash); + + for (Apk apk : apksMatchingHash) { + if (apkPath.equals(ApkCache.getApkDownloadPath(this, Uri.parse(apk.getUrl())))) { + return apk; + } + } + + return null; } } \ No newline at end of file diff --git a/app/src/main/java/org/fdroid/fdroid/data/ApkProvider.java b/app/src/main/java/org/fdroid/fdroid/data/ApkProvider.java index 138f96132..655510cf2 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/ApkProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/ApkProvider.java @@ -6,6 +6,7 @@ import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.net.Uri; +import android.support.annotation.NonNull; import android.util.Log; import org.fdroid.fdroid.data.Schema.ApkTable; @@ -173,6 +174,16 @@ public class ApkProvider extends FDroidProvider { } return apk; } + + @NonNull + public static List findApksByHash(Context context, String apkHash) { + ContentResolver resolver = context.getContentResolver(); + final Uri uri = getContentUri(); + String selection = " apk." + Cols.HASH + " = ? "; + String[] selectionArgs = new String[]{apkHash}; + Cursor cursor = resolver.query(uri, Cols.ALL, selection, selectionArgs, null); + return cursorToList(cursor); + } } private static final int CODE_PACKAGE = CODE_SINGLE + 1; diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java index 1237d5181..ae78434af 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -138,6 +138,7 @@ public class InstallManagerService extends Service { DownloaderService.cancel(this, apk.getPatchObbUrl()); DownloaderService.cancel(this, apk.getMainObbUrl()); } + appUpdateStatusManager.markAsNoLongerPendingInstall(urlString); appUpdateStatusManager.removeApk(urlString); return START_NOT_STICKY; } else if (!ACTION_INSTALL.equals(action)) { @@ -164,7 +165,9 @@ public class InstallManagerService extends Service { Utils.debugLog(TAG, "Intent had null EXTRA_APP and/or EXTRA_APK: " + intent); return START_NOT_STICKY; } + appUpdateStatusManager.addApk(apk, AppUpdateStatusManager.Status.Unknown, null); + appUpdateStatusManager.markAsPendingInstall(urlString); registerApkDownloaderReceivers(urlString); getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256); @@ -295,6 +298,7 @@ public class InstallManagerService extends Service { } break; case Downloader.ACTION_INTERRUPTED: + appUpdateStatusManager.markAsNoLongerPendingInstall(urlString); appUpdateStatusManager.updateApk(urlString, AppUpdateStatusManager.Status.Unknown, null); localBroadcastManager.unregisterReceiver(this); break; @@ -334,6 +338,7 @@ public class InstallManagerService extends Service { appUpdateStatusManager.updateApk(downloadUrl, AppUpdateStatusManager.Status.Installing, null); break; case Installer.ACTION_INSTALL_COMPLETE: + appUpdateStatusManager.markAsNoLongerPendingInstall(downloadUrl); appUpdateStatusManager.updateApk(downloadUrl, AppUpdateStatusManager.Status.Installed, null); Apk apkComplete = appUpdateStatusManager.getApk(downloadUrl); @@ -350,6 +355,7 @@ public class InstallManagerService extends Service { apk = intent.getParcelableExtra(Installer.EXTRA_APK); String errorMessage = intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE); + appUpdateStatusManager.markAsNoLongerPendingInstall(downloadUrl); if (!TextUtils.isEmpty(errorMessage)) { appUpdateStatusManager.setApkError(apk, errorMessage); } else {