Don't re-install apps that are already installed
This commit is contained in:
parent
cff5d20342
commit
109e0ae281
3 changed files with 182 additions and 4 deletions
|
@ -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(
|
||||
|
|
|
@ -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<List<File>>()
|
||||
|
||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
||||
every { strictContext.cacheDir } returns tmpFile
|
||||
every { crypto.getNameForApk(salt, packageName, "") } returns name
|
||||
coEvery { storagePlugin.getInputStream(token, name) } returns inputStream
|
||||
|
|
|
@ -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<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
|
||||
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<Int>()) } 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<Int>()) } 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<Int>()) } 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<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
|
||||
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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } throws NameNotFoundException()
|
||||
// cache APK and get icon as well as app name
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
||||
|
|
Loading…
Reference in a new issue