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:
Torsten Grote 2020-10-22 08:28:36 -03:00 committed by GitHub
commit 50f9dd6f13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 841 additions and 171 deletions

View file

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests"> <configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
<module name="app" /> <module name="seedvault.app" />
<option name="TESTING_TYPE" value="0" /> <option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" /> <option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" /> <option name="CLASS_NAME" value="" />
@ -38,8 +38,11 @@
</Native> </Native>
<Profilers> <Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" /> <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_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" /> <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> </Profilers>
<method v="2"> <method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" /> <option name="Android.Gradle.BeforeRunTask" enabled="true" />

View file

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit"> <configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
<module name="app" /> <module name="seedvault.app" />
<useClassPathOnly /> <useClassPathOnly />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" /> <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" /> <option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />

View file

@ -24,7 +24,7 @@ before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
script: ./gradlew check assemble ktlintCheck script: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
cache: cache:
directories: directories:

View file

@ -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

View file

@ -36,7 +36,9 @@
<!-- Getting info about installed packages via PackageManager is restricted since Android 11 <!-- 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, We need to know what is installed, with what signatures, etc. for APK backup,
triggering manual backup and other tasks --> 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 <application
android:name=".App" android:name=".App"

View file

@ -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)
} }

View file

@ -18,6 +18,9 @@ data class RestorableBackup(
val time: Long val time: Long
get() = backupMetadata.time get() = backupMetadata.time
val deviceName: String
get() = backupMetadata.deviceName
val packageMetadataMap: PackageMetadataMap val packageMetadataMap: PackageMetadataMap
get() = backupMetadata.packageMetadataMap get() = backupMetadata.packageMetadataMap

View file

@ -9,6 +9,7 @@ import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.Button import android.widget.Button
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getColor import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
@ -17,6 +18,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() { class RestoreProgressFragment : Fragment() {
@ -83,11 +85,25 @@ class RestoreProgressFragment : Fragment() {
backupNameView.setTextColor(getColor(requireContext(), R.color.red)) backupNameView.setTextColor(getColor(requireContext(), R.color.red))
} else { } else {
backupNameView.text = getString(R.string.restore_finished_success) backupNameView.text = getString(R.string.restore_finished_success)
onRestoreFinished()
} }
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON) 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) { private fun stayScrolledAtTop(add: () -> Unit) {
val position = layoutManager.findFirstVisibleItemPosition() val position = layoutManager.findFirstVisibleItemPosition()
add.invoke() add.invoke()

View file

@ -151,9 +151,9 @@ internal class RestoreViewModel(
closeSession() closeSession()
} }
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> { private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> {
@Suppress("EXPERIMENTAL_API_USAGE") @Suppress("EXPERIMENTAL_API_USAGE")
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap) return apkRestore.restore(backup.token, backup.deviceName, backup.packageMetadataMap)
.onStart { .onStart {
Log.d(TAG, "Start InstallResult Flow") Log.d(TAG, "Start InstallResult Flow")
}.catch { e -> }.catch { e ->

View 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

View file

@ -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,12 +27,13 @@ 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
) { ) {
private val pm = context.packageManager 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 // filter out packages without APK and get total
val packages = packageMetadataMap.filter { it.value.hasApk() } val packages = packageMetadataMap.filter { it.value.hasApk() }
val total = packages.size val total = packages.size
@ -53,7 +55,7 @@ internal class ApkRestore(
// re-install individual packages and emit updates // re-install individual packages and emit updates
for ((packageName, metadata) in packages) { for ((packageName, metadata) in packages) {
try { try {
restore(this, token, packageName, metadata, installResult) restore(this, token, deviceName, packageName, metadata, installResult)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) emit(installResult.fail(packageName))
@ -74,15 +76,13 @@ internal class ApkRestore(
private suspend fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>, collector: FlowCollector<InstallResult>,
token: Long, token: Long,
deviceName: String,
packageName: String, packageName: String,
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 +137,80 @@ internal class ApkRestore(
} }
} }
if (metadata.splits != null) { // process further APK splits, if available
// do not install APKs that require splits (for now) val cachedApks =
Log.w(TAG, "Not installing $packageName because it requires splits.") 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)) 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,
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, * 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.

View file

@ -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
}
}
}

View file

@ -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)
}

View file

