Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion app/src/main/java/org/mozilla/tryfox/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
131 changes: 108 additions & 23 deletions app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -40,6 +42,8 @@ class ApkInstallCoordinator(
val versionName: String?,
val versionCode: Long,
val provenance: TryBuildProvenance?,
val sharedUserId: String?,
val uninstallQueue: ArrayDeque<String> = ArrayDeque(),
)

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
Expand Down Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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))
Expand All @@ -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.")
Expand All @@ -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)
Expand Down Expand Up @@ -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<ConflictApp>, 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) {
Expand Down Expand Up @@ -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<ConflictApp> {
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) {
Expand All @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/org/mozilla/tryfox/install/InstallState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConflictApp>,
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,
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/org/mozilla/tryfox/util/Consts.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
5 changes: 3 additions & 2 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@
<string name="download_button_install">Install</string>
<string name="download_button_installing">Installing…</string>
<string name="download_button_open">Open</string>
<string name="install_conflict_title">Replace installed app?</string>
<string name="install_conflict_message">An incompatible or newer version of %1$s is installed. Uninstalling it deletes that app’s local data before this build is installed.</string>
<string name="install_conflict_title">Replace installed apps?</string>
<string name="install_conflict_message">To install this build, TryFox must uninstall the app below. Uninstalling it deletes that app’s local data.</string>
<string name="install_shared_user_conflict_message">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.</string>
<string name="install_conflict_confirm">Uninstall and install</string>
<string name="install_conflict_cancel">Cancel</string>
<string name="download_button_downloading">Downloading</string>
Expand Down
Loading