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

View file

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

View file

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

View file

@ -173,14 +173,18 @@ class PluginTest : KoinComponent {
backupPlugin.getApkOutputStream(packageInfo, "").writeAndClose(apk1)
// assert that read APK bytes match what was written
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName))
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName, ""))
// write random bytes as another APK
val suffix2 = getRandomBase64(23)
val apk2 = getRandomByteArray(23 * 1024 * 1024)
backupPlugin.getApkOutputStream(packageInfo2, "").writeAndClose(apk2)
backupPlugin.getApkOutputStream(packageInfo2, suffix2).writeAndClose(apk2)
// assert that read APK bytes match what was written
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))
assertReadEquals(
apk2,
restorePlugin.getApkInputStream(token, packageInfo2.packageName, suffix2)
)
}
@Test

View file

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

View file

@ -94,10 +94,14 @@ internal class DocumentsProviderRestorePlugin(
}
@Throws(IOException::class)
override suspend fun getApkInputStream(token: Long, packageName: String): InputStream {
override suspend fun getApkInputStream(
token: Long,
packageName: String,
suffix: String
): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file =
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
val file = setDir.findFileBlocking(context, "$packageName$suffix.apk")
?: throw FileNotFoundException()
return storage.getInputStream(file)
}

View file

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

View file

