diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt index dbf6bd7a..fbb5dded 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -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)) 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 0ba2a537..c71d4509 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 @@ -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) } 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 index f25f622a..139c996a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt @@ -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 { + 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(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) + } + } /** 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 d15cc8bc..b4cd67ac 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 @@ -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) 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 f6ffdbca..f8672503 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 @@ -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 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 index 96ffce05..e2235c32 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt @@ -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 {