Back up APKs to storage (when they changed) and save metadata about them
This commit is contained in:
parent
b9cac5ea87
commit
81c2031ce7
12 changed files with 418 additions and 23 deletions
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -62,6 +62,11 @@ class SettingsManager(context: Context) {
|
|||
return FlashDrive(name, serialNumber, vendorId, productId)
|
||||
}
|
||||
|
||||
fun backupApks(): Boolean {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class Storage(
|
||||
|
|
|
@ -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<String> {
|
||||
val signatures = ArrayList<String>()
|
||||
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<String>): 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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -5,7 +5,8 @@ import org.koin.dsl.module
|
|||
|
||||
val backupModule = module {
|
||||
single { InputFactory() }
|
||||
single { ApkBackup(androidContext().packageManager, get(), get(), get()) }
|
||||
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
|
||||
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
|
||||
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get()) }
|
||||
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<FullBackupPlugin>()
|
||||
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
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<RestorePlugin>()
|
||||
private val kvRestorePlugin = mockk<KVRestorePlugin>()
|
||||
|
@ -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
|
||||
|
||||
|
|
|
@ -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<Crypto>()
|
||||
protected val settingsManager = mockk<SettingsManager>()
|
||||
protected val metadataManager = mockk<MetadataManager>()
|
||||
protected val context = mockk<Context>(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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -23,9 +23,10 @@ internal class BackupCoordinatorTest: BackupTest() {
|
|||
private val plugin = mockk<BackupPlugin>()
|
||||
private val kv = mockk<KVBackup>()
|
||||
private val full = mockk<FullBackup>()
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
|
||||
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<OutputStream>()
|
||||
|
||||
|
|
Loading…
Reference in a new issue