From ba465d147e2985be847054919fd79421aa4f2b0f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 3 Nov 2025 17:03:00 -0300 Subject: [PATCH] Fix install related bugs Most prominently, pre-approval was broken when updating many apps at once, because the system doesn't stack the approval dialogs, so they get lost and installs stuck. --- .../org/fdroid/install/AppInstallManager.kt | 249 ++++++++++++------ .../install/InstallBroadcastReceiver.kt | 2 +- .../install/InstallNotificationState.kt | 3 + .../kotlin/org/fdroid/install/InstallState.kt | 27 +- .../org/fdroid/install/PreApprovalResult.kt | 7 + .../fdroid/install/SessionInstallManager.kt | 57 ++-- next/src/main/kotlin/org/fdroid/ui/Main.kt | 4 +- .../main/kotlin/org/fdroid/ui/apps/MyApps.kt | 15 +- .../kotlin/org/fdroid/ui/apps/MyAppsInfo.kt | 4 +- .../org/fdroid/ui/apps/MyAppsPresenter.kt | 33 ++- .../org/fdroid/ui/apps/MyAppsViewModel.kt | 12 +- .../fdroid/ui/details/AppDetailsViewModel.kt | 1 + .../org/fdroid/ui/utils/PreviewUtils.kt | 7 +- .../org/fdroid/updates/UpdatesManager.kt | 9 +- 14 files changed, 290 insertions(+), 140 deletions(-) diff --git a/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt index 376438a82..230ec3691 100644 --- a/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt +++ b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt @@ -17,6 +17,7 @@ import coil3.toBitmap import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext @@ -62,7 +63,7 @@ class AppInstallManager @Inject constructor( var numBytesDownloaded = 0L var numTotalBytes = 0L // go throw all apps that have active state - apps.value.toMap().forEach { packageName, state -> + apps.value.toMap().forEach { (packageName, state) -> // assign a category to each in progress state val appStateCategory = when (state) { is InstallState.Installing, is InstallState.PreApproved, @@ -101,6 +102,16 @@ class AppInstallManager @Inject constructor( return apps.map { it[packageName] ?: InstallState.Unknown } } + /** + * Installs the given [version]. + * + * @param canAskPreApprovalNow true if there will be only one approval dialog + * and the app is currently in the foreground. + * Reasoning: + * The system will swallow the second or third dialog we pop up + * before the user could respond to the first. + * Also we are not allowed anymore to start other activities while in the background. + */ @UiThread suspend fun install( appMetadata: AppMetadata, @@ -108,14 +119,31 @@ class AppInstallManager @Inject constructor( currentVersionName: String?, repo: Repository, iconModel: Any?, + canAskPreApprovalNow: Boolean, ): InstallState { val packageName = appMetadata.packageName + val currentState = apps.value[packageName] + if (currentState?.showProgress == true) { + log.warn { "Attempted to install $packageName with install in progress: $currentState" } + return currentState + } val iconDownloadRequest = iconModel as? DownloadRequest val job = scope.async { - installInt(appMetadata, version, currentVersionName, repo, iconDownloadRequest) + startInstall( + appMetadata = appMetadata, + version = version, + currentVersionName = currentVersionName, + repo = repo, + iconDownloadRequest = iconDownloadRequest, + canAskPreApprovalNow = canAskPreApprovalNow, + ) } // keep track of this job, in case we want to cancel it - jobs.put(packageName, job) + return trackJob(packageName, job) + } + + private suspend fun trackJob(packageName: String, job: Deferred): InstallState { + jobs[packageName] = job // wait for job to return val result = try { job.await() @@ -131,22 +159,23 @@ class AppInstallManager @Inject constructor( } @WorkerThread - private suspend fun installInt( + private suspend fun startInstall( appMetadata: AppMetadata, version: AppVersion, currentVersionName: String?, repo: Repository, iconDownloadRequest: DownloadRequest?, + canAskPreApprovalNow: Boolean, ): InstallState { - apps.updateApp(appMetadata.packageName) { - InstallState.Starting( - name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", - versionName = version.versionName, - currentVersionName = currentVersionName, - lastUpdated = version.added, - iconDownloadRequest = iconDownloadRequest, - ) - } + val startingState = InstallState.Starting( + name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", + versionName = version.versionName, + currentVersionName = currentVersionName, + lastUpdated = version.added, + iconDownloadRequest = iconDownloadRequest, + ) + apps.updateApp(appMetadata.packageName) { startingState } + log.info { "Started install of ${appMetadata.packageName}" } onStatesUpdated() val coroutineContext = currentCoroutineContext() // get the icon for pre-approval (usually in memory cache, so should be quick) @@ -159,20 +188,14 @@ class AppInstallManager @Inject constructor( icon = icon, isUpdate = currentVersionName != null, version = version, + canRequestUserConfirmationNow = canAskPreApprovalNow, ) + log.info { "Got pre-approval result $preApprovalResult for ${appMetadata.packageName}" } // continue depending on result, abort early if no approval was given return when (preApprovalResult) { - is PreApprovalResult.Error -> InstallState.Error( - msg = preApprovalResult.errorMsg, - name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", - versionName = version.versionName, - currentVersionName = currentVersionName, - lastUpdated = version.added, - iconDownloadRequest = iconDownloadRequest, - ) is PreApprovalResult.UserAborted -> InstallState.UserAborted is PreApprovalResult.Success, PreApprovalResult.NotSupported -> { - apps.checkAndUpdateApp(appMetadata.packageName) { + val newState = apps.checkAndUpdateApp(appMetadata.packageName) { InstallState.PreApproved( name = it.name, versionName = it.versionName, @@ -181,69 +204,127 @@ class AppInstallManager @Inject constructor( iconDownloadRequest = it.iconDownloadRequest, result = preApprovalResult, ) - } - val sessionId = (preApprovalResult as? PreApprovalResult.Success)?.sessionId - coroutineContext.ensureActive() - // download file - val file = File(context.cacheDir, version.file.sha256) - val uri = getUri(repo.address, version.file) - val downloader = downloaderFactory.create(repo, uri, version.file, file) - val now = System.currentTimeMillis() - downloader.setListener { bytesRead, totalBytes -> - coroutineContext.ensureActive() - apps.checkAndUpdateApp(appMetadata.packageName) { - InstallState.Downloading( - name = it.name, - versionName = it.versionName, - currentVersionName = it.currentVersionName, - lastUpdated = it.lastUpdated, - iconDownloadRequest = it.iconDownloadRequest, - downloadedBytes = bytesRead, - totalBytes = totalBytes, - startMillis = now, - ) - } - onStatesUpdated() - } - try { - downloader.download() - log.debug { "Download completed" } - } catch (e: Exception) { - if (e is CancellationException) throw e - log.error(e) { "Error downloading ${version.file}" } - val msg = "Download failed: ${e::class.java.simpleName} ${e.message}" - return InstallState.Error( - msg = msg, - name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) - ?: "Unknown", - versionName = version.versionName, - currentVersionName = currentVersionName, - lastUpdated = version.added, - iconDownloadRequest = iconDownloadRequest, - ) - } - coroutineContext.ensureActive() - val newState = apps.checkAndUpdateApp(appMetadata.packageName) { - InstallState.Installing( - name = it.name, - versionName = it.versionName, - currentVersionName = it.currentVersionName, - lastUpdated = it.lastUpdated, - iconDownloadRequest = it.iconDownloadRequest, - ) - } - val result = - sessionInstallManager.install(sessionId, version.packageName, newState, file) - 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) - } else { - result - } + } as InstallState.PreApproved + downloadAndInstall(newState, version, currentVersionName, repo, iconDownloadRequest) } + is PreApprovalResult.UserConfirmationRequired -> { + InstallState.PreApprovalConfirmationNeeded( + state = startingState, + version = version, + repo = repo, + sessionId = preApprovalResult.sessionId, + intent = preApprovalResult.intent, + ) + } + is PreApprovalResult.Error -> InstallState.Error( + msg = preApprovalResult.errorMsg, + s = startingState, + ) + } + } + + /** + * Request user confirmation for pre-approval and suspend until we get a result. + */ + @UiThread + suspend fun requestPreApprovalConfirmation( + packageName: String, + installState: InstallState.PreApprovalConfirmationNeeded, + ): InstallState? { + val state = apps.value[packageName] ?: error("No state for $packageName $installState") + if (state !is InstallState.PreApprovalConfirmationNeeded) { + log.error { "Unexpected state: $state" } + return null + } + log.info { "Requesting pre-approval confirmation for $packageName" } + val result = sessionInstallManager.requestUserConfirmation(installState) + log.info { "Pre-approval confirmation for $packageName $result" } + apps.updateApp(packageName) { result } + onStatesUpdated() + 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, + iconDownloadRequest = installState.iconDownloadRequest, + ) + } + // suspend/wait for this job and track it in case we want to cancel it + return trackJob(packageName, job) + } else result + } + + @WorkerThread + private suspend fun downloadAndInstall( + state: InstallState.PreApproved, + version: AppVersion, + currentVersionName: String?, + repo: Repository, + iconDownloadRequest: DownloadRequest?, + ): InstallState { + val sessionId = (state.result as? PreApprovalResult.Success)?.sessionId + val coroutineContext = currentCoroutineContext() + coroutineContext.ensureActive() + // download file + val file = File(context.cacheDir, version.file.sha256) + val uri = getUri(repo.address, version.file) + val downloader = downloaderFactory.create(repo, uri, version.file, file) + val now = System.currentTimeMillis() + downloader.setListener { bytesRead, totalBytes -> + coroutineContext.ensureActive() + apps.checkAndUpdateApp(version.packageName) { + InstallState.Downloading( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconDownloadRequest = it.iconDownloadRequest, + downloadedBytes = bytesRead, + totalBytes = totalBytes, + startMillis = now, + ) + } + onStatesUpdated() + } + try { + downloader.download() + log.debug { "Download completed" } + } catch (e: Exception) { + if (e is CancellationException) throw e + log.error(e) { "Error downloading ${version.file}" } + val msg = "Download failed: ${e::class.java.simpleName} ${e.message}" + return InstallState.Error( + msg = msg, + name = state.name, + versionName = version.versionName, + currentVersionName = currentVersionName, + lastUpdated = version.added, + iconDownloadRequest = iconDownloadRequest, + ) + } + currentCoroutineContext().ensureActive() + val newState = apps.checkAndUpdateApp(version.packageName) { + InstallState.Installing( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconDownloadRequest = it.iconDownloadRequest, + ) + } + val result = + sessionInstallManager.install(sessionId, version.packageName, newState, file) + 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) + } else { + result } } diff --git a/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt b/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt index 6878d05cc..e8d16bb86 100644 --- a/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt +++ b/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt @@ -32,7 +32,7 @@ class InstallBroadcastReceiver( } val confirmIntent = getParcelableExtra(intent, EXTRA_INTENT, Intent::class.java) val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) - val status = intent.getIntExtra(EXTRA_STATUS, Int.Companion.MIN_VALUE) + val status = intent.getIntExtra(EXTRA_STATUS, Int.MIN_VALUE) val msg = intent.getStringExtra(EXTRA_STATUS_MESSAGE) val warnings = intent.getStringArrayListExtra("android.content.pm.extra.WARNINGS") log.info { diff --git a/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt b/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt index e2387f3f4..4a1d8cd61 100644 --- a/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt +++ b/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt @@ -40,6 +40,9 @@ data class InstallNotificationState( val numInstalled: Int get() = apps.count { it.category == AppStateCategory.INSTALLED } fun getTitle(context: Context): String { + // can briefly show as foreground service notification, before we update real state + if (apps.isEmpty()) return context.getString(R.string.installing) + val titleRes = if (isUpdatingApps) { R.plurals.notification_updating_title } else { diff --git a/next/src/main/kotlin/org/fdroid/install/InstallState.kt b/next/src/main/kotlin/org/fdroid/install/InstallState.kt index da5470794..1fd743dda 100644 --- a/next/src/main/kotlin/org/fdroid/install/InstallState.kt +++ b/next/src/main/kotlin/org/fdroid/install/InstallState.kt @@ -1,6 +1,8 @@ package org.fdroid.install import android.app.PendingIntent +import org.fdroid.database.AppVersion +import org.fdroid.database.Repository import org.fdroid.download.DownloadRequest sealed class InstallState(val showProgress: Boolean) { @@ -13,6 +15,20 @@ sealed class InstallState(val showProgress: Boolean) { override val iconDownloadRequest: DownloadRequest? = null, ) : InstallStateWithInfo(true) + data class PreApprovalConfirmationNeeded( + private val state: InstallStateWithInfo, + val version: AppVersion, + val repo: Repository, + override val sessionId: Int, + override val intent: PendingIntent, + ) : InstallConfirmationState() { + override val name: String = state.name + override val versionName: String = state.versionName + override val currentVersionName: String? = state.currentVersionName + override val lastUpdated: Long = state.lastUpdated + override val iconDownloadRequest: DownloadRequest? = state.iconDownloadRequest + } + data class PreApproved( override val name: String, override val versionName: String, @@ -49,10 +65,10 @@ sealed class InstallState(val showProgress: Boolean) { override val currentVersionName: String?, override val lastUpdated: Long, override val iconDownloadRequest: DownloadRequest?, - val sessionId: Int, - val intent: PendingIntent, + override val sessionId: Int, + override val intent: PendingIntent, val progress: Float, - ) : InstallStateWithInfo(true) { + ) : InstallConfirmationState() { constructor( state: InstallStateWithInfo, sessionId: Int, @@ -108,3 +124,8 @@ sealed class InstallStateWithInfo(showProgress: Boolean) : InstallState(showProg abstract val lastUpdated: Long abstract val iconDownloadRequest: DownloadRequest? } + +sealed class InstallConfirmationState() : InstallStateWithInfo(true) { + abstract val sessionId: Int + abstract val intent: PendingIntent +} diff --git a/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt b/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt index ab0013ce2..3195c0a8b 100644 --- a/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt +++ b/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt @@ -1,8 +1,15 @@ package org.fdroid.install +import android.app.PendingIntent + sealed interface PreApprovalResult { data object NotSupported : PreApprovalResult data object UserAborted : PreApprovalResult + data class UserConfirmationRequired( + val sessionId: Int, + val intent: PendingIntent, + ) : PreApprovalResult + data class Success(val sessionId: Int) : PreApprovalResult data class Error(val errorMsg: String?) : PreApprovalResult } diff --git a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt index 2e1332971..6b9518c9c 100644 --- a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt +++ b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -93,21 +93,22 @@ class SessionInstallManager @Inject constructor( app: AppMetadata, icon: Bitmap?, isUpdate: Boolean, - version: AppVersion + version: AppVersion, + canRequestUserConfirmationNow: Boolean, ): PreApprovalResult { return if (!context.isAppInForeground()) { - log.info { "App not in foreground, pre-approval not supported." } + log.info { "App not in foreground, pre-approval for ${app.packageName} not supported." } PreApprovalResult.NotSupported } else if (isUpdate && canDoAutoUpdate(version)) { // should not be needed, so we say not supported - log.info { "Can do auto-update pre-approval not needed." } + log.info { "Can do auto-update pre-approval for ${app.packageName} not needed." } PreApprovalResult.NotSupported } else if (SDK_INT >= 34) { - log.info { "Requesting pre-approval..." } + log.info { "Requesting pre-approval for ${app.packageName}..." } try { - preapproval(app, icon) + preapproval(app, icon, canRequestUserConfirmationNow) } catch (e: Exception) { - log.error(e) { "Error requesting pre-approval: " } + log.error(e) { "Error requesting pre-approval for ${app.packageName}: " } PreApprovalResult.Error("${e::class.java.simpleName} ${e.message}") } } else { @@ -119,10 +120,11 @@ class SessionInstallManager @Inject constructor( private suspend fun preapproval( app: AppMetadata, icon: Bitmap?, + canRequestUserConfirmationNow: Boolean, ): PreApprovalResult = suspendCancellableCoroutine { cont -> val params = getSessionParams(app.packageName) val sessionId = installer.createSession(params) - log.info { "Opened session $sessionId" } + log.info { "Opened session $sessionId for ${app.packageName}" } val name = app.name.getBestLocale(LocaleListCompat.getDefault()) ?: "" val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg -> @@ -138,7 +140,15 @@ class SessionInstallManager @Inject constructor( // There should be no bugs on Android versions where this is supported // and we should be in the foreground right now, // so fire up intent here and now. - pendingIntent.send() + if (canRequestUserConfirmationNow) { + log.info { "Sending pre-approval intent for ${app.packageName}: $intent" } + pendingIntent.send() + } else { + log.info { "Can not ask pre-approval for ${app.packageName}: $intent" } + val s = PreApprovalResult.UserConfirmationRequired(sessionId, pendingIntent) + cont.resume(s) + context.unregisterReceiver(this) + } } else -> { // some error, can't help it now, continue if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { @@ -157,7 +167,7 @@ class SessionInstallManager @Inject constructor( RECEIVER_NOT_EXPORTED ) cont.invokeOnCancellation { - log.info { "Pre-approval cancelled." } + log.info { "Pre-approval for ${app.packageName} cancelled." } context.unregisterReceiver(receiver) } @@ -172,7 +182,6 @@ class SessionInstallManager @Inject constructor( val sender = getInstallIntentSender(sessionId, app.packageName) session.requestUserPreapproval(details, sender) } - sessionId } @WorkerThread @@ -290,18 +299,26 @@ class SessionInstallManager @Inject constructor( } suspend fun requestUserConfirmation( - installState: InstallState.UserConfirmationNeeded, + state: InstallConfirmationState, ): InstallState = suspendCancellableCoroutine { cont -> - val receiver = InstallBroadcastReceiver(installState.sessionId) { status, intent, msg -> + val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded + val receiver = InstallBroadcastReceiver(state.sessionId) { status, _, msg -> context.unregisterReceiver(this) when (status) { PackageInstaller.STATUS_SUCCESS -> { - val newState = InstallState.Installed( - name = installState.name, - versionName = installState.versionName, - currentVersionName = installState.currentVersionName, - lastUpdated = installState.lastUpdated, - iconDownloadRequest = installState.iconDownloadRequest, + val newState = if (isPreApproval) InstallState.PreApproved( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconDownloadRequest = state.iconDownloadRequest, + result = PreApprovalResult.Success(state.sessionId), + ) else InstallState.Installed( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconDownloadRequest = state.iconDownloadRequest, ) cont.resume(newState) } @@ -312,7 +329,7 @@ class SessionInstallManager @Inject constructor( if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { cont.resume(InstallState.UserAborted) } else { - cont.resume(InstallState.Error(msg, installState)) + cont.resume(InstallState.Error(msg, state)) } } } @@ -326,7 +343,7 @@ class SessionInstallManager @Inject constructor( cont.invokeOnCancellation { context.unregisterReceiver(receiver) } - installState.intent.send() + state.intent.send() } private fun getSessionParams(packageName: String, size: Long? = null): SessionParams { diff --git a/next/src/main/kotlin/org/fdroid/ui/Main.kt b/next/src/main/kotlin/org/fdroid/ui/Main.kt index b30aec20f..9b7b7afc4 100644 --- a/next/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/next/src/main/kotlin/org/fdroid/ui/Main.kt @@ -29,7 +29,7 @@ import com.viktormykhailiv.compose.hints.HintHost import org.fdroid.R import org.fdroid.database.AppListSortOrder import org.fdroid.fdroid.ui.theme.FDroidContent -import org.fdroid.install.InstallState +import org.fdroid.install.InstallConfirmationState import org.fdroid.ui.apps.MyApps import org.fdroid.ui.apps.MyAppsInfo import org.fdroid.ui.apps.MyAppsViewModel @@ -119,7 +119,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) { override fun search(query: String) = myAppsViewModel.search(query) override fun confirmAppInstall( packageName: String, - state: InstallState.UserConfirmationNeeded, + state: InstallConfirmationState, ) = myAppsViewModel.confirmAppInstall(packageName, state) } MyApps( diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt index 25f3ce1d2..e1b766abc 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -54,6 +54,7 @@ import org.fdroid.R import org.fdroid.database.AppListSortOrder import org.fdroid.database.AppListSortOrder.LAST_UPDATED import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState import org.fdroid.ui.BottomBar import org.fdroid.ui.NavigationKey @@ -78,7 +79,7 @@ fun MyApps( val appToConfirm by remember(myAppsInfo.model.installingApps) { derivedStateOf { myAppsInfo.model.installingApps.find { app -> - app.installState is InstallState.UserConfirmationNeeded + app.installState is InstallConfirmationState } } } @@ -92,7 +93,7 @@ fun MyApps( LaunchedEffect(appToConfirm) { val app = appToConfirm if (app != null && lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { - val state = app.installState as InstallState.UserConfirmationNeeded + val state = app.installState as InstallConfirmationState myAppsInfo.confirmAppInstall(app.packageName, state) } } @@ -194,6 +195,9 @@ fun MyApps( .padding(16.dp), ) } else { + var showUpdateAllButton by remember(updatableApps) { + mutableStateOf(true) + } LazyColumn( state = lazyListState, modifier = modifier @@ -214,8 +218,11 @@ fun MyApps( .padding(16.dp) .weight(1f), ) - Button( - onClick = myAppsInfo::updateAll, + if (showUpdateAllButton) Button( + onClick = { + myAppsInfo.updateAll() + showUpdateAllButton = false + }, modifier = Modifier.padding(end = 16.dp), ) { Text(stringResource(R.string.update_all)) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt index 0feca5b7e..df28d9957 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -1,7 +1,7 @@ package org.fdroid.ui.apps import org.fdroid.database.AppListSortOrder -import org.fdroid.install.InstallState +import org.fdroid.install.InstallConfirmationState interface MyAppsInfo { val model: MyAppsModel @@ -9,7 +9,7 @@ interface MyAppsInfo { fun updateAll() fun changeSortOrder(sort: AppListSortOrder) fun search(query: String) - fun confirmAppInstall(packageName: String, state: InstallState.UserConfirmationNeeded) + fun confirmAppInstall(packageName: String, state: InstallConfirmationState) } data class MyAppsModel( diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt index 368d3f633..ad5b83fce 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -12,6 +12,7 @@ import org.fdroid.ui.utils.normalize import java.text.Collator import java.util.Locale +// TODO add tests for this, similar to DetailsPresenter @Composable fun MyAppsPresenter( appUpdatesFlow: StateFlow?>, @@ -27,26 +28,30 @@ fun MyAppsPresenter( val sortOrder = sortOrderFlow.collectAsState().value val processedPackageNames = mutableSetOf() - val updates = appUpdates?.filter { - val keep = searchQuery.isBlank() || - it.name.normalize().contains(searchQuery, ignoreCase = true) - if (keep) processedPackageNames.add(it.packageName) - keep - } + // we want to show apps currently installing/updating even if they have updates available, + // so we need to handle those first val installingApps = appInstallStates.mapNotNull { (packageName, state) -> if (state is InstallStateWithInfo) { - val keep = if (searchQuery.isBlank()) { - packageName !in processedPackageNames - } else { - packageName !in processedPackageNames && - state.name.normalize().contains(searchQuery, ignoreCase = true) - } - processedPackageNames.add(packageName) - if (keep) InstallingAppItem(packageName, state) else null + val keep = searchQuery.isBlank() || + state.name.normalize().contains(searchQuery, ignoreCase = true) + if (keep) { + processedPackageNames.add(packageName) + InstallingAppItem(packageName, state) + } else null } else { null } } + val updates = appUpdates?.filter { + val keep = if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames + } else { + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) + } + if (keep) processedPackageNames.add(it.packageName) + keep + } val installed = installedApps?.filter { if (searchQuery.isBlank()) { it.packageName !in processedPackageNames diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt index 9b2a774b5..46b1b3636 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -23,6 +23,7 @@ import org.fdroid.database.FDroidDatabase import org.fdroid.download.getImageModel import org.fdroid.index.RepoManager import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState import org.fdroid.settings.SettingsManager import org.fdroid.updates.UpdatesManager @@ -109,10 +110,17 @@ class MyAppsViewModel @Inject constructor( } } - fun confirmAppInstall(packageName: String, state: InstallState.UserConfirmationNeeded) { + fun confirmAppInstall(packageName: String, state: InstallConfirmationState) { log.info { "Asking user to confirm install of $packageName..." } scope.launch(Dispatchers.Main) { - appInstallManager.requestUserConfirmation(packageName, state) + when (state) { + is InstallState.PreApprovalConfirmationNeeded -> { + appInstallManager.requestPreApprovalConfirmation(packageName, state) + } + is InstallState.UserConfirmationNeeded -> { + appInstallManager.requestUserConfirmation(packageName, state) + } + } } } } diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 2dc9540fb..2dd069ca1 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -102,6 +102,7 @@ class AppDetailsViewModel @Inject constructor( currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO iconModel = iconModel, + canAskPreApprovalNow = true, ) if (result is InstallState.Installed) { // to reload packageInfoFlow with fresh packageInfo diff --git a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index 1b7d79615..9c226ea51 100644 --- a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -11,6 +11,7 @@ import org.fdroid.index.IndexFormatVersion import org.fdroid.index.v2.PackageManifest import org.fdroid.index.v2.PackageVersion import org.fdroid.index.v2.SignerV2 +import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState import org.fdroid.ui.apps.MyAppsInfo import org.fdroid.ui.apps.MyAppsModel @@ -260,11 +261,7 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { override fun updateAll() {} override fun changeSortOrder(sort: AppListSortOrder) {} override fun search(query: String) {} - override fun confirmAppInstall( - packageName: String, - state: InstallState.UserConfirmationNeeded, - ) { - } + override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {} } fun getRepositoriesInfo( diff --git a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt index 10111dce4..3851d7c5e 100644 --- a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt +++ b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt @@ -105,6 +105,8 @@ class UpdatesManager @Inject constructor( suspend fun updateAll(): List { val appsToUpdate = updates.value ?: updates.first() ?: return emptyList() + // we could do more in-depth checks regarding pre-approval, but this also works + val canAskPreApprovalNow = appsToUpdate.size == 1 val concurrencyLimit = min(Runtime.getRuntime().availableProcessors(), 8) val semaphore = Semaphore(concurrencyLimit) return appsToUpdate.map { update -> @@ -112,13 +114,13 @@ class UpdatesManager @Inject constructor( coroutineScope.launch { // suspend here until we get a permit from the semaphore (there's free workers) semaphore.withPermit { - updateApp(update) + updateApp(update, canAskPreApprovalNow) } } } } - private suspend fun updateApp(update: AppUpdateItem) { + private suspend fun updateApp(update: AppUpdateItem, canAskPreApprovalNow: Boolean) { val app = db.getAppDao().getApp(update.repoId, update.packageName) ?: return appInstallManager.install( appMetadata = app.metadata, @@ -126,7 +128,8 @@ class UpdatesManager @Inject constructor( version = update.update as AppVersion, currentVersionName = update.installedVersionName, repo = repoManager.getRepository(update.repoId) ?: return, - iconModel = update.iconModel + iconModel = update.iconModel, + canAskPreApprovalNow = canAskPreApprovalNow, ) } }