Re-install APK splits if they are compatible and have proper hash
This commit is contained in:
parent
68a6403c4b
commit
b3db859b40
7 changed files with 109 additions and 35 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Reference in a new issue