From c1e4e2779709f733fa295cf01018c7ad43dd39e2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 12 Jun 2026 17:02:03 -0300 Subject: [PATCH] Auto-try legacy installer when install verification fails This is useful for users with Google Advanced Protection who can use this to circumvent install restrictions and still use F-Droid. See #3201 --- app/src/main/AndroidManifest.xml | 10 +++ .../fdroid/install/SessionInstallManager.kt | 64 ++++++++++++++----- app/src/main/res/xml/file_paths.xml | 6 ++ .../install/SessionInstallManagerTest.kt | 44 +++++++++++++ 4 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d6127967..d00539dd7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -145,11 +145,21 @@ android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + { - if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { - cont.resume(InstallState.UserAborted) - } else if ( - status == PackageInstaller.STATUS_FAILURE && - msg != null && - msg.contains("PreapprovalDetails") - ) { + PackageInstaller.STATUS_FAILURE_ABORTED -> { + if (msg != null && msg.contains("INSTALL_FAILED_VERIFICATION_FAILURE")) { + // This is useful for users with Google Advanced Protection + // who can use this to circumvent install restrictions (see #3201) + installLegacy(apkFile) + } + cont.resume(InstallState.UserAborted) + } + PackageInstaller.STATUS_FAILURE -> { + if (msg != null && msg.contains("PreapprovalDetails")) { val newState = InstallState.PreApproved( name = state.name, @@ -312,6 +320,7 @@ constructor( cont.resume(InstallState.Error(msg, state)) } } + else -> cont.resume(InstallState.Error(msg, state)) } } registerReceiver(context, receiver, IntentFilter(ACTION_INSTALL), RECEIVER_NOT_EXPORTED) @@ -385,14 +394,15 @@ constructor( // 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, - ) + 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 { log.info { "User aborted confirmation for ${state.name}" } @@ -519,6 +529,28 @@ constructor( block(safeCont) } + private fun installLegacy(apkFile: File) { + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile, + ) + @Suppress("DEPRECATION", "RequestInstallPackagesPolicy") + val intent = + Intent(ACTION_INSTALL_PACKAGE).apply { + setAction(ACTION_INSTALL_PACKAGE) + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_GRANT_READ_URI_PERMISSION) + putExtra(EXTRA_RETURN_RESULT, true) + putExtra(EXTRA_NOT_UNKNOWN_SOURCE, true) + } + log.warn { "Trying legacy install for ${apkFile.name} with $intent..." } + // doesn't need to use startActivitySafe(intent) because exceptions get caught + // and shown to the user which is better than hiding it + context.startActivity(intent) + } + private interface SafeContinuation { fun resume(value: T) diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..ba6920607 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt index 8a74d9638..92278dbad 100644 --- a/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt +++ b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt @@ -1,6 +1,7 @@ package org.fdroid.install import android.app.PendingIntent +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -9,9 +10,11 @@ import android.content.pm.InstallSourceInfo import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller.Session import android.content.pm.PackageManager +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.telephony.TelephonyManager import androidx.core.content.ContextCompat.registerReceiver +import androidx.core.content.FileProvider import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -24,6 +27,7 @@ import io.mockk.unmockkAll import io.mockk.verify import java.io.ByteArrayOutputStream import java.io.File +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -437,6 +441,46 @@ internal class SessionInstallManagerTest { } } + @Test + fun `install calls installLegacy when STATUS_FAILURE_ABORTED with INSTALL_FAILED_VERIFICATION_FAILURE`() = + runBlocking { + mockkStatic(FileProvider::class) + val uri: Uri = mockk() + every { FileProvider.getUriForFile(any(), any(), any()) } returns uri + every { context.startActivity(any()) } just runs + + val result = + installForStatus( + status = PackageInstaller.STATUS_FAILURE_ABORTED, + msg = + "INSTALL_FAILED_VERIFICATION_FAILURE: " + + "Install not allowed for file:///data/app/vmdl403786248.tmp", + ) + + assertIs(result) + // ensure that the legacy installer gets invoked + verify(exactly = 1) { context.startActivity(any()) } + } + + @Test + fun `install returns Error when installLegacy throws ActivityNotFoundException`() = runBlocking { + mockkStatic(FileProvider::class) + val uri: Uri = mockk() + every { FileProvider.getUriForFile(any(), any(), any()) } returns uri + every { context.startActivity(any()) } throws ActivityNotFoundException("no handler") + + val result = + installForStatus( + status = PackageInstaller.STATUS_FAILURE_ABORTED, + msg = + "INSTALL_FAILED_VERIFICATION_FAILURE: " + + "Install not allowed for file:///data/app/vmdl403786248.tmp", + ) + + assertIs(result) + assertContains(result.msg ?: "", "ActivityNotFoundException") + } + @Test fun `install cancellation abandons session`() = runBlocking { val apkFile: File =