Re-install APK splits if they are compatible and have proper hash

This commit is contained in:
Torsten Grote 2020-10-12 16:46:37 -03:00
parent 68a6403c4b
commit b3db859b40
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
7 changed files with 109 additions and 35 deletions

View file

@ -173,14 +173,18 @@ class PluginTest : KoinComponent {
backupPlugin.getApkOutputStream(packageInfo, "").writeAndClose(apk1)
// 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
val suffix2 = getRandomBase64(23)
val apk2 = getRandomByteArray(23 * 1024 * 1024)
backupPlugin.getApkOutputStream(packageInfo2, "").writeAndClose(apk2)
backupPlugin.getApkOutputStream(packageInfo2, suffix2).writeAndClose(apk2)
// assert that read APK bytes match what was written
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))
assertReadEquals(
apk2,
restorePlugin.getApkInputStream(token, packageInfo2.packageName, suffix2)
)
}
@Test

View file

@ -94,10 +94,14 @@ internal class DocumentsProviderRestorePlugin(
}
@Throws(IOException::class)
override suspend fun getApkInputStream(token: Long, packageName: String): InputStream {
override suspend fun getApkInputStream(
token: Long,
packageName: String,
suffix: String
): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file =
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
val file = setDir.findFileBlocking(context, "$packageName$suffix.apk")
?: throw FileNotFoundException()
return storage.getInputStream(file)
}

View file

