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)
|
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 suffix2 = getRandomBase64(23)
|
||||||
val apk2 = getRandomByteArray(23 * 1024 * 1024)
|
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
|
// 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
|
@Test
|
||||||
|
|
|
@ -94,10 +94,14 @@ internal class DocumentsProviderRestorePlugin(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@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 setDir = storage.getSetDir(token) ?: throw IOException()
|
||||||
val file =
|
val file = setDir.findFileBlocking(context, "$packageName$suffix.apk")
|
||||||
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
|
?: throw FileNotFoundException()
|
||||||
return storage.getInputStream(file)
|
return storage.getInputStream(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,7 @@ internal class ApkInstaller(private val context: Context) {
|
||||||
|
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
internal suspend fun install(
|
internal suspend fun install(
|
||||||
cachedApk: File,
|
cachedApks: List<File>,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
installerPackageName: String?,
|
installerPackageName: String?,
|
||||||
installResult: MutableInstallResult
|
installResult: MutableInstallResult
|
||||||
|
@ -47,16 +47,16 @@ internal class ApkInstaller(private val context: Context) {
|
||||||
override fun onReceive(context: Context, i: Intent) {
|
override fun onReceive(context: Context, i: Intent) {
|
||||||
if (i.action != BROADCAST_ACTION) return
|
if (i.action != BROADCAST_ACTION) return
|
||||||
context.unregisterReceiver(this)
|
context.unregisterReceiver(this)
|
||||||
cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult))
|
cont.resume(onBroadcastReceived(i, packageName, cachedApks, installResult))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
|
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
|
||||||
cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) }
|
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 {
|
val sessionParams = SessionParams(MODE_FULL_INSTALL).apply {
|
||||||
setInstallerPackageName(installerPackageName)
|
setInstallerPackageName(installerPackageName)
|
||||||
// Setting the INSTALL_ALLOW_TEST flag here does not allow us to install test apps,
|
// 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.
|
// Don't set more sessionParams intentionally here.
|
||||||
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
|
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
|
||||||
val session = installer.openSession(installer.createSession(sessionParams))
|
val session = installer.openSession(installer.createSession(sessionParams))
|
||||||
val sizeBytes = cachedApk.length()
|
|
||||||
session.use { s ->
|
session.use { s ->
|
||||||
cachedApk.inputStream().use { inputStream ->
|
cachedApks.forEach { cachedApk ->
|
||||||
s.openWrite("PackageInstaller", 0, sizeBytes).use { out ->
|
val sizeBytes = cachedApk.length()
|
||||||
inputStream.copyTo(out)
|
cachedApk.inputStream().use { inputStream ->
|
||||||
s.fsync(out)
|
s.openWrite(cachedApk.name, 0, sizeBytes).use { out ->
|
||||||
|
inputStream.copyTo(out)
|
||||||
|
s.fsync(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.commit(getIntentSender())
|
s.commit(getIntentSender())
|
||||||
|
@ -90,7 +92,7 @@ internal class ApkInstaller(private val context: Context) {
|
||||||
private fun onBroadcastReceived(
|
private fun onBroadcastReceived(
|
||||||
i: Intent,
|
i: Intent,
|
||||||
expectedPackageName: String,
|
expectedPackageName: String,
|
||||||
cachedApk: File,
|
cachedApks: List<File>,
|
||||||
installResult: MutableInstallResult
|
installResult: MutableInstallResult
|
||||||
): InstallResult {
|
): InstallResult {
|
||||||
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
|
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")
|
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) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
cachedApk.delete()
|
cachedApks.forEach { it.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// update status and offer result
|
// 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_SIGNATURES
|
||||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
|
@ -26,6 +27,7 @@ private val TAG = ApkRestore::class.java.simpleName
|
||||||
internal class ApkRestore(
|
internal class ApkRestore(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val restorePlugin: RestorePlugin,
|
private val restorePlugin: RestorePlugin,
|
||||||
|
private val splitCompatChecker: ApkSplitCompatibilityChecker,
|
||||||
private val apkInstaller: ApkInstaller
|
private val apkInstaller: ApkInstaller
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -78,11 +80,8 @@ internal class ApkRestore(
|
||||||
metadata: PackageMetadata,
|
metadata: PackageMetadata,
|
||||||
installResult: MutableInstallResult
|
installResult: MutableInstallResult
|
||||||
) {
|
) {
|
||||||
// create a cache file to write the APK into
|
// cache the APK and get its hash
|
||||||
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
|
val (cachedApk, sha256) = cacheApk(token, packageName)
|
||||||
// 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())
|
|
||||||
|
|
||||||
// check APK's SHA-256 hash
|
// check APK's SHA-256 hash
|
||||||
if (metadata.sha256 != sha256) throw SecurityException(
|
if (metadata.sha256 != sha256) throw SecurityException(
|
||||||
|
@ -137,18 +136,78 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.splits != null) {
|
// process further APK splits, if available
|
||||||
// do not install APKs that require splits (for now)
|
val cachedApks = cacheSplitsIfNeeded(token, packageName, cachedApk, metadata.splits)
|
||||||
Log.w(TAG, "Not installing $packageName because it requires splits.")
|
if (cachedApks == null) {
|
||||||
|
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
|
||||||
collector.emit(installResult.fail(packageName))
|
collector.emit(installResult.fail(packageName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// install APK and emit updates from it
|
// 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)
|
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,
|
* Returns null if this system app should get re-installed,
|
||||||
* or a new [InstallResult] to be emitted otherwise.
|
* or a new [InstallResult] to be emitted otherwise.
|
||||||
|
|
|
@ -5,5 +5,6 @@ import org.koin.dsl.module
|
||||||
|
|
||||||
val installModule = module {
|
val installModule = module {
|
||||||
factory { ApkInstaller(androidContext()) }
|
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.
|
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@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
|
every { packageManager } returns pm
|
||||||
}
|
}
|
||||||
private val restorePlugin: RestorePlugin = mockk()
|
private val restorePlugin: RestorePlugin = mockk()
|
||||||
|
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||||
private val apkInstaller: ApkInstaller = 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()
|
private val icon: Drawable = mockk()
|
||||||
|
|
||||||
|
@ -78,7 +80,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
|
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
|
||||||
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
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 ->
|
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
|
||||||
when (index) {
|
when (index) {
|
||||||
|
@ -109,7 +111,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
packageInfo.packageName = getRandomString()
|
packageInfo.packageName = getRandomString()
|
||||||
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
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.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||||
|
|
||||||
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
|
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
|
||||||
|
@ -137,7 +139,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
|
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
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.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||||
every {
|
every {
|
||||||
pm.loadItemIcon(
|
pm.loadItemIcon(
|
||||||
|
@ -194,7 +196,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
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.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||||
every {
|
every {
|
||||||
pm.loadItemIcon(
|
pm.loadItemIcon(
|
||||||
|
@ -247,7 +249,9 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
val isSystemApp = Random.nextBoolean()
|
val isSystemApp = Random.nextBoolean()
|
||||||
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
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.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||||
every {
|
every {
|
||||||
pm.loadItemIcon(
|
pm.loadItemIcon(
|
||||||
|
|
Loading…
Add table
Reference in a new issue