@ -9,6 +9,7 @@ import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
@ -17,6 +18,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() {
@ -83,11 +85,25 @@ class RestoreProgressFragment : Fragment() {
backupNameView.setTextColor(getColor(requireContext(), R.color.red))
} else {
backupNameView.text = getString(R.string.restore_finished_success)
onRestoreFinished()
}
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
})
}
private fun onRestoreFinished() {
// check if any restore failed, because the app is not installed
val failed = viewModel.restoreProgress.value?.any { it.state == FAILED_NOT_INSTALLED }
if (failed != true) return // nothing left to do if there's no failures due to not installed
AlertDialog.Builder(requireContext())
.setTitle(R.string.restore_restoring_error_title)
.setMessage(R.string.restore_restoring_error_message)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
.show()
}
private fun stayScrolledAtTop(add: () -> Unit) {
val position = layoutManager.findFirstVisibleItemPosition()
add.invoke()

View file

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

View file

@ -38,7 +38,7 @@ internal class ApkInstaller(private val context: Context) {
@Throws(IOException::class, SecurityException::class)
internal suspend fun install(
cachedApk: File,
cachedApks: List<File>,
packageName: String,
installerPackageName: String?,
installResult: MutableInstallResult
@ -47,16 +47,16 @@ internal class ApkInstaller(private val context: Context) {
override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return
context.unregisterReceiver(this)
cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult))
cont.resume(onBroadcastReceived(i, packageName, cachedApks, installResult))
}
}
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) }
install(cachedApk, installerPackageName)
install(cachedApks, installerPackageName)
}
private fun install(cachedApk: File, installerPackageName: String?) {
private fun install(cachedApks: List<File>, installerPackageName: String?) {
val sessionParams = SessionParams(MODE_FULL_INSTALL).apply {
setInstallerPackageName(installerPackageName)
// Setting the INSTALL_ALLOW_TEST flag here does not allow us to install test apps,
@ -65,12 +65,14 @@ internal class ApkInstaller(private val context: Context) {
// Don't set more sessionParams intentionally here.
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
val session = installer.openSession(installer.createSession(sessionParams))
val sizeBytes = cachedApk.length()
session.use { s ->
cachedApk.inputStream().use { inputStream ->
s.openWrite("PackageInstaller", 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
cachedApks.forEach { cachedApk ->
val sizeBytes = cachedApk.length()
cachedApk.inputStream().use { inputStream ->
s.openWrite(cachedApk.name, 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
}
}
}
s.commit(getIntentSender())
@ -90,7 +92,7 @@ internal class ApkInstaller(private val context: Context) {
private fun onBroadcastReceived(
i: Intent,
expectedPackageName: String,
cachedApk: File,
cachedApks: List<File>,
installResult: MutableInstallResult
): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
@ -102,9 +104,9 @@ internal class ApkInstaller(private val context: Context) {
}
Log.d(TAG, "Received result for $packageName: success=$success $statusMsg")
// delete cached APK file on I/O thread
// delete all cached APK files on I/O thread
GlobalScope.launch(Dispatchers.IO) {
cachedApk.delete()
cachedApks.forEach { it.delete() }
}
// update status and offer result

View file

@ -5,6 +5,7 @@ import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.util.Log
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -26,12 +27,13 @@ private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore(
private val context: Context,
private val restorePlugin: RestorePlugin,
private val splitCompatChecker: ApkSplitCompatibilityChecker,
private val apkInstaller: ApkInstaller
) {
private val pm = context.packageManager
fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow {
fun restore(token: Long, deviceName: String, packageMetadataMap: PackageMetadataMap) = flow {
// filter out packages without APK and get total
val packages = packageMetadataMap.filter { it.value.hasApk() }
val total = packages.size
@ -53,7 +55,7 @@ internal class ApkRestore(
// re-install individual packages and emit updates
for ((packageName, metadata) in packages) {
try {
restore(this, token, packageName, metadata, installResult)
restore(this, token, deviceName, packageName, metadata, installResult)
} catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName))
@ -74,15 +76,13 @@ internal class ApkRestore(
private suspend fun restore(
collector: FlowCollector<InstallResult>,
token: Long,
deviceName: String,
packageName: String,
metadata: PackageMetadata,
installResult: MutableInstallResult
) {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = restorePlugin.getApkInputStream(token, packageName)
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
// cache the APK and get its hash
val (cachedApk, sha256) = cacheApk(token, packageName)
// check APK's SHA-256 hash
if (metadata.sha256 != sha256) throw SecurityException(
@ -137,18 +137,80 @@ internal class ApkRestore(
}
}
if (metadata.splits != null) {
// do not install APKs that require splits (for now)
Log.w(TAG, "Not installing $packageName because it requires splits.")
// process further APK splits, if available
val cachedApks =
cacheSplitsIfNeeded(token, deviceName, packageName, cachedApk, metadata.splits)
if (cachedApks == null) {
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
collector.emit(installResult.fail(packageName))
return
}
// install APK and emit updates from it
val result = apkInstaller.install(cachedApk, packageName, metadata.installer, installResult)
val result =
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult)
collector.emit(result)
}
/**
* Retrieves APK splits from [RestorePlugin] and caches them locally.
*
* @throws SecurityException if a split has an unexpected SHA-256 hash.
* @return a list of all APKs that need to be installed
* or null if the splits are incompatible with this restore device.
*/
@Throws(IOException::class, SecurityException::class)
private suspend fun cacheSplitsIfNeeded(
token: Long,
deviceName: String,
packageName: String,
cachedApk: File,
splits: List<ApkSplit>?
): List<File>? {
// if null, there are no splits, so we just have a single base APK to consider
val splitNames = splits?.map { it.name } ?: return listOf(cachedApk)
// return null when splits are incompatible
if (!splitCompatChecker.isCompatible(deviceName, splitNames)) return null
// store coming splits in a list
val cachedApks = ArrayList<File>(splits.size + 1).apply {
add(cachedApk) // don't forget the base APK
}
splits.forEach { apkSplit -> // cache and check all splits
val suffix = "_${apkSplit.sha256}"
val (file, sha256) = cacheApk(token, packageName, suffix)
// check APK split's SHA-256 hash
if (apkSplit.sha256 != sha256) throw SecurityException(
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
" but '${apkSplit.sha256}' expected."
)
cachedApks.add(file)
}
return cachedApks
}
/**
* Retrieves an APK from the [RestorePlugin] and caches it locally
* while calculating its SHA-256 hash.
*
* @return a [Pair] of the cached [File] and SHA-256 hash.
*/
@Throws(IOException::class)
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
private suspend fun cacheApk(
token: Long,
packageName: String,
suffix: String = ""
): Pair<File, String> {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = restorePlugin.getApkInputStream(token, packageName, suffix)
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
return Pair(cachedApk, sha256)
}
/**
* Returns null if this system app should get re-installed,
* or a new [InstallResult] to be emitted otherwise.

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 {
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.
*/
@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>
<!-- Add only storage that is also available when restoring from backup (e.g. initial device setup) -->
<!--
Add only storage here that is also available
when restoring from backup (e.g. initial device setup)
-->
<string-array name="storage_authority_whitelist" tools:ignore="InconsistentArrays">
<item>com.android.externalstorage.documents</item>
<item>org.nextcloud.documents</item>
</string-array>
<!--
Android App Bundles split up the app into several APKs.
We always back up all the available split APKs
and do a compatibility check when re-installing them.
If a backed up split is not compatible, the re-install will fail
and the user will be given the opportunity to install the app manually before data restore.
Unknown splits are treated as compatible as we haven't yet seen a case
where this would cause a problem such as an app crashing when starting it after re-install.
However, if you prefer to be on the safe side, you can set this to true,
to only install unknown splits if they come from the same device.
-->
<bool name="re_install_unknown_splits_only_on_same_device">false</bool>
</resources>

View file

@ -118,6 +118,8 @@
<string name="restore_installing_tap_to_install">Tap to install</string>
<string name="restore_next">Next</string>
<string name="restore_restoring">Restoring backup</string>
<string name="restore_restoring_error_title">Unable to restore some apps</string>
<string name="restore_restoring_error_message">You can re-install these apps manually and "Automatic restore" will attempt to restore their data (when enabled).</string>
<string name="restore_magic_package">System package manager</string>
<string name="restore_finished_success">Restore complete</string>
<string name="restore_finished_error">An error occurred while restoring the backup.</string>

View file

@ -8,7 +8,10 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
@ -22,7 +25,6 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions
@ -34,6 +36,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.nio.file.Path
import kotlin.random.Random
@ -46,12 +49,15 @@ internal class ApkRestoreTest : TransportTest() {
every { packageManager } returns pm
}
private val restorePlugin: RestorePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = ApkRestore(strictContext, restorePlugin, apkInstaller)
private val apkRestore: ApkRestore =
ApkRestore(strictContext, restorePlugin, splitCompatChecker, apkInstaller)
private val icon: Drawable = mockk()
private val deviceName = getRandomString()
private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
@ -78,28 +84,10 @@ internal class ApkRestoreTest : TransportTest() {
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
assertFalse(value.isFinished)
}
2 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedFailFinished(i, value)
}
}
@ -109,75 +97,23 @@ internal class ApkRestoreTest : TransportTest() {
packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
}
2 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedFailFinished(i, value)
}
}
@Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
packageInfo.applicationInfo,
packageInfo.applicationInfo
)
} returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
cacheBaseApkAndGetInfo(tmpDir)
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(installerName, result.installerPackageName)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
assertFalse(value.hasFailed)
}
2 -> {
val result = value[packageName]
assertTrue(value.hasFailed)
assertEquals(FAILED, result.state)
assertFalse(value.isFinished)
}
3 -> {
assertTrue(value.hasFailed, "1")
assertTrue(value.isFinished, "2")
}
else -> fail("more values emitted")
}
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value)
}
}
@ -193,46 +129,13 @@ internal class ApkRestoreTest : TransportTest() {
)
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
packageInfo.applicationInfo,
packageInfo.applicationInfo
)
} returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
cacheBaseApkAndGetInfo(tmpDir)
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} returns installResult
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) {
0 -> {
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName]
assertEquals(SUCCEEDED, result.state)
}
3 -> {
assertFalse(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
i++
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressSuccessFinished(i, value)
}
}
@ -246,17 +149,9 @@ internal class ApkRestoreTest : TransportTest() {
val willFail = Random.nextBoolean()
val isSystemApp = Random.nextBoolean()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
packageInfo.applicationInfo,
packageInfo.applicationInfo
)
} returns icon
cacheBaseApkAndGetInfo(tmpDir)
every { packageInfo.applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
if (willFail) {
every {
pm.getPackageInfo(packageName, 0)
@ -276,12 +171,17 @@ internal class ApkRestoreTest : TransportTest() {
)
}
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
apkInstaller.install(
match { it.size == 1 },
packageName,
installerName,
any()
)
} returns installResult
}
}
apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value ->
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
when (i) {
0 -> {
val result = value[packageName]
@ -311,6 +211,205 @@ internal class ApkRestoreTest : TransportTest() {
}
}
@Test
fun `incompatible splits cause FAILED state`(@TempDir tmpDir: Path) = runBlocking {
// add one APK split to metadata
val split1Name = getRandomString()
val split2Name = getRandomString()
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(
ApkSplit(split1Name, getRandomBase64()),
ApkSplit(split2Name, getRandomBase64())
)
)
// cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir)
// splits are NOT compatible
every {
splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name))
} returns false
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value)
}
}
@Test
fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking {
// add one APK split to metadata
val splitName = getRandomString()
val sha256 = getRandomBase64(23)
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(ApkSplit(splitName, sha256))
)
// cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir)
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
coEvery {
restorePlugin.getApkInputStream(token, packageName, "_$sha256")
} returns ByteArrayInputStream(getRandomByteArray())
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value)
}
}
@Test
fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) =
runBlocking {
// add one APK split to metadata
val splitName = getRandomString()
val sha256 = getRandomBase64(23)
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(ApkSplit(splitName, sha256))
)
// cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir)
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
coEvery {
restorePlugin.getApkInputStream(token, packageName, "_$sha256")
} throws IOException()
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value)
}
}
@Test
fun `splits get installed along with base APK`(@TempDir tmpDir: Path) = runBlocking {
// add one APK split to metadata
val split1Name = getRandomString()
val split2Name = getRandomString()
val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(
ApkSplit(split1Name, split1sha256),
ApkSplit(split2Name, split2sha256)
)
)
// cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir)
every {
splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name))
} returns true
// define bytes of splits and return them as stream (matches above hashes)
val split1Bytes = byteArrayOf(0x01, 0x02, 0x03)
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
val split1InputStream = ByteArrayInputStream(split1Bytes)
val split2InputStream = ByteArrayInputStream(split2Bytes)
coEvery {
restorePlugin.getApkInputStream(token, packageName, "_$split1sha256")
} returns split1InputStream
coEvery {
restorePlugin.getApkInputStream(token, packageName, "_$split2sha256")
} returns split2InputStream
coEvery {
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
} returns MutableInstallResult(1).apply {
set(
packageName, ApkInstallResult(
packageName,
progress = 1,
state = SUCCEEDED
)
)
}
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value ->
assertQueuedProgressSuccessFinished(i, value)
}
}
private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every {
pm.loadItemIcon(
packageInfo.applicationInfo,
packageInfo.applicationInfo
)
} returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
}
private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) {
0 -> assertQueuedProgress(step, value)
1 -> {
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
assertFalse(value.isFinished)
}
2 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) {
0 -> assertQueuedProgress(step, value)
1 -> assertQueuedProgress(step, value)
2 -> {
val result = value[packageName]
assertEquals(SUCCEEDED, result.state)
}
3 -> {
assertFalse(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) {
0 -> assertQueuedProgress(step, value)
1 -> assertQueuedProgress(step, value)
2 -> {
// app install has failed
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
assertFalse(value.isFinished)
}
3 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) {
0 -> {
// single package gets queued
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(installerName, result.installerPackageName)
assertEquals(1, result.progress)
assertEquals(1, value.total)
}
1 -> {
// name and icon are available now
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
assertFalse(value.hasFailed)
}
else -> fail("more values emitted")
}
}
private operator fun InstallResult.get(packageName: String): ApkInstallResult {

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 {
//noinspection DifferentKotlinGradleVersion
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.android.tools.build:gradle:4.0.1'
classpath 'com.android.tools.build:gradle:4.1.0'
}
}