diff --git a/app/src/basic/res/xml/preferences.xml b/app/src/basic/res/xml/preferences.xml index 3693bfb24..3f0e10c66 100644 --- a/app/src/basic/res/xml/preferences.xml +++ b/app/src/basic/res/xml/preferences.xml @@ -42,12 +42,10 @@ android:title="@string/over_data" android:defaultValue="@integer/defaultOverData" android:layout="@layout/preference_seekbar"/> - + @@ -470,7 +471,8 @@ android:permission="android.permission.BIND_JOB_SERVICE" /> + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> - - + android:exported="false" + android:permission="android.permission.BIND_JOB_SERVICE" /> diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index b3c98cc28..5c4cdde9b 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -20,7 +20,6 @@ import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.ErrorDialogActivity; -import org.fdroid.fdroid.installer.InstallManagerService; import org.fdroid.fdroid.net.DownloaderService; import org.fdroid.fdroid.views.AppDetailsActivity; @@ -311,7 +310,6 @@ public final class AppUpdateStatusManager { notifyChange(entry, isStatusUpdate); if (status == Status.Installed) { - InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl()); // After an app got installed, update available updates checkForUpdates(); } @@ -324,10 +322,6 @@ public final class AppUpdateStatusManager { setEntryContentIntentIfEmpty(entry); appMapping.put(entry.getCanonicalUrl(), entry); notifyAdd(entry); - - if (status == Status.Installed) { - InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl()); - } } private void notifyChange(String reason) { @@ -479,7 +473,6 @@ public final class AppUpdateStatusManager { */ public void removeApk(String canonicalUrl) { synchronized (appMapping) { - InstallManagerService.removePendingInstall(context, canonicalUrl); AppUpdateStatus entry = appMapping.remove(canonicalUrl); if (entry != null) { Utils.debugLog(LOGTAG, "Remove APK " + entry.apk.getApkPath()); @@ -535,8 +528,6 @@ public final class AppUpdateStatusManager { entry.errorText = errorText; entry.intent = getAppErrorIntent(entry); notifyChange(entry, false); - - InstallManagerService.removePendingInstall(context, entry.getCanonicalUrl()); } } diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index a132f4b3f..41364eaf6 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -60,6 +60,7 @@ import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.ApkFileProvider; import org.fdroid.fdroid.installer.InstallHistoryService; +import org.fdroid.fdroid.installer.SessionInstallManager; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; import org.fdroid.fdroid.nearby.SDCardScannerService; import org.fdroid.fdroid.nearby.WifiStateChangeService; @@ -108,6 +109,7 @@ public class FDroidApp extends Application implements androidx.work.Configuratio public static volatile Repository repo; public static volatile List repos; + public static volatile SessionInstallManager sessionInstallManager; public static volatile int networkState = ConnectivityMonitorService.FLAG_NET_UNAVAILABLE; @@ -341,6 +343,7 @@ public class FDroidApp extends Application implements androidx.work.Configuratio CleanCacheWorker.schedule(this); + sessionInstallManager = new SessionInstallManager(getApplicationContext()); notificationHelper = new NotificationHelper(getApplicationContext()); if (preferences.isIndexNeverUpdated()) { diff --git a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java index 14f115d16..746fb7b43 100644 --- a/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/NotificationHelper.java @@ -24,7 +24,6 @@ import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import com.bumptech.glide.Glide; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; @@ -203,7 +202,6 @@ public class NotificationHelper { } else if (installed.size() == 1) { notification = createInstalledNotification(entry); notificationManager.cancel(entry.getCanonicalUrl(), NOTIFY_ID_UPDATES); - notificationManager.cancel(entry.getCanonicalUrl(), NOTIFY_ID_INSTALLED); notificationManager.notify(GROUP_INSTALLED, NOTIFY_ID_INSTALLED, notification); } } else { @@ -213,7 +211,6 @@ public class NotificationHelper { notificationManager.notify(entry.getCanonicalUrl(), NOTIFY_ID_UPDATES, notification); } else if (updates.size() == 1) { notification = createUpdateNotification(entry); - notificationManager.cancel(entry.getCanonicalUrl(), NOTIFY_ID_UPDATES); notificationManager.cancel(entry.getCanonicalUrl(), NOTIFY_ID_INSTALLED); notificationManager.notify(GROUP_UPDATES, NOTIFY_ID_UPDATES, notification); } @@ -526,48 +523,34 @@ public class NotificationHelper { NotificationCompat.Builder notificationBuilder, int notificationId, String notificationTag) { - final Point largeIconSize = getLargeIconSize(); + App.loadBitmapWithGlide(context, entry.app.repoId, entry.app.iconFile) + .fallback(R.drawable.ic_notification_download) + .error(R.drawable.ic_notification_download) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { + // update the loaded large icon, but don't expand + notificationBuilder.setLargeIcon(resource); + Notification notification = notificationBuilder.build(); + notificationManager.notify(notificationTag, notificationId, notification); + } - if (entry.status == AppUpdateStatusManager.Status.Downloading - || entry.status == AppUpdateStatusManager.Status.Installing) { - Bitmap bitmap = Bitmap.createBitmap(largeIconSize.x, largeIconSize.y, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - Drawable downloadIcon = ContextCompat.getDrawable(context, R.drawable.ic_notification_download); - if (downloadIcon != null) { - downloadIcon.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - downloadIcon.draw(canvas); - } - Glide.with(context) - .asBitmap() - .load(bitmap) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - // update the loaded large icon, but don't expand - notificationBuilder.setLargeIcon(resource); - Notification notification = notificationBuilder.build(); - notificationManager.notify(notificationTag, notificationId, notification); - } + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + if (errorDrawable == null) return; + final Point largeIconSize = getLargeIconSize(); + Bitmap bitmap = Bitmap.createBitmap(largeIconSize.x, largeIconSize.y, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + errorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + errorDrawable.draw(canvas); + notificationBuilder.setLargeIcon(bitmap); + Notification notification = notificationBuilder.build(); + notificationManager.notify(notificationTag, notificationId, notification); + } - @Override - public void onLoadCleared(@Nullable Drawable drawable) { - } - }); - } else { - App.loadBitmapWithGlide(context, entry.app.repoId, entry.app.iconFile) - .into(new CustomTarget() { - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - // update the loaded large icon, but don't expand - notificationBuilder.setLargeIcon(resource); - Notification notification = notificationBuilder.build(); - notificationManager.notify(notificationTag, notificationId, notification); - } - - @Override - public void onLoadCleared(@Nullable Drawable drawable) { - } - }); - } + @Override + public void onLoadCleared(@Nullable Drawable drawable) { + } + }); } } diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index 4ec8750df..a9441a091 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -209,7 +209,9 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh * @see org.fdroid.fdroid.views.PreferencesFragment#initPrivilegedInstallerPreference() */ public boolean isPrivilegedInstallerEnabled() { - return preferences.getBoolean(PREF_PRIVILEGED_INSTALLER, true); + // only use priv-ext by default with full flavor, because basic isn't allowed to use it + // and there's a bug with auto-detection: https://gitlab.com/fdroid/fdroidclient/-/issues/2593 + return preferences.getBoolean(PREF_PRIVILEGED_INSTALLER, BuildConfig.FLAVOR.equals("full")); } /** @@ -432,8 +434,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh } public boolean isAutoDownloadEnabled() { - return !"basic".equals(BuildConfig.FLAVOR) // TODO remove once basic can do auto-downloads - && preferences.getBoolean(PREF_AUTO_DOWNLOAD_INSTALL_UPDATES, IGNORED_B); + return preferences.getBoolean(PREF_AUTO_DOWNLOAD_INSTALL_UPDATES, IGNORED_B); } /** diff --git a/app/src/main/java/org/fdroid/fdroid/compat/PackageManagerCompat.java b/app/src/main/java/org/fdroid/fdroid/compat/PackageManagerCompat.java index 6c2c18c4e..37119f9c5 100644 --- a/app/src/main/java/org/fdroid/fdroid/compat/PackageManagerCompat.java +++ b/app/src/main/java/org/fdroid/fdroid/compat/PackageManagerCompat.java @@ -28,6 +28,7 @@ public class PackageManagerCompat { private static final String TAG = "PackageManagerCompat"; public static void setInstaller(Context context, PackageManager mPm, String packageName) { + if (Build.VERSION.SDK_INT >= 30) return; // not working anymore on this SDK level try { if (Build.VERSION.SDK_INT >= 24 && PrivilegedInstaller.isDefault(context)) { mPm.setInstallerPackageName(packageName, PrivilegedInstaller.PRIVILEGED_EXTENSION_PACKAGE_NAME); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java b/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java index fd4e0bc78..5ec9b691a 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java @@ -24,6 +24,9 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.net.Uri; +import androidx.annotation.WorkerThread; +import androidx.core.util.Pair; + import org.apache.commons.io.FileUtils; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; @@ -36,12 +39,27 @@ public class ApkCache { private static final String CACHE_DIR = "apks"; + public enum ApkCacheState { MISS_OR_PARTIAL, CACHED, CORRUPTED } + + @WorkerThread + static Pair getApkCacheState(Context context, Apk apk) { + SanitizedFile apkFilePath = getApkDownloadPath(context, apk.getCanonicalUrl()); + long apkFileSize = apkFilePath.length(); + if (!apkFilePath.exists() || apkFileSize < apk.size) { + return new Pair<>(ApkCacheState.MISS_OR_PARTIAL, apkFilePath); + } else if (apkIsCached(apkFilePath, apk)) { + return new Pair<>(ApkCacheState.CACHED, apkFilePath); + } else { + return new Pair<>(ApkCacheState.CORRUPTED, apkFilePath); + } + } + /** * Same as {@link #copyApkFromCacheToFiles(Context, File, Apk)}, except it does not need to * verify the hash after copying. This is because we are copying from an installed apk, which * other apps do not have permission to modify. */ - public static SanitizedFile copyInstalledApkToFiles(Context context, PackageInfo packageInfo) + static SanitizedFile copyInstalledApkToFiles(Context context, PackageInfo packageInfo) throws IOException { ApplicationInfo appInfo = packageInfo.applicationInfo; CharSequence name = context.getPackageManager().getApplicationLabel(appInfo); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallHistoryService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallHistoryService.java index 759472e23..a0f8a3122 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallHistoryService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallHistoryService.java @@ -19,7 +19,6 @@ package org.fdroid.fdroid.installer; -import android.app.IntentService; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -38,15 +37,18 @@ import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; import androidx.localbroadcastmanager.content.LocalBroadcastManager; /** - * Saves all activity of installs and uninstalls to the database for later use, like + * Saves all activity of installs and uninstalls to a file for later use, like * displaying in some kind of history viewer or reporting to a "popularity contest" * app tracker. */ -public class InstallHistoryService extends IntentService { +public class InstallHistoryService extends JobIntentService { public static final String TAG = "InstallHistoryService"; + private static final int JOB_ID = TAG.hashCode(); public static final Uri LOG_URI = Uri.parse("content://" + Installer.AUTHORITY + "/install_history/all"); @@ -85,10 +87,10 @@ public class InstallHistoryService extends IntentService { broadcastReceiver = null; } - public static void queue(Context context, Intent intent) { + private static void queue(Context context, Intent intent) { Utils.debugLog(TAG, "queue " + intent); intent.setClass(context, InstallHistoryService.class); - context.startService(intent); + JobIntentService.enqueueWork(context, InstallHistoryService.class, JOB_ID, intent); } public static File getInstallHistoryFile(Context context) { @@ -97,16 +99,9 @@ public class InstallHistoryService extends IntentService { return new File(installHistoryDir, "all"); } - public InstallHistoryService() { - super("InstallHistoryService"); - } - @Override - protected void onHandleIntent(Intent intent) { + protected void onHandleWork(@NonNull Intent intent) { Utils.debugLog(TAG, "onHandleIntent " + intent); - if (intent == null) { - return; - } Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); long timestamp = System.currentTimeMillis(); 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 3e8f2c9cc..b9ec4851b 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -1,15 +1,15 @@ package org.fdroid.fdroid.installer; +import android.annotation.SuppressLint; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; +import android.content.IntentFilter; import android.content.pm.PackageInfo; import android.net.Uri; -import android.os.IBinder; import android.text.TextUtils; import android.util.Log; @@ -20,6 +20,7 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.PackageManagerCompat; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.SanitizedFile; import org.fdroid.fdroid.net.DownloaderService; import java.io.File; @@ -35,7 +36,7 @@ import static vendored.org.apache.commons.codec.digest.MessageDigestAlgorithms.S * Manages the whole process when a background update triggers an install or the user * requests an APK to be installed. It handles checking whether the APK is cached, * downloading it, putting up and maintaining a {@link Notification}, and more. This - * {@code Service} tracks packages that are in the process as "Pending Installs". + * class tracks packages that are in the process as "Pending Installs". * Then {@link DownloaderService} and {@link InstallerService} individually track * packages for those phases of the whole install process. Each of those * {@code Services} have their own related events. For tracking status during the @@ -45,15 +46,9 @@ import static vendored.org.apache.commons.codec.digest.MessageDigestAlgorithms.S * The {@link App} and {@link Apk} instances are sent via * {@link Intent#putExtra(String, android.os.Bundle)} * so that Android handles the message queuing and {@link Service} lifecycle for us. - * For example, if this {@code InstallManagerService} gets killed, Android will cache + * For example, if {@link DownloaderService} gets killed, Android will cache * and then redeliver the {@link Intent} for us, which includes all of the data needed * for {@code InstallManagerService} to do its job for the whole lifecycle of an install. - * This {@code Service} never stops itself after completing the action, e.g. - * {@code {@link #stopSelf(int)}}, so {@code Intent}s are sometimes redelivered even - * though they are no longer valid. {@link #onStartCommand(Intent, int, int)} checks - * first that the incoming {@code Intent} is not an invalid, redelivered {@code Intent}. - * {@link #isPendingInstall(String)} and other checks are used to check whether to - * process the redelivered {@code Intent} or not. *

* The canonical URL for the APK file to download is also used as the unique ID to * represent the download itself throughout F-Droid. This follows the model @@ -87,57 +82,55 @@ import static vendored.org.apache.commons.codec.digest.MessageDigestAlgorithms.S * @see forced to vendor Apache Commons Codec */ @SuppressWarnings("LineLength") -public class InstallManagerService extends Service { +public class InstallManagerService { private static final String TAG = "InstallManagerService"; - private static final String ACTION_INSTALL = "org.fdroid.fdroid.installer.action.INSTALL"; private static final String ACTION_CANCEL = "org.fdroid.fdroid.installer.action.CANCEL"; - private static final String EXTRA_APP = "org.fdroid.fdroid.installer.extra.APP"; - private static final String EXTRA_APK = "org.fdroid.fdroid.installer.extra.APK"; + @SuppressLint("StaticFieldLeak") // we are using ApplicationContext, so hopefully that's fine + private static InstallManagerService instance; - private static SharedPreferences pendingInstalls; + private final Context context; + private final LocalBroadcastManager localBroadcastManager; + private final AppUpdateStatusManager appUpdateStatusManager; - private LocalBroadcastManager localBroadcastManager; - private AppUpdateStatusManager appUpdateStatusManager; - private boolean running = false; - - /** - * This service does not use binding, so no need to implement this method - */ - @Override - public IBinder onBind(Intent intent) { - return null; + public static InstallManagerService getInstance(Context context) { + if (instance == null) { + instance = new InstallManagerService(context.getApplicationContext()); + } + return instance; } - @Override - public void onCreate() { - super.onCreate(); - localBroadcastManager = LocalBroadcastManager.getInstance(this); - appUpdateStatusManager = AppUpdateStatusManager.getInstance(this); - running = true; - pendingInstalls = getPendingInstalls(this); + public InstallManagerService(Context context) { + this.context = context; + this.localBroadcastManager = LocalBroadcastManager.getInstance(context); + this.appUpdateStatusManager = AppUpdateStatusManager.getInstance(context); + // cancel intent can't use LocalBroadcastManager, because it comes from system process + IntentFilter cancelFilter = new IntentFilter(); + cancelFilter.addAction(ACTION_CANCEL); + context.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "Received cancel intent: " + intent); + if (!ACTION_CANCEL.equals(intent.getAction())) return; + cancel(context, intent.getStringExtra(DownloaderService.EXTRA_CANONICAL_URL)); + } + }, cancelFilter); } - /** - * If this {@link Service} is stopped, then all of the various - * {@link BroadcastReceiver}s need to unregister themselves if they get - * called. There can be multiple {@code BroadcastReceiver}s registered, - * so it can't be done with a simple call here. So {@link #running} is the - * signal to all the existing {@code BroadcastReceiver}s to unregister. - */ - @Override - public void onDestroy() { - running = false; - super.onDestroy(); + private void onCancel(String canonicalUrl) { + DownloaderService.cancel(canonicalUrl); + Apk apk = appUpdateStatusManager.getApk(canonicalUrl); + if (apk != null) { + Utils.debugLog(TAG, "also canceling OBB downloads"); + DownloaderService.cancel(apk.getPatchObbUrl()); + DownloaderService.cancel(apk.getMainObbUrl()); + } } /** * This goes through a series of checks to make sure that the incoming - * {@link Intent} is still valid. The default {@link Intent#getAction() action} - * in the logic is {@link #ACTION_INSTALL} since it is the most complicate - * case. Since the {@code Intent} will be redelivered by Android if the - * app was killed, this needs to check that it still makes sense to handle. + * {@link Intent} is still valid. *

* For example, if F-Droid is killed while installing, it might not receive * the message that the install completed successfully. The checks need to be @@ -145,93 +138,93 @@ public class InstallManagerService extends Service { * with the same {@link PackageInfo#versionCode}, which happens sometimes, * and is allowed by Android. */ - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Utils.debugLog(TAG, "onStartCommand " + intent); + private void queue(String canonicalUrl, String packageName, @NonNull App app, @NonNull Apk apk) { + Utils.debugLog(TAG, "queue " + packageName); - String canonicalUrl = intent.getDataString(); if (TextUtils.isEmpty(canonicalUrl)) { Utils.debugLog(TAG, "empty canonicalUrl, nothing to do"); - return START_NOT_STICKY; + return; } - String action = intent.getAction(); - - if (ACTION_CANCEL.equals(action)) { - DownloaderService.cancel(this, canonicalUrl); - Apk apk = appUpdateStatusManager.getApk(canonicalUrl); - if (apk != null) { - Utils.debugLog(TAG, "also canceling OBB downloads"); - DownloaderService.cancel(this, apk.getPatchObbUrl()); - DownloaderService.cancel(this, apk.getMainObbUrl()); - } - return START_NOT_STICKY; - } else if (ACTION_INSTALL.equals(action)) { - if (!isPendingInstall(canonicalUrl)) { - Log.i(TAG, "Ignoring INSTALL that is not Pending Install: " + intent); - return START_NOT_STICKY; - } - } else { - Log.i(TAG, "Ignoring unknown intent action: " + intent); - return START_NOT_STICKY; - } - - if (!intent.hasExtra(EXTRA_APP) || !intent.hasExtra(EXTRA_APK)) { - Utils.debugLog(TAG, canonicalUrl + " did not include both an App and Apk instance, ignoring"); - return START_NOT_STICKY; - } - - if ((flags & START_FLAG_REDELIVERY) == START_FLAG_REDELIVERY - && !DownloaderService.isQueuedOrActive(canonicalUrl)) { - Utils.debugLog(TAG, canonicalUrl + " finished downloading while InstallManagerService was killed."); - appUpdateStatusManager.removeApk(canonicalUrl); - return START_NOT_STICKY; - } - - App app = intent.getParcelableExtra(EXTRA_APP); - Apk apk = intent.getParcelableExtra(EXTRA_APK); - if (app == null || apk == null) { - Utils.debugLog(TAG, "Intent had null EXTRA_APP and/or EXTRA_APK: " + intent); - return START_NOT_STICKY; - } - - PackageInfo packageInfo = Utils.getPackageInfo(this, apk.packageName); - if ((flags & START_FLAG_REDELIVERY) == START_FLAG_REDELIVERY - && packageInfo != null && packageInfo.versionCode == apk.versionCode + PackageInfo packageInfo = Utils.getPackageInfo(context, packageName); + if (packageInfo != null && packageInfo.versionCode == apk.versionCode && TextUtils.equals(packageInfo.versionName, apk.versionName)) { - Log.i(TAG, "INSTALL Intent no longer valid since its installed, ignoring: " + intent); - return START_NOT_STICKY; + Log.i(TAG, "Install action no longer valid since its installed, ignoring: " + packageName); + return; } appUpdateStatusManager.addApk(app, apk, AppUpdateStatusManager.Status.Downloading, null); - registerPackageDownloaderReceivers(canonicalUrl); getMainObb(canonicalUrl, apk); getPatchObb(canonicalUrl, apk); - File apkFilePath = ApkCache.getApkDownloadPath(this, apk.getCanonicalUrl()); - long apkFileSize = apkFilePath.length(); - if (!apkFilePath.exists() || apkFileSize < apk.size) { - Utils.debugLog(TAG, "download " + canonicalUrl + " " + apkFilePath); - DownloaderService.queue(this, apk.repoId, canonicalUrl, apk.getDownloadUrl(), apk.apkFile); - } else if (ApkCache.apkIsCached(apkFilePath, apk)) { - Utils.debugLog(TAG, "skip download, we have it, straight to install " + canonicalUrl + " " + apkFilePath); - sendBroadcast(intent.getData(), DownloaderService.ACTION_STARTED, apkFilePath); - sendBroadcast(intent.getData(), DownloaderService.ACTION_COMPLETE, apkFilePath); - } else { - Utils.debugLog(TAG, "delete and download again " + canonicalUrl + " " + apkFilePath); - apkFilePath.delete(); - DownloaderService.queue(this, apk.repoId, canonicalUrl, apk.getDownloadUrl(), apk.apkFile); - } - - return START_REDELIVER_INTENT; // if killed before completion, retry Intent + Utils.runOffUiThread(() -> ApkCache.getApkCacheState(context, apk), pair -> { + ApkCache.ApkCacheState state = pair.first; + SanitizedFile apkFilePath = pair.second; + if (state == ApkCache.ApkCacheState.MISS_OR_PARTIAL) { + Utils.debugLog(TAG, "download " + canonicalUrl + " " + apkFilePath); + DownloaderService.queue(context, canonicalUrl, app, apk); + } else if (state == ApkCache.ApkCacheState.CACHED) { + Utils.debugLog(TAG, "skip download, we have it, straight to install " + canonicalUrl + " " + apkFilePath); + Uri canonicalUri = Uri.parse(canonicalUrl); + onDownloadStarted(canonicalUri); + onDownloadComplete(canonicalUri, apkFilePath, app, apk); + } else { + Utils.debugLog(TAG, "delete and download again " + canonicalUrl + " " + apkFilePath); + Utils.runOffUiThread(apkFilePath::delete); + DownloaderService.queue(context, canonicalUrl, app, apk); + } + }); } - private void sendBroadcast(Uri uri, String action, File file) { - Intent intent = new Intent(action); - intent.setData(uri); - intent.putExtra(DownloaderService.EXTRA_DOWNLOAD_PATH, file.getAbsolutePath()); - localBroadcastManager.sendBroadcast(intent); + public void onDownloadStarted(Uri canonicalUri) { + // App should currently be in the "PendingDownload" state, so this changes it to "Downloading". + appUpdateStatusManager.updateApk(canonicalUri.toString(), + AppUpdateStatusManager.Status.Downloading, getDownloadCancelIntent(canonicalUri)); + } + + public void onDownloadProgress(Uri canonicalUri, App app, Apk apk, long bytesRead, long totalBytes) { + if (appUpdateStatusManager.get(canonicalUri.toString()) == null) { + // if our app got killed, we need to re-add the APK here + appUpdateStatusManager.addApk(app, apk, AppUpdateStatusManager.Status.Downloading, + getDownloadCancelIntent(canonicalUri)); + } + appUpdateStatusManager.updateApkProgress(canonicalUri.toString(), totalBytes, bytesRead); + } + + public void onDownloadComplete(Uri canonicalUri, File file, App intentApp, Apk intentApk) { + String canonicalUrl = canonicalUri.toString(); + Uri localApkUri = Uri.fromFile(file); + + Utils.debugLog(TAG, "download completed of " + canonicalUri + " to " + localApkUri); + appUpdateStatusManager.updateApk(canonicalUrl, + AppUpdateStatusManager.Status.ReadyToInstall, null); + + App app = appUpdateStatusManager.getApp(canonicalUrl); + Apk apk = appUpdateStatusManager.getApk(canonicalUrl); + if (app == null || apk == null) { + // These may be null if our app was killed and the download job restarted. + // Then, we can take the objects we saved in the intent which survive app death. + app = intentApp; + apk = intentApk; + } + if (app != null && apk != null) { + registerInstallReceiver(canonicalUrl); + InstallerService.install(context, localApkUri, canonicalUri, app, apk); + } else { + Log.e(TAG, "Could not install " + canonicalUrl + " because no app or apk available."); + } + } + + public void onDownloadFailed(Uri canonicalUri, String errorMsg) { + appUpdateStatusManager.setDownloadError(canonicalUri.toString(), errorMsg); + } + + private PendingIntent getDownloadCancelIntent(Uri canonicalUri) { + Intent intentObject = new Intent(ACTION_CANCEL); + intentObject.putExtra(DownloaderService.EXTRA_CANONICAL_URL, canonicalUri.toString()); + return PendingIntent.getBroadcast(context, 0, intentObject, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } private void getMainObb(final String canonicalUrl, Apk apk) { @@ -257,10 +250,6 @@ public class InstallManagerService extends Service { final BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - if (!running) { - localBroadcastManager.unregisterReceiver(this); - return; - } String action = intent.getAction(); if (DownloaderService.ACTION_STARTED.equals(action)) { Utils.debugLog(TAG, action + " " + intent); @@ -306,90 +295,19 @@ public class InstallManagerService extends Service { } } }; - DownloaderService.queue(this, repoId, obbUrlString, obbUrlString, null); + DownloaderService.queue(context, repoId, obbUrlString, obbUrlString); localBroadcastManager.registerReceiver(downloadReceiver, DownloaderService.getIntentFilter(obbUrlString)); } - /** - * Register a {@link BroadcastReceiver} for tracking download progress for a - * give {@code canonicalUrl}. There can be multiple of these registered at a time. - */ - private void registerPackageDownloaderReceivers(String canonicalUrl) { - - BroadcastReceiver downloadReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (!running) { - localBroadcastManager.unregisterReceiver(this); - return; - } - Uri canonicalUri = intent.getData(); - String canonicalUrl = intent.getDataString(); - - switch (intent.getAction()) { - case DownloaderService.ACTION_STARTED: - // App should currently be in the "PendingDownload" state, so this changes it to "Downloading". - Intent intentObject = new Intent(context, InstallManagerService.class); - intentObject.setAction(ACTION_CANCEL); - intentObject.setData(canonicalUri); - PendingIntent action = - PendingIntent.getService(context, 0, intentObject, PendingIntent.FLAG_IMMUTABLE); - appUpdateStatusManager.updateApk(canonicalUrl, - AppUpdateStatusManager.Status.Downloading, action); - break; - case DownloaderService.ACTION_PROGRESS: - long bytesRead = intent.getLongExtra(DownloaderService.EXTRA_BYTES_READ, 0); - long totalBytes = intent.getLongExtra(DownloaderService.EXTRA_TOTAL_BYTES, 0); - appUpdateStatusManager.updateApkProgress(canonicalUrl, totalBytes, bytesRead); - break; - case DownloaderService.ACTION_COMPLETE: - File localFile = new File(intent.getStringExtra(DownloaderService.EXTRA_DOWNLOAD_PATH)); - Uri localApkUri = Uri.fromFile(localFile); - - Utils.debugLog(TAG, "download completed of " - + intent.getStringExtra(DownloaderService.EXTRA_MIRROR_URL) + " to " + localApkUri); - appUpdateStatusManager.updateApk(canonicalUrl, - AppUpdateStatusManager.Status.ReadyToInstall, null); - - localBroadcastManager.unregisterReceiver(this); - registerInstallReceiver(canonicalUrl); - - App app = appUpdateStatusManager.getApp(canonicalUrl); - Apk apk = appUpdateStatusManager.getApk(canonicalUrl); - if (apk != null) { - InstallerService.install(context, localApkUri, canonicalUri, app, apk); - } - break; - case DownloaderService.ACTION_INTERRUPTED: - case DownloaderService.ACTION_CONNECTION_FAILED: - appUpdateStatusManager.setDownloadError(canonicalUrl, - intent.getStringExtra(DownloaderService.EXTRA_ERROR_MESSAGE)); - localBroadcastManager.unregisterReceiver(this); - break; - default: - throw new RuntimeException("intent action not handled!"); - } - } - }; - - localBroadcastManager.registerReceiver(downloadReceiver, - DownloaderService.getIntentFilter(canonicalUrl)); - } - /** * Register a {@link BroadcastReceiver} for tracking install progress for a * give {@link Uri}. There can be multiple of these registered at a time. */ private void registerInstallReceiver(String canonicalUrl) { - BroadcastReceiver installReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - if (!running) { - localBroadcastManager.unregisterReceiver(this); - return; - } String canonicalUrl = intent.getDataString(); App app; Apk apk; @@ -405,7 +323,7 @@ public class InstallManagerService extends Service { if (apkComplete != null && apkComplete.isApk()) { try { - PackageManagerCompat.setInstaller(context, getPackageManager(), apkComplete.packageName); + PackageManagerCompat.setInstaller(context, context.getPackageManager(), apkComplete.packageName); } catch (SecurityException e) { // Will happen if we fell back to DefaultInstaller for some reason. } @@ -437,7 +355,7 @@ public class InstallManagerService extends Service { }; localBroadcastManager.registerReceiver(installReceiver, - Installer.getInstallIntentFilter(canonicalUrl)); + Installer.getInstallIntentFilter(Uri.parse(canonicalUrl))); } /** @@ -455,57 +373,11 @@ public class InstallManagerService extends Service { public static void queue(Context context, @NonNull App app, @NonNull Apk apk) { String canonicalUrl = apk.getCanonicalUrl(); AppUpdateStatusManager.getInstance(context).addApk(app, apk, AppUpdateStatusManager.Status.PendingInstall, null); - putPendingInstall(context, canonicalUrl, apk.packageName); Utils.debugLog(TAG, "queue " + app.packageName + " " + apk.versionCode + " from " + canonicalUrl); - Intent intent = new Intent(context, InstallManagerService.class); - intent.setAction(ACTION_INSTALL); - intent.setData(Uri.parse(canonicalUrl)); - intent.putExtra(EXTRA_APP, app); - intent.putExtra(EXTRA_APK, apk); - context.startService(intent); + InstallManagerService.getInstance(context).queue(canonicalUrl, apk.packageName, app, apk); } public static void cancel(Context context, String canonicalUrl) { - removePendingInstall(context, canonicalUrl); - Intent intent = new Intent(context, InstallManagerService.class); - intent.setAction(ACTION_CANCEL); - intent.setData(Uri.parse(canonicalUrl)); - context.startService(intent); - } - - /** - * Is the APK that matches the provided {@code hash} still waiting to be - * installed? This restarts the install process for this APK if it was - * interrupted somehow, like if F-Droid was killed before the download - * completed, or the device lost power in the middle of the install - * process. - */ - public boolean isPendingInstall(String canonicalUrl) { - return pendingInstalls.contains(canonicalUrl); - } - - /** - * Mark a given APK as in the process of being installed, with - * the {@code canonicalUrl} of the download used as the unique ID, - * and the file hash used to verify that things are the same. - * - * @see #isPendingInstall(String) - */ - public static void putPendingInstall(Context context, String canonicalUrl, String packageName) { - if (pendingInstalls == null) { - pendingInstalls = getPendingInstalls(context); - } - pendingInstalls.edit().putString(canonicalUrl, packageName).apply(); - } - - public static void removePendingInstall(Context context, String canonicalUrl) { - if (pendingInstalls == null) { - pendingInstalls = getPendingInstalls(context); - } - pendingInstalls.edit().remove(canonicalUrl).apply(); - } - - private static SharedPreferences getPendingInstalls(Context context) { - return context.getSharedPreferences("pending-installs", Context.MODE_PRIVATE); + InstallManagerService.getInstance(context).onCancel(canonicalUrl); } } diff --git a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java index 995b9754c..2bc213bc2 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java @@ -210,7 +210,7 @@ public abstract class Installer { sendBroadcastUninstall(context, app, apk, action, null, null); } - private static void sendBroadcastUninstall(Context context, App app, Apk apk, String action, + static void sendBroadcastUninstall(Context context, App app, Apk apk, String action, PendingIntent pendingIntent, String errorMessage) { Uri uri = Uri.fromParts("package", apk.packageName, null); @@ -233,10 +233,22 @@ public abstract class Installer { * @see InstallManagerService for more about {@code canonicalUri} */ public static IntentFilter getInstallIntentFilter(Uri canonicalUri) { - IntentFilter intentFilter = new IntentFilter(); + IntentFilter intentFilter = getInstallInteractionIntentFilter(canonicalUri); intentFilter.addAction(Installer.ACTION_INSTALL_STARTED); intentFilter.addAction(Installer.ACTION_INSTALL_COMPLETE); intentFilter.addAction(Installer.ACTION_INSTALL_INTERRUPTED); + return intentFilter; + } + + /** + * Gets an {@link IntentFilter} for user interaction needed events from the install + * process based on {@code canonicalUri}, which is the global unique + * ID for a package going through the install process. + * + * @see InstallManagerService for more about {@code canonicalUri} + */ + public static IntentFilter getInstallInteractionIntentFilter(Uri canonicalUri) { + IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(Installer.ACTION_INSTALL_USER_INTERACTION); intentFilter.addDataScheme(canonicalUri.getScheme()); intentFilter.addDataAuthority(canonicalUri.getHost(), String.valueOf(canonicalUri.getPort())); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java index 340519865..497fadd85 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java @@ -23,12 +23,12 @@ package org.fdroid.fdroid.installer; import android.content.Context; import android.text.TextUtils; -import org.fdroid.fdroid.Utils; -import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.Apk; - import androidx.annotation.NonNull; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; + public class InstallerFactory { private static final String TAG = "InstallerFactory"; @@ -55,6 +55,10 @@ public class InstallerFactory { } else if (PrivilegedInstaller.isDefault(context)) { Utils.debugLog(TAG, "privileged extension correctly installed -> PrivilegedInstaller"); installer = new PrivilegedInstaller(context, app, apk); + } else if (SessionInstallManager.isTargetSdkSupported(apk.targetSdkVersion) + && SessionInstallManager.canBeUsed()) { + Utils.debugLog(TAG, "using experimental SessionInstaller, because app targets " + apk.targetSdkVersion); + installer = new SessionInstaller(context, app, apk); } else { installer = new DefaultInstaller(context, app, apk); } diff --git a/app/src/main/java/org/fdroid/fdroid/installer/SessionInstallManager.java b/app/src/main/java/org/fdroid/fdroid/installer/SessionInstallManager.java new file mode 100644 index 000000000..de5cb443b --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/installer/SessionInstallManager.java @@ -0,0 +1,245 @@ +package org.fdroid.fdroid.installer; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.IntentSender; +import android.content.pm.PackageInstaller; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.util.ObjectsCompat; +import androidx.documentfile.provider.DocumentFile; + +import org.apache.commons.io.IOUtils; +import org.fdroid.fdroid.BuildConfig; +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.net.DownloaderService; + +import java.io.InputStream; +import java.io.OutputStream; + +public class SessionInstallManager extends BroadcastReceiver { + + private static final String TAG = "SessionInstallManager"; + private static final String INSTALLER_ACTION_INSTALL = + "org.fdroid.fdroid.installer.SessionInstallManager.install"; + private static final String INSTALLER_ACTION_UNINSTALL = + "org.fdroid.fdroid.installer.SessionInstallManager.uninstall"; + /** + * An intent extra needed only due to a bug in Android 12 (#2599) where our App parcelable in the confirmation + * intent causes a crash. + * To prevent this, we wrap the App and Apk parcelables in this bundle. + */ + private static final String EXTRA_BUNDLE = + "org.fdroid.fdroid.installer.SessionInstallManager.bundle"; + + private final Context context; + + public SessionInstallManager(Context context) { + this.context = context; + context.registerReceiver(this, new IntentFilter(INSTALLER_ACTION_INSTALL)); + context.registerReceiver(this, new IntentFilter(INSTALLER_ACTION_UNINSTALL)); + PackageInstaller installer = context.getPackageManager().getPackageInstaller(); + // abandon old sessions, because there's a limit + // that will throw IllegalStateException when we try to open new sessions + Utils.runOffUiThread(() -> { + for (PackageInstaller.SessionInfo session : installer.getMySessions()) { + Utils.debugLog(TAG, "Abandon session " + session.getSessionId()); + installer.abandonSession(session.getSessionId()); + } + }); + } + + @WorkerThread + public void install(App app, Apk apk, Uri localApkUri, Uri canonicalUri) { + DocumentFile documentFile = ObjectsCompat.requireNonNull(DocumentFile.fromSingleUri(context, localApkUri)); + long size = documentFile.length(); + PackageInstaller.SessionParams params = + new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL); + params.setAppPackageName(app.packageName); + params.setSize(size); + if (Build.VERSION.SDK_INT >= 31) { + params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED); + } + if (Build.VERSION.SDK_INT >= 33) { + params.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE); + } + PackageInstaller installer = context.getPackageManager().getPackageInstaller(); + try { + int sessionId = installer.createSession(params); + ContentResolver contentResolver = context.getContentResolver(); + try (PackageInstaller.Session session = installer.openSession(sessionId)) { + try (InputStream inputStream = contentResolver.openInputStream(localApkUri)) { + try (OutputStream outputStream = session.openWrite(app.packageName, 0, size)) { + IOUtils.copy(inputStream, outputStream); + session.fsync(outputStream); + } + } + session.commit(getInstallIntentSender(sessionId, app, apk, canonicalUri)); + } + } catch (Exception e) { + Log.e(TAG, "I/O Error during install session: ", e); + Installer.sendBroadcastInstall(context, canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED, app, apk, + null, e.getLocalizedMessage()); + } + } + + @WorkerThread + public void uninstall(String packageName) { + PackageInstaller installer = context.getPackageManager().getPackageInstaller(); + installer.uninstall(packageName, getUninstallIntentSender(packageName)); + } + + private IntentSender getInstallIntentSender(int sessionId, App app, Apk apk, Uri canonicalUri) { + Intent broadcastIntent = new Intent(INSTALLER_ACTION_INSTALL); + broadcastIntent.setPackage(context.getPackageName()); + broadcastIntent.putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId); + Bundle bundle = new Bundle(); + bundle.putParcelable(Installer.EXTRA_APP, app); + bundle.putParcelable(Installer.EXTRA_APK, apk); + broadcastIntent.putExtra(EXTRA_BUNDLE, bundle); + broadcastIntent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, canonicalUri); + // we are stuffing this intent pretty full, hopefully won't run into the size limit + broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + // intent flag needs to be mutable, otherwise the intent has no extras + int flags = Build.VERSION.SDK_INT >= 31 ? + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE : + PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags); + return pendingIntent.getIntentSender(); + } + + private IntentSender getUninstallIntentSender(String packageName) { + Intent broadcastIntent = new Intent(INSTALLER_ACTION_UNINSTALL); + broadcastIntent.setPackage(context.getPackageName()); + broadcastIntent.putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName); + broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + // intent flag needs to be mutable, otherwise the intent has no extras + int flags = Build.VERSION.SDK_INT >= 31 ? + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE : + PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent pendingIntent = + PendingIntent.getBroadcast(context, packageName.hashCode(), broadcastIntent, flags); + return pendingIntent.getIntentSender(); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (INSTALLER_ACTION_INSTALL.equals(intent.getAction())) { + onInstallReceived(intent); + } else if (INSTALLER_ACTION_UNINSTALL.equals(intent.getAction())) { + onUninstallReceived(intent); + } else { + throw new IllegalStateException("Unsupported broadcast action: " + intent.getAction()); + } + } + + private void onInstallReceived(Intent intent) { + int sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1); + Intent confirmIntent = (Intent) intent.getParcelableExtra(Intent.EXTRA_INTENT); + + Bundle bundle = intent.getBundleExtra(EXTRA_BUNDLE); + App app = bundle.getParcelable(Installer.EXTRA_APP); + Apk apk = bundle.getParcelable(Installer.EXTRA_APK); + Uri canonicalUri = intent.getParcelableExtra(DownloaderService.EXTRA_CANONICAL_URL); + + int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Integer.MIN_VALUE); + String msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + + Log.i(TAG, "Received install broadcast for " + app.packageName + " " + status + ": " + msg); + + if (status == PackageInstaller.STATUS_SUCCESS) { + String action = Installer.ACTION_INSTALL_COMPLETE; + Installer.sendBroadcastInstall(context, canonicalUri, action, app, apk, null, null); + } else if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + int flags = Build.VERSION.SDK_INT >= 31 ? + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : + PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent pendingIntent = PendingIntent.getActivity(context, sessionId, confirmIntent, flags); + String action = Installer.ACTION_INSTALL_USER_INTERACTION; + Installer.sendBroadcastInstall(context, canonicalUri, action, app, apk, pendingIntent, null); + } else { + String action = Installer.ACTION_INSTALL_INTERRUPTED; + Installer.sendBroadcastInstall(context, canonicalUri, action, app, apk, null, msg); + } + } + + private void onUninstallReceived(Intent intent) { + String packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME); + Intent confirmIntent = (Intent) intent.getParcelableExtra(Intent.EXTRA_INTENT); + int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Integer.MIN_VALUE); + String msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); + + Log.i(TAG, "Received uninstall broadcast for " + packageName + " " + status + ": " + msg); + + if (status == PackageInstaller.STATUS_SUCCESS) { + String action = Installer.ACTION_UNINSTALL_COMPLETE; + sendBroadcastUninstall(packageName, action, null, null); + } else if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + int flags = Build.VERSION.SDK_INT >= 31 ? + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : + PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent pendingIntent = + PendingIntent.getActivity(context, packageName.hashCode(), confirmIntent, flags); + String action = Installer.ACTION_UNINSTALL_USER_INTERACTION; + sendBroadcastUninstall(packageName, action, pendingIntent, null); + } else { + String action = Installer.ACTION_UNINSTALL_INTERRUPTED; + sendBroadcastUninstall(packageName, action, null, msg); + } + } + + private void sendBroadcastUninstall(String packageName, String action, @Nullable PendingIntent pendingIntent, + @Nullable String errorMessage) { + App app = new App(); + app.packageName = packageName; + Apk apk = new Apk(); + apk.packageName = packageName; + Installer.sendBroadcastUninstall(context, app, apk, action, pendingIntent, errorMessage); + } + + /** + * Returns true if the {@link SessionInstaller} can be used on this device. + */ + public static boolean canBeUsed() { + // We could use the SessionInstaller also with the full flavor, + // but for now we limit it to basic to limit potential damage. + if (!BuildConfig.FLAVOR.equals("basic")) return false; + // We could use the SessionInstaller also on lower versions, + // but the benefit of unattended updates only starts with SDK 31. + // Before the extra bugs it has aren't worth it. + if (Build.VERSION.SDK_INT < 31) return false; + // Xiaomi MIUI (at least in version 12) is known to break the PackageInstaller API in several ways. + // Disabling MIUI "optimizations" in developer options fixes it, but we can't ask users to do this (bad UX). + // Therefore, we have no choice, but to disable it completely for those devices. + // See: https://github.com/vvb2060/PackageInstallerTest + return !isXiaomiDevice(); + } + + private static boolean isXiaomiDevice() { + return "Xiaomi".equalsIgnoreCase(Build.BRAND) || "Redmi".equalsIgnoreCase(Build.BRAND); + } + + public static boolean isTargetSdkSupported(int targetSdk) { + if (Build.VERSION.SDK_INT < 31) return false; // not supported below Android 12 + if (Build.VERSION.SDK_INT == 31 && targetSdk >= 29) return true; + if (Build.VERSION.SDK_INT == 32 && targetSdk >= 29) return true; + if (Build.VERSION.SDK_INT == 33 && targetSdk >= 30) return true; + // This needs to be adjusted as new Android versions are released + // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) + // https://cs.android.com/android/platform/superproject/+/android-13.0.0_r42:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=2095;drc=6aba151873bfae198ef9eceb10f943e18b52d58c + // TODO check targetSdk Android 14 sources have been published, just a guess so far + return Build.VERSION.SDK_INT == 34 && targetSdk >= 31; + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/installer/SessionInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/SessionInstaller.java new file mode 100644 index 000000000..532b53a57 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/installer/SessionInstaller.java @@ -0,0 +1,43 @@ +package org.fdroid.fdroid.installer; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; + +class SessionInstaller extends Installer { + + private final SessionInstallManager sessionInstallManager = FDroidApp.sessionInstallManager; + + SessionInstaller(Context context, @NonNull App app, @NonNull Apk apk) { + super(context, app, apk); + } + + @Override + protected void installPackageInternal(Uri localApkUri, Uri canonicalUri) { + sessionInstallManager.install(app, apk, localApkUri, canonicalUri); + } + + @Override + protected void uninstallPackage() { + sessionInstallManager.uninstall(app.packageName); + } + + @Override + public Intent getUninstallScreen() { + // we handle uninstall on our own, no need for special screen + return null; + } + + @Override + protected boolean isUnattended() { + // may not always be unattended, but no easy way to find out up-front + return SessionInstallManager.canBeUsed(); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java index 8e9923fd3..1913d1175 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java +++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java @@ -22,28 +22,26 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.net.Uri; -import android.os.Handler; -import android.os.HandlerThread; -import android.os.IBinder; -import android.os.Looper; -import android.os.Message; import android.os.PatternMatcher; -import android.os.Process; import android.text.TextUtils; import android.util.Log; -import android.util.LogPrinter; + +import androidx.annotation.NonNull; +import androidx.core.app.JobIntentService; import org.fdroid.database.FDroidDatabase; import org.fdroid.database.Repository; import org.fdroid.download.Downloader; import org.fdroid.download.NotFoundException; -import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.FDroidApp; -import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.data.SanitizedFile; import org.fdroid.fdroid.installer.ApkCache; +import org.fdroid.fdroid.installer.InstallManagerService; +import org.fdroid.fdroid.installer.Installer; import org.fdroid.index.v2.FileV1; import java.io.File; @@ -68,16 +66,13 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; * through {@link #queue(Context, long, String, String, FileV1)} calls. The * service is started as needed, it handles each {@code Intent} using a worker * thread, and stops itself when it runs out of work. Requests can be canceled - * using {@link #cancel(Context, String)}. If this service is killed during - * operation, it will receive the queued {@link #queue(Context, long, String, String, FileV1)} - * and {@link #cancel(Context, String)} requests again due to - * {@link Service#START_REDELIVER_INTENT}. Bad requests will be ignored, + * using {@link #cancel(Context, String)}. Bad requests will be ignored, * including on restart after killing via {@link Service#START_NOT_STICKY}. *

* This "work queue processor" pattern is commonly used to offload tasks * from an application's main thread. The DownloaderService class exists to * simplify this pattern and take care of the mechanics. DownloaderService - * will receive the Intents, launch a worker thread, and stop the service as + * will receive the Intents, use a worker thread, and stop the service as * appropriate. *

* All requests are handled on a single worker thread -- they may take as @@ -94,14 +89,14 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; * than with APKs since there is not reliable standard for a unique ID for * media files, unlike APKs with {@code packageName} and {@code versionCode}. * - * @see android.app.IntentService + * @see androidx.core.app.JobIntentService * @see org.fdroid.fdroid.installer.InstallManagerService */ -public class DownloaderService extends Service { +public class DownloaderService extends JobIntentService { private static final String TAG = "DownloaderService"; + private static final int JOB_ID = TAG.hashCode(); private static final String ACTION_QUEUE = "org.fdroid.fdroid.net.DownloaderService.action.QUEUE"; - private static final String ACTION_CANCEL = "org.fdroid.fdroid.net.DownloaderService.action.CANCEL"; public static final String ACTION_STARTED = "org.fdroid.fdroid.net.Downloader.action.STARTED"; public static final String ACTION_PROGRESS = "org.fdroid.fdroid.net.Downloader.action.PROGRESS"; @@ -125,106 +120,39 @@ public class DownloaderService extends Service { * @see android.content.Intent#EXTRA_ORIGINATING_URI */ public static final String EXTRA_CANONICAL_URL = "org.fdroid.fdroid.net.Downloader.extra.CANONICAL_URL"; - private static final String EXTRA_INDEX_FILE_V1 = "org.fdroid.fdroid.net.Downloader.extra.INDEX_FILE_V1"; - private volatile Looper serviceLooper; - private static volatile ServiceHandler serviceHandler; private static volatile Downloader downloader; private static volatile String activeCanonicalUrl; + private InstallManagerService installManagerService; private LocalBroadcastManager localBroadcastManager; - private final class ServiceHandler extends Handler { - static final String TAG = "ServiceHandler"; - - ServiceHandler(Looper looper) { - super(looper); - } - - @Override - public void handleMessage(Message msg) { - Utils.debugLog(TAG, "Handling download message with ID of " + msg.what); - handleIntent((Intent) msg.obj); - stopSelf(msg.arg1); - } - } - @Override public void onCreate() { super.onCreate(); Utils.debugLog(TAG, "Creating downloader service."); - - HandlerThread thread = new HandlerThread(TAG, Process.THREAD_PRIORITY_BACKGROUND); - thread.start(); - - serviceLooper = thread.getLooper(); - if (BuildConfig.DEBUG) { - serviceLooper.setMessageLogging(new LogPrinter(Log.DEBUG, ServiceHandler.TAG)); - } - serviceHandler = new ServiceHandler(serviceLooper); + installManagerService = InstallManagerService.getInstance(this); localBroadcastManager = LocalBroadcastManager.getInstance(this); } @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Utils.debugLog(TAG, "Received Intent for downloading: " + intent + " (with a startId of " + startId + ")"); + protected void onHandleWork(@NonNull Intent intent) { + Utils.debugLog(TAG, "Received Intent for downloading: " + intent); - if (intent == null) { - return START_NOT_STICKY; - } - - String downloadUrl = intent.getDataString(); - if (downloadUrl == null) { - Utils.debugLog(TAG, "Received Intent with no URI: " + intent); - return START_NOT_STICKY; - } - String canonicalUrl = intent.getStringExtra(DownloaderService.EXTRA_CANONICAL_URL); + String canonicalUrl = intent.getDataString(); if (canonicalUrl == null) { - Utils.debugLog(TAG, "Received Intent with no EXTRA_CANONICAL_URL: " + intent); - return START_NOT_STICKY; + Utils.debugLog(TAG, "Received Intent with no URI: " + intent); + return; } - - if (ACTION_CANCEL.equals(intent.getAction())) { - Utils.debugLog(TAG, "Cancelling download of " + canonicalUrl.hashCode() + "/" + canonicalUrl - + " downloading from " + downloadUrl); - Integer whatToRemove = canonicalUrl.hashCode(); - if (serviceHandler.hasMessages(whatToRemove)) { - Utils.debugLog(TAG, "Removing download with ID of " + whatToRemove - + " from service handler, then sending interrupted event."); - serviceHandler.removeMessages(whatToRemove); - sendCancelledBroadcast(intent.getData(), canonicalUrl); - } else if (isActive(canonicalUrl)) { - downloader.cancelDownload(); - } else { - Utils.debugLog(TAG, "ACTION_CANCEL called on something not queued or running" - + " (expected to find message with ID of " + whatToRemove + " in queue)."); - } - } else if (ACTION_QUEUE.equals(intent.getAction())) { - Message msg = serviceHandler.obtainMessage(); - msg.arg1 = startId; - msg.obj = intent; - msg.what = canonicalUrl.hashCode(); - serviceHandler.sendMessage(msg); - Utils.debugLog(TAG, "Queued download of " + canonicalUrl.hashCode() + "/" + canonicalUrl - + " using " + downloadUrl); + if (ACTION_QUEUE.equals(intent.getAction())) { + handleIntent(intent); } else { Utils.debugLog(TAG, "Received Intent with unknown action: " + intent); } - - return START_REDELIVER_INTENT; // if killed before completion, retry Intent } @Override public void onDestroy() { - Utils.debugLog(TAG, "Destroying downloader service. Will move to background and stop our Looper."); - serviceLooper.quit(); //NOPMD - this is copied from IntentService, no super call needed - } - - /** - * This service does not use binding, so no need to implement this method - */ - @Override - public IBinder onBind(Intent intent) { - return null; + Utils.debugLog(TAG, "Destroying downloader service."); } /** @@ -244,14 +172,20 @@ public class DownloaderService extends Service { * android.content.Context#startService(Intent)}. */ private void handleIntent(Intent intent) { - final Uri uri = intent.getData(); - final long repoId = intent.getLongExtra(DownloaderService.EXTRA_REPO_ID, 0); final Uri canonicalUrl = intent.getData(); - final Uri downloadUrl = - Uri.parse(intent.getStringExtra(DownloaderService.EXTRA_CANONICAL_URL)); - final FileV1 fileV1 = FileV1.deserialize(intent.getStringExtra(DownloaderService.EXTRA_INDEX_FILE_V1)); + final App app = intent.getParcelableExtra(Installer.EXTRA_APP); + final Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK); + final long repoId = intent.getLongExtra(DownloaderService.EXTRA_REPO_ID, apk.repoId); + final String extraUrl = intent.getStringExtra(DownloaderService.EXTRA_CANONICAL_URL); + final Uri downloadUrl = Uri.parse(extraUrl == null ? apk.getDownloadUrl() : extraUrl); + final FileV1 fileV1 = apk.apkFile; final SanitizedFile localFile = ApkCache.getApkDownloadPath(this, canonicalUrl); - sendBroadcast(uri, DownloaderService.ACTION_STARTED, localFile, repoId, canonicalUrl); + + Utils.debugLog(TAG, "Queued download of " + canonicalUrl.hashCode() + "/" + canonicalUrl + + " using " + downloadUrl); + + sendBroadcast(canonicalUrl, DownloaderService.ACTION_STARTED, localFile, repoId, canonicalUrl); + installManagerService.onDownloadStarted(canonicalUrl); try { activeCanonicalUrl = canonicalUrl.toString(); @@ -269,30 +203,38 @@ public class DownloaderService extends Service { } } downloader = DownloaderFactory.INSTANCE.create(repo, downloadUrl, fileV1, localFile); - downloader.setListener(new ProgressListener() { - @Override - public void onProgress(long bytesRead, long totalBytes) { - Intent intent = new Intent(DownloaderService.ACTION_PROGRESS); - intent.setData(canonicalUrl); - intent.putExtra(DownloaderService.EXTRA_BYTES_READ, bytesRead); - intent.putExtra(DownloaderService.EXTRA_TOTAL_BYTES, totalBytes); - localBroadcastManager.sendBroadcast(intent); - } + final long[] lastProgressSent = {0}; + downloader.setListener((bytesRead, totalBytes) -> { + // don't send a progress updates out to frequently, to not hit notification rate-limiting + // this can cause us to miss critical notification updates + long now = System.currentTimeMillis(); + if (now - lastProgressSent[0] < 1_000) return; + lastProgressSent[0] = now; + Intent intent1 = new Intent(DownloaderService.ACTION_PROGRESS); + intent1.setData(canonicalUrl); + intent1.putExtra(DownloaderService.EXTRA_BYTES_READ, bytesRead); + intent1.putExtra(DownloaderService.EXTRA_TOTAL_BYTES, totalBytes); + localBroadcastManager.sendBroadcast(intent1); + installManagerService.onDownloadProgress(canonicalUrl, app, apk, bytesRead, totalBytes); }); downloader.download(); - sendBroadcast(uri, DownloaderService.ACTION_COMPLETE, localFile, repoId, canonicalUrl); + sendBroadcast(canonicalUrl, DownloaderService.ACTION_COMPLETE, localFile, repoId, canonicalUrl); + installManagerService.onDownloadComplete(canonicalUrl, localFile, app, apk); } catch (InterruptedException e) { - sendBroadcast(uri, DownloaderService.ACTION_INTERRUPTED, localFile, repoId, canonicalUrl); + sendBroadcast(canonicalUrl, DownloaderService.ACTION_INTERRUPTED, localFile, repoId, canonicalUrl); + installManagerService.onDownloadFailed(canonicalUrl, null); } catch (ConnectException | HttpRetryException | NoRouteToHostException | SocketTimeoutException - | SSLHandshakeException | SSLKeyException | SSLPeerUnverifiedException | SSLProtocolException - | ProtocolException | UnknownHostException | NotFoundException e) { + | SSLHandshakeException | SSLKeyException | SSLPeerUnverifiedException | SSLProtocolException + | ProtocolException | UnknownHostException | NotFoundException e) { // if the above list of exceptions changes, also change it in IndexV1Updater.update() Log.e(TAG, "CONNECTION_FAILED: " + e.getLocalizedMessage()); - sendBroadcast(uri, DownloaderService.ACTION_CONNECTION_FAILED, localFile, repoId, canonicalUrl); + sendBroadcast(canonicalUrl, DownloaderService.ACTION_CONNECTION_FAILED, localFile, repoId, canonicalUrl); + installManagerService.onDownloadFailed(canonicalUrl, e.getLocalizedMessage()); } catch (IOException e) { - e.printStackTrace(); - sendBroadcast(uri, DownloaderService.ACTION_INTERRUPTED, localFile, + Log.e(TAG, "Error downloading: ", e); + sendBroadcast(canonicalUrl, DownloaderService.ACTION_INTERRUPTED, localFile, e.getLocalizedMessage(), repoId, canonicalUrl); + installManagerService.onDownloadFailed(canonicalUrl, e.getLocalizedMessage()); } finally { if (downloader != null) { downloader.close(); @@ -302,10 +244,6 @@ public class DownloaderService extends Service { activeCanonicalUrl = null; } - private void sendCancelledBroadcast(Uri uri, String canonicalUrl) { - sendBroadcast(uri, DownloaderService.ACTION_INTERRUPTED, null, 0, Uri.parse(canonicalUrl)); - } - private void sendBroadcast(Uri uri, String action, File file, long repoId, Uri canonicalUrl) { sendBroadcast(uri, action, file, null, repoId, canonicalUrl); } @@ -335,10 +273,10 @@ public class DownloaderService extends Service { * @param context this app's {@link Context} * @param repoId the database ID number representing one repo * @param canonicalUrl the URL used as the unique ID throughout F-Droid - * @see #cancel(Context, String) + * @see #cancel(String) */ public static void queue(Context context, long repoId, String canonicalUrl, - String downloadUrl, FileV1 fileV1) { + String downloadUrl) { if (TextUtils.isEmpty(canonicalUrl)) { return; } @@ -348,8 +286,20 @@ public class DownloaderService extends Service { intent.setData(Uri.parse(canonicalUrl)); intent.putExtra(DownloaderService.EXTRA_REPO_ID, repoId); intent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, downloadUrl); - intent.putExtra(DownloaderService.EXTRA_INDEX_FILE_V1, fileV1.serialize()); - context.startService(intent); + JobIntentService.enqueueWork(context, DownloaderService.class, JOB_ID, intent); + } + + public static void queue(Context context, String canonicalUrl, @NonNull App app, @NonNull Apk apk) { + if (TextUtils.isEmpty(canonicalUrl) || apk.apkFile == null) { + return; + } + Utils.debugLog(TAG, "Queue download " + canonicalUrl.hashCode() + "/" + canonicalUrl); + Intent intent = new Intent(context, DownloaderService.class); + intent.setAction(ACTION_QUEUE); + intent.setData(Uri.parse(canonicalUrl)); + intent.putExtra(Installer.EXTRA_APP, app); + intent.putExtra(Installer.EXTRA_APK, apk); + JobIntentService.enqueueWork(context, DownloaderService.class, JOB_ID, intent); } /** @@ -357,35 +307,22 @@ public class DownloaderService extends Service { *

* All notifications are sent as an {@link Intent} via local broadcasts to be received by * - * @param context this app's {@link Context} * @param canonicalUrl The URL to remove from the download queue - * @see #queue(Context, long, String, String, FileV1 + * @see #queue(Context, String, App, Apk) */ - public static void cancel(Context context, String canonicalUrl) { + public static void cancel(String canonicalUrl) { if (TextUtils.isEmpty(canonicalUrl)) { return; } - Utils.debugLog(TAG, "Send cancel for " + canonicalUrl.hashCode() + "/" + canonicalUrl); - Intent intent = new Intent(context, DownloaderService.class); - intent.setAction(ACTION_CANCEL); - intent.setData(Uri.parse(canonicalUrl)); - intent.putExtra(DownloaderService.EXTRA_CANONICAL_URL, canonicalUrl); - context.startService(intent); - } - - /** - * Check if a URL is waiting in the queue for downloading or if actively being downloaded. - * This is useful for checking whether to re-register {@link android.content.BroadcastReceiver}s - * in {@link android.app.AppCompatActivity#onResume()}. - */ - public static boolean isQueuedOrActive(String canonicalUrl) { - if (TextUtils.isEmpty(canonicalUrl)) { //NOPMD - suggests unreadable format - return false; + Utils.debugLog(TAG, "Cancelling download of " + canonicalUrl.hashCode() + "/" + canonicalUrl + + " downloading from " + canonicalUrl); + int whatToRemove = canonicalUrl.hashCode(); + if (isActive(canonicalUrl)) { + downloader.cancelDownload(); + } else { + Utils.debugLog(TAG, "ACTION_CANCEL called on something not queued or running" + + " (expected to find message with ID of " + whatToRemove + " in queue)."); } - if (serviceHandler == null) { - return false; // this service is not even running - } - return serviceHandler.hasMessages(canonicalUrl.hashCode()) || isActive(canonicalUrl); } /** diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index 40e700365..21f6147e4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -189,11 +189,13 @@ public class AppDetailsActivity extends AppCompatActivity } @Override - protected void onResume() { - super.onResume(); + protected void onStart() { + super.onStart(); visiblePackageName = packageName; updateNotificationsForApp(); + // don't call this in onResume() because while install confirmation dialog gets shown we pause/resume, + // so it would get called twice for the same state refreshStatus(); registerAppStatusReceiver(); @@ -211,20 +213,18 @@ public class AppDetailsActivity extends AppCompatActivity if (statuses.hasNext()) { AppUpdateStatusManager.AppUpdateStatus status = statuses.next(); updateAppStatus(status, false); + } else { + // no status found, so we should update to reflect that as well + updateAppStatus(null, false); } currentStatus = null; } - @Override - protected void onPause() { - super.onPause(); - unregisterAppStatusReceiver(); - } - protected void onStop() { super.onStop(); visiblePackageName = null; + unregisterAppStatusReceiver(); // When leaving the app details, make sure to refresh app status for this app, since // we might want to show notifications for it now. @@ -473,6 +473,10 @@ public class AppDetailsActivity extends AppCompatActivity } private void updateAppStatus(@Nullable AppUpdateStatusManager.AppUpdateStatus newStatus, boolean justReceived) { + if (!justReceived && newStatus == null && currentStatus != null) { + // clear progress if the state got removed in the meantime (e.g. download canceled) + adapter.clearProgress(); + } this.currentStatus = newStatus; if (this.currentStatus == null) { return; @@ -495,6 +499,17 @@ public class AppDetailsActivity extends AppCompatActivity adapter.setIndeterminateProgress(R.string.installing); localBroadcastManager.registerReceiver(installReceiver, Installer.getInstallIntentFilter(newStatus.getCanonicalUrl())); + } else { + try { + if (newStatus.intent != null) { + localBroadcastManager.registerReceiver(installReceiver, + Installer.getInstallIntentFilter(newStatus.getCanonicalUrl())); + newStatus.intent.send(); + } + } catch (PendingIntent.CanceledException e) { + Log.e(TAG, "PI canceled", e); + } + adapter.clearProgress(); } break; @@ -510,9 +525,8 @@ public class AppDetailsActivity extends AppCompatActivity Toast.makeText(this, R.string.download_error, Toast.LENGTH_SHORT).show(); Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); } - - adapter.clearProgress(); } + adapter.clearProgress(); break; case Installing: diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index ada3ef9e0..6c58523b8 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -14,6 +14,7 @@ import android.text.Spannable; import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateFormat; +import android.text.format.Formatter; import android.text.method.LinkMovementMethod; import android.text.style.URLSpan; import android.text.util.Linkify; @@ -60,6 +61,7 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.installer.Installer; +import org.fdroid.fdroid.installer.SessionInstallManager; import org.fdroid.fdroid.privileged.views.AppDiff; import org.fdroid.fdroid.privileged.views.AppSecurityPermissions; import org.fdroid.fdroid.views.appdetails.AntiFeaturesListingView; @@ -376,6 +378,7 @@ public class AppDetailsRecyclerViewAdapter final TextView titleView; final TextView authorView; final TextView lastUpdateView; + final TextView warningView; final TextView summaryView; final TextView whatsNewView; final TextView descriptionView; @@ -400,6 +403,7 @@ public class AppDetailsRecyclerViewAdapter titleView = (TextView) view.findViewById(R.id.title); authorView = (TextView) view.findViewById(R.id.author); lastUpdateView = (TextView) view.findViewById(R.id.text_last_update); + warningView = (TextView) view.findViewById(R.id.warning); summaryView = (TextView) view.findViewById(R.id.summary); whatsNewView = (TextView) view.findViewById(R.id.latest); descriptionView = (TextView) view.findViewById(R.id.description); @@ -490,12 +494,27 @@ public class AppDetailsRecyclerViewAdapter } if (app.lastUpdated != null) { Resources res = lastUpdateView.getContext().getResources(); - lastUpdateView.setText(Utils.formatLastUpdated(res, app.lastUpdated)); + String lastUpdated = Utils.formatLastUpdated(res, app.lastUpdated); + String text; + if (Preferences.get().expertMode() && suggestedApk != null && suggestedApk.apkFile != null + && suggestedApk.apkFile.getSize() != null) { + String size = Formatter.formatFileSize(context, suggestedApk.apkFile.getSize()); + text = lastUpdated + " (" + size + ")"; + } else { + text = lastUpdated; + } + lastUpdateView.setText(text); lastUpdateView.setVisibility(View.VISIBLE); } else { lastUpdateView.setVisibility(View.GONE); } + if (SessionInstallManager.canBeUsed() && suggestedApk != null + && !SessionInstallManager.isTargetSdkSupported(suggestedApk.targetSdkVersion)) { + warningView.setVisibility(View.VISIBLE); + } else { + warningView.setVisibility(View.GONE); + } if (!TextUtils.isEmpty(app.summary)) { summaryView.setText(app.summary); summaryView.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java index 8715c42a6..132bbeb00 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java @@ -43,7 +43,6 @@ import com.bumptech.glide.RequestManager; import com.bumptech.glide.request.RequestOptions; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Languages; import org.fdroid.fdroid.Preferences; @@ -437,7 +436,11 @@ public class PreferencesFragment extends PreferenceFragmentCompat String versionName = Utils.getVersionName(context); if (versionName != null) { - ((TextView) view.findViewById(R.id.version)).setText(versionName); + TextView versionNameView = view.findViewById(R.id.version); + versionNameView.setText(versionName); + versionNameView.setOnLongClickListener(v -> { + throw new RuntimeException("BOOM!"); + }); } new MaterialAlertDialogBuilder(context) .setView(view) @@ -565,7 +568,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat currentKeepCacheTime = Preferences.get().getKeepCacheTime(); - if (!"basic".equals(BuildConfig.FLAVOR)) initAutoFetchUpdatesPreference(); // TODO remove once basic can do it + initAutoFetchUpdatesPreference(); initPrivilegedInstallerPreference(); initUseTorPreference(getActivity().getApplicationContext()); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java index d4757cd89..3135f287c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -9,6 +9,7 @@ import android.graphics.Outline; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.ViewOutlineProvider; import android.widget.Button; @@ -513,32 +514,29 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { } return; } - + final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(activity); + final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (Installer.ACTION_INSTALL_USER_INTERACTION.equals(intent.getAction())) { + broadcastManager.unregisterReceiver(this); + PendingIntent pendingIntent = + intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); + try { + pendingIntent.send(); + } catch (PendingIntent.CanceledException e) { + Log.e(TAG, "Error starting pending intent: ", e); + } + } + } + }; if (currentStatus != null && currentStatus.status == AppUpdateStatusManager.Status.ReadyToInstall) { String canonicalUrl = currentStatus.apk.getCanonicalUrl(); File apkFilePath = ApkCache.getApkDownloadPath(activity, canonicalUrl); Utils.debugLog(TAG, "skip download, we have already downloaded " + currentStatus.apk.getCanonicalUrl() + " to " + apkFilePath); - - final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(activity); - final BroadcastReceiver receiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - broadcastManager.unregisterReceiver(this); - - if (Installer.ACTION_INSTALL_USER_INTERACTION.equals(intent.getAction())) { - PendingIntent pendingIntent = - intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI); - try { - pendingIntent.send(); - } catch (PendingIntent.CanceledException ignored) { - } - } - } - }; - Uri canonicalUri = Uri.parse(canonicalUrl); - broadcastManager.registerReceiver(receiver, Installer.getInstallIntentFilter(canonicalUri)); + broadcastManager.registerReceiver(receiver, Installer.getInstallInteractionIntentFilter(canonicalUri)); Installer installer = InstallerFactory.create(activity, currentStatus.app, currentStatus.apk); installer.installPackage(Uri.parse(apkFilePath.toURI().toString()), canonicalUri); } else { @@ -553,7 +551,13 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { Repository repo = FDroidApp.getRepo(version.getRepoId()); return new Apk(version, repo); }, receivedApk -> { - if (receivedApk != null) InstallManagerService.queue(activity, app, receivedApk); + if (receivedApk != null) { + String canonicalUrl = receivedApk.getCanonicalUrl(); + Uri canonicalUri = Uri.parse(canonicalUrl); + broadcastManager.registerReceiver(receiver, + Installer.getInstallInteractionIntentFilter(canonicalUri)); + InstallManagerService.queue(activity, app, receivedApk); + } }); } } diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 1c135e10d..1d24c4337 100644 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -130,7 +130,6 @@ android:layout_below="@id/icon_and_name" android:clipToPadding="false" android:gravity="end" - android:paddingBottom="4dp" android:visibility="visible"> + + #ff8ab000 #cc222222 + #827717 #fff4511e diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6160c1b4d..3750e3bb2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -702,6 +702,7 @@ This often occurs with apps installed via Google Play or other sources, if they Updated today Your camera doesn\'t seem to have an autofocus. It might be difficult to scan the code. + This app was built for an older version of Android and cannot be updated automatically. Undo Installation cancelled