From 53496d1d3aa5acf8a705807933504b7abf646a03 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 12 Mar 2026 12:07:59 -0300 Subject: [PATCH] Add tests for SessionInstallManager --- .../install/SessionInstallManagerTest.kt | 572 ++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt diff --git a/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt new file mode 100644 index 000000000..a173e98eb --- /dev/null +++ b/app/src/test/java/org/fdroid/install/SessionInstallManagerTest.kt @@ -0,0 +1,572 @@ +package org.fdroid.install + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.IntentSender +import android.content.pm.InstallSourceInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.Session +import android.content.pm.PackageManager +import android.os.Build.VERSION.SDK_INT +import androidx.core.content.ContextCompat.registerReceiver +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import java.io.ByteArrayOutputStream +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import org.fdroid.database.AppMetadata +import org.fdroid.database.AppVersion +import org.fdroid.ui.utils.isAppInForeground +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +internal class SessionInstallManagerTest { + + @get:Rule var tmpFolder: TemporaryFolder = TemporaryFolder() + + private val context: Context = mockk(relaxed = true) + private val packageManager: PackageManager = mockk() + private val packageInstaller: PackageInstaller = mockk() + private val receiverFactory: InstallBroadcastReceiverFactory = mockk() + private val scope = CoroutineScope(Dispatchers.Unconfined) + + private lateinit var sessionInstallManager: SessionInstallManager + + private val receiver: InstallBroadcastReceiver = mockk() + private val pendingIntent: PendingIntent = mockk() + private val session: Session = mockk() + + private val sessionId = 123 + private val packageName = "com.example.app" + private val appMetadata = + AppMetadata( + repoId = 1L, + packageName = packageName, + added = 0L, + lastUpdated = 0L, + name = mapOf("en-US" to "Example App"), + isCompatible = true, + ) + private val appVersion: AppVersion = mockk(relaxed = true) + + private val installingState = + InstallState.Installing( + name = "Example App", + versionName = "1.0", + currentVersionName = "0.9", + lastUpdated = 42, + iconModel = null, + ) + private val confirmationNeededState = + InstallState.UserConfirmationNeeded( + name = "Example App", + versionName = "1.0", + currentVersionName = "0.9", + lastUpdated = 42, + iconModel = null, + sessionId = sessionId, + intent = pendingIntent, + creationTimeMillis = 1L, + progress = 0.2f, + ) + + @Before + fun setUp() { + mockkStatic(PendingIntent::class) + mockkStatic("androidx.core.content.ContextCompat") + mockkStatic("org.fdroid.ui.utils.UiUtilsKt") + + mockkConstructor(Intent::class) + every { anyConstructed().setPackage(any()) } returns mockk() + every { anyConstructed().putExtra(any(), any()) } returns mockk() + every { anyConstructed().putExtra(any(), any()) } returns mockk() + every { anyConstructed().addFlags(any()) } returns mockk() + + every { context.packageManager } returns packageManager + every { packageManager.packageInstaller } returns packageInstaller + every { packageInstaller.mySessions } returns emptyList() + every { context.unregisterReceiver(any()) } just runs + + sessionInstallManager = + SessionInstallManager( + context = context, + coroutineScope = scope, + receiverFactory = receiverFactory, + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `constructor abandons existing install sessions`() { + val oldSession1: PackageInstaller.SessionInfo = mockk(relaxed = true) + val oldSession2: PackageInstaller.SessionInfo = mockk(relaxed = true) + every { oldSession1.sessionId } returns 1 + every { oldSession2.sessionId } returns 2 + every { oldSession1.appPackageName } returns "a" + every { oldSession2.appPackageName } returns "b" + every { packageInstaller.mySessions } returns listOf(oldSession1, oldSession2) + every { packageInstaller.abandonSession(any()) } just runs + + SessionInstallManager( + context = context, + coroutineScope = scope, + receiverFactory = receiverFactory, + ) + + verify(exactly = 1) { packageInstaller.abandonSession(1) } + verify(exactly = 1) { packageInstaller.abandonSession(2) } + } + + @Test + fun `requestPreapproval happy path calls iconGetter requests preapproval and closes session`() = + runBlocking { + val preapprovalSessionId = 1337 + var iconGetterCalls = 0 + + every { context.isAppInForeground() } returns true + every { packageInstaller.createSession(any()) } returns preapprovalSessionId + val preapprovalSession: Session = mockk(relaxed = true) + every { packageInstaller.openSession(preapprovalSessionId) } returns preapprovalSession + every { preapprovalSession.close() } just Runs + + val listenerSlot = slot() + val preapprovalReceiver: InstallBroadcastReceiver = mockk(relaxed = true) + every { receiverFactory.create(preapprovalSessionId, capture(listenerSlot)) } returns + preapprovalReceiver + every { registerReceiver(context, preapprovalReceiver, any(), any()) } returns + mockk() + + val sender: IntentSender = mockk(relaxed = true) + every { PendingIntent.getBroadcast(any(), any(), any(), any()) } returns pendingIntent + every { pendingIntent.intentSender } returns sender + + every { preapprovalSession.requestUserPreapproval(any(), any()) } answers + { + listenerSlot.captured.invoke( + preapprovalReceiver, + PackageInstaller.STATUS_SUCCESS, + null, + null, + ) + } + + val result = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { + iconGetterCalls += 1 + null + }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + + assertIs(result) + assertEquals(preapprovalSessionId, result.sessionId) + assertEquals(1, iconGetterCalls) + verify(exactly = 1) { preapprovalSession.requestUserPreapproval(any(), any()) } + verify(exactly = 1) { preapprovalSession.close() } + } + + @Test + fun `requestPreapproval NotSupported cases`(): Unit = runBlocking { + // not in foreground -> NotSupported + every { context.isAppInForeground() } returns false + val notForegroundResult = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(notForegroundResult) + + // in foreground + update that can auto-update -> NotSupported + every { context.isAppInForeground() } returns true + every { appVersion.packageName } returns packageName + every { appVersion.manifest.targetSdkVersion } returns 34 + val sourceInfo: InstallSourceInfo = mockk(relaxed = true) + every { sourceInfo.installingPackageName } returns context.packageName + if (SDK_INT >= 34) { + every { sourceInfo.updateOwnerPackageName } returns null + } + every { packageManager.getInstallSourceInfo(packageName) } returns sourceInfo + + val autoUpdateNotNeeded = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = true, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(autoUpdateNotNeeded) + + // isUpdate = true but not our package, and we are not the update owner -> NotSupported, + // because canDoAutoUpdate() returns false when getInstallSourceInfo() throws + every { context.isAppInForeground() } returns true + every { appVersion.packageName } returns packageName + every { appVersion.manifest.targetSdkVersion } returns 34 + every { packageManager.getInstallSourceInfo(packageName) } throws SecurityException("nope") + + val installSourceError = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = true, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + // canDoAutoUpdate returned false due to exception, so we fall through to preapproval() + // which means we should NOT get NotSupported here but proceed to attempt preapproval. + // This asserts that the exception path does not crash and returns some result. + assertIs(installSourceError) + + // iconGetter throwing an exception -> Error (not NotSupported) + every { context.isAppInForeground() } returns true + every { packageInstaller.createSession(any()) } returns 999 + every { packageInstaller.openSession(999) } returns mockk(relaxed = true) + val preapprovalReceiver: InstallBroadcastReceiver = mockk(relaxed = true) + every { receiverFactory.create(999, any()) } returns preapprovalReceiver + every { registerReceiver(context, preapprovalReceiver, any(), any()) } returns mockk() + + val iconError = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { throw RuntimeException("icon load failed") }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(iconError) + assertNotNull(iconError.errorMsg) + assertTrue(iconError.errorMsg.contains("icon load failed")) + } + + @Test + fun `requestPreapproval returns errors when preapproval fails`() = runBlocking { + every { context.isAppInForeground() } returns true + + // pending user action -> UserConfirmationRequired + expectPreApprovalSession(PackageInstaller.STATUS_PENDING_USER_ACTION) + val pending = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = false, + ) + assertIs(pending) + + // aborted -> UserAborted + expectPreApprovalSession(PackageInstaller.STATUS_FAILURE_ABORTED) + val aborted = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(aborted) + + // blocked -> NotSupported + expectPreApprovalSession(PackageInstaller.STATUS_FAILURE_BLOCKED) + val blocked = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(blocked) + + // unknown status -> Error(msg) + expectPreApprovalSession(1337, "foo bar") + val unknown = + sessionInstallManager.requestPreapproval( + app = appMetadata, + iconGetter = { null }, + isUpdate = false, + version = appVersion, + canRequestUserConfirmationNow = true, + ) + assertIs(unknown) + assertNotNull(unknown.errorMsg) + assertTrue(unknown.errorMsg.contains("foo bar")) + } + + @Test + fun `install returns Installed when receiver callback reports STATUS_SUCCESS`() = runBlocking { + val apkFile: File = tmpFolder.newFile("app.apk").apply { writeBytes(byteArrayOf(1, 2, 3)) } + + expectInstallSession(PackageInstaller.STATUS_SUCCESS, apkFile) + + val result = + sessionInstallManager.install( + sessionId = sessionId, + packageName = packageName, + state = installingState, + apkFile = apkFile, + ) + + assertIs(result) + verify(exactly = 1) { + session.openWrite(any(), any(), apkFile.length()) + session.commit(any()) + session.close() + } + verify(atLeast = 1) { context.unregisterReceiver(any()) } + } + + @Test + fun `install returns UserConfirmationNeeded when STATUS_PENDING_USER_ACTION`() { + runBlocking { + val apkFile = tmpFolder.newFile("app-pending.apk") + + val sessionInfo: PackageInstaller.SessionInfo = mockk(relaxed = true) + every { sessionInfo.progress } returns 0.7f + every { packageInstaller.getSessionInfo(sessionId) } returns sessionInfo + every { PendingIntent.getActivity(any(), any(), any(), any()) } returns pendingIntent + + expectInstallSession(PackageInstaller.STATUS_PENDING_USER_ACTION, apkFile) + + val result = + sessionInstallManager.install( + sessionId = sessionId, + packageName = packageName, + state = installingState, + apkFile = apkFile, + ) + + assertIs(result) + assertEquals(0.7f, result.progress) + assertEquals(pendingIntent, result.intent) + } + } + + @Test + fun `install returns UserAborted, PreApprovedError and Error from receiver failures`() { + runBlocking { + // aborted + val aborted = installForStatus(PackageInstaller.STATUS_FAILURE_ABORTED, null) + assertIs(aborted) + + // preapproval-details mismatch gets mapped to PreApproved(result=Error) + val preapprovalMsg = "Some error containing PreapprovalDetails mismatch" + val preapprovalState = installForStatus(PackageInstaller.STATUS_FAILURE, preapprovalMsg) + assertIs(preapprovalState) + assertIs(preapprovalState.result) + + // generic failure maps to Error + val genericError = installForStatus(PackageInstaller.STATUS_FAILURE, "generic failure") + assertIs(genericError) + } + } + + @Test + fun `install cancellation abandons session`() = runBlocking { + val apkFile: File = + tmpFolder.newFile("app-cancel.apk").apply { writeBytes(byteArrayOf(1, 2, 3)) } + + val listenerSlot = slot() + every { receiverFactory.create(sessionId, capture(listenerSlot)) } returns receiver + + val sender: IntentSender = mockk(relaxed = true) + every { PendingIntent.getBroadcast(any(), any(), any(), any()) } returns pendingIntent + every { pendingIntent.intentSender } returns sender + + every { packageInstaller.openSession(sessionId) } returns session + every { session.openWrite(packageName, 0, apkFile.length()) } returns ByteArrayOutputStream() + every { session.fsync(any()) } just runs + every { session.commit(any()) } just Runs + every { session.close() } just Runs + every { registerReceiver(context, receiver, any(), any()) } returns mockk() + every { packageInstaller.abandonSession(sessionId) } just runs + + // start the installation in the background so we can cancel() it + val installScope = CoroutineScope(Dispatchers.Unconfined) + val installJob = + installScope.async { + sessionInstallManager.install( + sessionId = sessionId, + packageName = packageName, + state = installingState, + apkFile = apkFile, + ) + } + installJob.cancelAndJoin() + + verify(atLeast = 1) { context.unregisterReceiver(receiver) } + verify(exactly = 1) { packageInstaller.abandonSession(sessionId) } + } + + @Test + fun `requestUserConfirmation returns PreApproved and Installed on success`() { + runBlocking { + val appVersion: AppVersion = mockk(relaxed = true) + // preapproval-confirmation state -> STATUS_SUCCESS => PreApproved(Success) + 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, + ) + val preapprovalResult = + requestUserConfirmationForStatus(preApprovalState, PackageInstaller.STATUS_SUCCESS, null) + assertIs(preapprovalResult) + assertIs(preapprovalResult.result) + assertEquals(confirmationNeededState.versionName, preapprovalResult.versionName) + assertEquals(confirmationNeededState.currentVersionName, preapprovalResult.currentVersionName) + + // install-confirmation state -> STATUS_SUCCESS => Installed + val installResult = + requestUserConfirmationForStatus( + confirmationNeededState, + PackageInstaller.STATUS_SUCCESS, + null, + ) + assertIs(installResult) + assertEquals(confirmationNeededState.versionName, installResult.versionName) + assertEquals(confirmationNeededState.currentVersionName, installResult.currentVersionName) + } + } + + @Test + fun `requestUserConfirmation maps aborted and failure statuses correctly`() { + runBlocking { + val aborted = + requestUserConfirmationForStatus( + confirmationNeededState, + PackageInstaller.STATUS_FAILURE_ABORTED, + null, + ) + assertIs(aborted) + + val error = + requestUserConfirmationForStatus( + confirmationNeededState, + PackageInstaller.STATUS_FAILURE, + "install failed", + ) + assertIs(error) + } + } + + private fun expectInstallSession( + packageInstallerResult: Int, + apkFile: File, + msg: String? = null, + ) { + val listenerSlot = slot() + every { receiverFactory.create(sessionId, capture(listenerSlot)) } returns receiver + + val sender: IntentSender = mockk(relaxed = true) + every { PendingIntent.getBroadcast(any(), any(), any(), any()) } returns pendingIntent + every { pendingIntent.intentSender } returns sender + + every { packageInstaller.openSession(sessionId) } returns session + every { session.openWrite(packageName, 0, apkFile.length()) } returns ByteArrayOutputStream() + every { session.fsync(any()) } just runs + every { session.commit(any()) } answers + { + listenerSlot.captured.invoke(receiver, packageInstallerResult, null, msg) + } + every { session.close() } just Runs + + every { registerReceiver(context, receiver, any(), any()) } returns mockk() + } + + private suspend fun installForStatus(status: Int, msg: String?): InstallState { + val apkFile: File = tmpFolder.newFile() + + expectInstallSession(status, apkFile, msg) + + return sessionInstallManager.install( + sessionId = sessionId, + packageName = packageName, + state = installingState, + apkFile = apkFile, + ) + } + + private suspend fun requestUserConfirmationForStatus( + state: InstallConfirmationState, + status: Int, + msg: String?, + ): InstallState { + val listenerSlot = slot() + every { receiverFactory.create(sessionId, capture(listenerSlot)) } returns receiver + every { registerReceiver(context, receiver, any(), any()) } returns mockk() + every { context.unregisterReceiver(any()) } just runs + every { state.intent.send() } answers + { + listenerSlot.captured.invoke(receiver, status, null, msg) + } + + return sessionInstallManager.requestUserConfirmation(state) + } + + private fun expectPreApprovalSession(packageInstallerResult: Int, msg: String? = null) { + every { packageInstaller.createSession(any()) } returns 1 + val preapprovalSession: Session = mockk(relaxed = true) + every { packageInstaller.openSession(1) } returns preapprovalSession + + val receiver: InstallBroadcastReceiver = mockk(relaxed = true) + val listenerSlot = slot() + every { receiverFactory.create(1, capture(listenerSlot)) } returns receiver + every { registerReceiver(context, receiver, any(), any()) } returns mockk() + + every { PendingIntent.getBroadcast(any(), any(), any(), any()) } returns pendingIntent + every { pendingIntent.intentSender } returns mockk(relaxed = true) + every { PendingIntent.getActivity(any(), any(), any(), any()) } returns pendingIntent + + every { preapprovalSession.requestUserPreapproval(any(), any()) } answers + { + listenerSlot.captured.invoke(receiver, packageInstallerResult, Intent("confirm"), msg) + } + } +}