Re-factor and improve ApkRestore
This commit is contained in:
parent
05c39e98fa
commit
e54d96d548
10 changed files with 383 additions and 404 deletions
|
@ -181,7 +181,9 @@ dependencies {
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
|
testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
|
||||||
testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
|
testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}")
|
testImplementation(
|
||||||
|
"org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}"
|
||||||
|
)
|
||||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||||
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
||||||
|
|
|
@ -108,9 +108,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
withTimeout(RESTORE_TIMEOUT) {
|
withTimeout(RESTORE_TIMEOUT) {
|
||||||
while (spyRestoreViewModel.installResult.value == null ||
|
while (spyRestoreViewModel.installResult.value?.isFinished != true) {
|
||||||
spyRestoreViewModel.nextButtonEnabled.value == false
|
|
||||||
) {
|
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,6 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.asLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
import androidx.lifecycle.switchMap
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.BackupMonitor
|
import com.stevesoltys.seedvault.BackupMonitor
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
|
@ -68,10 +67,6 @@ import com.stevesoltys.seedvault.worker.IconManager
|
||||||
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.calyxos.backup.storage.api.SnapshotItem
|
import org.calyxos.backup.storage.api.SnapshotItem
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
|
@ -117,16 +112,9 @@ internal class RestoreViewModel(
|
||||||
internal val selectedApps: LiveData<SelectedAppsState> =
|
internal val selectedApps: LiveData<SelectedAppsState> =
|
||||||
appSelectionManager.selectedAppsLiveData
|
appSelectionManager.selectedAppsLiveData
|
||||||
|
|
||||||
internal val installResult: LiveData<InstallResult> =
|
internal val installResult: LiveData<InstallResult> = apkRestore.installResult.asLiveData()
|
||||||
mChosenRestorableBackup.switchMap { backup ->
|
|
||||||
// TODO does this stay stable when re-observing this LiveData?
|
|
||||||
// TODO pass in app selection done by user
|
|
||||||
getInstallResult(backup)
|
|
||||||
}
|
|
||||||
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
|
|
||||||
|
|
||||||
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false }
|
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
|
||||||
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
|
|
||||||
|
|
||||||
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
|
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
|
||||||
value = LinkedList<AppRestoreResult>().apply {
|
value = LinkedList<AppRestoreResult>().apply {
|
||||||
|
@ -193,33 +181,26 @@ internal class RestoreViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun loadIcon(packageName: String, callback: (Drawable) -> Unit) {
|
||||||
|
iconManager.loadIcon(packageName, callback)
|
||||||
|
}
|
||||||
|
|
||||||
fun onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked()
|
fun onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked()
|
||||||
fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item)
|
fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item)
|
||||||
|
|
||||||
internal fun onNextClickedAfterSelectingApps() {
|
internal fun onNextClickedAfterSelectingApps() {
|
||||||
val backup = chosenRestorableBackup.value ?: error("No chosen backup")
|
val backup = chosenRestorableBackup.value ?: error("No chosen backup")
|
||||||
// replace original chosen backup with unselected packages removed
|
// replace original chosen backup with unselected packages removed
|
||||||
mChosenRestorableBackup.value = appSelectionManager.onAppSelectionFinished(backup)
|
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
|
||||||
|
mChosenRestorableBackup.value = filteredBackup
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
apkRestore.restore(filteredBackup)
|
||||||
|
}
|
||||||
// tell UI to move to InstallFragment
|
// tell UI to move to InstallFragment
|
||||||
mDisplayFragment.setEvent(RESTORE_APPS)
|
mDisplayFragment.setEvent(RESTORE_APPS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> {
|
fun reCheckFailedPackage(packageName: String) = apkRestore.reCheckFailedPackage(packageName)
|
||||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
|
||||||
return apkRestore.restore(backup)
|
|
||||||
.onStart {
|
|
||||||
Log.d(TAG, "Start InstallResult Flow")
|
|
||||||
}.catch { e ->
|
|
||||||
Log.d(TAG, "Exception in InstallResult Flow", e)
|
|
||||||
}.onCompletion { e ->
|
|
||||||
Log.d(TAG, "Completed InstallResult Flow", e)
|
|
||||||
mNextButtonEnabled.postValue(true)
|
|
||||||
}
|
|
||||||
.flowOn(ioDispatcher)
|
|
||||||
// collect on the same thread, so concurrency issues don't mess up live data updates
|
|
||||||
// e.g. InstallResult#isFinished isn't reported too early.
|
|
||||||
.asLiveData(ioDispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun onNextClickedAfterInstallingApps() {
|
internal fun onNextClickedAfterInstallingApps() {
|
||||||
mDisplayFragment.postEvent(RESTORE_BACKUP)
|
mDisplayFragment.postEvent(RESTORE_BACKUP)
|
||||||
|
|
|
@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) {
|
||||||
cachedApks: List<File>,
|
cachedApks: List<File>,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
installerPackageName: String?,
|
installerPackageName: String?,
|
||||||
installResult: MutableInstallResult,
|
installResult: InstallResult,
|
||||||
) = suspendCancellableCoroutine<InstallResult> { cont ->
|
) = suspendCancellableCoroutine { cont ->
|
||||||
val broadcastReceiver = object : BroadcastReceiver() {
|
val broadcastReceiver = object : BroadcastReceiver() {
|
||||||
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
|
||||||
|
@ -110,7 +110,7 @@ internal class ApkInstaller(private val context: Context) {
|
||||||
i: Intent,
|
i: Intent,
|
||||||
expectedPackageName: String,
|
expectedPackageName: String,
|
||||||
cachedApks: List<File>,
|
cachedApks: List<File>,
|
||||||
installResult: MutableInstallResult,
|
installResult: InstallResult,
|
||||||
): InstallResult {
|
): InstallResult {
|
||||||
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
|
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
|
||||||
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS
|
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS
|
||||||
|
|
|
@ -10,6 +10,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.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
|
@ -26,10 +27,12 @@ import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
||||||
import com.stevesoltys.seedvault.worker.getSignatures
|
import com.stevesoltys.seedvault.worker.getSignatures
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
private val TAG = ApkRestore::class.java.simpleName
|
private val TAG = ApkRestore::class.java.simpleName
|
||||||
|
|
||||||
|
@ -47,71 +50,66 @@ internal class ApkRestore(
|
||||||
private val pm = context.packageManager
|
private val pm = context.packageManager
|
||||||
private val storagePlugin get() = pluginManager.appPlugin
|
private val storagePlugin get() = pluginManager.appPlugin
|
||||||
|
|
||||||
fun restore(backup: RestorableBackup) = flow {
|
private val mInstallResult = MutableStateFlow(InstallResult())
|
||||||
// we don't filter out apps without APK, so the user can manually install them
|
val installResult = mInstallResult.asStateFlow()
|
||||||
val packages = backup.packageMetadataMap.filter {
|
|
||||||
|
suspend fun restore(backup: RestorableBackup) {
|
||||||
|
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks()
|
||||||
|
// assemble all apps in a list and sort it by name, than transform it back to a (sorted) map
|
||||||
|
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
|
||||||
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
||||||
// Otherwise, it gets killed when we install it, terminating our restoration.
|
// Otherwise, it gets killed when we install it, terminating our restoration.
|
||||||
it.key != storagePlugin.providerPackageName
|
if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
|
||||||
}
|
// The @pm@ package needs to be included in [backup], but can't be installed like an app
|
||||||
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks()
|
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
|
||||||
val total = packages.size
|
// we don't filter out apps without APK, so the user can manually install them
|
||||||
var progress = 0
|
ApkInstallResult(
|
||||||
|
|
||||||
// queue all packages and emit LiveData
|
|
||||||
val installResult = MutableInstallResult(total)
|
|
||||||
packages.forEach { (packageName, metadata) ->
|
|
||||||
progress++
|
|
||||||
installResult[packageName] = ApkInstallResult(
|
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
progress = progress,
|
|
||||||
state = if (isAllowedToInstallApks) QUEUED else FAILED,
|
state = if (isAllowedToInstallApks) QUEUED else FAILED,
|
||||||
name = metadata.name?.toString(),
|
metadata = metadata,
|
||||||
installerPackageName = metadata.installer
|
|
||||||
)
|
)
|
||||||
|
}.sortedBy { apkInstallResult -> // sort list alphabetically ignoring case
|
||||||
|
apkInstallResult.name?.lowercase(Locale.getDefault())
|
||||||
|
}.associateBy { apkInstallResult -> // use a map, so we can quickly update individual apps
|
||||||
|
apkInstallResult.packageName
|
||||||
}
|
}
|
||||||
if (isAllowedToInstallApks) {
|
if (!isAllowedToInstallApks) { // not allowed to install, so return list with all failed
|
||||||
emit(installResult)
|
mInstallResult.value = InstallResult(packages, true)
|
||||||
} else {
|
return
|
||||||
installResult.isFinished = true
|
|
||||||
emit(installResult)
|
|
||||||
return@flow
|
|
||||||
}
|
}
|
||||||
|
mInstallResult.value = InstallResult(packages)
|
||||||
|
|
||||||
// re-install individual packages and emit updates
|
// re-install individual packages and emit updates (start from last and work your way up)
|
||||||
for ((packageName, metadata) in packages) {
|
for ((packageName, apkInstallResult) in packages.asIterable().reversed()) {
|
||||||
try {
|
try {
|
||||||
if (metadata.hasApk()) {
|
if (apkInstallResult.metadata.hasApk()) {
|
||||||
restore(this, backup, packageName, metadata, installResult)
|
restore(backup, packageName, apkInstallResult.metadata)
|
||||||
} else {
|
} else {
|
||||||
emit(installResult.fail(packageName))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
}
|
}
|
||||||
} 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))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Security error re-installing APK for $packageName.", e)
|
Log.e(TAG, "Security error re-installing APK for $packageName.", e)
|
||||||
emit(installResult.fail(packageName))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
} catch (e: TimeoutCancellationException) {
|
} catch (e: TimeoutCancellationException) {
|
||||||
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
||||||
emit(installResult.fail(packageName))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||||
emit(installResult.fail(packageName))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
installResult.isFinished = true
|
mInstallResult.update { it.copy(isFinished = true) }
|
||||||
emit(installResult)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ThrowsCount")
|
@Suppress("ThrowsCount")
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
private suspend fun restore(
|
private suspend fun restore(
|
||||||
collector: FlowCollector<InstallResult>,
|
|
||||||
backup: RestorableBackup,
|
backup: RestorableBackup,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
metadata: PackageMetadata,
|
metadata: PackageMetadata,
|
||||||
installResult: MutableInstallResult,
|
|
||||||
) {
|
) {
|
||||||
// cache the APK and get its hash
|
// cache the APK and get its hash
|
||||||
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
||||||
|
@ -156,32 +154,30 @@ internal class ApkRestore(
|
||||||
val icon = appInfo?.loadIcon(pm)
|
val icon = appInfo?.loadIcon(pm)
|
||||||
val name = appInfo?.let { pm.getApplicationLabel(it).toString() }
|
val name = appInfo?.let { pm.getApplicationLabel(it).toString() }
|
||||||
|
|
||||||
installResult.update(packageName) { result ->
|
mInstallResult.update {
|
||||||
|
it.update(packageName) { result ->
|
||||||
result.copy(state = IN_PROGRESS, name = name, icon = icon)
|
result.copy(state = IN_PROGRESS, name = name, icon = icon)
|
||||||
}
|
}
|
||||||
collector.emit(installResult)
|
}
|
||||||
|
|
||||||
// ensure system apps are actually already installed and newer system apps as well
|
// ensure system apps are actually already installed and newer system apps as well
|
||||||
if (metadata.system) {
|
if (metadata.system) shouldInstallSystemApp(packageName, metadata)?.let {
|
||||||
shouldInstallSystemApp(packageName, metadata, installResult)?.let {
|
mInstallResult.value = it
|
||||||
collector.emit(it)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// process further APK splits, if available
|
// process further APK splits, if available
|
||||||
val cachedApks =
|
val cachedApks = cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
|
||||||
cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
|
|
||||||
if (cachedApks == null) {
|
if (cachedApks == null) {
|
||||||
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
|
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
|
||||||
collector.emit(installResult.fail(packageName))
|
mInstallResult.update { it.fail(packageName) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// install APK and emit updates from it
|
// install APK and emit updates from it
|
||||||
val result =
|
val result =
|
||||||
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult)
|
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult.value)
|
||||||
collector.emit(result)
|
mInstallResult.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -240,7 +236,6 @@ internal class ApkRestore(
|
||||||
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
||||||
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
||||||
val inputStream = if (version == 0.toByte()) {
|
val inputStream = if (version == 0.toByte()) {
|
||||||
@Suppress("Deprecation")
|
|
||||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
||||||
} else {
|
} else {
|
||||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
val name = crypto.getNameForApk(salt, packageName, suffix)
|
||||||
|
@ -257,26 +252,38 @@ internal class ApkRestore(
|
||||||
private fun shouldInstallSystemApp(
|
private fun shouldInstallSystemApp(
|
||||||
packageName: String,
|
packageName: String,
|
||||||
metadata: PackageMetadata,
|
metadata: PackageMetadata,
|
||||||
installResult: MutableInstallResult,
|
|
||||||
): InstallResult? {
|
): InstallResult? {
|
||||||
val installedPackageInfo = try {
|
val installedPackageInfo = try {
|
||||||
pm.getPackageInfo(packageName, 0)
|
pm.getPackageInfo(packageName, 0)
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
Log.w(TAG, "Not installing system app $packageName because not installed here.")
|
Log.w(TAG, "Not installing system app $packageName because not installed here.")
|
||||||
// we report a different FAILED status here to prevent manual installs
|
// we report a different FAILED status here to prevent manual installs
|
||||||
return installResult.fail(packageName, FAILED_SYSTEM_APP)
|
return installResult.value.fail(packageName, FAILED_SYSTEM_APP)
|
||||||
}
|
}
|
||||||
// metadata.version is not null, because here hasApk() must be true
|
// metadata.version is not null, because here hasApk() must be true
|
||||||
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
|
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
|
||||||
return if (isOlder) {
|
return if (isOlder) {
|
||||||
Log.w(TAG, "Not installing $packageName because ours is older.")
|
Log.w(TAG, "Not installing $packageName because ours is older.")
|
||||||
installResult.update(packageName) { it.copy(state = SUCCEEDED) }
|
installResult.value.update(packageName) { it.copy(state = SUCCEEDED) }
|
||||||
} else if (!installedPackageInfo.isSystemApp()) {
|
} else if (!installedPackageInfo.isSystemApp()) {
|
||||||
Log.w(TAG, "Not installing $packageName because not a system app here.")
|
Log.w(TAG, "Not installing $packageName because not a system app here.")
|
||||||
installResult.update(packageName) { it.copy(state = SUCCEEDED) }
|
installResult.value.update(packageName) { it.copy(state = SUCCEEDED) }
|
||||||
} else {
|
} else {
|
||||||
null // everything is good, we can re-install this
|
null // everything is good, we can re-install this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once [InstallResult.isFinished] is true,
|
||||||
|
* this can be called to re-check a package in state [FAILED].
|
||||||
|
* If it is now installed, the state will be changed to [SUCCEEDED].
|
||||||
|
*/
|
||||||
|
fun reCheckFailedPackage(packageName: String) {
|
||||||
|
check(installResult.value.isFinished) {
|
||||||
|
"re-checking failed packages only allowed when finished"
|
||||||
|
}
|
||||||
|
if (context.packageManager.isInstalled(packageName)) mInstallResult.update { result ->
|
||||||
|
result.update(packageName) { it.copy(state = SUCCEEDED) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,15 +5,16 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.restore.install
|
package com.stevesoltys.seedvault.restore.install
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.GONE
|
import android.view.View.GONE
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||||
import androidx.recyclerview.widget.SortedList
|
|
||||||
import androidx.recyclerview.widget.SortedListAdapterCallback
|
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
|
@ -22,21 +23,24 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
internal interface InstallItemListener {
|
internal interface InstallItemListener {
|
||||||
fun onFailedItemClicked(item: ApkInstallResult)
|
fun onFailedItemClicked(item: ApkInstallResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class InstallProgressAdapter(
|
internal class InstallProgressAdapter(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val iconLoader: suspend (ApkInstallResult, (Drawable) -> Unit) -> Unit,
|
||||||
private val listener: InstallItemListener,
|
private val listener: InstallItemListener,
|
||||||
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
|
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
|
||||||
|
|
||||||
private var finished = false
|
private var finished = false
|
||||||
private val finishedComparator = FailedFirstComparator()
|
|
||||||
private val items = SortedList(
|
private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
|
||||||
ApkInstallResult::class.java,
|
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean =
|
||||||
object : SortedListAdapterCallback<ApkInstallResult>(this) {
|
|
||||||
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
|
|
||||||
item1.packageName == item2.packageName
|
item1.packageName == item2.packageName
|
||||||
|
|
||||||
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
|
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
|
||||||
|
@ -44,13 +48,8 @@ internal class InstallProgressAdapter(
|
||||||
return if (finished) new.state != FAILED && old == new
|
return if (finished) new.state != FAILED && old == new
|
||||||
else old == new
|
else old == new
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int {
|
|
||||||
return if (finished) finishedComparator.compare(item1, item2)
|
|
||||||
else item1.compareTo(item2)
|
|
||||||
}
|
}
|
||||||
}
|
private val differ = AsyncListDiffer(this, diffCallback)
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
|
||||||
val v = LayoutInflater.from(parent.context)
|
val v = LayoutInflater.from(parent.context)
|
||||||
|
@ -58,27 +57,33 @@ internal class InstallProgressAdapter(
|
||||||
return AppInstallViewHolder(v)
|
return AppInstallViewHolder(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = items.size()
|
override fun getItemCount() = differ.currentList.size
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
|
||||||
holder.bind(items[position])
|
holder.bind(differ.currentList[position])
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(items: Collection<ApkInstallResult>) {
|
fun update(items: List<ApkInstallResult>, block: Runnable) {
|
||||||
this.items.replaceAll(items)
|
differ.submitList(items, block)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFinished() {
|
fun setFinished() {
|
||||||
finished = true
|
finished = true
|
||||||
}
|
}
|
||||||
|
|
||||||
internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) {
|
override fun onViewRecycled(holder: AppInstallViewHolder) {
|
||||||
|
holder.iconJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) {
|
||||||
|
var iconJob: Job? = null
|
||||||
fun bind(item: ApkInstallResult) {
|
fun bind(item: ApkInstallResult) {
|
||||||
v.setOnClickListener(null)
|
v.setOnClickListener(null)
|
||||||
v.background = null
|
v.background = null
|
||||||
|
|
||||||
appIcon.setImageDrawable(item.icon)
|
if (item.icon == null) iconJob = scope.launch {
|
||||||
|
iconLoader(item, appIcon::setImageDrawable)
|
||||||
|
} else appIcon.setImageDrawable(item.icon)
|
||||||
appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
|
appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
|
||||||
appInfo.visibility = GONE
|
appInfo.visibility = GONE
|
||||||
when (item.state) {
|
when (item.state) {
|
||||||
|
|
|
@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore.install
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -20,6 +21,7 @@ import android.widget.Toast.LENGTH_LONG
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
@ -31,7 +33,8 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
||||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = InstallProgressAdapter(this)
|
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
|
||||||
|
private var hasShownFailDialog = false
|
||||||
|
|
||||||
private lateinit var progressBar: ProgressBar
|
private lateinit var progressBar: ProgressBar
|
||||||
private lateinit var titleView: TextView
|
private lateinit var titleView: TextView
|
||||||
|
@ -72,35 +75,27 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
||||||
viewModel.installResult.observe(viewLifecycleOwner) { result ->
|
viewModel.installResult.observe(viewLifecycleOwner) { result ->
|
||||||
onInstallResult(result)
|
onInstallResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.nextButtonEnabled.observe(viewLifecycleOwner) { enabled ->
|
|
||||||
button.isEnabled = enabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onInstallResult(installResult: InstallResult) {
|
private fun onInstallResult(installResult: InstallResult) {
|
||||||
// skip this screen, if there are no apps to install
|
// skip this screen, if there are no apps to install
|
||||||
if (installResult.isFinished && installResult.isEmpty) {
|
if (installResult.hasNoAppsToInstall) {
|
||||||
viewModel.onNextClickedAfterInstallingApps()
|
viewModel.onNextClickedAfterInstallingApps()
|
||||||
}
|
} else {
|
||||||
|
|
||||||
// if finished, treat all still queued apps as failed and resort/redisplay adapter items
|
|
||||||
if (installResult.isFinished) {
|
|
||||||
installResult.queuedToFailed()
|
|
||||||
adapter.setFinished()
|
|
||||||
}
|
|
||||||
|
|
||||||
// update progress bar
|
// update progress bar
|
||||||
progressBar.progress = installResult.progress
|
progressBar.progress = installResult.progress
|
||||||
progressBar.max = installResult.total
|
progressBar.max = installResult.total
|
||||||
|
|
||||||
// just update adapter, or perform final action, if finished
|
// just update adapter, or perform final action, if finished
|
||||||
if (installResult.isFinished) onFinished(installResult)
|
if (installResult.isFinished) onFinished(installResult)
|
||||||
else updateAdapter(installResult.getNotQueued())
|
else updateAdapter(installResult.list)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onFinished(installResult: InstallResult) {
|
private fun onFinished(installResult: InstallResult) {
|
||||||
if (installResult.hasFailed) {
|
adapter.setFinished()
|
||||||
|
button.isEnabled = true
|
||||||
|
if (!hasShownFailDialog && installResult.hasFailed) {
|
||||||
AlertDialog.Builder(requireContext())
|
AlertDialog.Builder(requireContext())
|
||||||
.setIcon(R.drawable.ic_warning)
|
.setIcon(R.drawable.ic_warning)
|
||||||
.setTitle(R.string.restore_installing_error_title)
|
.setTitle(R.string.restore_installing_error_title)
|
||||||
|
@ -109,19 +104,21 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setOnDismissListener {
|
.setOnDismissListener {
|
||||||
updateAdapter(installResult.getNotQueued())
|
hasShownFailDialog = true
|
||||||
|
updateAdapter(installResult.list)
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
} else {
|
} else {
|
||||||
updateAdapter(installResult.getNotQueued())
|
updateAdapter(installResult.list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAdapter(items: Collection<ApkInstallResult>) {
|
private fun updateAdapter(items: List<ApkInstallResult>) {
|
||||||
val position = layoutManager.findFirstVisibleItemPosition()
|
val position = layoutManager.findFirstVisibleItemPosition()
|
||||||
adapter.update(items)
|
adapter.update(items) {
|
||||||
if (position == 0) layoutManager.scrollToPosition(0)
|
if (position == 0) layoutManager.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onFailedItemClicked(item: ApkInstallResult) {
|
override fun onFailedItemClicked(item: ApkInstallResult) {
|
||||||
try {
|
try {
|
||||||
|
@ -131,14 +128,14 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadIcon(item: ApkInstallResult, callback: (Drawable) -> Unit) {
|
||||||
|
viewModel.loadIcon(item.packageName, callback)
|
||||||
|
}
|
||||||
|
|
||||||
private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName ->
|
private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName ->
|
||||||
val result = viewModel.installResult.value ?: return@registerForActivityResult
|
val result = viewModel.installResult.value ?: return@registerForActivityResult
|
||||||
if (result.isFinished) {
|
if (result.isFinished) {
|
||||||
val changed = result.reCheckFailedPackage(
|
viewModel.reCheckFailedPackage(packageName.toString())
|
||||||
requireContext().packageManager,
|
|
||||||
packageName.toString()
|
|
||||||
)
|
|
||||||
if (changed) adapter.update(result.getNotQueued())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,129 +5,79 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.restore.install
|
package com.stevesoltys.seedvault.restore.install
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
internal interface InstallResult {
|
|
||||||
/**
|
|
||||||
* The number of packages already processed.
|
|
||||||
*/
|
|
||||||
val progress: Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The total number of packages to be considered for re-install.
|
|
||||||
*/
|
|
||||||
val total: Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is true, if there is no packages to install and false otherwise.
|
|
||||||
*/
|
|
||||||
val isEmpty: Boolean
|
|
||||||
|
|
||||||
|
internal data class InstallResult(
|
||||||
|
@get:VisibleForTesting
|
||||||
|
val installResults: Map<String, ApkInstallResult> = mapOf(),
|
||||||
/**
|
/**
|
||||||
* Is true, if the installation is finished, either because all packages were processed
|
* Is true, if the installation is finished, either because all packages were processed
|
||||||
* or because an unexpected error happened along the way.
|
* or because an unexpected error happened along the way.
|
||||||
* Is false, if the installation is still ongoing.
|
* Is false, if the installation is still ongoing.
|
||||||
*/
|
*/
|
||||||
val isFinished: Boolean
|
val isFinished: Boolean = false,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The number of packages already processed.
|
||||||
|
*/
|
||||||
|
val progress: Int = installResults.count {
|
||||||
|
val state = it.value.state
|
||||||
|
state != QUEUED && state != IN_PROGRESS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of packages to be considered for re-install.
|
||||||
|
*/
|
||||||
|
val total: Int = installResults.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all [ApkInstallResult]s that are not in state [QUEUED].
|
||||||
|
*/
|
||||||
|
val list: List<ApkInstallResult> = installResults.filterValues { result ->
|
||||||
|
result.state != QUEUED
|
||||||
|
}.values.run {
|
||||||
|
if (isFinished) sortedWith(FailedFirstComparator()) else this
|
||||||
|
}.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is true, if there is no packages to install and false otherwise.
|
||||||
|
*/
|
||||||
|
val hasNoAppsToInstall: Boolean = installResults.isEmpty() && isFinished
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is true when one or more packages failed to install.
|
* Is true when one or more packages failed to install.
|
||||||
*/
|
*/
|
||||||
val hasFailed: Boolean
|
val hasFailed: Boolean = installResults.any { it.value.state == FAILED }
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all [ApkInstallResult]s that are not in state [QUEUED].
|
|
||||||
*/
|
|
||||||
fun getNotQueued(): Collection<ApkInstallResult>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the set of all [ApkInstallResult]s that are still [QUEUED] to [FAILED].
|
|
||||||
* This is useful after [isFinished] is true due to an error
|
|
||||||
* and we need to treat all packages as failed that haven't been processed.
|
|
||||||
*/
|
|
||||||
fun queuedToFailed()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Once [isFinished] is true, this can be called to re-check a package in state [FAILED].
|
|
||||||
* If it is now installed, the state will be changed to [SUCCEEDED] and true returned.
|
|
||||||
*/
|
|
||||||
fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class MutableInstallResult(override val total: Int) : InstallResult {
|
|
||||||
|
|
||||||
private val installResults = ConcurrentHashMap<String, ApkInstallResult>(total)
|
|
||||||
override val isEmpty get() = installResults.isEmpty()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
override var isFinished = false
|
|
||||||
override val progress
|
|
||||||
get() = installResults.count {
|
|
||||||
val state = it.value.state
|
|
||||||
state != QUEUED && state != IN_PROGRESS
|
|
||||||
}
|
|
||||||
override val hasFailed get() = installResults.any { it.value.state == FAILED }
|
|
||||||
|
|
||||||
override fun getNotQueued(): Collection<ApkInstallResult> {
|
|
||||||
return installResults.filterValues { result -> result.state != QUEUED }.values
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun queuedToFailed() {
|
|
||||||
installResults.forEach { entry ->
|
|
||||||
val result = entry.value
|
|
||||||
if (result.state == QUEUED) installResults[entry.key] = result.copy(state = FAILED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun get(packageName: String) = installResults[packageName]
|
|
||||||
|
|
||||||
operator fun set(packageName: String, installResult: ApkInstallResult) {
|
|
||||||
installResults[packageName] = installResult
|
|
||||||
check(installResults.size <= total) { "Attempting to add more packages than total" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(
|
fun update(
|
||||||
packageName: String,
|
packageName: String,
|
||||||
updateFun: (ApkInstallResult) -> ApkInstallResult,
|
updateFun: (ApkInstallResult) -> ApkInstallResult,
|
||||||
): MutableInstallResult {
|
): InstallResult {
|
||||||
val result = get(packageName)
|
val results = installResults.toMutableMap()
|
||||||
|
val result = results[packageName]
|
||||||
check(result != null) { "ApkRestoreResult for $packageName does not exist." }
|
check(result != null) { "ApkRestoreResult for $packageName does not exist." }
|
||||||
installResults[packageName] = updateFun(result)
|
results[packageName] = updateFun(result)
|
||||||
return this
|
return copy(installResults = results)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult {
|
fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult {
|
||||||
return update(packageName) { it.copy(state = state) }
|
return update(packageName) { it.copy(state = state) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean {
|
|
||||||
check(isFinished) { "re-checking failed packages only allowed when finished" }
|
|
||||||
if (pm.isInstalled(packageName)) {
|
|
||||||
update(packageName) { it.copy(state = SUCCEEDED) }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ApkInstallResult(
|
data class ApkInstallResult(
|
||||||
val packageName: String,
|
val packageName: String,
|
||||||
val progress: Int,
|
|
||||||
val state: ApkInstallState,
|
val state: ApkInstallState,
|
||||||
val name: String? = null,
|
val metadata: PackageMetadata,
|
||||||
|
val name: String? = metadata.name?.toString(),
|
||||||
val icon: Drawable? = null,
|
val icon: Drawable? = null,
|
||||||
val installerPackageName: CharSequence? = null,
|
) {
|
||||||
) : Comparable<ApkInstallResult> {
|
val installerPackageName: CharSequence? get() = metadata.installer
|
||||||
override fun compareTo(other: ApkInstallResult): Int {
|
|
||||||
return other.progress.compareTo(progress)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FailedFirstComparator : Comparator<ApkInstallResult> {
|
internal class FailedFirstComparator : Comparator<ApkInstallResult> {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import android.content.pm.PackageManager
|
||||||
import android.content.pm.Signature
|
import android.content.pm.Signature
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.PackageUtils
|
import android.util.PackageUtils
|
||||||
|
import app.cash.turbine.test
|
||||||
import com.stevesoltys.seedvault.assertReadEquals
|
import com.stevesoltys.seedvault.assertReadEquals
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
|
@ -19,6 +20,9 @@ import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
@ -27,12 +31,12 @@ import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.collectIndexed
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Assertions.fail
|
||||||
import org.junit.jupiter.api.Test
|
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
|
||||||
|
@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storagePluginManager: StoragePluginManager = mockk()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||||
private val storagePlugin: StoragePlugin<*> = mockk()
|
private val storagePlugin: StoragePlugin<*> = mockk()
|
||||||
|
@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
} returns true
|
} returns true
|
||||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
||||||
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
|
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
|
||||||
|
val resultMap = mapOf(
|
||||||
|
packageName to ApkInstallResult(
|
||||||
|
packageName,
|
||||||
|
state = SUCCEEDED,
|
||||||
|
metadata = packageMetadataMap[packageName] ?: fail(),
|
||||||
|
)
|
||||||
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
|
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
|
||||||
} returns MutableInstallResult(1).apply {
|
} returns InstallResult(resultMap)
|
||||||
set(
|
|
||||||
packageName, ApkInstallResult(
|
|
||||||
packageName,
|
|
||||||
progress = 1,
|
|
||||||
state = ApkInstallState.SUCCEEDED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
|
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertFalse(value.hasFailed)
|
awaitItem() // initial empty state
|
||||||
assertEquals(1, value.total)
|
apkRestore.restore(backup)
|
||||||
if (i == 3) assertTrue(value.isFinished)
|
awaitItem().also {
|
||||||
|
assertFalse(it.hasFailed)
|
||||||
|
assertEquals(1, it.total)
|
||||||
|
assertEquals(0, it.list.size)
|
||||||
|
assertEquals(QUEUED, it.installResults[packageName]?.state)
|
||||||
|
assertFalse(it.isFinished)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertFalse(it.hasFailed)
|
||||||
|
assertEquals(1, it.total)
|
||||||
|
assertEquals(1, it.list.size)
|
||||||
|
assertEquals(IN_PROGRESS, it.installResults[packageName]?.state)
|
||||||
|
assertFalse(it.isFinished)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertFalse(it.hasFailed)
|
||||||
|
assertEquals(1, it.total)
|
||||||
|
assertEquals(1, it.list.size)
|
||||||
|
assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
|
||||||
|
assertFalse(it.isFinished)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertFalse(it.hasFailed)
|
||||||
|
assertEquals(1, it.total)
|
||||||
|
assertEquals(1, it.list.size)
|
||||||
|
assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
|
||||||
|
assertTrue(it.isFinished)
|
||||||
|
}
|
||||||
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
val apkFile = File(apkPath.captured)
|
val apkFile = File(apkPath.captured)
|
||||||
|
|
|
@ -12,6 +12,8 @@ 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 app.cash.turbine.TurbineTestContext
|
||||||
|
import app.cash.turbine.test
|
||||||
import com.stevesoltys.seedvault.getRandomBase64
|
import com.stevesoltys.seedvault.getRandomBase64
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
|
@ -32,13 +34,11 @@ 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.collectIndexed
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions
|
import org.junit.jupiter.api.Assertions
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Assertions.fail
|
|
||||||
import org.junit.jupiter.api.Test
|
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
|
||||||
|
@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
|
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -126,8 +128,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
} throws SecurityException()
|
} throws SecurityException()
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
|
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
|
||||||
val installResult = MutableInstallResult(1).apply {
|
val packagesMap = mapOf(
|
||||||
set(
|
packageName to ApkInstallResult(
|
||||||
packageName, ApkInstallResult(
|
|
||||||
packageName,
|
packageName,
|
||||||
progress = 1,
|
state = SUCCEEDED,
|
||||||
state = SUCCEEDED
|
metadata = PackageMetadata(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
val installResult = InstallResult(packagesMap)
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
cacheBaseApkAndGetInfo(tmpDir)
|
cacheBaseApkAndGetInfo(tmpDir)
|
||||||
|
@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
} returns installResult
|
} returns installResult
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressSuccessFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressSuccessFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
// This is a legacy backup with version 0
|
// This is a legacy backup with version 0
|
||||||
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
|
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
|
||||||
// Install will be successful
|
// Install will be successful
|
||||||
val installResult = MutableInstallResult(1).apply {
|
val packagesMap = mapOf(
|
||||||
set(
|
packageName to ApkInstallResult(
|
||||||
packageName, ApkInstallResult(
|
|
||||||
packageName,
|
packageName,
|
||||||
progress = 1,
|
state = SUCCEEDED,
|
||||||
state = SUCCEEDED
|
metadata = PackageMetadata(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
val installResult = InstallResult(packagesMap)
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||||
@Suppress("Deprecation")
|
|
||||||
coEvery {
|
coEvery {
|
||||||
legacyStoragePlugin.getApkInputStream(token, packageName, "")
|
legacyStoragePlugin.getApkInputStream(token, packageName, "")
|
||||||
} returns apkInputStream
|
} returns apkInputStream
|
||||||
|
@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
} returns installResult
|
} returns installResult
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressSuccessFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressSuccessFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
|
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
|
||||||
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
|
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
|
||||||
if (isSystemApp) { // if the installed app is not a system app, we don't install
|
if (isSystemApp) { // if the installed app is not a system app, we don't install
|
||||||
val installResult = MutableInstallResult(1).apply {
|
val packagesMap = mapOf(
|
||||||
set(
|
packageName to ApkInstallResult(
|
||||||
packageName,
|
packageName,
|
||||||
ApkInstallResult(packageName, progress = 1, state = SUCCEEDED)
|
state = SUCCEEDED,
|
||||||
|
metadata = PackageMetadata(),
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
val installResult = InstallResult(packagesMap)
|
||||||
coEvery {
|
coEvery {
|
||||||
apkInstaller.install(
|
apkInstaller.install(
|
||||||
match { it.size == 1 },
|
match { it.size == 1 },
|
||||||
|
@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
when (i) {
|
awaitItem() // initial empty state
|
||||||
0 -> {
|
apkRestore.restore(backup)
|
||||||
val result = value[packageName]
|
awaitQueuedItem()
|
||||||
assertEquals(QUEUED, result.state)
|
awaitInProgressItem()
|
||||||
assertEquals(1, result.progress)
|
awaitItem().also { systemItem ->
|
||||||
assertEquals(1, value.total)
|
val result = systemItem[packageName]
|
||||||
}
|
|
||||||
1 -> {
|
|
||||||
val result = value[packageName]
|
|
||||||
assertEquals(IN_PROGRESS, result.state)
|
|
||||||
assertEquals(appName, result.name)
|
|
||||||
assertEquals(icon, result.icon)
|
|
||||||
}
|
|
||||||
2 -> {
|
|
||||||
val result = value[packageName]
|
|
||||||
if (willFail) {
|
if (willFail) {
|
||||||
assertEquals(FAILED_SYSTEM_APP, result.state)
|
assertEquals(FAILED_SYSTEM_APP, result.state)
|
||||||
} else {
|
} else {
|
||||||
assertEquals(SUCCEEDED, result.state)
|
assertEquals(SUCCEEDED, result.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3 -> {
|
awaitItem().also { finishedItem ->
|
||||||
assertTrue(value.isFinished)
|
assertTrue(finishedItem.isFinished)
|
||||||
}
|
|
||||||
else -> fail("more values emitted")
|
|
||||||
}
|
}
|
||||||
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -297,8 +296,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
} returns false
|
} returns false
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
} returns ByteArrayInputStream(getRandomByteArray())
|
} returns ByteArrayInputStream(getRandomByteArray())
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
|
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressFailFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressFailFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -385,60 +390,58 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
|
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
|
val resultMap = mapOf(
|
||||||
|
packageName to ApkInstallResult(
|
||||||
|
packageName,
|
||||||
|
state = SUCCEEDED,
|
||||||
|
metadata = PackageMetadata(),
|
||||||
|
)
|
||||||
|
)
|
||||||
coEvery {
|
coEvery {
|
||||||
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
|
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
|
||||||
} returns MutableInstallResult(1).apply {
|
} returns InstallResult(resultMap)
|
||||||
set(
|
|
||||||
packageName, ApkInstallResult(
|
|
||||||
packageName,
|
|
||||||
progress = 1,
|
|
||||||
state = SUCCEEDED
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
assertQueuedProgressSuccessFinished(i, value)
|
awaitItem() // initial empty state
|
||||||
|
apkRestore.restore(backup)
|
||||||
|
assertQueuedProgressSuccessFinished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `storage provider app does not get reinstalled`(@TempDir tmpDir: Path) = runBlocking {
|
fun `storage provider app does not get reinstalled`() = runBlocking {
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
// set the storage provider package name to match our current package name,
|
// set the storage provider package name to match our current package name,
|
||||||
// and ensure that the current package is therefore skipped.
|
// and ensure that the current package is therefore skipped.
|
||||||
every { storagePlugin.providerPackageName } returns packageName
|
every { storagePlugin.providerPackageName } returns packageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
when (i) {
|
awaitItem() // initial empty state
|
||||||
0 -> {
|
apkRestore.restore(backup)
|
||||||
assertFalse(value.isFinished)
|
awaitItem().also { finishedItem ->
|
||||||
}
|
|
||||||
1 -> {
|
|
||||||
// the only package provided should have been filtered, leaving 0 packages.
|
// the only package provided should have been filtered, leaving 0 packages.
|
||||||
assertEquals(0, value.total)
|
assertEquals(0, finishedItem.total)
|
||||||
assertTrue(value.isFinished)
|
assertTrue(finishedItem.isFinished)
|
||||||
}
|
|
||||||
else -> fail("more values emitted")
|
|
||||||
}
|
}
|
||||||
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `no apks get installed when blocked by policy`(@TempDir tmpDir: Path) = runBlocking {
|
fun `no apks get installed when blocked by policy`() = runBlocking {
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns false
|
every { installRestriction.isAllowedToInstallApks() } returns false
|
||||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
apkRestore.installResult.test {
|
||||||
when (i) {
|
awaitItem() // initial empty state
|
||||||
0 -> {
|
apkRestore.restore(backup)
|
||||||
|
awaitItem().also { queuedItem ->
|
||||||
// single package fails without attempting to install it
|
// single package fails without attempting to install it
|
||||||
assertEquals(1, value.total)
|
assertEquals(1, queuedItem.total)
|
||||||
assertEquals(FAILED, value[packageName].state)
|
assertEquals(FAILED, queuedItem[packageName].state)
|
||||||
assertTrue(value.isFinished)
|
assertTrue(queuedItem.isFinished)
|
||||||
}
|
|
||||||
else -> fail("more values emitted")
|
|
||||||
}
|
}
|
||||||
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -456,74 +459,78 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) {
|
private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
|
||||||
0 -> assertQueuedProgress(step, value)
|
awaitQueuedItem()
|
||||||
1 -> {
|
awaitItem().also { failedItem ->
|
||||||
val result = value[packageName]
|
val result = failedItem[packageName]
|
||||||
assertEquals(FAILED, result.state)
|
assertEquals(FAILED, result.state)
|
||||||
assertTrue(value.hasFailed)
|
assertTrue(failedItem.hasFailed)
|
||||||
assertFalse(value.isFinished)
|
assertFalse(failedItem.isFinished)
|
||||||
}
|
}
|
||||||
2 -> {
|
awaitItem().also { finishedItem ->
|
||||||
assertTrue(value.hasFailed)
|
assertTrue(finishedItem.hasFailed)
|
||||||
assertTrue(value.isFinished)
|
assertTrue(finishedItem.isFinished)
|
||||||
}
|
}
|
||||||
else -> fail("more values emitted")
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) {
|
private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressSuccessFinished() {
|
||||||
0 -> assertQueuedProgress(step, value)
|
awaitQueuedItem()
|
||||||
1 -> assertQueuedProgress(step, value)
|
awaitInProgressItem()
|
||||||
2 -> {
|
awaitItem().also { successItem ->
|
||||||
val result = value[packageName]
|
val result = successItem[packageName]
|
||||||
assertEquals(SUCCEEDED, result.state)
|
assertEquals(SUCCEEDED, result.state)
|
||||||
}
|
}
|
||||||
3 -> {
|
awaitItem().also { finishedItem ->
|
||||||
assertFalse(value.hasFailed)
|
assertFalse(finishedItem.hasFailed)
|
||||||
assertTrue(value.isFinished)
|
assertTrue(finishedItem.isFinished)
|
||||||
}
|
}
|
||||||
else -> fail("more values emitted")
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) {
|
private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressFailFinished() {
|
||||||
0 -> assertQueuedProgress(step, value)
|
awaitQueuedItem()
|
||||||
1 -> assertQueuedProgress(step, value)
|
awaitInProgressItem()
|
||||||
2 -> {
|
awaitItem().also { failedItem ->
|
||||||
// app install has failed
|
// app install has failed
|
||||||
val result = value[packageName]
|
val result = failedItem[packageName]
|
||||||
assertEquals(FAILED, result.state)
|
assertEquals(FAILED, result.state)
|
||||||
assertTrue(value.hasFailed)
|
assertTrue(failedItem.hasFailed)
|
||||||
assertFalse(value.isFinished)
|
assertFalse(failedItem.isFinished)
|
||||||
}
|
}
|
||||||
3 -> {
|
awaitItem().also { finishedItem ->
|
||||||
assertTrue(value.hasFailed)
|
assertTrue(finishedItem.hasFailed)
|
||||||
assertTrue(value.isFinished)
|
assertTrue(finishedItem.isFinished)
|
||||||
}
|
}
|
||||||
else -> fail("more values emitted")
|
ensureAllEventsConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) {
|
private suspend fun TurbineTestContext<InstallResult>.awaitQueuedItem(): InstallResult {
|
||||||
0 -> {
|
val item = awaitItem()
|
||||||
// single package gets queued
|
// single package gets queued
|
||||||
val result = value[packageName]
|
val result = item[packageName]
|
||||||
assertEquals(QUEUED, result.state)
|
assertEquals(QUEUED, result.state)
|
||||||
assertEquals(installerName, result.installerPackageName)
|
assertEquals(installerName, result.installerPackageName)
|
||||||
assertEquals(1, result.progress)
|
assertEquals(1, item.total)
|
||||||
assertEquals(1, value.total)
|
assertEquals(0, item.list.size) // all items still queued
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
1 -> {
|
|
||||||
|
private suspend fun TurbineTestContext<InstallResult>.awaitInProgressItem(): InstallResult {
|
||||||
|
val item = awaitItem()
|
||||||
// name and icon are available now
|
// name and icon are available now
|
||||||
val result = value[packageName]
|
val result = item[packageName]
|
||||||
assertEquals(IN_PROGRESS, result.state)
|
assertEquals(IN_PROGRESS, result.state)
|
||||||
assertEquals(appName, result.name)
|
assertEquals(appName, result.name)
|
||||||
assertEquals(icon, result.icon)
|
assertEquals(icon, result.icon)
|
||||||
assertFalse(value.hasFailed)
|
assertFalse(item.hasFailed)
|
||||||
}
|
assertEquals(1, item.total)
|
||||||
else -> fail("more values emitted")
|
assertEquals(1, item.list.size)
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
|
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
|
||||||
return (this as MutableInstallResult)[packageName] ?: Assertions.fail("$packageName not found")
|
return this.installResults[packageName] ?: Assertions.fail("$packageName not found")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue