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
This commit is contained in:
Torsten Grote
2026-06-12 17:02:03 -03:00
parent 751b00242b
commit c1e4e27797
4 changed files with 108 additions and 16 deletions

View File

@@ -145,11 +145,21 @@
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<provider
android:name="org.fdroid.install.ApkFileProvider"
android:authorities="${applicationId}.install.ApkFileProvider"
android:exported="false"
android:grantUriPermissions="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- disable WorkManager initialization, needed for Hilt -->
<provider
android:name="androidx.startup.InitializationProvider"

View File

@@ -7,6 +7,11 @@ import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_INSTALL_PACKAGE
import android.content.Intent.EXTRA_NOT_UNKNOWN_SOURCE
import android.content.Intent.EXTRA_RETURN_RESULT
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.IntentFilter
import android.content.IntentSender
import android.content.pm.PackageInfo
@@ -23,6 +28,7 @@ import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
import androidx.core.content.ContextCompat.registerReceiver
import androidx.core.content.FileProvider
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
@@ -290,14 +296,16 @@ constructor(
)
)
}
else -> {
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<T> {
fun resume(value: T)

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="apks"
path="." />
</paths>

View File

@@ -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<InstallState.UserAborted>(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<InstallState.Error>(result)
assertContains(result.msg ?: "", "ActivityNotFoundException")
}
@Test
fun `install cancellation abandons session`() = runBlocking {
val apkFile: File =