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 =