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..ae16652f 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 @@ -85,10 +85,14 @@ internal class ApkRestore( // re-install individual packages and emit updates (start from last and work your way up) for ((packageName, apkInstallResult) in packages.asIterable().reversed()) { 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) @@ -107,6 +111,23 @@ internal class ApkRestore( 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") @Throws(IOException::class, SecurityException::class) private suspend fun restore( 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..0d28f165 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 @@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.restore.install 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 @@ -145,6 +146,7 @@ internal class ApkBackupRestoreTest : TransportTest() { val cacheFiles = slot>() every { installRestriction.isAllowedToInstallApks() } returns true + 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..ccbdab15 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 @@ -11,6 +11,7 @@ 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 @@ -30,9 +31,11 @@ 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.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions @@ -116,6 +119,59 @@ 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 { 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 { 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 @@ -138,6 +194,7 @@ internal class ApkRestoreTest : TransportTest() { @Test fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) @@ -163,6 +220,7 @@ internal class ApkRestoreTest : TransportTest() { val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() cacheBaseApkAndGetInfo(tmpDir) coEvery { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) @@ -191,6 +249,7 @@ internal class ApkRestoreTest : TransportTest() { val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns File(tmpDir.toString()) coEvery { legacyStoragePlugin.getApkInputStream(token, packageName, "") @@ -210,6 +269,97 @@ 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 { 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 { 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 { 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 +370,14 @@ internal class ApkRestoreTest : TransportTest() { val isSystemApp = Random.nextBoolean() every { installRestriction.isAllowedToInstallApks() } returns true + 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 +438,7 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -312,6 +464,7 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -340,6 +493,7 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -370,6 +524,7 @@ internal class ApkRestoreTest : TransportTest() { ) every { installRestriction.isAllowedToInstallApks() } returns true + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir)