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 new file mode 100644 index 00000000..721e7e35 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityChecker.kt @@ -0,0 +1,118 @@ +package com.stevesoltys.seedvault.restore.install + +import android.content.Context +import android.os.Build +import android.util.Log + +private const val TAG = "SplitCompatChecker" + +private const val CONFIG_PREFIX = "config." +private const val CONFIG_LENGTH = CONFIG_PREFIX.length + +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 + +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. + * 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(splitNames: Collection): Boolean = splitNames.all { splitName -> + // all individual splits need to be compatible (which can be hard to judge by name only) + isCompatible(splitName) + } + + private fun isCompatible(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) { + Log.v(TAG, "Not a config split '$splitName'. Assuming it is ok.") + return true + } + + 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) + } + + // 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 + } + + 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 + } + } + +} 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 new file mode 100644 index 00000000..710768af --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkSplitCompatibilityCheckerTest.kt @@ -0,0 +1,172 @@ +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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.jupiter.api.Test + +class ApkSplitCompatibilityCheckerTest : TransportTest() { + + private val deviceInfo: DeviceInfo = mockk() + + private val checker = ApkSplitCompatibilityChecker(deviceInfo) + + @Test + fun `non-config splits always get accepted`() { + assertTrue( + checker.isCompatible( + listOf( + getRandomString(), + getRandomString(), + getRandomString(), + getRandomString(), + getRandomString(), + getRandomString() + ) + ) + ) + } + + @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() + ) + ) + ) + } + + @Test + fun `all supported ABIs get accepted, non-supported rejected`() { + 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"))) + + 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"))) + } + + @Test + fun `armeabi rejects arm64_v8a and armeabi-v7a`() { + every { deviceInfo.supportedABIs } returns listOf("armeabi") + + assertTrue(checker.isCompatible(listOf("config.armeabi"))) + assertTrue(checker.isCompatible(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"))) + } + + @Test + fun `screen density is accepted when not too low`() { + 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() + ) + ) + ) + // 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"))) + } + + @Test + fun `screen density accepts all higher densities`() { + 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"))) + } + + @Test + fun `test mix of unknown and all known config splits`() { + every { deviceInfo.supportedABIs } returns listOf("armeabi-v7a", "armeabi") + every { deviceInfo.densityDpi } returns 240 + + assertTrue( + checker.isCompatible( + listOf( + "config.de", + "config.xhdpi", + "config.armeabi", + getRandomString() + ) + ) + ) + // same as above, but feature split with unsupported ABI config gets rejected + assertFalse( + checker.isCompatible( + listOf( + "config.de", + "config.xhdpi", + "config.armeabi", + "${getRandomString()}.config.arm64_v8a", + getRandomString() + ) + ) + ) + + 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"))) + } + +}