Back up split APKs as well and store them in the metadata

This will enable us to check compatibility of the splits with the restore device and if compatible, re-install them.
This commit is contained in:
Torsten Grote 2020-10-06 16:55:57 -03:00 committed by Chirayu Desai
parent af2bf4f60a
commit 3a31e09a04
6 changed files with 139 additions and 21 deletions

View file

@ -170,14 +170,14 @@ class PluginTest : KoinComponent {
// write random bytes as APK // write random bytes as APK
val apk1 = getRandomByteArray(1337 * 1024) val apk1 = getRandomByteArray(1337 * 1024)
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk1) backupPlugin.getApkOutputStream(packageInfo, "").writeAndClose(apk1)
// assert that read APK bytes match what was written // assert that read APK bytes match what was written
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName)) assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName))
// write random bytes as another APK // write random bytes as another APK
val apk2 = getRandomByteArray(23 * 1024 * 1024) val apk2 = getRandomByteArray(23 * 1024 * 1024)
backupPlugin.getApkOutputStream(packageInfo2).writeAndClose(apk2) backupPlugin.getApkOutputStream(packageInfo2, "").writeAndClose(apk2)
// assert that read APK bytes match what was written // assert that read APK bytes match what was written
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName)) assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))

View file

@ -52,9 +52,13 @@ internal class DocumentsProviderBackupPlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream { override suspend fun getApkOutputStream(
packageInfo: PackageInfo,
suffix: String
): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir() ?: throw IOException()
val file = setDir.createOrGetFile(context, "${packageInfo.packageName}.apk", MIME_TYPE_APK) val name = "${packageInfo.packageName}$suffix.apk"
val file = setDir.createOrGetFile(context, name, MIME_TYPE_APK)
return storage.getOutputStream(file) return storage.getOutputStream(file)
} }

View file