@ -5,5 +5,7 @@ import org.koin.dsl.module
val installModule = module { val installModule = module {
factory { ApkInstaller(androidContext()) } factory { ApkInstaller(androidContext()) }
factory { ApkRestore(androidContext(), get(), get()) } factory { DeviceInfo(androidContext()) }
factory { ApkSplitCompatibilityChecker(get()) }
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. * 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
} }

View file

@ -10,10 +10,26 @@
--> -->
<bool name="show_restore_in_settings">false</bool> <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"> <string-array name="storage_authority_whitelist" tools:ignore="InconsistentArrays">
<item>com.android.externalstorage.documents</item> <item>com.android.externalstorage.documents</item>
<item>org.nextcloud.documents</item> <item>org.nextcloud.documents</item>
</string-array> </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> </resources>

View file

@ -118,6 +118,8 @@
<string name="restore_installing_tap_to_install">Tap to install</string> <string name="restore_installing_tap_to_install">Tap to install</string>
<string name="restore_next">Next</string> <string name="restore_next">Next</string>
<string name="restore_restoring">Restoring backup</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_magic_package">System package manager</string>
<string name="restore_finished_success">Restore complete</string> <string name="restore_finished_success">Restore complete</string>
<string name="restore_finished_error">An error occurred while restoring the backup.</string> <string name="restore_finished_error">An error occurred while restoring the backup.</string>

View file

@ -8,7 +8,10 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
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 import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
@ -22,7 +25,6 @@ import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
@ -34,6 +36,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException
import java.nio.file.Path import java.nio.file.Path
import kotlin.random.Random import kotlin.random.Random
@ -46,12 +49,15 @@ 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()
private val deviceName = getRandomString()
private val packageName = packageInfo.packageName private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata( private val packageMetadata = PackageMetadata(
time = Random.nextLong(), time = Random.nextLong(),
@ -78,28 +84,10 @@ 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, deviceName, packageMetadataMap).collectIndexed { i, value ->
when (index) { assertQueuedFailFinished(i, value)
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")
}
} }
} }
@ -109,75 +97,23 @@ 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, deviceName, packageMetadataMap).collectIndexed { i, value ->
when (index) { assertQueuedFailFinished(i, value)
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")
}
} }
} }
@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()) cacheBaseApkAndGetInfo(tmpDir)
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
coEvery { coEvery {
apkInstaller.install(any(), packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} throws SecurityException() } throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
when (index) { assertQueuedProgressFailFinished(i, value)
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")
}
} }
} }
@ -193,46 +129,13 @@ internal class ApkRestoreTest : TransportTest() {
) )
} }
every { strictContext.cacheDir } returns File(tmpDir.toString()) cacheBaseApkAndGetInfo(tmpDir)
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
coEvery { coEvery {
apkInstaller.install(any(), packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} returns installResult } returns installResult
var i = 0 apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
apkRestore.restore(token, packageMetadataMap).collect { value -> assertQueuedProgressSuccessFinished(i, 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++
} }
} }
@ -246,17 +149,9 @@ internal class ApkRestoreTest : TransportTest() {
val willFail = Random.nextBoolean() val willFail = Random.nextBoolean()
val isSystemApp = Random.nextBoolean() val isSystemApp = Random.nextBoolean()
every { strictContext.cacheDir } returns File(tmpDir.toString()) cacheBaseApkAndGetInfo(tmpDir)
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
packageInfo.applicationInfo,
packageInfo.applicationInfo
)
} returns icon
every { packageInfo.applicationInfo.loadIcon(pm) } returns icon every { packageInfo.applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
if (willFail) { if (willFail) {
every { every {
pm.getPackageInfo(packageName, 0) pm.getPackageInfo(packageName, 0)
@ -276,12 +171,17 @@ internal class ApkRestoreTest : TransportTest() {
) )
} }
coEvery { coEvery {
apkInstaller.install(any(), packageName, installerName, any()) apkInstaller.install(
match { it.size == 1 },
packageName,
installerName,
any()
)
} returns installResult } returns installResult
} }
} }
apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
when (i) { when (i) {
0 -> { 0 -> {
val result = value[packageName] 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 { private operator fun InstallResult.get(packageName: String): ApkInstallResult {

View file

@ -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"))
)
}
}

View file

@ -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()))
}
}

View file

@ -13,7 +13,7 @@ buildscript {
dependencies { dependencies {
//noinspection DifferentKotlinGradleVersion //noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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'
} }
} }