Merge pull request #145 from grote/91-app-bundles-re-install
Re-install apps with APK splits if they are compatible
This commit is contained in:
commit
50f9dd6f13
21 changed files with 841 additions and 171 deletions
|
@ -1,6 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
|
||||
<module name="app" />
|
||||
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
|
||||
<module name="seedvault.app" />
|
||||
<option name="TESTING_TYPE" value="0" />
|
||||
<option name="METHOD_NAME" value="" />
|
||||
<option name="CLASS_NAME" value="" />
|
||||
|
@ -38,8 +38,11 @@
|
|||
</Native>
|
||||
<Profilers>
|
||||
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
|
||||
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
|
||||
<option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
|
||||
<option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
|
||||
</Profilers>
|
||||
<method v="2">
|
||||
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
|
||||
<module name="app" />
|
||||
<module name="seedvault.app" />
|
||||
<useClassPathOnly />
|
||||
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />
|
||||
|
|
|
@ -24,7 +24,7 @@ before_cache:
|
|||
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
|
||||
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
|
||||
|
||||
script: ./gradlew check assemble ktlintCheck
|
||||
script: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
||||
|
||||
cache:
|
||||
directories:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -36,7 +36,9 @@
|
|||
<!-- Getting info about installed packages via PackageManager is restricted since Android 11
|
||||
We need to know what is installed, with what signatures, etc. for APK backup,
|
||||
triggering manual backup and other tasks -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,9 @@ data class RestorableBackup(
|
|||
val time: Long
|
||||
get() = backupMetadata.time
|
||||
|
||||
val deviceName: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
||||
val packageMetadataMap: PackageMetadataMap
|
||||
get() = backupMetadata.packageMetadataMap
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
|||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat.getColor
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
|
@ -17,6 +18,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class RestoreProgressFragment : Fragment() {
|
||||
|
@ -83,11 +85,25 @@ class RestoreProgressFragment : Fragment() {
|
|||
backupNameView.setTextColor(getColor(requireContext(), R.color.red))
|
||||
} else {
|
||||
backupNameView.text = getString(R.string.restore_finished_success)
|
||||
onRestoreFinished()
|
||||
}
|
||||
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
|
||||
})
|
||||
}
|
||||
|
||||
private fun onRestoreFinished() {
|
||||
// check if any restore failed, because the app is not installed
|
||||
val failed = viewModel.restoreProgress.value?.any { it.state == FAILED_NOT_INSTALLED }
|
||||
if (failed != true) return // nothing left to do if there's no failures due to not installed
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.restore_restoring_error_title)
|
||||
.setMessage(R.string.restore_restoring_error_message)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun stayScrolledAtTop(add: () -> Unit) {
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
add.invoke()
|
||||
|
|
|
@ -151,9 +151,9 @@ internal class RestoreViewModel(
|
|||
closeSession()
|
||||
}
|
||||
|
||||
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
|
||||
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> {
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
|
||||
return apkRestore.restore(backup.token, backup.deviceName, backup.packageMetadataMap)
|
||||
.onStart {
|
||||
Log.d(TAG, "Start InstallResult Flow")
|
||||
}.catch { e ->
|
||||
|
|
|
@ -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,12 +27,13 @@ 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
|
||||
) {
|
||||
|
||||
private val pm = context.packageManager
|
||||
|
||||
fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow {
|
||||
fun restore(token: Long, deviceName: String, packageMetadataMap: PackageMetadataMap) = flow {
|
||||
// filter out packages without APK and get total
|
||||
val packages = packageMetadataMap.filter { it.value.hasApk() }
|
||||
val total = packages.size
|
||||
|
@ -53,7 +55,7 @@ internal class ApkRestore(
|
|||
// re-install individual packages and emit updates
|
||||
for ((packageName, metadata) in packages) {
|
||||
try {
|
||||
restore(this, token, packageName, metadata, installResult)
|
||||
restore(this, token, deviceName, packageName, metadata, installResult)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
|
@ -74,15 +76,13 @@ internal class ApkRestore(
|
|||
private suspend fun restore(
|
||||
collector: FlowCollector<InstallResult>,
|
||||
token: Long,
|
||||
deviceName: String,
|
||||
packageName: String,
|
||||
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 +137,80 @@ 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, deviceName, 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,
|
||||
deviceName: String,
|
||||
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(deviceName, 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.
|
||||
|
|
|
@ -0,0 +1,134 @@
|
|||
package com.stevesoltys.seedvault.restore.install
|
||||
|
||||
import android.util.Log
|
||||
|
||||
private const val TAG = "SplitCompatChecker"
|
||||
|
||||
private const val CONFIG_PREFIX = "config."
|
||||
private const val CONFIG_LENGTH = CONFIG_PREFIX.length
|
||||
|
||||
// see https://developer.android.com/training/multiscreen/screendensities#TaskProvideAltBmp
|
||||
private const val LDPI_VALUE = 120
|
||||
private const val MDPI_VALUE = 160
|
||||
private const val TVDPI_VALUE = 213
|
||||
private const val HDPI_VALUE = 240
|
||||
private const val XHDPI_VALUE = 320
|
||||
private const val XXHDPI_VALUE = 480
|
||||
private const val XXXHDPI_VALUE = 640
|
||||
|
||||
/**
|
||||
* Tries to determine APK split compatibility with a device by examining the list of split names.
|
||||
* This only looks on the supported ABIs, the screen density and supported languages.
|
||||
* Other config splits e.g. based on OpenGL or Vulkan version are also possible,
|
||||
* but don't seem to be widely used, so we don't consider those for now.
|
||||
*/
|
||||
class ApkSplitCompatibilityChecker(private val deviceInfo: DeviceInfo) {
|
||||
|
||||
private val abiMap = mapOf(
|
||||
"armeabi" to "armeabi",
|
||||
"armeabi_v7a" to "armeabi-v7a",
|
||||
"arm64_v8a" to "arm64-v8a",
|
||||
"x86" to "x86",
|
||||
"x86_64" to "x86_64",
|
||||
"mips" to "mips",
|
||||
"mips64" to "mips64"
|
||||
)
|
||||
private val densityMap = mapOf(
|
||||
"ldpi" to LDPI_VALUE,
|
||||
"mdpi" to MDPI_VALUE,
|
||||
"tvdpi" to TVDPI_VALUE,
|
||||
"hdpi" to HDPI_VALUE,
|
||||
"xhdpi" to XHDPI_VALUE,
|
||||
"xxhdpi" to XXHDPI_VALUE,
|
||||
"xxxhdpi" to XXXHDPI_VALUE
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true if the list of splits can be considered compatible with the current device,
|
||||
* and false otherwise.
|
||||
*/
|
||||
fun isCompatible(deviceName: String, splitNames: Collection<String>): Boolean {
|
||||
val unknownAllowed = deviceInfo.areUnknownSplitsAllowed(deviceName)
|
||||
return splitNames.all { splitName ->
|
||||
// all individual splits need to be compatible (which can be hard to judge by name only)
|
||||
isCompatible(deviceName, unknownAllowed, splitName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCompatible(
|
||||
deviceName: String,
|
||||
unknownAllowed: Boolean,
|
||||
splitName: String
|
||||
): Boolean {
|
||||
val index = splitName.indexOf(CONFIG_PREFIX)
|
||||
// If this is not a standardized config split
|
||||
if (index == -1 && unknownAllowed) {
|
||||
// we assume that it will work, as it is most likely a dynamic feature module
|
||||
Log.v(TAG, "Not a config split '$splitName'. Assuming it is ok.")
|
||||
return true
|
||||
} else if (index != 0 && !unknownAllowed) { // not a normal config split at all
|
||||
// we refuse it, since unknown splits are not allowed
|
||||
Log.v(TAG, "Not a config split '$splitName'. Not allowed.")
|
||||
return false
|
||||
}
|
||||
|
||||
val name = splitName.substring(index + CONFIG_LENGTH)
|
||||
|
||||
// Check if this is a known ABI config
|
||||
if (abiMap.containsKey(name)) {
|
||||
// The ABI split must be supported by the current device
|
||||
return isAbiCompatible(name)
|
||||
}
|
||||
|
||||
// Check if this is a known screen density config
|
||||
densityMap[name]?.let { splitDensity ->
|
||||
// The split's density must not be much lower than the device's.
|
||||
return isDensityCompatible(splitDensity)
|
||||
}
|
||||
|
||||
// Check if this is a language split
|
||||
if (deviceInfo.isSupportedLanguage(name)) {
|
||||
// accept all language splits as an extra language should not break things
|
||||
return true
|
||||
}
|
||||
|
||||
// At this point we don't know what to make of that split
|
||||
return if (deviceInfo.isSameDevice(deviceName)) {
|
||||
// so we only allow it if it came from the same device
|
||||
Log.v(TAG, "Unhandled config split '$splitName'. Came from same device.")
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "Unhandled config split '$splitName'. Not allowed.")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAbiCompatible(name: String): Boolean {
|
||||
return if (deviceInfo.supportedABIs.contains(abiMap[name])) {
|
||||
Log.v(TAG, "Config split '$name' is supported ABI (${deviceInfo.supportedABIs})")
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "Config split '$name' is not supported ABI (${deviceInfo.supportedABIs})")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDensityCompatible(splitDensity: Int): Boolean {
|
||||
@Suppress("MagicNumber")
|
||||
val acceptableDiff = deviceInfo.densityDpi / 3
|
||||
return if (deviceInfo.densityDpi - splitDensity > acceptableDiff) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Config split density $splitDensity not compatible with ${deviceInfo.densityDpi}"
|
||||
)
|
||||
false
|
||||
} else {
|
||||
Log.v(
|
||||
TAG,
|
||||
"Config split density $splitDensity compatible with ${deviceInfo.densityDpi}"
|
||||
)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package com.stevesoltys.seedvault.restore.install
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.android.internal.app.LocalePicker
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
||||
class DeviceInfo(context: Context) {
|
||||
val densityDpi: Int = context.resources.displayMetrics.densityDpi
|
||||
val supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList()
|
||||
private val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
private val languages = LocalePicker.getSupportedLocales(context)
|
||||
.map { it.substringBefore('-') }
|
||||
.toSet()
|
||||
private val unknownSplitsOnlySameDevice =
|
||||
context.resources.getBoolean(R.bool.re_install_unknown_splits_only_on_same_device)
|
||||
|
||||
fun areUnknownSplitsAllowed(deviceName: String): Boolean {
|
||||
return !unknownSplitsOnlySameDevice || this.deviceName == deviceName
|
||||
}
|
||||
|
||||
fun isSameDevice(deviceName: String): Boolean {
|
||||
return this.deviceName == deviceName
|
||||
}
|
||||
|
||||
fun isSupportedLanguage(name: String): Boolean = languages.contains(name)
|
||||
}
|
|
@ -5,5 +5,7 @@ import org.koin.dsl.module
|
|||
|
||||
val installModule = module {
|
||||
factory { ApkInstaller(androidContext()) }
|
||||
factory { ApkRestore(androidContext(), get(), get()) }
|
||||
factory { DeviceInfo(androidContext()) }
|
||||
factory { ApkSplitCompatibilityChecker(get()) }
|
||||
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
|
||||
|
||||
}
|
||||
|
|
|
@ -10,10 +10,26 @@
|
|||
-->
|
||||
<bool name="show_restore_in_settings">false</bool>
|
||||
|
||||
<!-- Add only storage that is also available when restoring from backup (e.g. initial device setup) -->
|
||||
<!--
|
||||
Add only storage here that is also available
|
||||
when restoring from backup (e.g. initial device setup)
|
||||
-->
|
||||
<string-array name="storage_authority_whitelist" tools:ignore="InconsistentArrays">
|
||||
<item>com.android.externalstorage.documents</item>
|
||||
<item>org.nextcloud.documents</item>
|
||||
</string-array>
|
||||
|
||||
<!--
|
||||
Android App Bundles split up the app into several APKs.
|
||||
We always back up all the available split APKs
|
||||
and do a compatibility check when re-installing them.
|
||||
If a backed up split is not compatible, the re-install will fail
|
||||
and the user will be given the opportunity to install the app manually before data restore.
|
||||
Unknown splits are treated as compatible as we haven't yet seen a case
|
||||
where this would cause a problem such as an app crashing when starting it after re-install.
|
||||
However, if you prefer to be on the safe side, you can set this to true,
|
||||
to only install unknown splits if they come from the same device.
|
||||
-->
|
||||
<bool name="re_install_unknown_splits_only_on_same_device">false</bool>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -118,6 +118,8 @@
|
|||
<string name="restore_installing_tap_to_install">Tap to install</string>
|
||||
<string name="restore_next">Next</string>
|
||||
<string name="restore_restoring">Restoring backup</string>
|
||||
<string name="restore_restoring_error_title">Unable to restore some apps</string>
|
||||
<string name="restore_restoring_error_message">You can re-install these apps manually and "Automatic restore" will attempt to restore their data (when enabled).</string>
|
||||
<string name="restore_magic_package">System package manager</string>
|
||||
<string name="restore_finished_success">Restore complete</string>
|
||||
<string name="restore_finished_error">An error occurred while restoring the backup.</string>
|
||||
|
|
|
@ -8,7 +8,10 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
|||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.stevesoltys.seedvault.getRandomBase64
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
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
|
||||
|
@ -22,7 +25,6 @@ import io.mockk.coEvery
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions
|
||||
|
@ -34,6 +36,7 @@ import org.junit.jupiter.api.Test
|
|||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Path
|
||||
import kotlin.random.Random
|
||||
|
||||
|
@ -46,12 +49,15 @@ 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()
|
||||
|
||||
private val deviceName = getRandomString()
|
||||
private val packageName = packageInfo.packageName
|
||||
private val packageMetadata = PackageMetadata(
|
||||
time = Random.nextLong(),
|
||||
|
@ -78,28 +84,10 @@ 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) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
}
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
assertFalse(value.isFinished)
|
||||
}
|
||||
2 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,75 +97,23 @@ 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 ->
|
||||
when (index) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
}
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
}
|
||||
2 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
|
||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
|
||||
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||
every {
|
||||
pm.loadItemIcon(
|
||||
packageInfo.applicationInfo,
|
||||
packageInfo.applicationInfo
|
||||
)
|
||||
} returns icon
|
||||
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
coEvery {
|
||||
apkInstaller.install(any(), packageName, installerName, any())
|
||||
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
|
||||
} throws SecurityException()
|
||||
|
||||
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
|
||||
when (index) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(installerName, result.installerPackageName)
|
||||
assertEquals(1, value.total)
|
||||
}
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
assertFalse(value.hasFailed)
|
||||
}
|
||||
2 -> {
|
||||
val result = value[packageName]
|
||||
assertTrue(value.hasFailed)
|
||||
assertEquals(FAILED, result.state)
|
||||
assertFalse(value.isFinished)
|
||||
}
|
||||
3 -> {
|
||||
assertTrue(value.hasFailed, "1")
|
||||
assertTrue(value.isFinished, "2")
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -193,46 +129,13 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
)
|
||||
}
|
||||
|
||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
|
||||
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||
every {
|
||||
pm.loadItemIcon(
|
||||
packageInfo.applicationInfo,
|
||||
packageInfo.applicationInfo
|
||||
)
|
||||
} returns icon
|
||||
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
coEvery {
|
||||
apkInstaller.install(any(), packageName, installerName, any())
|
||||
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
|
||||
} returns installResult
|
||||
|
||||
var i = 0
|
||||
apkRestore.restore(token, packageMetadataMap).collect { value ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
}
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
}
|
||||
2 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(SUCCEEDED, result.state)
|
||||
}
|
||||
3 -> {
|
||||
assertFalse(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
i++
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressSuccessFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -246,17 +149,9 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
val willFail = Random.nextBoolean()
|
||||
val isSystemApp = Random.nextBoolean()
|
||||
|
||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
|
||||
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||
every {
|
||||
pm.loadItemIcon(
|
||||
packageInfo.applicationInfo,
|
||||
packageInfo.applicationInfo
|
||||
)
|
||||
} returns icon
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
every { packageInfo.applicationInfo.loadIcon(pm) } returns icon
|
||||
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
||||
|
||||
if (willFail) {
|
||||
every {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
|
@ -276,12 +171,17 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
)
|
||||
}
|
||||
coEvery {
|
||||
apkInstaller.install(any(), packageName, installerName, any())
|
||||
apkInstaller.install(
|
||||
match { it.size == 1 },
|
||||
packageName,
|
||||
installerName,
|
||||
any()
|
||||
)
|
||||
} returns installResult
|
||||
}
|
||||
}
|
||||
|
||||
apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value ->
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
|
@ -311,6 +211,205 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incompatible splits cause FAILED state`(@TempDir tmpDir: Path) = runBlocking {
|
||||
// add one APK split to metadata
|
||||
val split1Name = getRandomString()
|
||||
val split2Name = getRandomString()
|
||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
||||
splits = listOf(
|
||||
ApkSplit(split1Name, getRandomBase64()),
|
||||
ApkSplit(split2Name, getRandomBase64())
|
||||
)
|
||||
)
|
||||
|
||||
// cache APK and get icon as well as app name
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
||||
// splits are NOT compatible
|
||||
every {
|
||||
splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name))
|
||||
} returns false
|
||||
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking {
|
||||
// add one APK split to metadata
|
||||
val splitName = getRandomString()
|
||||
val sha256 = getRandomBase64(23)
|
||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
||||
splits = listOf(ApkSplit(splitName, sha256))
|
||||
)
|
||||
|
||||
// cache APK and get icon as well as app name
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
||||
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
|
||||
coEvery {
|
||||
restorePlugin.getApkInputStream(token, packageName, "_$sha256")
|
||||
} returns ByteArrayInputStream(getRandomByteArray())
|
||||
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) =
|
||||
runBlocking {
|
||||
// add one APK split to metadata
|
||||
val splitName = getRandomString()
|
||||
val sha256 = getRandomBase64(23)
|
||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
||||
splits = listOf(ApkSplit(splitName, sha256))
|
||||
)
|
||||
|
||||
// cache APK and get icon as well as app name
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
||||
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
|
||||
coEvery {
|
||||
restorePlugin.getApkInputStream(token, packageName, "_$sha256")
|
||||
} throws IOException()
|
||||
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `splits get installed along with base APK`(@TempDir tmpDir: Path) = runBlocking {
|
||||
// add one APK split to metadata
|
||||
val split1Name = getRandomString()
|
||||
val split2Name = getRandomString()
|
||||
val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
|
||||
val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
|
||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
||||
splits = listOf(
|
||||
ApkSplit(split1Name, split1sha256),
|
||||
ApkSplit(split2Name, split2sha256)
|
||||
)
|
||||
)
|
||||
|
||||
// cache APK and get icon as well as app name
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
||||
every {
|
||||
splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name))
|
||||
} returns true
|
||||
|
||||
// define bytes of splits and return them as stream (matches above hashes)
|
||||
val split1Bytes = byteArrayOf(0x01, 0x02, 0x03)
|
||||
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
|
||||
val split1InputStream = ByteArrayInputStream(split1Bytes)
|
||||
val split2InputStream = ByteArrayInputStream(split2Bytes)
|
||||
coEvery {
|
||||
restorePlugin.getApkInputStream(token, packageName, "_$split1sha256")
|
||||
} returns split1InputStream
|
||||
coEvery {
|
||||
restorePlugin.getApkInputStream(token, packageName, "_$split2sha256")
|
||||
} returns split2InputStream
|
||||
|
||||
coEvery {
|
||||
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
|
||||
} returns MutableInstallResult(1).apply {
|
||||
set(
|
||||
packageName, ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
|
||||
assertQueuedProgressSuccessFinished(i, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
|
||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
|
||||
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
|
||||
every {
|
||||
pm.loadItemIcon(
|
||||
packageInfo.applicationInfo,
|
||||
packageInfo.applicationInfo
|
||||
)
|
||||
} returns icon
|
||||
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
||||
}
|
||||
|
||||
private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> assertQueuedProgress(step, value)
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
assertFalse(value.isFinished)
|
||||
}
|
||||
2 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
|
||||
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> assertQueuedProgress(step, value)
|
||||
1 -> assertQueuedProgress(step, value)
|
||||
2 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(SUCCEEDED, result.state)
|
||||
}
|
||||
3 -> {
|
||||
assertFalse(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
|
||||
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> assertQueuedProgress(step, value)
|
||||
1 -> assertQueuedProgress(step, value)
|
||||
2 -> {
|
||||
// app install has failed
|
||||
val result = value[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
assertFalse(value.isFinished)
|
||||
}
|
||||
3 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
|
||||
private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> {
|
||||
// single package gets queued
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(installerName, result.installerPackageName)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
}
|
||||
1 -> {
|
||||
// name and icon are available now
|
||||
val result = value[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
assertFalse(value.hasFailed)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
|
||||
|
|
|
@ -0,0 +1,208 @@
|
|||
package com.stevesoltys.seedvault.restore.install
|
||||
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
class ApkSplitCompatibilityCheckerTest : TransportTest() {
|
||||
|
||||
private val deviceInfo: DeviceInfo = mockk()
|
||||
private val deviceName = getRandomString()
|
||||
|
||||
private val checker = ApkSplitCompatibilityChecker(deviceInfo)
|
||||
|
||||
@Test
|
||||
fun `non-config splits always get accepted except when unknowns are not allowed`() {
|
||||
val splits = listOf(
|
||||
getRandomString(),
|
||||
getRandomString(),
|
||||
getRandomString(),
|
||||
getRandomString(),
|
||||
getRandomString(),
|
||||
getRandomString()
|
||||
)
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns true andThen false
|
||||
assertTrue(checker.isCompatible(deviceName, splits))
|
||||
assertFalse(checker.isCompatible(deviceName, splits))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-config splits mixed with language config splits get accepted iff allowed`() {
|
||||
val splits = listOf(
|
||||
"config.de",
|
||||
"config.en",
|
||||
"config.gu",
|
||||
getRandomString(),
|
||||
getRandomString(),
|
||||
getRandomString()
|
||||
)
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns true andThen false
|
||||
every { deviceInfo.isSupportedLanguage("de") } returns true
|
||||
every { deviceInfo.isSupportedLanguage("en") } returns true
|
||||
every { deviceInfo.isSupportedLanguage("gu") } returns true
|
||||
assertTrue(checker.isCompatible(deviceName, splits))
|
||||
assertFalse(checker.isCompatible(deviceName, splits))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown config splits get rejected if from different device`() {
|
||||
val unknownName = getRandomString()
|
||||
val splits = listOf("config.$unknownName")
|
||||
every { deviceInfo.isSupportedLanguage(unknownName) } returns false
|
||||
|
||||
// reject if on different device
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns Random.nextBoolean()
|
||||
every { deviceInfo.isSameDevice(deviceName) } returns false
|
||||
assertFalse(checker.isCompatible(deviceName, splits))
|
||||
|
||||
// accept if same device
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns Random.nextBoolean()
|
||||
every { deviceInfo.isSameDevice(deviceName) } returns true
|
||||
assertTrue(checker.isCompatible(deviceName, splits))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all supported ABIs get accepted, non-supported rejected`() {
|
||||
val unknownAllowed = Random.nextBoolean()
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns unknownAllowed
|
||||
every { deviceInfo.supportedABIs } returns listOf("arm64-v8a", "armeabi-v7a", "armeabi")
|
||||
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.arm64_v8a")))
|
||||
assertEquals(
|
||||
unknownAllowed,
|
||||
checker.isCompatible(deviceName, listOf("${getRandomString()}.config.arm64_v8a"))
|
||||
)
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.armeabi_v7a")))
|
||||
assertEquals(
|
||||
unknownAllowed,
|
||||
checker.isCompatible(deviceName, listOf("${getRandomString()}.config.armeabi_v7a"))
|
||||
)
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.armeabi")))
|
||||
assertEquals(
|
||||
unknownAllowed,
|
||||
checker.isCompatible(deviceName, listOf("${getRandomString()}.config.armeabi"))
|
||||
)
|
||||
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.x86")))
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.x86_64")))
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.mips")))
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.mips64")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `armeabi rejects arm64_v8a and armeabi-v7a`() {
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns true
|
||||
every { deviceInfo.supportedABIs } returns listOf("armeabi")
|
||||
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.armeabi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("${getRandomString()}.config.armeabi")))
|
||||
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.arm64_v8a")))
|
||||
assertFalse(
|
||||
checker.isCompatible(deviceName, listOf("${getRandomString()}.config.arm64_v8a"))
|
||||
)
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.armeabi_v7a")))
|
||||
assertFalse(
|
||||
checker.isCompatible(deviceName, listOf("${getRandomString()}.config.armeabi_v7a"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen density is accepted when not too low`() {
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns Random.nextBoolean()
|
||||
every { deviceInfo.densityDpi } returns 440 // xxhdpi - Pixel 4
|
||||
|
||||
// higher density is accepted
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xxxhdpi")))
|
||||
// same density is accepted
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xxhdpi")))
|
||||
// one lower density is accepted
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xhdpi")))
|
||||
// too low density is not accepted
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.hdpi")))
|
||||
// even lower densities are also not accepted
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.tvdpi")))
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.mdpi")))
|
||||
assertFalse(checker.isCompatible(deviceName, listOf("config.ldpi")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen density accepts all higher densities`() {
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns Random.nextBoolean()
|
||||
every { deviceInfo.densityDpi } returns 120
|
||||
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xxxhdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xxhdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xhdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.hdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.tvdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.mdpi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.ldpi")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config splits in feature modules are considered unknown splits`() {
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns false
|
||||
|
||||
assertFalse(
|
||||
checker.isCompatible(
|
||||
deviceName,
|
||||
listOf(
|
||||
"${getRandomString()}.config.xhdpi",
|
||||
"${getRandomString()}.config.arm64_v8a"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test mix of unknown and all known config splits`() {
|
||||
val unknownAllowed = Random.nextBoolean()
|
||||
every { deviceInfo.areUnknownSplitsAllowed(deviceName) } returns unknownAllowed
|
||||
every { deviceInfo.supportedABIs } returns listOf("armeabi-v7a", "armeabi")
|
||||
every { deviceInfo.densityDpi } returns 240
|
||||
every { deviceInfo.isSupportedLanguage("de") } returns true
|
||||
|
||||
assertEquals(
|
||||
unknownAllowed,
|
||||
checker.isCompatible(
|
||||
deviceName,
|
||||
listOf(
|
||||
"config.de",
|
||||
"config.xhdpi",
|
||||
"config.armeabi",
|
||||
getRandomString()
|
||||
)
|
||||
)
|
||||
)
|
||||
// same as above, but feature split with unsupported ABI config gets rejected
|
||||
assertFalse(
|
||||
checker.isCompatible(
|
||||
deviceName,
|
||||
listOf(
|
||||
"config.de",
|
||||
"config.xhdpi",
|
||||
"config.armeabi",
|
||||
"${getRandomString()}.config.arm64_v8a",
|
||||
getRandomString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.xhdpi", "config.armeabi")))
|
||||
assertTrue(checker.isCompatible(deviceName, listOf("config.hdpi", "config.armeabi_v7a")))
|
||||
assertFalse(
|
||||
checker.isCompatible(deviceName, listOf("foo.config.ldpi", "config.armeabi_v7a"))
|
||||
)
|
||||
assertFalse(
|
||||
checker.isCompatible(deviceName, listOf("foo.config.xxxhdpi", "bar.config.arm64_v8a"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package com.stevesoltys.seedvault.restore.install
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(sdk = [29]) // robolectric does not support 30, yet
|
||||
internal class DeviceInfoTest {
|
||||
|
||||
@After
|
||||
fun afterEachTest() {
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test with mocked context`() {
|
||||
val context: Context = mockk()
|
||||
val resources: Resources = mockk()
|
||||
val onlyOnSameDevice = Random.nextBoolean()
|
||||
|
||||
every { context.resources } returns resources
|
||||
every { resources.displayMetrics } returns DisplayMetrics().apply {
|
||||
this.densityDpi = 1337
|
||||
}
|
||||
every { resources.getStringArray(any()) } returns arrayOf("foo-123", "bar-rev")
|
||||
every {
|
||||
resources.getBoolean(R.bool.re_install_unknown_splits_only_on_same_device)
|
||||
} returns onlyOnSameDevice
|
||||
|
||||
val deviceInfo = DeviceInfo(context)
|
||||
|
||||
// the ABI comes from robolectric
|
||||
assertEquals(listOf("armeabi-v7a"), deviceInfo.supportedABIs)
|
||||
|
||||
// check that density is returned as expected
|
||||
assertEquals(1337, deviceInfo.densityDpi)
|
||||
|
||||
// test languages results are as expected
|
||||
assertTrue(deviceInfo.isSupportedLanguage("foo"))
|
||||
assertTrue(deviceInfo.isSupportedLanguage("bar"))
|
||||
assertFalse(deviceInfo.isSupportedLanguage("en"))
|
||||
assertFalse(deviceInfo.isSupportedLanguage("de"))
|
||||
|
||||
// test areUnknownSplitsAllowed
|
||||
val deviceName = "unknown robolectric"
|
||||
if (onlyOnSameDevice) {
|
||||
assertTrue(deviceInfo.areUnknownSplitsAllowed(deviceName))
|
||||
assertFalse(deviceInfo.areUnknownSplitsAllowed("foo bar"))
|
||||
} else {
|
||||
assertTrue(deviceInfo.areUnknownSplitsAllowed(deviceName))
|
||||
assertTrue(deviceInfo.areUnknownSplitsAllowed("foo bar"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test supported languages`() {
|
||||
val deviceInfo = DeviceInfo(ApplicationProvider.getApplicationContext())
|
||||
|
||||
assertTrue(deviceInfo.isSupportedLanguage("en"))
|
||||
assertTrue(deviceInfo.isSupportedLanguage("de"))
|
||||
assertTrue(deviceInfo.isSupportedLanguage("gu"))
|
||||
assertTrue(deviceInfo.isSupportedLanguage("pt"))
|
||||
|
||||
assertFalse(deviceInfo.isSupportedLanguage("foo"))
|
||||
assertFalse(deviceInfo.isSupportedLanguage("bar"))
|
||||
assertFalse(deviceInfo.isSupportedLanguage(getRandomString()))
|
||||
assertFalse(deviceInfo.isSupportedLanguage(getRandomString()))
|
||||
}
|
||||
|
||||
}
|
|
@ -13,7 +13,7 @@ buildscript {
|
|||
dependencies {
|
||||
//noinspection DifferentKotlinGradleVersion
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.android.tools.build:gradle:4.0.1'
|
||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue