diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 0f396d42..79c9b0eb 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -52,6 +52,27 @@ class MetadataManager( writeMetadataToCache() } + /** + * Call this after an APK as been successfully written to backup storage. + * It will update the package's metadata, but NOT write it storage or internal cache. + * You still need to call [onPackageBackedUp] afterwards to write it out. + */ + @Synchronized + fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata) { + metadata.packageMetadata[packageName]?.let { + check(it.time <= packageMetadata.time) { + "APK backup set time of $packageName backwards" + } + check(packageMetadata.version != null) { + "APK backup returned version null" + } + check(it.version == null || it.version < packageMetadata.version) { + "APK backup backed up the same or a smaller version: was ${it.version} is ${packageMetadata.version}" + } + } + metadata.packageMetadata[packageName] = packageMetadata + } + /** * Call this after a package has been backed up successfully. * @@ -91,6 +112,11 @@ class MetadataManager( @Synchronized fun getLastBackupTime(): Long = metadata.time + @Synchronized + fun getPackageMetadata(packageName: String): PackageMetadata? { + return metadata.packageMetadata[packageName]?.copy() + } + @Synchronized @VisibleForTesting private fun getMetadataFromCache(): BackupMetadata? { diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt index c201cef1..e26ce164 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.plugins.saf +import android.content.pm.PackageInfo import android.content.pm.PackageManager import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin @@ -7,6 +8,8 @@ import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin import java.io.IOException import java.io.OutputStream +private const val MIME_TYPE_APK = "application/vnd.android.package-archive" + internal class DocumentsProviderBackupPlugin( private val storage: DocumentsStorage, packageManager: PackageManager) : BackupPlugin { @@ -41,6 +44,13 @@ internal class DocumentsProviderBackupPlugin( return storage.getOutputStream(metadataFile) } + @Throws(IOException::class) + override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream { + val setDir = storage.getSetDir() ?: throw IOException() + val file = setDir.createOrGetFile("${packageInfo.packageName}.apk", MIME_TYPE_APK) + return storage.getOutputStream(file) + } + override val providerPackageName: String? by lazy { val authority = storage.getAuthority() ?: return@lazy null val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 0c236151..805cd915 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -62,6 +62,11 @@ class SettingsManager(context: Context) { return FlashDrive(name, serialNumber, vendorId, productId) } + fun backupApks(): Boolean { + // TODO + return true + } + } data class Storage( diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt new file mode 100644 index 00000000..151e2c20 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt @@ -0,0 +1,141 @@ +package com.stevesoltys.seedvault.transport.backup + +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.Signature +import android.content.pm.SigningInfo +import android.util.Log +import com.stevesoltys.seedvault.Clock +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.encodeBase64 +import com.stevesoltys.seedvault.metadata.MetadataManager +import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.settings.SettingsManager +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.OutputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +private val TAG = ApkBackup::class.java.simpleName + +class ApkBackup( + private val pm: PackageManager, + private val clock: Clock, + private val settingsManager: SettingsManager, + private val metadataManager: MetadataManager) { + + @Throws(IOException::class) + fun backupApkIfNecessary(packageInfo: PackageInfo, streamGetter: () -> OutputStream): Boolean { + // do not back up @pm@ + val packageName = packageInfo.packageName + if (packageName == MAGIC_PACKAGE_MANAGER) return false + + // do not back up when setting is not enabled + if (!settingsManager.backupApks()) return false + + // do not back up system apps that haven't been updated + val isSystemApp = packageInfo.applicationInfo.flags and FLAG_SYSTEM != 0 + val isUpdatedSystemApp = packageInfo.applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0 + if (isSystemApp && !isUpdatedSystemApp) { + Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.") + return false + } + + // get cached metadata about package + val packageMetadata = metadataManager.getPackageMetadata(packageName) + ?: PackageMetadata(time = clock.time()) + + // TODO remove when adding support in [signaturesChanged] + if (packageInfo.signingInfo.hasMultipleSigners()) { + Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.") + return false + } + + // get signatures + val signatures = getSignatures(packageInfo.signingInfo) + if (signatures.isEmpty()) { + Log.e(TAG, "Package $packageName has no signatures. Not backing it up.") + return false + } + + // get version codes + val version = packageInfo.longVersionCode + val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup + + // do not backup if we have the version already and signatures did not change + if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) { + Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.") + return false + } + + // get an InputStream for the APK + val apk = File(packageInfo.applicationInfo.sourceDir) + val inputStream = try { + apk.inputStream() + } catch (e: FileNotFoundException) { + Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e) + throw IOException(e) + } catch (e: SecurityException) { + Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e) + throw IOException(e) + } + + // copy the APK to the storage's output + streamGetter.invoke().use { outputStream -> + inputStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + Log.d(TAG, "Backed up new APK of $packageName with version $version.") + + // update the metadata + val installer = pm.getInstallerPackageName(packageName) + val updatedMetadata = PackageMetadata( + time = clock.time(), + version = version, + installer = installer, + signatures = signatures + ) + metadataManager.onApkBackedUp(packageName, updatedMetadata) + return true + } + + private fun getSignatures(signingInfo: SigningInfo): List { + val signatures = ArrayList() + if (signingInfo.hasMultipleSigners()) { + for (sig in signingInfo.apkContentsSigners) { + signatures.add(hashSignature(sig).encodeBase64()) + } + } else { + for (sig in signingInfo.signingCertificateHistory) { + signatures.add(hashSignature(sig).encodeBase64()) + } + } + return signatures + } + + private fun hashSignature(signature: Signature): ByteArray { + try { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(signature.toByteArray()) + return digest.digest() + } catch (e: NoSuchAlgorithmException) { + Log.e(TAG, "No SHA-256 algorithm found!", e) + throw AssertionError(e) + } + } + + private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List): Boolean { + // no signatures in package metadata counts as them not having changed + if (packageMetadata.signatures == null) return false + // TODO this is probably more complicated, need to verify + // 1. multiple signers: need to match all signatures in list + // 2. single signer (with or without history): the intersection of both lists must not be empty. + return packageMetadata.signatures.intersect(signatures).isEmpty() + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 29553fee..d04664c8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -23,6 +23,7 @@ internal class BackupCoordinator( private val plugin: BackupPlugin, private val kv: KVBackup, private val full: FullBackup, + private val apkBackup: ApkBackup, private val metadataManager: MetadataManager, private val settingsManager: SettingsManager, private val nm: BackupNotificationManager) { @@ -196,6 +197,7 @@ internal class BackupCoordinator( if (result != TRANSPORT_OK) return result val packageName = packageInfo.packageName try { + apkBackup.backupApkIfNecessary(packageInfo) { plugin.getApkOutputStream(packageInfo) } val outputStream = plugin.getMetadataOutputStream() metadataManager.onPackageBackedUp(packageName, outputStream) } catch (e: IOException) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index d01835f1..abda907d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -5,7 +5,8 @@ import org.koin.dsl.module val backupModule = module { single { InputFactory() } + single { ApkBackup(androidContext().packageManager, get(), get(), get()) } single { KVBackup(get().kvBackupPlugin, get(), get(), get()) } single { FullBackup(get().fullBackupPlugin, get(), get(), get()) } - single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get()) } + single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt index fe53dca7..42d6c8ca 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.transport.backup +import android.content.pm.PackageInfo import java.io.IOException import java.io.OutputStream @@ -21,6 +22,12 @@ interface BackupPlugin { @Throws(IOException::class) fun getMetadataOutputStream(): OutputStream + /** + * Returns an [OutputStream] for writing an APK to be backed up. + */ + @Throws(IOException::class) + fun getApkOutputStream(packageInfo: PackageInfo): OutputStream + /** * Returns the package name of the app that provides the backend storage * which is used for the current backup location. diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt index 5f2dc5b4..b13a9e4a 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt @@ -17,6 +17,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.stopKoin import java.io.* +import kotlin.random.Random @RunWith(AndroidJUnit4::class) class MetadataManagerTest { @@ -29,6 +30,7 @@ class MetadataManagerTest { private val manager = MetadataManager(context, clock, metadataWriter, metadataReader) private val time = 42L + private val packageName = getRandomString() private val initialMetadata = BackupMetadata(token = time) private val storageOutputStream = ByteArrayOutputStream() private val cacheOutputStream: FileOutputStream = mockk() @@ -52,9 +54,49 @@ class MetadataManagerTest { assertEquals(0L, manager.getLastBackupTime()) } + @Test + fun `test onApkBackedUp() with no prior package metadata`() { + val packageMetadata = PackageMetadata( + time = time + 1, + version = Random.nextLong(Long.MAX_VALUE), + installer = getRandomString(), + signatures = listOf("sig") + ) + + every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() + every { clock.time() } returns time + + manager.onApkBackedUp(packageName, packageMetadata) + + assertEquals(packageMetadata, manager.getPackageMetadata(packageName)) + } + + @Test + fun `test onApkBackedUp() with existing package metadata`() { + val cachedMetadata = initialMetadata.copy() + val packageMetadata = PackageMetadata( + time = time, + version = Random.nextLong(Long.MAX_VALUE), + installer = getRandomString(), + signatures = listOf("sig") + ) + cachedMetadata.packageMetadata[packageName] = packageMetadata + val updatedPackageMetadata = PackageMetadata( + time = time + 1, + version = packageMetadata.version!! + 1, + installer = getRandomString(), + signatures = listOf("sig foo") + ) + + expectReadFromCache(cachedMetadata) + + manager.onApkBackedUp(packageName, updatedPackageMetadata) + + assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName)) + } + @Test fun `test onPackageBackedUp()`() { - val packageName = getRandomString() val updatedMetadata = initialMetadata.copy() updatedMetadata.time = time updatedMetadata.packageMetadata[packageName] = PackageMetadata(time) @@ -71,13 +113,13 @@ class MetadataManagerTest { @Test fun `test onPackageBackedUp() fails to write to storage`() { - val packageName = getRandomString() + val updateTime = time + 1 val updatedMetadata = initialMetadata.copy() - updatedMetadata.time = time - updatedMetadata.packageMetadata[packageName] = PackageMetadata(time) + updatedMetadata.time = updateTime + updatedMetadata.packageMetadata[packageName] = PackageMetadata(updateTime) every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() - every { clock.time() } returns time + every { clock.time() } returns time andThen updateTime every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException() try { @@ -88,31 +130,32 @@ class MetadataManagerTest { } assertEquals(0L, manager.getLastBackupTime()) // time was reverted - // TODO also assert reverted PackageMetadata once possible + assertEquals(initialMetadata.packageMetadata[packageName], manager.getPackageMetadata(packageName)) } @Test fun `test onPackageBackedUp() with filled cache`() { val cachedPackageName = getRandomString() - val packageName = getRandomString() - val byteArray = ByteArray(DEFAULT_BUFFER_SIZE) - val cachedMetadata = initialMetadata.copy(time = 23) - cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(23) - cachedMetadata.packageMetadata[packageName] = PackageMetadata(23) + val cacheTime = time - 1 + val cachedMetadata = initialMetadata.copy(time = cacheTime) + cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(cacheTime) + cachedMetadata.packageMetadata[packageName] = PackageMetadata(cacheTime) - every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream - every { cacheInputStream.available() } returns byteArray.size andThen 0 - every { cacheInputStream.read(byteArray) } returns -1 - every { metadataReader.decode(ByteArray(0)) } returns cachedMetadata + val updatedMetadata = cachedMetadata.copy(time = time) + cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(time) + cachedMetadata.packageMetadata[packageName] = PackageMetadata(time) + + expectReadFromCache(cachedMetadata) every { clock.time() } returns time - every { metadataWriter.write(cachedMetadata, storageOutputStream) } just Runs - expectWriteToCache(cachedMetadata) + every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs + expectWriteToCache(updatedMetadata) manager.onPackageBackedUp(packageName, storageOutputStream) assertEquals(time, manager.getLastBackupTime()) - // TODO also assert updated PackageMetadata once possible + assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName)) + assertEquals(PackageMetadata(time), manager.getPackageMetadata(packageName)) } private fun expectWriteToCache(metadata: BackupMetadata) { @@ -121,4 +164,12 @@ class MetadataManagerTest { every { cacheOutputStream.write(encodedMetadata) } just Runs } + private fun expectReadFromCache(metadata: BackupMetadata) { + val byteArray = ByteArray(DEFAULT_BUFFER_SIZE) + every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream + every { cacheInputStream.available() } returns byteArray.size andThen 0 + every { cacheInputStream.read(byteArray) } returns -1 + every { metadataReader.decode(ByteArray(0)) } returns metadata + } + } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 6be29db8..f457491f 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.OutputStream import kotlin.random.Random internal class CoordinatorIntegrationTest : TransportTest() { @@ -41,8 +42,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val fullBackupPlugin = mockk() private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) + private val apkBackup = mockk() private val notificationManager = mockk() - private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataManager, settingsManager, notificationManager) + private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, metadataManager, settingsManager, notificationManager) private val restorePlugin = mockk() private val kvRestorePlugin = mockk() @@ -91,6 +93,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData2.size } every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2 + every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs @@ -143,7 +146,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData.size } every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream - every { settingsManager.saveNewBackupTime() } just Runs + every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns false + every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream + every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs // start and finish K/V backup assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) @@ -179,6 +184,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP + every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt index 9e307916..93e94326 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt @@ -1,8 +1,13 @@ package com.stevesoltys.seedvault.transport import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP +import android.content.pm.ApplicationInfo.FLAG_INSTALLED import android.content.pm.PackageInfo +import android.content.pm.SigningInfo import android.util.Log +import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.settings.SettingsManager @@ -11,16 +16,26 @@ import io.mockk.mockk import io.mockk.mockkStatic import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import kotlin.random.Random @TestInstance(PER_METHOD) abstract class TransportTest { + protected val clock: Clock = mockk() protected val crypto = mockk() protected val settingsManager = mockk() protected val metadataManager = mockk() protected val context = mockk(relaxed = true) - protected val packageInfo = PackageInfo().apply { packageName = "org.example" } + protected val sigInfo: SigningInfo = mockk() + protected val packageInfo = PackageInfo().apply { + packageName = "org.example" + longVersionCode = Random.nextLong() + applicationInfo = ApplicationInfo().apply { + flags = FLAG_ALLOW_BACKUP or FLAG_INSTALLED + } + signingInfo = sigInfo + } init { mockkStatic(Log::class) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt new file mode 100644 index 00000000..c83a839a --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt @@ -0,0 +1,130 @@ +package com.stevesoltys.seedvault.transport.backup + +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.Signature +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.getRandomByteArray +import com.stevesoltys.seedvault.getRandomString +import com.stevesoltys.seedvault.metadata.PackageMetadata +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.nio.file.Path +import kotlin.random.Random + + +internal class ApkBackupTest : BackupTest() { + + private val pm: PackageManager = mockk() + private val streamGetter: () -> OutputStream = mockk() + + private val apkBackup = ApkBackup(pm, clock, settingsManager, metadataManager) + + private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03) + private val sigs = arrayOf(Signature(signatureBytes)) + private val packageMetadata = PackageMetadata( + time = Random.nextLong(), + version = packageInfo.longVersionCode - 1, + signatures = listOf("A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E") + ) + + @Test + fun `does not back up @pm@`() { + val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + + @Test + fun `does not back up when setting disabled`() { + every { settingsManager.backupApks() } returns false + + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + + @Test + fun `does not back up system apps`() { + packageInfo.applicationInfo.flags = FLAG_SYSTEM + + every { settingsManager.backupApks() } returns true + + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + + @Test + fun `does not back up the same version`() { + packageInfo.applicationInfo.flags = FLAG_UPDATED_SYSTEM_APP + val packageMetadata = packageMetadata.copy( + version = packageInfo.longVersionCode + ) + + expectChecks(packageMetadata) + + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + + @Test + fun `does back up the same version when signatures changes`() { + packageInfo.applicationInfo.sourceDir = "/tmp/doesNotExist" + + expectChecks() + + assertThrows(IOException::class.java) { + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + } + + @Test + fun `do not accept empty signature`() { + every { settingsManager.backupApks() } returns true + every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata + every { sigInfo.hasMultipleSigners() } returns false + every { sigInfo.signingCertificateHistory } returns emptyArray() + + assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + } + + @Test + fun `test successful APK backup`(@TempDir tmpDir: Path) { + val apkBytes = getRandomByteArray() + val tmpFile = File(tmpDir.toAbsolutePath().toString()) + packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply { + assertTrue(createNewFile()) + writeBytes(apkBytes) + }.absolutePath + val apkOutputStream = ByteArrayOutputStream() + val updatedMetadata = PackageMetadata( + time = Random.nextLong(), + version = packageInfo.longVersionCode, + installer = getRandomString(), + signatures = packageMetadata.signatures + ) + + expectChecks() + every { streamGetter.invoke() } returns apkOutputStream + every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer + every { clock.time() } returns updatedMetadata.time + every { metadataManager.onApkBackedUp(packageInfo.packageName, updatedMetadata) } just Runs + + assertTrue(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) + } + + private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) { + every { settingsManager.backupApks() } returns true + every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata + every { sigInfo.hasMultipleSigners() } returns false + every { sigInfo.signingCertificateHistory } returns sigs + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 96d666b4..c3a3c306 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -23,9 +23,10 @@ internal class BackupCoordinatorTest: BackupTest() { private val plugin = mockk() private val kv = mockk() private val full = mockk() + private val apkBackup = mockk() private val notificationManager = mockk() - private val backup = BackupCoordinator(context, plugin, kv, full, metadataManager, settingsManager, notificationManager) + private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, metadataManager, settingsManager, notificationManager) private val metadataOutputStream = mockk()