Use more generic PackageVersion in AppInstallManager

This commit is contained in:
Torsten Grote
2026-03-31 09:34:46 -03:00
parent 8535a89bfc
commit 54e69a49fa
11 changed files with 190 additions and 165 deletions

View File

@@ -31,13 +31,13 @@ import mu.KotlinLogging
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.NotificationManager
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.download.getUri
import org.fdroid.history.HistoryManager
import org.fdroid.history.InstallEvent
import org.fdroid.history.UninstallEvent
import org.fdroid.index.v2.PackageVersion
import org.fdroid.utils.IoDispatcher
@Singleton
@@ -81,18 +81,17 @@ constructor(
else -> null
}
// track app state for in progress apps
val appState =
appStateCategory?.let {
// all states that get a category above must be InstallStateWithInfo
state as InstallStateWithInfo
AppState(
packageName = packageName,
category = it,
name = state.name,
installVersionName = state.versionName,
currentVersionName = state.currentVersionName,
)
}
val appState = appStateCategory?.let {
// all states that get a category above must be InstallStateWithInfo
state as InstallStateWithInfo
AppState(
packageName = packageName,
category = it,
name = state.name,
installVersionName = state.versionName,
currentVersionName = state.currentVersionName,
)
}
if (appState != null) appStates.add(appState)
}
return InstallNotificationState(
@@ -116,19 +115,20 @@ constructor(
*/
@UiThread
suspend fun install(
packageName: String,
appMetadata: AppMetadata?,
version: AppVersion,
version: PackageVersion,
currentVersionName: String?,
repo: Repository?,
iconModel: Any?,
canAskPreApprovalNow: Boolean,
): InstallState {
if (appMetadata == null || repo == null) {
log.error { "Can't install app without metadata for ${version.packageName}" }
log.error { "Can't install app without metadata for $packageName" }
val error =
InstallState.Error(
msg = "App ${version.packageName} no longer in DB.",
name = version.packageName,
msg = "App $packageName no longer in DB.",
name = packageName,
versionName = version.versionName,
currentVersionName = currentVersionName,
lastUpdated = version.added,
@@ -136,27 +136,25 @@ constructor(
)
// Write the terminal state so any prior Waiting state is cleared and the
// service stop logic in onStatesUpdated() has a chance to run.
updateAppState(version.packageName, error)
updateAppState(packageName, error)
return error
}
val packageName = appMetadata.packageName
val currentState = apps.value[packageName]
if (currentState?.showProgress == true && currentState !is InstallState.Waiting) {
log.warn { "Attempted to install $packageName with install in progress: $currentState" }
return currentState
}
currentCoroutineContext().ensureActive()
val job =
scope.async {
startInstall(
appMetadata = appMetadata,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconModel = iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
)
}
val job = scope.async {
startInstall(
appMetadata = appMetadata,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconModel = iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
)
}
// keep track of this job, in case we want to cancel it
return trackJob(packageName, job)
}
@@ -212,7 +210,7 @@ constructor(
@WorkerThread
private suspend fun startInstall(
appMetadata: AppMetadata,
version: AppVersion,
version: PackageVersion,
currentVersionName: String?,
repo: Repository,
iconModel: Any?,
@@ -256,7 +254,14 @@ constructor(
)
}
as InstallState.PreApproved
downloadAndInstall(newState, version, currentVersionName, repo, iconModel)
downloadAndInstall(
state = newState,
packageName = appMetadata.packageName,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconModel = iconModel,
)
}
is PreApprovalResult.UserConfirmationRequired -> {
InstallState.PreApprovalConfirmationNeeded(
@@ -295,16 +300,16 @@ constructor(
updateAppState(packageName, result)
return if (result is InstallState.PreApproved) {
// move us off the UiThread, so we can download/install this app now
val job =
scope.async {
downloadAndInstall(
state = result,
version = installState.version,
currentVersionName = installState.currentVersionName,
repo = installState.repo,
iconModel = installState.iconModel,
)
}
val job = scope.async {
downloadAndInstall(
state = result,
packageName = packageName,
version = installState.version,
currentVersionName = installState.currentVersionName,
repo = installState.repo,
iconModel = installState.iconModel,
)
}
// suspend/wait for this job and track it in case we want to cancel it
trackJob(packageName, job)
} else result
@@ -313,7 +318,8 @@ constructor(
@WorkerThread
private suspend fun downloadAndInstall(
state: InstallState.PreApproved,
version: AppVersion,
packageName: String,
version: PackageVersion,
currentVersionName: String?,
repo: Repository,
iconModel: Any?,
@@ -328,7 +334,7 @@ constructor(
val now = System.currentTimeMillis()
downloader.setListener { bytesRead, totalBytes ->
coroutineContext.ensureActive()
updateAndGetAppState(version.packageName) {
updateAndGetAppState(packageName) {
InstallState.Downloading(
name = it.name,
versionName = it.versionName,
@@ -359,7 +365,7 @@ constructor(
}
currentCoroutineContext().ensureActive()
val newState =
updateAndGetAppState(version.packageName) {
updateAndGetAppState(packageName) {
InstallState.Installing(
name = it.name,
versionName = it.versionName,
@@ -368,12 +374,12 @@ constructor(
iconModel = it.iconModel,
)
}
val result = sessionInstallManager.install(sessionId, version.packageName, newState, file)
val result = sessionInstallManager.install(sessionId, packageName, newState, file)
log.debug { "Install result: $result" }
return if (result is InstallState.PreApproved && result.result is PreApprovalResult.Error) {
// if pre-approval failed (e.g. due to app label mismatch),
// then try to install again, this time not using the pre-approved session
sessionInstallManager.install(null, version.packageName, newState, file)
sessionInstallManager.install(null, packageName, newState, file)
} else {
result
}

View File

@@ -1,8 +1,8 @@
package org.fdroid.install
import android.app.PendingIntent
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.index.v2.PackageVersion
sealed class InstallState(val showProgress: Boolean) {
data object Unknown : InstallState(false)
@@ -30,7 +30,7 @@ sealed class InstallState(val showProgress: Boolean) {
data class PreApprovalConfirmationNeeded(
private val state: InstallStateWithInfo,
val version: AppVersion,
val version: PackageVersion,
val repo: Repository,
override val sessionId: Int,
override val creationTimeMillis: Long = System.currentTimeMillis(),

View File

@@ -34,7 +34,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import mu.KotlinLogging
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.index.v2.PackageVersion
import org.fdroid.ui.utils.isAppInForeground
import org.fdroid.utils.IoDispatcher
@@ -93,13 +93,13 @@ constructor(
app: AppMetadata,
iconGetter: suspend () -> Bitmap?,
isUpdate: Boolean,
version: AppVersion,
version: PackageVersion,
canRequestUserConfirmationNow: Boolean,
): PreApprovalResult {
return if (!context.isAppInForeground()) {
log.info { "App not in foreground, pre-approval for ${app.packageName} not supported." }
PreApprovalResult.NotSupported
} else if (isUpdate && canDoAutoUpdate(version)) {
} else if (isUpdate && canDoAutoUpdate(app.packageName, version)) {
// should not be needed, so we say not supported
log.info { "Can do auto-update pre-approval for ${app.packageName} not needed." }
PreApprovalResult.NotSupported
@@ -368,17 +368,17 @@ constructor(
return params
}
private fun canDoAutoUpdate(version: AppVersion): Boolean {
private fun canDoAutoUpdate(packageName: String, version: PackageVersion): Boolean {
if (SDK_INT < 31) return false
val targetSdkVersion = version.manifest.targetSdkVersion ?: return false
val targetSdkVersion = version.packageManifest.targetSdkVersion ?: return false
// docs:
// https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int)
return if (isAutoUpdateSupported(targetSdkVersion)) {
val ourPackageName = context.packageName
if (ourPackageName == version.packageName) return true
if (ourPackageName == packageName) return true
val sourceInfo =
try {
context.packageManager.getInstallSourceInfo(version.packageName)
context.packageManager.getInstallSourceInfo(packageName)
} catch (e: Exception) {
log.error(e) { "Could not get package info: " }
return false

View File

@@ -83,12 +83,13 @@ constructor(
// TODO we could do better error handling, e.g. when metadata or repo are null
// or install state is an error, also maybe show a Toast to user on error
appInstallManager.install(
appMetadata = db.getAppDao().getApp(version.repoId, packageName)?.metadata,
version = version,
currentVersionName = null,
repo = repoManager.getRepository(version.repoId),
iconModel = null,
canAskPreApprovalNow = true,
packageName = packageName,
appMetadata = db.getAppDao().getApp(version.repoId, packageName)?.metadata,
version = version,
currentVersionName = null,
repo = repoManager.getRepository(version.repoId),
iconModel = null,
canAskPreApprovalNow = true,
)
Result.success()
}

View File

@@ -53,7 +53,7 @@ data class AppDetailsItem(
*/
val installedSigner: String? = null,
/** The currently suggested version for installation. */
val suggestedVersion: AppVersion? = null,
val suggestedVersion: PackageVersion? = null,
/**
* Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions. This is
* useful for (un-)ignoring this version.
@@ -225,8 +225,8 @@ data class AppDetailsItem(
val bitcoinUri = app.bitcoin?.let { "bitcoin:$it" }
}
class AppDetailsActions(
val installAction: (AppMetadata, AppVersion, Any?) -> Unit,
data class AppDetailsActions(
val installAction: (AppMetadata, PackageVersion, Any?) -> Unit,
val requestUserConfirmation: (InstallState.UserConfirmationNeeded) -> Unit,
/**
* A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog

View File

@@ -31,13 +31,13 @@ import kotlinx.coroutines.withContext
import mu.KotlinLogging
import org.fdroid.UpdateChecker
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloadRequest
import org.fdroid.download.NetworkMonitor
import org.fdroid.getCacheKey
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.RepoManager
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallState
import org.fdroid.repo.RepoPreLoader
@@ -116,14 +116,15 @@ constructor(
}
@UiThread
fun install(appMetadata: AppMetadata, version: AppVersion, iconModel: Any?) {
fun install(appMetadata: AppMetadata, version: PackageVersion, iconModel: Any?) {
scope.launch(Dispatchers.Main) {
val result =
appInstallManager.install(
packageName = packageName,
appMetadata = appMetadata,
version = version,
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName,
repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, // TODO
repo = repoManager.getRepository(appMetadata.repoId) ?: return@launch,
iconModel = iconModel,
canAskPreApprovalNow = true,
)

View File

@@ -13,6 +13,7 @@ import org.fdroid.database.Repository
import org.fdroid.download.Mirror
import org.fdroid.download.NetworkState
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.v2.FileV1
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersion
import org.fdroid.index.v2.SignerV2
@@ -81,6 +82,7 @@ val testVersion1 =
object : PackageVersion {
override val versionCode: Long = 42
override val versionName: String = "42.23.0-alpha1337-33d2252b90"
override val file: FileV1 = FileV1("foo/bar", "abcd", 23)
override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4)
override val size: Long = 1024 * 1024 * 42
override val signer: SignerV2 =
@@ -100,6 +102,7 @@ val testVersion2 =
object : PackageVersion {
override val versionCode: Long = 23
override val versionName: String = "23.42.0"
override val file: FileV1 = FileV1("foo/bar", "abcd", 23)
override val added: Long = System.currentTimeMillis() - DAYS.toMillis(4)
override val size: Long = 1024 * 1024 * 23
override val signer: SignerV2 =
@@ -252,6 +255,7 @@ fun getPreviewVersion(versionName: String, size: Long? = null) =
object : PackageVersion {
override val versionCode: Long = 23
override val versionName: String = versionName
override val file: FileV1 = FileV1("foo/bar", "abcd", 23)
override val added: Long = System.currentTimeMillis() - DAYS.toMillis(3)
override val size: Long? = size
override val signer: SignerV2? = null

View File

@@ -114,12 +114,13 @@ constructor(
private suspend fun updateApp(update: AppUpdateItem, canAskPreApprovalNow: Boolean) {
val app = db.getAppDao().getApp(update.repoId, update.packageName)
appInstallManager.install(
appMetadata = app?.metadata,
version = update.update as AppVersion,
currentVersionName = update.installedVersionName,
repo = repoManager.getRepository(update.repoId),
iconModel = update.iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
packageName = update.packageName,
appMetadata = app?.metadata,
version = update.update as AppVersion,
currentVersionName = update.installedVersionName,
repo = repoManager.getRepository(update.repoId),
iconModel = update.iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
)
}
}

View File

@@ -624,12 +624,13 @@ internal class AppInstallManagerTest {
val installJob =
installScope.async {
appInstallManager.install(
appMetadata = appMetadata,
version = version,
currentVersionName = installedVersionName,
repo = repo,
iconModel = null,
canAskPreApprovalNow = false,
packageName = packageName,
appMetadata = appMetadata,
version = version,
currentVersionName = installedVersionName,
repo = repo,
iconModel = null,
canAskPreApprovalNow = false,
)
}
@@ -648,12 +649,13 @@ internal class AppInstallManagerTest {
canAskPreApprovalNow: Boolean = false,
): InstallState {
return appInstallManager.install(
appMetadata = appMetadata,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconModel = iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
packageName = packageName,
appMetadata = appMetadata,
version = version,
currentVersionName = currentVersionName,
repo = repo,
iconModel = iconModel,
canAskPreApprovalNow = canAskPreApprovalNow,
)
}
}

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.index.v2.PackageVersion
import org.fdroid.ui.utils.isAppInForeground
import org.junit.After
import org.junit.Before
@@ -73,7 +74,7 @@ internal class SessionInstallManagerTest {
name = mapOf("en-US" to "Example App"),
isCompatible = true,
)
private val appVersion: AppVersion = mockk(relaxed = true)
private val appVersion: PackageVersion = mockk(relaxed = true)
private val installingState =
InstallState.Installing(
@@ -213,14 +214,13 @@ internal class SessionInstallManagerTest {
)
assertIs<PreApprovalResult.NotSupported>(notForegroundResult)
// in foreground + update that can auto-update -> NotSupported
// in foreground + update that can auto-update, we say NotSupported, because not needed
every { context.isAppInForeground() } returns true
every { appVersion.packageName } returns packageName
every { appVersion.manifest.targetSdkVersion } returns 34
every { appVersion.packageManifest.targetSdkVersion } returns 42
val sourceInfo: InstallSourceInfo = mockk(relaxed = true)
every { sourceInfo.installingPackageName } returns context.packageName
if (SDK_INT >= 34) {
every { sourceInfo.updateOwnerPackageName } returns null
every { sourceInfo.updateOwnerPackageName } returns context.packageName
}
every { packageManager.getInstallSourceInfo(packageName) } returns sourceInfo
@@ -237,8 +237,7 @@ internal class SessionInstallManagerTest {
// isUpdate = true but not our package, and we are not the update owner -> NotSupported,
// because canDoAutoUpdate() returns false when getInstallSourceInfo() throws
every { context.isAppInForeground() } returns true
every { appVersion.packageName } returns packageName
every { appVersion.manifest.targetSdkVersion } returns 34
every { appVersion.packageManifest.targetSdkVersion } returns 34
every { packageManager.getInstallSourceInfo(packageName) } throws SecurityException("nope")
val installSourceError =

View File

@@ -62,23 +62,25 @@ internal class UpdateInstallerTest {
}
@Test
fun `updateAll returns early when updates list is empty`() =
testScope.runTest {
val installer = createUpdateInstaller()
fun `updateAll returns early when updates list is empty`() = testScope.runTest {
val installer = createUpdateInstaller()
installer.updateAll(emptyList(), canAskPreApprovalNow = false)
advanceUntilIdle()
installer.updateAll(emptyList(), canAskPreApprovalNow = false)
advanceUntilIdle()
coVerify(exactly = 0) { appInstallManager.install(any(), any(), any(), any(), any(), any()) }
verify(exactly = 0) { appInstallManager.setWaitingState(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) {
appInstallManager.install(any(), any(), any(), any(), any(), any(), any())
}
verify(exactly = 0) { appInstallManager.setWaitingState(any(), any(), any(), any(), any()) }
}
@Test
fun `updateAll preApproval is true for single app and forced false for multiple`() =
testScope.runTest {
every { repoManager.getRepository(1L) } returns makeRepository()
coEvery { appInstallManager.install(any(), any(), any(), any(), any(), any()) } returns
mockk()
coEvery {
appInstallManager.install(any(), any(), any(), any(), any(), any(), any())
} returns mockk()
// single app + canAsk=true -> canAskPreApprovalNow=true
val ver = makeAppVersion(versionName = "5.0", versionCode = 50)
@@ -98,6 +100,7 @@ internal class UpdateInstallerTest {
coVerify(exactly = 1) {
appInstallManager.install(
packageName = "com.example.app",
appMetadata =
match { metadata ->
metadata.packageName == "com.example.app" && metadata.repoId == 1L
@@ -121,6 +124,7 @@ internal class UpdateInstallerTest {
coVerify(exactly = 2) {
appInstallManager.install(
packageName = any(),
appMetadata = any(),
version = any(),
currentVersionName = any(),
@@ -132,70 +136,72 @@ internal class UpdateInstallerTest {
}
@Test
fun `updateAll updates own app last and sets waiting state`() =
testScope.runTest {
val otherPkg = "com.example.other"
every { repoManager.getRepository(1L) } returns makeRepository()
coEvery { appInstallManager.install(any(), any(), any(), any(), any(), any()) } returns
mockk()
fun `updateAll updates own app last and sets waiting state`() = testScope.runTest {
val otherPkg = "com.example.other"
every { repoManager.getRepository(1L) } returns makeRepository()
coEvery {
appInstallManager.install(any(), any(), any(), any(), any(), any(), any())
} returns mockk()
val ownVersion =
makeAppVersion(packageName = OWN_PACKAGE_NAME, versionName = "3.0", added = 9999L)
every { appDao.getApp(1L, OWN_PACKAGE_NAME) } returns makeApp(packageName = OWN_PACKAGE_NAME)
every { appDao.getApp(1L, otherPkg) } returns makeApp(packageName = otherPkg)
every {
appInstallManager.setWaitingState(
val ownVersion =
makeAppVersion(packageName = OWN_PACKAGE_NAME, versionName = "3.0", added = 9999L)
every { appDao.getApp(1L, OWN_PACKAGE_NAME) } returns makeApp(packageName = OWN_PACKAGE_NAME)
every { appDao.getApp(1L, otherPkg) } returns makeApp(packageName = otherPkg)
every {
appInstallManager.setWaitingState(
packageName = OWN_PACKAGE_NAME,
name = any(),
versionName = any(),
currentVersionName = any(),
lastUpdated = any(),
)
} just runs
val updates =
listOf(
makeAppUpdateItem(
packageName = OWN_PACKAGE_NAME,
name = any(),
versionName = any(),
currentVersionName = any(),
lastUpdated = any(),
)
} just runs
installedVersionName = "2.0",
update = ownVersion,
),
makeAppUpdateItem(packageName = otherPkg),
)
val updates =
listOf(
makeAppUpdateItem(
packageName = OWN_PACKAGE_NAME,
installedVersionName = "2.0",
update = ownVersion,
),
makeAppUpdateItem(packageName = otherPkg),
)
createUpdateInstaller().updateAll(updates, canAskPreApprovalNow = false)
advanceUntilIdle()
createUpdateInstaller().updateAll(updates, canAskPreApprovalNow = false)
advanceUntilIdle()
verify(exactly = 1) {
appInstallManager.setWaitingState(
packageName = OWN_PACKAGE_NAME,
name = any(),
versionName = "3.0",
currentVersionName = "2.0",
lastUpdated = 9999L,
)
}
coVerifyOrder {
appInstallManager.install(
appMetadata = match { metadata -> metadata.packageName == otherPkg },
version = any(),
currentVersionName = any(),
repo = any(),
iconModel = any(),
canAskPreApprovalNow = any(),
)
appInstallManager.install(
appMetadata = match { metadata -> metadata.packageName == OWN_PACKAGE_NAME },
version = any(),
currentVersionName = any(),
repo = any(),
iconModel = any(),
canAskPreApprovalNow = any(),
)
}
verify(exactly = 1) {
appInstallManager.setWaitingState(
packageName = OWN_PACKAGE_NAME,
name = any(),
versionName = "3.0",
currentVersionName = "2.0",
lastUpdated = 9999L,
)
}
coVerifyOrder {
appInstallManager.install(
packageName = otherPkg,
appMetadata = match { metadata -> metadata.packageName == otherPkg },
version = any(),
currentVersionName = any(),
repo = any(),
iconModel = any(),
canAskPreApprovalNow = any(),
)
appInstallManager.install(
packageName = OWN_PACKAGE_NAME,
appMetadata = match { metadata -> metadata.packageName == OWN_PACKAGE_NAME },
version = any(),
currentVersionName = any(),
repo = any(),
iconModel = any(),
canAskPreApprovalNow = any(),
)
}
}
@Test
fun `updateApp continues with null values if app missing in DB or repo missing`() =
testScope.runTest {
@@ -208,22 +214,27 @@ internal class UpdateInstallerTest {
update = ver,
)
)
coEvery { appInstallManager.install(any(), any(), any(), any(), any(), any()) } returns
mockk()
coEvery {
appInstallManager.install(any(), any(), any(), any(), any(), any(), any())
} returns mockk()
// repo is null
every { repoManager.getRepository(1L) } returns null
every { appDao.getApp(1L, "com.example.app") } returns makeApp()
createUpdateInstaller().updateAll(updates, canAskPreApprovalNow = false)
advanceUntilIdle()
coVerify(exactly = 1) { appInstallManager.install(any(), any(), any(), null, any(), any()) }
coVerify(exactly = 1) {
appInstallManager.install(any(), any(), any(), any(), null, any(), any())
}
// app is null
every { repoManager.getRepository(1L) } returns makeRepository()
every { appDao.getApp(1L, "com.example.app") } returns null
createUpdateInstaller().updateAll(updates, canAskPreApprovalNow = false)
advanceUntilIdle()
coVerify(exactly = 1) { appInstallManager.install(null, any(), any(), any(), any(), any()) }
coVerify(exactly = 1) {
appInstallManager.install(any(), null, any(), any(), any(), any(), any())
}
}
private fun makeAppUpdateItem(