Skip to content

Commit

Permalink
Don't re-install apps that are already installed
Browse files Browse the repository at this point in the history
  • Loading branch information
grote committed Aug 15, 2024
1 parent b571da7 commit a6fff47
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand All @@ -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())
Expand Down Expand Up @@ -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, "")
Expand All @@ -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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down

0 comments on commit a6fff47

Please sign in to comment.