@ -9,6 +9,7 @@ import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
@ -43,7 +44,7 @@ class ApkBackup(
suspend fun backupApkIfNecessary( suspend fun backupApkIfNecessary(
packageInfo: PackageInfo, packageInfo: PackageInfo,
packageState: PackageState, packageState: PackageState,
streamGetter: suspend () -> OutputStream streamGetter: suspend (suffix: String) -> OutputStream
): PackageMetadata? { ): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
@ -86,13 +87,19 @@ class ApkBackup(
" already has a backup ($backedUpVersion)" + " already has a backup ($backedUpVersion)" +
" with the same signature. Not backing it up." " with the same signature. Not backing it up."
) )
// We could also check if there are new feature module splits to back up,
// but we rely on the app themselves to re-download those, if needed after restore.
return null return null
} }
// get an InputStream for the APK // get an InputStream for the APK
val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir) val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir)
// copy the APK to the storage's output and calculate SHA-256 hash while at it // copy the APK to the storage's output and calculate SHA-256 hash while at it
val sha256 = copyStreamsAndGetHash(inputStream, streamGetter()) val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(""))
// back up splits if they exist
val splits =
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter)
Log.d(TAG, "Backed up new APK of $packageName with version $version.") Log.d(TAG, "Backed up new APK of $packageName with version $version.")
@ -101,6 +108,7 @@ class ApkBackup(
state = packageState, state = packageState,
version = version, version = version,
installer = pm.getInstallSourceInfo(packageName).installingPackageName, installer = pm.getInstallSourceInfo(packageName).installingPackageName,
splits = splits,
sha256 = sha256, sha256 = sha256,
signatures = signatures signatures = signatures
) )
@ -130,6 +138,56 @@ class ApkBackup(
} }
} }
@Throws(IOException::class)
private suspend fun backupSplitApks(
packageInfo: PackageInfo,
streamGetter: suspend (suffix: String) -> OutputStream
): List<ApkSplit> {
check(packageInfo.splitNames != null)
val splitSourceDirs = packageInfo.applicationInfo.splitSourceDirs
check(packageInfo.splitNames.size == splitSourceDirs.size) {
"Size Mismatch! ${packageInfo.splitNames.size} != ${splitSourceDirs.size} " +
"splitNames is ${packageInfo.splitNames.toList()}, " +
"but splitSourceDirs is ${splitSourceDirs.toList()}"
}
val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size)
for (i in packageInfo.splitNames.indices) {
val split = backupSplitApk(packageInfo.splitNames[i], splitSourceDirs[i], streamGetter)
splits.add(split)
}
return splits
}
@Throws(IOException::class)
private suspend fun backupSplitApk(
name: String,
sourceDir: String,
streamGetter: suspend (suffix: String) -> OutputStream
): ApkSplit {
// Calculate sha256 hash first to determine file name suffix.
// We could also just use the split name as a suffix, but there is a theoretical risk
// that we exceed the maximum file name length, so we use the hash instead.
// The downside is that we need to read the file two times.
val messageDigest = MessageDigest.getInstance("SHA-256")
getApkInputStream(sourceDir).use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
val sha256 = messageDigest.digest().encodeBase64()
val suffix = "_$sha256"
// copy the split APK to the storage stream
getApkInputStream(sourceDir).use { inputStream ->
streamGetter(suffix).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return ApkSplit(name, sha256)
}
} }
/** /**

View file

@ -364,7 +364,7 @@ internal class BackupCoordinator(
metadataManager.getPackageMetadata(packageName) metadataManager.getPackageMetadata(packageName)
val oldPackageState = packageMetadata?.state val oldPackageState = packageMetadata?.state
if (oldPackageState != null && oldPackageState != packageState) { if (oldPackageState != null && oldPackageState != packageState) {
Log.e( Log.i(
TAG, "Package $packageName was in $oldPackageState" + TAG, "Package $packageName was in $oldPackageState" +
", update to $packageState" ", update to $packageState"
) )
@ -390,8 +390,8 @@ internal class BackupCoordinator(
): Boolean { ): Boolean {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
return try { return try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) { apkBackup.backupApkIfNecessary(packageInfo, packageState) { suffix ->
plugin.getApkOutputStream(packageInfo) plugin.getApkOutputStream(packageInfo, suffix)
}?.let { packageMetadata -> }?.let { packageMetadata ->
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onApkBackedUp(packageInfo, packageMetadata, it) metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)

View file

@ -35,7 +35,7 @@ interface BackupPlugin {
* Returns an [OutputStream] for writing an APK to be backed up. * Returns an [OutputStream] for writing an APK to be backed up.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream suspend fun getApkOutputStream(packageInfo: PackageInfo, suffix: String): OutputStream
/** /**
* Returns the package name of the app that provides the backend storage * Returns the package name of the app that provides the backend storage

View file

@ -9,12 +9,11 @@ import android.content.pm.Signature
import android.util.PackageUtils import android.util.PackageUtils
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -36,7 +35,7 @@ import kotlin.random.Random
internal class ApkBackupTest : BackupTest() { internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk() private val pm: PackageManager = mockk()
private val streamGetter: suspend () -> OutputStream = mockk() private val streamGetter: suspend (suffix: String) -> OutputStream = mockk()
private val apkBackup = ApkBackup(pm, settingsManager, metadataManager) private val apkBackup = ApkBackup(pm, settingsManager, metadataManager)
@ -131,17 +130,10 @@ internal class ApkBackupTest : BackupTest() {
) )
expectChecks() expectChecks()
coEvery { streamGetter.invoke() } returns apkOutputStream coEvery { streamGetter.invoke("") } returns apkOutputStream
every { every {
pm.getInstallSourceInfo(packageInfo.packageName) pm.getInstallSourceInfo(packageInfo.packageName)
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer) } returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
every {
metadataManager.onApkBackedUp(
packageInfo,
updatedMetadata,
outputStream
)
} just Runs
assertEquals( assertEquals(
updatedMetadata, updatedMetadata,
@ -150,6 +142,70 @@ internal class ApkBackupTest : BackupTest() {
assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
} }
@Test
fun `test successful APK backup with two splits`(@TempDir tmpDir: Path) = runBlocking {
// create base APK
val apkBytes = byteArrayOf(0x04, 0x05, 0x06) // not random because of hash
val tmpFile = File(tmpDir.toAbsolutePath().toString())
packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply {
assertTrue(createNewFile())
writeBytes(apkBytes)
}.absolutePath
// set split names
val split1Name = "config.arm64_v8a"
val split2Name = "config.xxxhdpi"
packageInfo.splitNames = arrayOf(split1Name, split2Name)
// create two split APKs
val split1Bytes = byteArrayOf(0x07, 0x08, 0x09)
val split1Sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
val split2Bytes = byteArrayOf(0x01, 0x02, 0x03)
val split2Sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
packageInfo.applicationInfo.splitSourceDirs = arrayOf(
File(tmpFile, "test-$split1Name.apk").apply {
assertTrue(createNewFile())
writeBytes(split1Bytes)
}.absolutePath,
File(tmpFile, "test-$split2Name.apk").apply {
assertTrue(createNewFile())
writeBytes(split2Bytes)
}.absolutePath
)
// create streams
val apkOutputStream = ByteArrayOutputStream()
val split1OutputStream = ByteArrayOutputStream()
val split2OutputStream = ByteArrayOutputStream()
// expected new metadata for package
val updatedMetadata = PackageMetadata(
time = 0L,
state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode,
installer = getRandomString(),
splits = listOf(
ApkSplit(split1Name, split1Sha256),
ApkSplit(split2Name, split2Sha256)
),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = packageMetadata.signatures
)
expectChecks()
coEvery { streamGetter.invoke("") } returns apkOutputStream
coEvery { streamGetter.invoke("_$split1Sha256") } returns split1OutputStream
coEvery { streamGetter.invoke("_$split2Sha256") } returns split2OutputStream
every {
pm.getInstallSourceInfo(packageInfo.packageName)
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
assertEquals(
updatedMetadata,
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)
)
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())
assertArrayEquals(split2Bytes, split2OutputStream.toByteArray())
}
private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) { private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) {
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
every { every {