@ -38,7 +38,7 @@ internal class ApkInstaller(private val context: Context) {
@Throws(IOException::class, SecurityException::class)
internal suspend fun install(
cachedApk: File,
cachedApks: List<File>,
packageName: String,
installerPackageName: String?,
installResult: MutableInstallResult
@ -47,16 +47,16 @@ internal class ApkInstaller(private val context: Context) {
override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return
context.unregisterReceiver(this)
cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult))
cont.resume(onBroadcastReceived(i, packageName, cachedApks, installResult))
}
}
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) }
install(cachedApk, installerPackageName)
install(cachedApks, installerPackageName)
}
private fun install(cachedApk: File, installerPackageName: String?) {
private fun install(cachedApks: List<File>, installerPackageName: String?) {
val sessionParams = SessionParams(MODE_FULL_INSTALL).apply {
setInstallerPackageName(installerPackageName)
// Setting the INSTALL_ALLOW_TEST flag here does not allow us to install test apps,
@ -65,12 +65,14 @@ internal class ApkInstaller(private val context: Context) {
// Don't set more sessionParams intentionally here.
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
val session = installer.openSession(installer.createSession(sessionParams))
val sizeBytes = cachedApk.length()
session.use { s ->
cachedApk.inputStream().use { inputStream ->
s.openWrite("PackageInstaller", 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
cachedApks.forEach { cachedApk ->
val sizeBytes = cachedApk.length()
cachedApk.inputStream().use { inputStream ->
s.openWrite(cachedApk.name, 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
}
}
}
s.commit(getIntentSender())
@ -90,7 +92,7 @@ internal class ApkInstaller(private val context: Context) {
private fun onBroadcastReceived(
i: Intent,
expectedPackageName: String,
cachedApk: File,
cachedApks: List<File>,
installResult: MutableInstallResult
): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
@ -102,9 +104,9 @@ internal class ApkInstaller(private val context: Context) {
}
Log.d(TAG, "Received result for $packageName: success=$success $statusMsg")
// delete cached APK file on I/O thread
// delete all cached APK files on I/O thread
GlobalScope.launch(Dispatchers.IO) {
cachedApk.delete()
cachedApks.forEach { it.delete() }
}
// update status and offer result

View file

@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.util.Log
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -26,6 +27,7 @@ private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore(
private val context: Context,
private val restorePlugin: RestorePlugin,
private val splitCompatChecker: ApkSplitCompatibilityChecker,
private val apkInstaller: ApkInstaller
) {
@ -78,11 +80,8 @@ internal class ApkRestore(
metadata: PackageMetadata,
installResult: MutableInstallResult
) {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = restorePlugin.getApkInputStream(token, packageName)
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
// cache the APK and get its hash
val (cachedApk, sha256) = cacheApk(token, packageName)
// check APK's SHA-256 hash
if (metadata.sha256 != sha256) throw SecurityException(
@ -137,18 +136,78 @@ internal class ApkRestore(
}
}
if (metadata.splits != null) {
// do not install APKs that require splits (for now)
Log.w(TAG, "Not installing $packageName because it requires splits.")
// process further APK splits, if available
val cachedApks = cacheSplitsIfNeeded(token, packageName, cachedApk, metadata.splits)
if (cachedApks == null) {
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
collector.emit(installResult.fail(packageName))
return
}
// install APK and emit updates from it
val result = apkInstaller.install(cachedApk, packageName, metadata.installer, installResult)
val result =
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult)
collector.emit(result)
}
/**
* Retrieves APK splits from [RestorePlugin] and caches them locally.
*
* @throws SecurityException if a split has an unexpected SHA-256 hash.
* @return a list of all APKs that need to be installed
* or null if the splits are incompatible with this restore device.
*/
@Throws(IOException::class, SecurityException::class)
private suspend fun cacheSplitsIfNeeded(
token: Long,
packageName: String,
cachedApk: File,
splits: List<ApkSplit>?
): List<File>? {
// if null, there are no splits, so we just have a single base APK to consider
val splitNames = splits?.map { it.name } ?: return listOf(cachedApk)
// return null when splits are incompatible
if (!splitCompatChecker.isCompatible(splitNames)) return null
// store coming splits in a list
val cachedApks = ArrayList<File>(splits.size + 1).apply {
add(cachedApk) // don't forget the base APK
}
splits.forEach { apkSplit -> // cache and check all splits
val suffix = "_${apkSplit.sha256}"
val (file, sha256) = cacheApk(token, packageName, suffix)
// check APK split's SHA-256 hash
if (apkSplit.sha256 != sha256) throw SecurityException(
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
" but '${apkSplit.sha256}' expected."
)
cachedApks.add(file)
}
return cachedApks
}
/**
* Retrieves an APK from the [RestorePlugin] and caches it locally
* while calculating its SHA-256 hash.
*
* @return a [Pair] of the cached [File] and SHA-256 hash.
*/
@Throws(IOException::class)
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
private suspend fun cacheApk(
token: Long,
packageName: String,
suffix: String = ""
): Pair<File, String> {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = restorePlugin.getApkInputStream(token, packageName, suffix)
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
return Pair(cachedApk, sha256)
}
/**
* Returns null if this system app should get re-installed,
* or a new [InstallResult] to be emitted otherwise.

View file

@ -5,5 +5,6 @@ import org.koin.dsl.module
val installModule = module {
factory { ApkInstaller(androidContext()) }
factory { ApkRestore(androidContext(), get(), get()) }
factory { ApkSplitCompatibilityChecker(DeviceInfo(androidContext())) }
factory { ApkRestore(androidContext(), get(), get(), get()) }
}

View file

@ -34,6 +34,6 @@ interface RestorePlugin {
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/
@Throws(IOException::class)
suspend fun getApkInputStream(token: Long, packageName: String): InputStream
suspend fun getApkInputStream(token: Long, packageName: String, suffix: String): InputStream
}

View file

@ -46,9 +46,11 @@ internal class ApkRestoreTest : TransportTest() {
every { packageManager } returns pm
}
private val restorePlugin: RestorePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = ApkRestore(strictContext, restorePlugin, apkInstaller)
private val apkRestore: ApkRestore =
ApkRestore(strictContext, restorePlugin, splitCompatChecker, apkInstaller)
private val icon: Drawable = mockk()
@ -78,7 +80,7 @@ internal class ApkRestoreTest : TransportTest() {
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
@ -109,7 +111,7 @@ internal class ApkRestoreTest : TransportTest() {
packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
@ -137,7 +139,7 @@ internal class ApkRestoreTest : TransportTest() {
@Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
@ -194,7 +196,7 @@ internal class ApkRestoreTest : TransportTest() {
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
@ -247,7 +249,9 @@ internal class ApkRestoreTest : TransportTest() {
val isSystemApp = Random.nextBoolean()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery {
restorePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(