diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 0189cbe7..aea38f1c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -17,7 +17,6 @@ import android.os.ServiceManager.getService import android.os.StrictMode import android.os.UserHandle import android.os.UserManager -import android.provider.Settings import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.WorkManager import com.google.android.material.color.DynamicColors @@ -148,6 +147,7 @@ open class App : Application() { private val metadataManager: MetadataManager by inject() private val backupManager: IBackupManager by inject() private val pluginManager: StoragePluginManager by inject() + private val backupStateManager: BackupStateManager by inject() /** * The responsibility for the current token was moved to the [SettingsManager] @@ -168,7 +168,7 @@ open class App : Application() { * Introduced in the first half of 2024 and can be removed after a suitable migration period. */ protected open fun migrateToOwnScheduling() { - if (!isFrameworkSchedulingEnabled()) { // already on own scheduling + if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling // fix things for removable drive users who had a job scheduled here before if (pluginManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext) return @@ -184,10 +184,6 @@ open class App : Application() { } } - private fun isFrameworkSchedulingEnabled(): Boolean = Settings.Secure.getInt( - contentResolver, Settings.Secure.BACKUP_SCHEDULING_ENABLED, 1 - ) == 1 // 1 means enabled which is the default - } const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL diff --git a/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt index 036a4387..ac762a37 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt @@ -6,6 +6,9 @@ package com.stevesoltys.seedvault import android.content.Context +import android.provider.Settings +import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE +import android.provider.Settings.Secure.BACKUP_SCHEDULING_ENABLED import android.util.Log import androidx.work.WorkInfo.State.RUNNING import androidx.work.WorkManager @@ -22,6 +25,7 @@ class BackupStateManager( ) { private val workManager = WorkManager.getInstance(context) + private val contentResolver = context.contentResolver val isBackupRunning: Flow = combine( flow = ConfigurableBackupTransportService.isRunning, @@ -37,4 +41,10 @@ class BackupStateManager( appBackupRunning || filesBackupRunning || workInfoState == RUNNING } + val isAutoRestoreEnabled: Boolean + get() = Settings.Secure.getInt(contentResolver, BACKUP_AUTO_RESTORE, 1) == 1 + + val isFrameworkSchedulingEnabled: Boolean + get() = Settings.Secure.getInt(contentResolver, BACKUP_SCHEDULING_ENABLED, 1) == 1 + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 85013361..dc3af875 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -5,11 +5,13 @@ package com.stevesoltys.seedvault.restore.install +import android.app.backup.IBackupManager import android.content.Context import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.util.Log +import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.metadata.ApkSplit @@ -38,6 +40,8 @@ private val TAG = ApkRestore::class.java.simpleName internal class ApkRestore( private val context: Context, + private val backupManager: IBackupManager, + private val backupStateManager: BackupStateManager, private val pluginManager: StoragePluginManager, @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin, @@ -81,14 +85,33 @@ internal class ApkRestore( return } mInstallResult.value = InstallResult(packages) + val autoRestore = backupStateManager.isAutoRestoreEnabled + try { + // disable auto-restore before installing apps, if it was enabled before + if (autoRestore) backupManager.setAutoRestore(false) + reInstallApps(backup, packages.asIterable().reversed()) + } finally { + // re-enable auto-restore, if it was enabled before + if (autoRestore) backupManager.setAutoRestore(true) + } + mInstallResult.update { it.copy(isFinished = true) } + } + private suspend fun reInstallApps( + backup: RestorableBackup, + packages: List>, + ) { // re-install individual packages and emit updates (start from last and work your way up) - for ((packageName, apkInstallResult) in packages.asIterable().reversed()) { + for ((packageName, apkInstallResult) in packages) { try { - if (apkInstallResult.metadata.hasApk()) { - restore(backup, packageName, apkInstallResult.metadata) - } else { + if (isInstalled(packageName, apkInstallResult.metadata)) { + mInstallResult.update { result -> + result.update(packageName) { it.copy(state = SUCCEEDED) } + } + } else if (!apkInstallResult.metadata.hasApk()) { // no APK available for install mInstallResult.update { it.fail(packageName) } + } else { + restore(backup, packageName, apkInstallResult.metadata) } } catch (e: IOException) { Log.e(TAG, "Error re-installing APK for $packageName.", e) @@ -104,7 +127,23 @@ internal class ApkRestore( mInstallResult.update { it.fail(packageName) } } } - mInstallResult.update { it.copy(isFinished = true) } + } + + @Throws(SecurityException::class) + private fun isInstalled(packageName: String, metadata: PackageMetadata): Boolean { + @Suppress("DEPRECATION") // GET_SIGNATURES is needed even though deprecated + val flags = GET_SIGNING_CERTIFICATES or GET_SIGNATURES + val packageInfo = try { + pm.getPackageInfo(packageName, flags) + } catch (e: PackageManager.NameNotFoundException) { + null + } ?: return false + val signatures = metadata.signatures + if (signatures != null && signatures != packageInfo.signingInfo.getSignatures()) { + // this will get caught and flag app as failed, could receive dedicated handling later + throw SecurityException("Signature mismatch for $packageName") + } + return packageInfo.longVersionCode >= (metadata.version ?: 0) } @Suppress("ThrowsCount") diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt index 812e6928..8fbeaaae 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt @@ -14,7 +14,7 @@ val installModule = module { factory { DeviceInfo(androidContext()) } factory { ApkSplitCompatibilityChecker(get()) } factory { - ApkRestore(androidContext(), get(), get(), get(), get(), get()) { + ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) { androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks() } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index df2d9bfc..8ea5949c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -10,8 +10,6 @@ import android.content.Intent import android.os.Bundle import android.os.PowerManager import android.os.RemoteException -import android.provider.Settings -import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE import android.util.Log import android.view.Menu import android.view.MenuInflater @@ -25,6 +23,7 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference import androidx.work.WorkInfo import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.plugins.StoragePluginManager @@ -42,6 +41,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private val viewModel: SettingsViewModel by sharedViewModel() private val storagePluginManager: StoragePluginManager by inject() + private val backupStateManager: BackupStateManager by inject() private val backupManager: IBackupManager by inject() private val notificationManager: BackupNotificationManager by inject() @@ -271,7 +271,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setAutoRestoreState() { activity?.contentResolver?.let { - autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1 + autoRestore.isChecked = backupStateManager.isAutoRestoreEnabled } val storage = this.storageProperties if (storage?.isUsb == true) { diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index 0fb2f2b9..562766b7 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -5,12 +5,15 @@ package com.stevesoltys.seedvault.restore.install +import android.app.backup.IBackupManager import android.content.Context import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.Signature import android.graphics.drawable.Drawable import android.util.PackageUtils import app.cash.turbine.test +import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.assertReadEquals import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit @@ -56,6 +59,8 @@ internal class ApkBackupRestoreTest : TransportTest() { } private val storagePluginManager: StoragePluginManager = mockk() + private val backupManager: IBackupManager = mockk() + private val backupStateManager: BackupStateManager = mockk() @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = mockk() @@ -67,6 +72,8 @@ internal class ApkBackupRestoreTest : TransportTest() { private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) private val apkRestore: ApkRestore = ApkRestore( context = strictContext, + backupManager = backupManager, + backupStateManager = backupStateManager, pluginManager = storagePluginManager, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, @@ -145,6 +152,8 @@ internal class ApkBackupRestoreTest : TransportTest() { val cacheFiles = slot>() every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns tmpFile every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { storagePlugin.getInputStream(token, name) } returns inputStream diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index 2cb74bd5..8d2548c4 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -5,15 +5,18 @@ package com.stevesoltys.seedvault.restore.install +import android.app.backup.IBackupManager import android.content.Context import android.content.pm.ApplicationInfo.FLAG_INSTALLED import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException import android.graphics.drawable.Drawable import app.cash.turbine.TurbineTestContext import app.cash.turbine.test +import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.getRandomBase64 import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomString @@ -30,9 +33,14 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.transport.TransportTest +import com.stevesoltys.seedvault.worker.getSignatures +import io.mockk.Runs import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions @@ -54,6 +62,8 @@ internal class ApkRestoreTest : TransportTest() { private val strictContext: Context = mockk().apply { every { packageManager } returns pm } + private val backupManager: IBackupManager = mockk() + private val backupStateManager: BackupStateManager = mockk() private val storagePluginManager: StoragePluginManager = mockk() private val storagePlugin: StoragePlugin<*> = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk() @@ -63,6 +73,8 @@ internal class ApkRestoreTest : TransportTest() { private val apkRestore: ApkRestore = ApkRestore( context = strictContext, + backupManager = backupManager, + backupStateManager = backupStateManager, pluginManager = storagePluginManager, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, @@ -104,6 +116,7 @@ internal class ApkRestoreTest : TransportTest() { val backup = swapPackages(hashMapOf(packageName to packageMetadata)) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false every { strictContext.cacheDir } returns File(tmpDir.toString()) every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream @@ -116,12 +129,68 @@ internal class ApkRestoreTest : TransportTest() { } } + @Test + fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking { + // remove all APK info + val packageMetadata = packageMetadata.copy( + version = null, + installer = null, + sha256 = null, + signatures = null, + ) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertEquals(QUEUED, awaitItem()[packageName].state) + assertEquals(FAILED, awaitItem()[packageName].state) + assertTrue(awaitItem().isFinished) + ensureAllEventsConsumed() + } + } + + @Test + fun `test app without APK succeeds if installed`(@TempDir tmpDir: Path) = runBlocking { + // remove all APK info + val packageMetadata = packageMetadata.copy( + version = null, + installer = null, + sha256 = null, + signatures = null, + ) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { storagePlugin.providerPackageName } returns storageProviderPackageName + + val packageInfo: PackageInfo = mockk() + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.longVersionCode } returns 42 + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertEquals(QUEUED, awaitItem()[packageName].state) + assertEquals(SUCCEEDED, awaitItem()[packageName].state) + assertTrue(awaitItem().isFinished) + ensureAllEventsConsumed() + } + } + @Test fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { // change package name to random string packageInfo.packageName = getRandomString() every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false every { strictContext.cacheDir } returns File(tmpDir.toString()) every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream @@ -138,6 +207,8 @@ internal class ApkRestoreTest : TransportTest() { @Test fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) @@ -163,6 +234,8 @@ internal class ApkRestoreTest : TransportTest() { val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) @@ -191,6 +264,8 @@ internal class ApkRestoreTest : TransportTest() { val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns File(tmpDir.toString()) coEvery { legacyStoragePlugin.getApkInputStream(token, packageName, "") @@ -210,6 +285,100 @@ internal class ApkRestoreTest : TransportTest() { } } + @Test + fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! + every { + packageInfo.longVersionCode + } returns packageMetadata.version!! + Random.nextLong(0, 2) // can be newer + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(SUCCEEDED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) = + runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! + every { packageInfo.longVersionCode } returns packageMetadata.version!! - 1 + + cacheBaseApkAndGetInfo(tmpDir) + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(SUCCEEDED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns listOf("foobar") + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(FAILED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + @Test fun `test system apps only reinstalled when older system apps exist`(@TempDir tmpDir: Path) = runBlocking { @@ -220,13 +389,15 @@ internal class ApkRestoreTest : TransportTest() { val isSystemApp = Random.nextBoolean() every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) every { storagePlugin.providerPackageName } returns storageProviderPackageName if (willFail) { every { pm.getPackageInfo(packageName, 0) - } throws PackageManager.NameNotFoundException() + } throws NameNotFoundException() } else { installedPackageInfo.applicationInfo = mockk { flags = @@ -287,6 +458,8 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -312,6 +485,8 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -340,6 +515,8 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -370,6 +547,8 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -411,6 +590,7 @@ internal class ApkRestoreTest : TransportTest() { @Test fun `storage provider app does not get reinstalled`() = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false // set the storage provider package name to match our current package name, // and ensure that the current package is therefore skipped. every { storagePlugin.providerPackageName } returns packageName @@ -437,6 +617,7 @@ internal class ApkRestoreTest : TransportTest() { ).also { assertFalse(it.hasApk()) } every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false every { storagePlugin.providerPackageName } returns storageProviderPackageName apkRestore.installResult.test { @@ -453,6 +634,40 @@ internal class ApkRestoreTest : TransportTest() { } } + @Test + fun `auto restore gets turned off, if it was on`(@TempDir tmpDir: Path) = runBlocking { + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns true + every { storagePlugin.providerPackageName } returns storageProviderPackageName + every { backupManager.setAutoRestore(false) } just Runs + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + every { backupManager.setAutoRestore(true) } just Runs + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() + } + verifyOrder { + backupManager.setAutoRestore(false) + backupManager.setAutoRestore(true) + } + } + @Test fun `no apks get installed when blocked by policy`() = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns false