From 180e5c779eeafc08fe305d023675759b1908faa6 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 11 Aug 2026 19:00:31 +0200 Subject: [PATCH] Show uninstall conflicts before APK install --- .../java/org/mozilla/tryfox/MainActivity.kt | 20 ++- .../tryfox/install/ApkInstallCoordinator.kt | 131 +++++++++++++++--- .../mozilla/tryfox/install/InstallState.kt | 15 +- .../java/org/mozilla/tryfox/util/Consts.kt | 11 ++ app/src/main/res/values/strings.xml | 5 +- 5 files changed, 155 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 91ebbc4..541ed9f 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.tween import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -157,7 +158,24 @@ class MainActivity : ComponentActivity() { AlertDialog( onDismissRequest = { installCoordinator.cancelConflict(artifactKey) }, title = { Text(stringResource(id = R.string.install_conflict_title)) }, - text = { Text(stringResource(R.string.install_conflict_message, conflict.packageName)) }, + text = { + Column { + Text( + stringResource( + if (conflict.reason == org.mozilla.tryfox.install.ConflictReason.SHARED_USER_SIGNATURE) { + R.string.install_shared_user_conflict_message + } else { + R.string.install_conflict_message + }, + ), + ) + Text( + conflict.apps.joinToString(separator = "\n\n") { app -> + "• ${app.label}: ${app.packageName}" + }, + ) + } + }, confirmButton = { Button(onClick = { installCoordinator.confirmUninstallAndRetry(artifactKey) }) { Text(stringResource(id = R.string.install_conflict_confirm)) diff --git a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt index 1db60b3..9e1ba36 100644 --- a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -24,7 +24,9 @@ import logcat.logcat import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import org.mozilla.tryfox.util.MOZILLA_PACKAGE_NAMES import java.io.File +import java.util.ArrayDeque import java.util.concurrent.atomic.AtomicInteger /** Owns every APK installation session started by TryFox. */ @@ -40,6 +42,8 @@ class ApkInstallCoordinator( val versionName: String?, val versionCode: Long, val provenance: TryBuildProvenance?, + val sharedUserId: String?, + val uninstallQueue: ArrayDeque = ArrayDeque(), ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -70,20 +74,31 @@ class ApkInstallCoordinator( fun confirmUninstallAndRetry(artifactKey: String) { val operation = operations[artifactKey] ?: return + val packageName = operation.uninstallQueue.firstOrNull() ?: return _states.value = _states.value + (artifactKey to InstallState.Uninstalling) - _uninstallRequests.tryEmit(UninstallRequest(artifactKey, operation.packageName)) + _uninstallRequests.tryEmit(UninstallRequest(artifactKey, packageName)) } fun onUninstallResult(artifactKey: String, succeeded: Boolean) { val operation = operations[artifactKey] ?: return - if (!succeeded || isInstalled(operation.packageName)) { + val packageName = operation.uninstallQueue.firstOrNull() + if (packageName == null) { + fail(artifactKey, "No app was selected for uninstallation.") + return + } + if (!succeeded || isInstalled(packageName)) { logcat(LogPriority.WARN, TAG) { - "Uninstall failed artifactKey=$artifactKey package=${operation.packageName} " + - "activitySucceeded=$succeeded packageStillInstalled=${isInstalled(operation.packageName)}" + "Uninstall failed artifactKey=$artifactKey package=$packageName " + + "activitySucceeded=$succeeded packageStillInstalled=${isInstalled(packageName)}" } fail(artifactKey, "Uninstall was canceled or did not complete.") return } + operation.uninstallQueue.removeFirst() + if (operation.uninstallQueue.isNotEmpty()) { + _uninstallRequests.tryEmit(UninstallRequest(artifactKey, operation.uninstallQueue.first())) + return + } _states.value = _states.value + (artifactKey to InstallState.Installing) scope.launch { commit(operation) } } @@ -122,24 +137,15 @@ class ApkInstallCoordinator( } PackageInstaller.STATUS_FAILURE_CONFLICT -> { - if (statusMessage.contains(SHARED_USER_SIGNATURE_FAILURE)) { - logcat(LogPriority.WARN, TAG) { - "Shared-user signature conflict artifactKey=$artifactKey package=${operation.packageName} " + - "message=$statusMessage" - } - fail(artifactKey, SHARED_USER_SIGNATURE_USER_MESSAGE) - return - } - val conflictingPackage = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) ?: operation.packageName - logcat(LogPriority.WARN, TAG) { - "Install conflict artifactKey=$artifactKey package=${operation.packageName} " + - "conflictingPackage=$conflictingPackage message=$statusMessage" - } - conflict(artifactKey, conflictingPackage) + handleInstallConflict(artifactKey, operation, intent, statusMessage) } else -> { if (statusMessage.contains("VERSION_DOWNGRADE", ignoreCase = true) && isInstalled(operation.packageName)) { - conflict(artifactKey, operation.packageName) + conflict( + artifactKey, + listOf(conflictApp(operation.packageName)), + ConflictReason.INCOMPATIBLE_OR_NEWER, + ) } else { logcat(LogPriority.WARN, TAG) { "Install failed status=$status message=$statusMessage" } fail(artifactKey, userMessage(status)) @@ -148,6 +154,38 @@ class ApkInstallCoordinator( } } + private fun handleInstallConflict( + artifactKey: String, + operation: Operation, + intent: Intent, + statusMessage: String, + ) { + if (statusMessage.contains(SHARED_USER_SIGNATURE_FAILURE)) { + logcat(LogPriority.WARN, TAG) { + "Shared-user signature conflict artifactKey=$artifactKey package=${operation.packageName} " + + "message=$statusMessage" + } + val conflictingApps = findSharedUserConflicts(operation) + if (conflictingApps.isEmpty()) { + fail(artifactKey, SHARED_USER_SIGNATURE_USER_MESSAGE) + } else { + conflict(artifactKey, conflictingApps, ConflictReason.SHARED_USER_SIGNATURE) + } + return + } + val conflictingPackage = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) + ?: operation.packageName + logcat(LogPriority.WARN, TAG) { + "Install conflict artifactKey=$artifactKey package=${operation.packageName} " + + "conflictingPackage=$conflictingPackage message=$statusMessage" + } + conflict( + artifactKey, + listOf(conflictApp(conflictingPackage)), + ConflictReason.INCOMPATIBLE_OR_NEWER, + ) + } + private fun prepareAndInstall(artifactKey: String, file: File, provenance: TryBuildProvenance?) { if (!file.isFile) { fail(artifactKey, "The downloaded APK is no longer available.") @@ -160,11 +198,23 @@ class ApkInstallCoordinator( return } val incomingVersion = archive.let(PackageInfoCompat::getLongVersionCode) - val operation = Operation(artifactKey, file, packageName, archive.versionName, incomingVersion, provenance) + val operation = Operation( + artifactKey = artifactKey, + file = file, + packageName = packageName, + versionName = archive.versionName, + versionCode = incomingVersion, + provenance = provenance, + sharedUserId = archive.sharedUserId, + ) operations[artifactKey] = operation val installedVersion = installedVersion(packageName) if (installedVersion != null && installedVersion > incomingVersion) { - conflict(artifactKey, packageName) + conflict( + artifactKey, + listOf(conflictApp(packageName)), + ConflictReason.INCOMPATIBLE_OR_NEWER, + ) return } commit(operation) @@ -200,8 +250,11 @@ class ApkInstallCoordinator( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ).intentSender - private fun conflict(artifactKey: String, packageName: String) { - _states.value = _states.value + (artifactKey to InstallState.Conflict(packageName)) + private fun conflict(artifactKey: String, apps: List, reason: ConflictReason) { + val operation = operations[artifactKey] ?: return + operation.uninstallQueue.clear() + operation.uninstallQueue.addAll(apps.map(ConflictApp::packageName)) + _states.value = _states.value + (artifactKey to InstallState.Conflict(apps, reason)) } private suspend fun succeed(artifactKey: String, operation: Operation) { @@ -251,9 +304,35 @@ class ApkInstallCoordinator( versionName = getStringExtra(EXTRA_VERSION_NAME), versionCode = versionCode, provenance = provenance, + sharedUserId = null, ) } + @Suppress("DEPRECATION") + private fun findSharedUserConflicts(operation: Operation): List { + val sharedUserId = operation.sharedUserId ?: return emptyList() + return MOZILLA_PACKAGE_NAMES + .asSequence() + .filter { it != operation.packageName } + .mapNotNull { packageName -> installedPackageInfo(packageName)?.takeIf { it.sharedUserId == sharedUserId } } + .map { packageInfo -> + val packageName = packageInfo.packageName + val label = packageInfo.applicationInfo?.let(context.packageManager::getApplicationLabel)?.toString() + .orEmpty() + .ifBlank { packageName } + ConflictApp(label, packageName) + } + .toList() + } + + private fun conflictApp(packageName: String): ConflictApp { + val packageInfo = installedPackageInfo(packageName) + val label = packageInfo?.applicationInfo?.let(context.packageManager::getApplicationLabel)?.toString() + .orEmpty() + .ifBlank { packageName } + return ConflictApp(label, packageName) + } + private fun fail(artifactKey: String, message: String) { val packageName = operations[artifactKey]?.packageName logcat(LogPriority.ERROR, TAG) { @@ -270,6 +349,12 @@ class ApkInstallCoordinator( null } + private fun installedPackageInfo(packageName: String) = try { + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + null + } + private fun isInstalled(packageName: String) = installedVersion(packageName) != null private fun userMessage(status: Int) = when (status) { diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt index 4196ee4..db2a6c0 100644 --- a/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt @@ -3,12 +3,25 @@ package org.mozilla.tryfox.install sealed interface InstallState { data object Idle : InstallState data object Installing : InstallState - data class Conflict(val packageName: String) : InstallState + data class Conflict( + val apps: List, + val reason: ConflictReason, + ) : InstallState data object Uninstalling : InstallState data class Installed(val packageName: String) : InstallState data class Failed(val message: String) : InstallState } +data class ConflictApp( + val label: String, + val packageName: String, +) + +enum class ConflictReason { + INCOMPATIBLE_OR_NEWER, + SHARED_USER_SIGNATURE, +} + data class UninstallRequest( val operationId: String, val packageName: String, diff --git a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt index e795522..c5afdd3 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -23,3 +23,14 @@ const val FOCUS_BETA_PACKAGE = "org.mozilla.focus.beta" const val FOCUS_RELEASE_PACKAGE = "org.mozilla.focus" const val REFERENCE_BROWSER_PACKAGE = "org.mozilla.reference.browser" const val TRYFOX_PACKAGE = "org.mozilla.tryfox" + +val MOZILLA_PACKAGE_NAMES = listOf( + FENIX_NIGHTLY_PACKAGE, + FENIX_RELEASE_PACKAGE, + FENIX_BETA_PACKAGE, + FENIX_DEBUG_PACKAGE, + FOCUS_NIGHTLY_PACKAGE, + FOCUS_BETA_PACKAGE, + FOCUS_RELEASE_PACKAGE, + REFERENCE_BROWSER_PACKAGE, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index caff014..73a7ccc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,8 +57,9 @@ Install Installing… Open - Replace installed app? - An incompatible or newer version of %1$s is installed. Uninstalling it deletes that app’s local data before this build is installed. + Replace installed apps? + To install this build, TryFox must uninstall the app below. Uninstalling it deletes that app’s local data. + This build is signed differently from the apps below. To install it, TryFox must uninstall all of them. Uninstalling these apps deletes their local data. Uninstall and install Cancel Downloading