Don't re-install apps that are already installed

This commit is contained in:
Torsten Grote 2024-08-15 17:41:31 -03:00
parent cff5d20342
commit 109e0ae281
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
3 changed files with 182 additions and 4 deletions

View file

@ -85,10 +85,14 @@ internal class ApkRestore(
// re-install individual packages and emit updates (start from last and work your way up) // 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.asIterable().reversed()) {
try { try {
if (apkInstallResult.metadata.hasApk()) { if (isInstalled(packageName, apkInstallResult.metadata)) {
restore(backup, packageName, apkInstallResult.metadata) mInstallResult.update { result ->
} else { result.update(packageName) { it.copy(state = SUCCEEDED) }
}
} else if (!apkInstallResult.metadata.hasApk()) { // no APK available for install
mInstallResult.update { it.fail(packageName) } mInstallResult.update { it.fail(packageName) }
} else {
restore(backup, packageName, apkInstallResult.metadata)
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
@ -107,6 +111,23 @@ internal class ApkRestore(
mInstallResult.update { it.copy(isFinished = true) } 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") @Suppress("ThrowsCount")
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun restore( private suspend fun restore(

View file

@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.restore.install
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.content.pm.Signature import android.content.pm.Signature
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.PackageUtils import android.util.PackageUtils
@ -145,6 +146,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
val cacheFiles = slot<List<File>>() val cacheFiles = slot<List<File>>()
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns tmpFile every { strictContext.cacheDir } returns tmpFile
every { crypto.getNameForApk(salt, packageName, "") } returns name every { crypto.getNameForApk(salt, packageName, "") } returns name
coEvery { storagePlugin.getInputStream(token, name) } returns inputStream coEvery { storagePlugin.getInputStream(token, name) } returns inputStream

View file

@ -11,6 +11,7 @@ import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test 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.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.worker.getSignatures
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions 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<Int>()) } 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<Int>()) } 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 @Test
fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change package name to random string // change package name to random string
@ -138,6 +194,7 @@ internal class ApkRestoreTest : TransportTest() {
@Test @Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking { fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
coEvery { coEvery {
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
@ -163,6 +220,7 @@ internal class ApkRestoreTest : TransportTest() {
val installResult = InstallResult(packagesMap) val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
coEvery { coEvery {
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
@ -191,6 +249,7 @@ internal class ApkRestoreTest : TransportTest() {
val installResult = InstallResult(packagesMap) val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString()) every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "") 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<Int>()) } 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<Int>()) } 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<Int>()) } 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 @Test
fun `test system apps only reinstalled when older system apps exist`(@TempDir tmpDir: Path) = fun `test system apps only reinstalled when older system apps exist`(@TempDir tmpDir: Path) =
runBlocking { runBlocking {
@ -220,13 +370,14 @@ internal class ApkRestoreTest : TransportTest() {
val isSystemApp = Random.nextBoolean() val isSystemApp = Random.nextBoolean()
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
if (willFail) { if (willFail) {
every { every {
pm.getPackageInfo(packageName, 0) pm.getPackageInfo(packageName, 0)
} throws PackageManager.NameNotFoundException() } throws NameNotFoundException()
} else { } else {
installedPackageInfo.applicationInfo = mockk { installedPackageInfo.applicationInfo = mockk {
flags = flags =
@ -287,6 +438,7 @@ internal class ApkRestoreTest : TransportTest() {
) )
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -312,6 +464,7 @@ internal class ApkRestoreTest : TransportTest() {
) )
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -340,6 +493,7 @@ internal class ApkRestoreTest : TransportTest() {
) )
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -370,6 +524,7 @@ internal class ApkRestoreTest : TransportTest() {
) )
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)