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:
parent
af2bf4f60a
commit
3a31e09a04
6 changed files with 139 additions and 21 deletions
|
@ -170,14 +170,14 @@ class PluginTest : KoinComponent {
|
|||
|
||||
// write random bytes as APK
|
||||
val apk1 = getRandomByteArray(1337 * 1024)
|
||||
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk1)
|
||||
backupPlugin.getApkOutputStream(packageInfo, "").writeAndClose(apk1)
|
||||
|
||||
// assert that read APK bytes match what was written
|
||||
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName))
|
||||
|
||||
// write random bytes as another APK
|
||||
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
|
||||
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))
|
||||
|
|
|
@ -52,9 +52,13 @@ internal class DocumentsProviderBackupPlugin(
|
|||
}
|
||||
|
||||
@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 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)
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.util.Log
|
|||
import android.util.PackageUtils.computeSha256DigestBytes
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
|
@ -43,7 +44,7 @@ class ApkBackup(
|
|||
suspend fun backupApkIfNecessary(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState,
|
||||
streamGetter: suspend () -> OutputStream
|
||||
streamGetter: suspend (suffix: String) -> OutputStream
|
||||
): PackageMetadata? {
|
||||
// do not back up @pm@
|
||||
val packageName = packageInfo.packageName
|
||||
|
@ -86,13 +87,19 @@ class ApkBackup(
|
|||
" already has a backup ($backedUpVersion)" +
|
||||
" 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
|
||||
}
|
||||
|
||||
// get an InputStream for the APK
|
||||
val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir)
|
||||
// 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.")
|
||||
|
||||
|
@ -101,6 +108,7 @@ class ApkBackup(
|
|||
state = packageState,
|
||||
version = version,
|
||||
installer = pm.getInstallSourceInfo(packageName).installingPackageName,
|
||||
splits = splits,
|
||||
sha256 = sha256,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -364,7 +364,7 @@ internal class BackupCoordinator(
|
|||
metadataManager.getPackageMetadata(packageName)
|
||||
val oldPackageState = packageMetadata?.state
|
||||
if (oldPackageState != null && oldPackageState != packageState) {
|
||||
Log.e(
|
||||
Log.i(
|
||||
TAG, "Package $packageName was in $oldPackageState" +
|
||||
", update to $packageState"
|
||||
)
|
||||
|
@ -390,8 +390,8 @@ internal class BackupCoordinator(
|
|||
): Boolean {
|
||||
val packageName = packageInfo.packageName
|
||||
return try {
|
||||
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
|
||||
plugin.getApkOutputStream(packageInfo)
|
||||
apkBackup.backupApkIfNecessary(packageInfo, packageState) { suffix ->
|
||||
plugin.getApkOutputStream(packageInfo, suffix)
|
||||
}?.let { packageMetadata ->
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)
|
||||
|
|
|
@ -35,7 +35,7 @@ interface BackupPlugin {
|
|||
* Returns an [OutputStream] for writing an APK to be backed up.
|
||||
*/
|
||||
@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
|
||||
|
|
|
@ -9,12 +9,11 @@ import android.content.pm.Signature
|
|||
import android.util.PackageUtils
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -36,7 +35,7 @@ import kotlin.random.Random
|
|||
internal class ApkBackupTest : BackupTest() {
|
||||
|
||||
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)
|
||||
|
||||
|
@ -131,17 +130,10 @@ internal class ApkBackupTest : BackupTest() {
|
|||
)
|
||||
|
||||
expectChecks()
|
||||
coEvery { streamGetter.invoke() } returns apkOutputStream
|
||||
coEvery { streamGetter.invoke("") } returns apkOutputStream
|
||||
every {
|
||||
pm.getInstallSourceInfo(packageInfo.packageName)
|
||||
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
|
||||
every {
|
||||
metadataManager.onApkBackedUp(
|
||||
packageInfo,
|
||||
updatedMetadata,
|
||||
outputStream
|
||||
)
|
||||
} just Runs
|
||||
|
||||
assertEquals(
|
||||
updatedMetadata,
|
||||
|
@ -150,6 +142,70 @@ internal class ApkBackupTest : BackupTest() {
|
|||
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) {
|
||||
every { settingsManager.backupApks() } returns true
|
||||
every {
|
||||
|
|
Loading…
Reference in a new issue