diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt index 953dd750..3985a74c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index b5f1131e..199de755 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -151,9 +151,9 @@ internal class RestoreViewModel( closeSession() } - private fun getInstallResult(restorableBackup: RestorableBackup): LiveData { + private fun getInstallResult(backup: RestorableBackup): LiveData { @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 -> diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 886d41d9..d3215326 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -33,7 +33,7 @@ internal class ApkRestore( 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 @@ -55,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)) @@ -76,6 +76,7 @@ internal class ApkRestore( private suspend fun restore( collector: FlowCollector, token: Long, + deviceName: String, packageName: String, metadata: PackageMetadata, installResult: MutableInstallResult @@ -137,7 +138,8 @@ internal class ApkRestore( } // process further APK splits, if available - val cachedApks = cacheSplitsIfNeeded(token, packageName, cachedApk, metadata.splits) + 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)) @@ -160,6 +162,7 @@ internal class ApkRestore( @Throws(IOException::class, SecurityException::class) private suspend fun cacheSplitsIfNeeded( token: Long, + deviceName: String, packageName: String, cachedApk: File, splits: List? @@ -168,7 +171,7 @@ internal class ApkRestore( val splitNames = splits?.map { it.name } ?: return listOf(cachedApk) // return null when splits are incompatible - if (!splitCompatChecker.isCompatible(splitNames)) return null + if (!splitCompatChecker.isCompatible(deviceName, splitNames)) return null // store coming splits in a list val cachedApks = ArrayList(splits.size + 1).apply { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityChecker.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityChecker.kt index 721e7e35..79138d90 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityChecker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityChecker.kt @@ -1,7 +1,5 @@ package com.stevesoltys.seedvault.restore.install -import android.content.Context -import android.os.Build import android.util.Log private const val TAG = "SplitCompatChecker" @@ -9,6 +7,7 @@ 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 @@ -17,14 +16,9 @@ private const val XHDPI_VALUE = 320 private const val XXHDPI_VALUE = 480 private const val XXXHDPI_VALUE = 640 -class DeviceInfo(context: Context) { - val densityDpi: Int = context.resources.displayMetrics.densityDpi - val supportedABIs: List = Build.SUPPORTED_ABIS.toList() -} - /** * Tries to determine APK split compatibility with a device by examining the list of split names. - * This only looks on the supported ABIs and the screen density. + * 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. */ @@ -53,18 +47,29 @@ class ApkSplitCompatibilityChecker(private val deviceInfo: DeviceInfo) { * Returns true if the list of splits can be considered compatible with the current device, * and false otherwise. */ - fun isCompatible(splitNames: Collection): Boolean = splitNames.all { splitName -> - // all individual splits need to be compatible (which can be hard to judge by name only) - isCompatible(splitName) + fun isCompatible(deviceName: String, splitNames: Collection): 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(splitName: String): Boolean { + private fun isCompatible( + deviceName: String, + unknownAllowed: Boolean, + splitName: String + ): Boolean { val index = splitName.indexOf(CONFIG_PREFIX) - // If this is not a standardized config split, we just assume that it will work, - // as it is most likely a dynamic feature module. - if (index == -1) { + // 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) @@ -77,14 +82,25 @@ class ApkSplitCompatibilityChecker(private val deviceInfo: DeviceInfo) { // 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. + // The split's density must not be much lower than the device's. return isDensityCompatible(splitDensity) } - // At this point we don't know what to make of that split, - // so let's just hope that it will work. It might just be a language. - Log.v(TAG, "Unhandled config split '$splitName'. Assuming it is ok.") - return true + // 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 { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/DeviceInfo.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/DeviceInfo.kt new file mode 100644 index 00000000..614e2c87 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/DeviceInfo.kt @@ -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 = 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) +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt index 9c484e8f..60cc4488 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt @@ -5,6 +5,7 @@ import org.koin.dsl.module val installModule = module { factory { ApkInstaller(androidContext()) } - factory { ApkSplitCompatibilityChecker(DeviceInfo(androidContext())) } + factory { DeviceInfo(androidContext()) } + factory { ApkSplitCompatibilityChecker(get()) } factory { ApkRestore(androidContext(), get(), get(), get()) } } diff --git a/app/src/main/res/values/config.xml b/app/src/main/res/values/config.xml index 450c668b..57e20dec 100644 --- a/app/src/main/res/values/config.xml +++ b/app/src/main/res/values/config.xml @@ -10,10 +10,26 @@ --> false - + com.android.externalstorage.documents org.nextcloud.documents + + false + diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index 75a14505..da50652b 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -57,6 +57,7 @@ internal class ApkRestoreTest : TransportTest() { private val icon: Drawable = mockk() + private val deviceName = getRandomString() private val packageName = packageInfo.packageName private val packageMetadata = PackageMetadata( time = Random.nextLong(), @@ -85,7 +86,7 @@ internal class ApkRestoreTest : TransportTest() { every { strictContext.cacheDir } returns File(tmpDir.toString()) coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedFailFinished(i, value) } } @@ -99,7 +100,7 @@ internal class ApkRestoreTest : TransportTest() { coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedFailFinished(i, value) } } @@ -111,7 +112,7 @@ internal class ApkRestoreTest : TransportTest() { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } throws SecurityException() - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -133,7 +134,7 @@ internal class ApkRestoreTest : TransportTest() { 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 -> assertQueuedProgressSuccessFinished(i, value) } } @@ -180,7 +181,7 @@ internal class ApkRestoreTest : TransportTest() { } } - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> when (i) { 0 -> { val result = value[packageName] @@ -226,9 +227,11 @@ internal class ApkRestoreTest : TransportTest() { cacheBaseApkAndGetInfo(tmpDir) // splits are NOT compatible - every { splitCompatChecker.isCompatible(listOf(split1Name, split2Name)) } returns false + every { + splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) + } returns false - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -245,12 +248,12 @@ internal class ApkRestoreTest : TransportTest() { // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) - every { splitCompatChecker.isCompatible(listOf(splitName)) } returns true + every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true coEvery { restorePlugin.getApkInputStream(token, packageName, "_$sha256") } returns ByteArrayInputStream(getRandomByteArray()) - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -268,12 +271,12 @@ internal class ApkRestoreTest : TransportTest() { // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) - every { splitCompatChecker.isCompatible(listOf(splitName)) } returns true + every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true coEvery { restorePlugin.getApkInputStream(token, packageName, "_$sha256") } throws IOException() - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -295,7 +298,9 @@ internal class ApkRestoreTest : TransportTest() { // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) - every { splitCompatChecker.isCompatible(listOf(split1Name, split2Name)) } returns true + 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) @@ -321,7 +326,7 @@ internal class ApkRestoreTest : TransportTest() { ) } - apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> assertQueuedProgressSuccessFinished(i, value) } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityCheckerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityCheckerTest.kt index 710768af..aff0ab78 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityCheckerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityCheckerTest.kt @@ -4,144 +4,175 @@ 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`() { - assertTrue( - checker.isCompatible( - listOf( - getRandomString(), - getRandomString(), - getRandomString(), - getRandomString(), - getRandomString(), - getRandomString() - ) - ) + 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 unknown config splits always get accepted`() { - assertTrue( - checker.isCompatible( - listOf( - "config.de", - "config.en", - "config.gu", - getRandomString(), - getRandomString(), - getRandomString() - ) - ) + 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(listOf("config.arm64_v8a"))) - assertTrue(checker.isCompatible(listOf("${getRandomString()}.config.arm64_v8a"))) - assertTrue(checker.isCompatible(listOf("config.armeabi_v7a"))) - assertTrue(checker.isCompatible(listOf("${getRandomString()}.config.armeabi_v7a"))) - assertTrue(checker.isCompatible(listOf("config.armeabi"))) - assertTrue(checker.isCompatible(listOf("${getRandomString()}.config.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(listOf("config.x86"))) - assertFalse(checker.isCompatible(listOf("config.x86_64"))) - assertFalse(checker.isCompatible(listOf("config.mips"))) - assertFalse(checker.isCompatible(listOf("config.mips64"))) + 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(listOf("config.armeabi"))) - assertTrue(checker.isCompatible(listOf("${getRandomString()}.config.armeabi"))) + assertTrue(checker.isCompatible(deviceName, listOf("config.armeabi"))) + assertTrue(checker.isCompatible(deviceName, listOf("${getRandomString()}.config.armeabi"))) - assertFalse(checker.isCompatible(listOf("config.arm64_v8a"))) - assertFalse(checker.isCompatible(listOf("${getRandomString()}.config.arm64_v8a"))) - assertFalse(checker.isCompatible(listOf("config.armeabi_v7a"))) - assertFalse(checker.isCompatible(listOf("${getRandomString()}.config.armeabi_v7a"))) + 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 - assertTrue( - checker.isCompatible( - listOf( - "config.de", - "config.xxxhdpi", // higher density is accepted - getRandomString() - ) - ) - ) - assertTrue( - checker.isCompatible( - listOf( - "config.de", - "config.xxhdpi", // same density is accepted - getRandomString() - ) - ) - ) - assertTrue( - checker.isCompatible( - listOf( - "config.de", - "config.xhdpi", // one lower density is accepted - getRandomString() - ) - ) - ) - assertFalse( - checker.isCompatible( - listOf( - "config.de", - "config.hdpi", // two lower density is not accepted - getRandomString() - ) - ) - ) + // 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(listOf("config.tvdpi"))) - assertFalse(checker.isCompatible(listOf("config.mdpi"))) - assertFalse(checker.isCompatible(listOf("config.ldpi"))) + 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(listOf("config.xxxhdpi"))) - assertTrue(checker.isCompatible(listOf("config.xxhdpi"))) - assertTrue(checker.isCompatible(listOf("config.xhdpi"))) - assertTrue(checker.isCompatible(listOf("config.hdpi"))) - assertTrue(checker.isCompatible(listOf("config.tvdpi"))) - assertTrue(checker.isCompatible(listOf("config.mdpi"))) - assertTrue(checker.isCompatible(listOf("config.ldpi"))) + 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 - assertTrue( + assertEquals( + unknownAllowed, checker.isCompatible( + deviceName, listOf( "config.de", "config.xhdpi", @@ -153,6 +184,7 @@ class ApkSplitCompatibilityCheckerTest : TransportTest() { // same as above, but feature split with unsupported ABI config gets rejected assertFalse( checker.isCompatible( + deviceName, listOf( "config.de", "config.xhdpi", @@ -163,10 +195,14 @@ class ApkSplitCompatibilityCheckerTest : TransportTest() { ) ) - assertTrue(checker.isCompatible(listOf("config.xhdpi", "config.armeabi"))) - assertTrue(checker.isCompatible(listOf("config.hdpi", "config.armeabi_v7a"))) - assertFalse(checker.isCompatible(listOf("foo.config.ldpi", "config.armeabi_v7a"))) - assertFalse(checker.isCompatible(listOf("foo.config.xxxhdpi", "bar.config.arm64_v8a"))) + 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")) + ) } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt new file mode 100644 index 00000000..4afe4ec7 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt @@ -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())) + } + +}