From f4f60415188c433f727a3e2a4bbe5cb236a61fbb Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 12 Jun 2026 09:53:26 -0300 Subject: [PATCH] Work around Chinese ROMs aborting installation pre-approval There's a second code path for updates where we can't ask for pre-approval right away, so it needs to be triggered by the user when they see the UI. This code path gets hit for automatic updates or when the user wants to update many apps at once. There we also measure time to abort and if it is faster than 250ms we continue without pre-approval. --- .../fdroid/install/SessionInstallManager.kt | 31 ++++++++++++++---- .../install/SessionInstallManagerTest.kt | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt index 6c65d1160..d0c165c61 100644 --- a/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt +++ b/app/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -347,13 +347,15 @@ constructor( suspend fun requestUserConfirmation(state: InstallConfirmationState): InstallState = safeSuspendCoroutine { cont -> val isPreApproval = state is InstallState.PreApprovalConfirmationNeeded + val timeSource = TimeSource.Monotonic + var preapprovalMark: TimeSource.Monotonic.ValueTimeMark? = null val receiver = receiverFactory.create(state.sessionId) { status, _, msg -> unregisterReceiver(this) when (status) { PackageInstaller.STATUS_SUCCESS -> { val newState = - if (isPreApproval) + if (isPreApproval) { InstallState.PreApproved( name = state.name, versionName = state.versionName, @@ -362,7 +364,7 @@ constructor( iconModel = state.iconModel, result = PreApprovalResult.Success(state.sessionId), ) - else + } else { InstallState.Installed( name = state.name, versionName = state.versionName, @@ -370,22 +372,39 @@ constructor( lastUpdated = state.lastUpdated, iconModel = state.iconModel, ) + } cont.resume(newState) } PackageInstaller.STATUS_PENDING_USER_ACTION -> { error("Got STATUS_PENDING_USER_ACTION again") } - else -> { - if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { - cont.resume(InstallState.UserAborted) + PackageInstaller.STATUS_FAILURE_ABORTED -> { + val mark = preapprovalMark + if (isPreApproval && mark != null && mark.elapsedNow() < 250.milliseconds) { + // As of 2026 some Chinese ROMs currently have not implemented pre-approval + // and just return this error as if it was the user who aborted. See #3254 + // So we count fast aborts as not supported, so normal installation can proceed. + log.warn { "Fast pre-approval abort for ${state.name}, trying without..." } + val state = InstallState.PreApproved( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconModel = state.iconModel, + result = PreApprovalResult.NotSupported, + ) + cont.resume(state) } else { - cont.resume(InstallState.Error(msg, state)) + log.info { "User aborted confirmation for ${state.name}" } + cont.resume(InstallState.UserAborted) } } + else -> cont.resume(InstallState.Error(msg, state)) } } registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED) cont.invokeOnCancellation { unregisterReceiver(receiver) } + if (isPreApproval) preapprovalMark = timeSource.markNow() try { state.intent.send() } catch (e: Exception) { diff --git a/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt index ac8386a18..8a74d9638 100644 --- a/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt +++ b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt @@ -608,6 +608,38 @@ internal class SessionInstallManagerTest { } } + @Test + fun `requestUserConfirmation fast pre-approval abort returns PreApproved with NotSupported`() { + runBlocking { + val appVersion: AppVersion = mockk(relaxed = true) + val preApprovalState = + InstallState.PreApprovalConfirmationNeeded( + state = + InstallState.Starting( + name = "Example App", + versionName = "1.0", + currentVersionName = "0.9", + lastUpdated = 42, + iconModel = null, + ), + version = appVersion, + repo = mockk(relaxed = true), + sessionId = sessionId, + intent = pendingIntent, + ) + // The callback fires synchronously inside intent.send(), so elapsedNow() < 250ms, + // which triggers the fast-abort path that returns PreApproved(NotSupported). + val result = + requestUserConfirmationForStatus( + preApprovalState, + PackageInstaller.STATUS_FAILURE_ABORTED, + null, + ) + assertIs(result) + assertIs(result.result) + } + } + @Test fun `requestPreapproval not supported in China`(): Unit = runBlocking { every { telephonyManager.simCountryIso } returns "CN"