Merge pull request #670 from grote/309-restore-choose-apps
Allow choosing which apps will get restored
This commit is contained in:
commit
22ca2550c2
69 changed files with 2398 additions and 897 deletions
17
.github/scripts/run_tests.sh
vendored
17
.github/scripts/run_tests.sh
vendored
|
@ -3,21 +3,12 @@
|
|||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
adb root
|
||||
sleep 5
|
||||
adb remount
|
||||
echo "Disable auto-restore"
|
||||
adb shell bmgr autorestore false
|
||||
|
||||
echo "Installing Seedvault app..."
|
||||
adb shell mkdir -p /system/priv-app/Seedvault
|
||||
adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
|
||||
|
||||
echo "Installing Seedvault permissions..."
|
||||
adb push permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
|
||||
adb push allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
|
||||
|
||||
echo "Setting Seedvault transport..."
|
||||
sleep 10
|
||||
adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
|
||||
./gradlew --stacktrace :app:installDebugAndroidTest
|
||||
sleep 60
|
||||
|
||||
D2D_BACKUP_TEST=$1
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<w>ejectable</w>
|
||||
<w>hasher</w>
|
||||
<w>hkdf</w>
|
||||
<w>launchable</w>
|
||||
<w>restorable</w>
|
||||
<w>seedvault</w>
|
||||
<w>snowden</w>
|
||||
|
|
|
@ -176,11 +176,15 @@ dependencies {
|
|||
// anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation(aospLibs)
|
||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||
testImplementation("org.robolectric:robolectric:4.10.3")
|
||||
testImplementation("org.robolectric:robolectric:4.12.2")
|
||||
testImplementation("org.hamcrest:hamcrest:2.2")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
|
||||
testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
|
||||
testImplementation(
|
||||
"org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}"
|
||||
)
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
||||
|
|
|
@ -11,10 +11,9 @@ if [ -z "$ANDROID_HOME" ]; then
|
|||
fi
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
|
||||
DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
||||
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
||||
|
||||
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
|
||||
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
|
||||
echo "Emulator device name not found"
|
||||
|
@ -29,13 +28,9 @@ $ADB remount # remount /system as writable
|
|||
|
||||
echo "Installing Seedvault app..."
|
||||
$ADB shell mkdir -p /system/priv-app/Seedvault
|
||||
$ADB push $ROOT_PROJECT_DIR/app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
|
||||
$ADB push "$ROOT_PROJECT_DIR"/app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
|
||||
|
||||
echo "Installing Seedvault permissions..."
|
||||
$ADB push $ROOT_PROJECT_DIR/permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
|
||||
$ADB push $ROOT_PROJECT_DIR/allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
|
||||
$ADB shell am force-stop com.stevesoltys.seedvault
|
||||
$ADB push "$ROOT_PROJECT_DIR"/permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
|
||||
$ADB push "$ROOT_PROJECT_DIR"/allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
|
||||
$ADB shell am broadcast -a android.intent.action.BOOT_COMPLETED
|
||||
|
||||
echo "Setting Seedvault transport..."
|
||||
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
|
||||
|
|
|
@ -20,25 +20,23 @@ EMULATOR_NAME=$1
|
|||
SYSTEM_IMAGE=$2
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
|
||||
DEVELOPMENT_DIR=$SCRIPT_DIR/..
|
||||
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
|
||||
|
||||
echo "Downloading system image..."
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
|
||||
yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
|
||||
|
||||
# create AVD if it doesn't exist
|
||||
if $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
|
||||
if "$ANDROID_HOME"/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
|
||||
echo "AVD already exists. Skipping creation."
|
||||
else
|
||||
echo "Creating AVD..."
|
||||
echo 'no' | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
|
||||
echo 'no' | "$ANDROID_HOME"/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
|
||||
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
|
||||
$SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME"
|
||||
"$SCRIPT_DIR"/start_emulator.sh "$EMULATOR_NAME"
|
||||
fi
|
||||
|
||||
# wait for emulator device to appear with 180 second timeout
|
||||
|
@ -47,7 +45,7 @@ echo "Waiting for emulator device..."
|
|||
for i in {1..180}; do
|
||||
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
|
||||
sleep 1
|
||||
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
|
||||
else
|
||||
break
|
||||
fi
|
||||
|
@ -73,16 +71,14 @@ $ADB reboot # need to reboot first time we remount
|
|||
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
||||
|
||||
echo "Provisioning emulator for Seedvault..."
|
||||
$SCRIPT_DIR/install_app.sh
|
||||
"$SCRIPT_DIR"/install_app.sh
|
||||
|
||||
echo "Rebooting emulator..."
|
||||
$ADB reboot
|
||||
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
|
||||
|
||||
echo "Setting backup transport to Seedvault..."
|
||||
$ADB shell bmgr enable true
|
||||
sleep 5
|
||||
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
|
||||
echo "Disabling backup..."
|
||||
$ADB shell bmgr enable false
|
||||
|
||||
echo "Downloading and extracting test backup to '/sdcard/seedvault_baseline'..."
|
||||
|
||||
|
|
|
@ -46,7 +46,19 @@ class KoinInstrumentationTestApp : App() {
|
|||
|
||||
viewModel {
|
||||
currentRestoreViewModel =
|
||||
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
|
||||
spyk(
|
||||
RestoreViewModel(
|
||||
app = context,
|
||||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
pluginManager = get(),
|
||||
)
|
||||
)
|
||||
currentRestoreViewModel!!
|
||||
}
|
||||
|
||||
|
|
|
@ -72,6 +72,10 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
backupListItem.clickAndWaitForNewWindow()
|
||||
waitUntilIdle()
|
||||
|
||||
waitForAppSelectionLoaded()
|
||||
// just tap next in app selection
|
||||
appsSelectedButton.clickAndWaitForNewWindow()
|
||||
|
||||
waitForInstallResult()
|
||||
|
||||
if (someAppsNotInstalledText.exists()) {
|
||||
|
@ -104,13 +108,22 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
spyOnKVRestoreData(result)
|
||||
}
|
||||
|
||||
private fun waitForAppSelectionLoaded() = runBlocking {
|
||||
withContext(Dispatchers.Main) {
|
||||
withTimeout(RESTORE_TIMEOUT) {
|
||||
while (spyRestoreViewModel.selectedApps.value?.apps?.isNotEmpty() != true) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
waitUntilIdle()
|
||||
}
|
||||
|
||||
private fun waitForInstallResult() = runBlocking {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
withTimeout(RESTORE_TIMEOUT) {
|
||||
while (spyRestoreViewModel.installResult.value == null ||
|
||||
spyRestoreViewModel.nextButtonEnabled.value == false
|
||||
) {
|
||||
while (spyRestoreViewModel.installResult.value?.isFinished != true) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -113,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
|
|||
}
|
||||
|
||||
fun testResultFilename(testName: String): String {
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
val d2d = if (arguments.getString("d2d_backup_test") == "true") "d2d" else ""
|
||||
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
|
||||
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
|
||||
return "${timeStamp}_${testName.replace(" ", "_")}"
|
||||
return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
|
|
|
@ -16,6 +16,7 @@ import org.junit.rules.TestName
|
|||
import org.junit.runner.RunWith
|
||||
import org.koin.core.component.KoinComponent
|
||||
import java.io.File
|
||||
import java.lang.Thread.sleep
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
|
@ -44,6 +45,11 @@ internal abstract class SeedvaultLargeTest :
|
|||
resetApplicationState()
|
||||
clearTestBackups()
|
||||
|
||||
runCommand("bmgr enable true")
|
||||
sleep(60_000)
|
||||
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
|
||||
sleep(60_000)
|
||||
|
||||
startRecordingTest(keepRecordingScreen, name.methodName)
|
||||
restoreBaselineBackup()
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
package com.stevesoltys.seedvault.e2e.impl
|
||||
|
||||
import androidx.test.filters.LargeTest
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
|
||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
|
@ -127,17 +128,19 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
|||
) {
|
||||
// Assert all "key/value" restored data matches the backup data.
|
||||
restore.kv.forEach { (pkg, kvData) ->
|
||||
assert(backup.kv.containsKey(pkg)) {
|
||||
"KV data for $pkg missing from backup."
|
||||
}
|
||||
|
||||
kvData.forEach { (key, value) ->
|
||||
assert(backup.kv[pkg]!!.containsKey(key)) {
|
||||
"KV data for $pkg/$key exists in restore but is missing from backup."
|
||||
if (pkg != MAGIC_PACKAGE_MANAGER) {
|
||||
assert(backup.kv.containsKey(pkg)) {
|
||||
"KV data for $pkg missing from backup."
|
||||
}
|
||||
|
||||
assert(value.contentEquals(backup.kv[pkg]!![key]!!)) {
|
||||
"KV data for $pkg/$key does not match."
|
||||
kvData.forEach { (key, value) ->
|
||||
assert(backup.kv[pkg]!!.containsKey(key)) {
|
||||
"KV data for $pkg/$key exists in restore but is missing from backup."
|
||||
}
|
||||
|
||||
assert(value.contentEquals(backup.kv[pkg]!![key]!!)) {
|
||||
"KV data for $pkg/$key does not match."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
|
|||
|
||||
val backupListItem = findObject { textContains("Last backup") }
|
||||
|
||||
val appsSelectedButton = findObject { text("Restore backup") }
|
||||
|
||||
val nextButton = findObject { text("Next") }
|
||||
|
||||
val finishButton = findObject { text("Finish") }
|
||||
|
|
|
@ -95,7 +95,19 @@ open class App : Application() {
|
|||
)
|
||||
}
|
||||
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel {
|
||||
RestoreViewModel(
|
||||
app = this@App,
|
||||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
pluginManager = get(),
|
||||
)
|
||||
}
|
||||
viewModel { FileSelectionViewModel(this@App, get()) }
|
||||
}
|
||||
|
||||
|
@ -189,6 +201,7 @@ open class App : Application() {
|
|||
|
||||
const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL
|
||||
const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
|
||||
const val NO_DATA_END_SENTINEL = "@end@"
|
||||
const val GLOBAL_METADATA_KEY = "@meta@"
|
||||
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED
|
||||
|
||||
|
|
|
@ -120,6 +120,7 @@ internal interface Crypto {
|
|||
internal const val TYPE_METADATA: Byte = 0x00
|
||||
internal const val TYPE_BACKUP_KV: Byte = 0x01
|
||||
internal const val TYPE_BACKUP_FULL: Byte = 0x02
|
||||
internal const val TYPE_ICONS: Byte = 0x03
|
||||
|
||||
internal class CryptoImpl(
|
||||
private val keyManager: KeyManager,
|
||||
|
|
|
@ -26,7 +26,7 @@ data class BackupMetadata(
|
|||
internal var d2dBackup: Boolean = false,
|
||||
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
|
||||
) {
|
||||
val size: Long?
|
||||
val size: Long
|
||||
get() = packageMetadataMap.values.sumOf { m ->
|
||||
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
|
||||
}
|
||||
|
@ -85,13 +85,16 @@ data class PackageMetadata(
|
|||
internal var state: PackageState = UNKNOWN_ERROR,
|
||||
internal var backupType: BackupType? = null,
|
||||
internal var size: Long? = null,
|
||||
internal var name: CharSequence? = null,
|
||||
internal val system: Boolean = false,
|
||||
internal val isLaunchableSystemApp: Boolean = false,
|
||||
internal val version: Long? = null,
|
||||
internal val installer: String? = null,
|
||||
internal val splits: List<ApkSplit>? = null,
|
||||
internal val sha256: String? = null,
|
||||
internal val signatures: List<String>? = null,
|
||||
) {
|
||||
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
||||
fun hasApk(): Boolean {
|
||||
return version != null && sha256 != null && signatures != null
|
||||
}
|
||||
|
@ -110,7 +113,9 @@ internal const val JSON_PACKAGE_TIME = "time"
|
|||
internal const val JSON_PACKAGE_BACKUP_TYPE = "backupType"
|
||||
internal const val JSON_PACKAGE_STATE = "state"
|
||||
internal const val JSON_PACKAGE_SIZE = "size"
|
||||
internal const val JSON_PACKAGE_APP_NAME = "name"
|
||||
internal const val JSON_PACKAGE_SYSTEM = "system"
|
||||
internal const val JSON_PACKAGE_SYSTEM_LAUNCHER = "systemLauncher"
|
||||
internal const val JSON_PACKAGE_VERSION = "version"
|
||||
internal const val JSON_PACKAGE_INSTALLER = "installer"
|
||||
internal const val JSON_PACKAGE_SPLITS = "splits"
|
||||
|
|
|
@ -23,6 +23,7 @@ import com.stevesoltys.seedvault.encodeBase64
|
|||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
@ -41,6 +42,7 @@ internal class MetadataManager(
|
|||
private val crypto: Crypto,
|
||||
private val metadataWriter: MetadataWriter,
|
||||
private val metadataReader: MetadataReader,
|
||||
private val packageService: PackageService,
|
||||
private val settingsManager: SettingsManager,
|
||||
) {
|
||||
|
||||
|
@ -63,7 +65,11 @@ internal class MetadataManager(
|
|||
return field
|
||||
}
|
||||
|
||||
val backupSize: Long? get() = metadata.size
|
||||
val backupSize: Long get() = metadata.size
|
||||
|
||||
private val launchableSystemApps by lazy {
|
||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when initializing a new device.
|
||||
|
@ -111,8 +117,11 @@ internal class MetadataManager(
|
|||
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
||||
?: PackageMetadata()
|
||||
modifyCachedMetadata {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
||||
system = packageInfo.isSystemApp(),
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName),
|
||||
version = packageMetadata.version,
|
||||
installer = packageMetadata.installer,
|
||||
splits = packageMetadata.splits,
|
||||
|
@ -144,12 +153,16 @@ internal class MetadataManager(
|
|||
metadata.time = now
|
||||
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||
metadata.packageMetadataMap.getOrPut(packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = now,
|
||||
state = APK_AND_DATA,
|
||||
backupType = type,
|
||||
size = size,
|
||||
system = packageInfo.isSystemApp(),
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageName),
|
||||
)
|
||||
}.apply {
|
||||
time = now
|
||||
|
@ -157,6 +170,10 @@ internal class MetadataManager(
|
|||
backupType = type
|
||||
// don't override a previous K/V size, if there were no K/V changes
|
||||
if (size != null) this.size = size
|
||||
// update name, if none was set, yet (can happen while migrating to storing names)
|
||||
if (this.name == null) {
|
||||
this.name = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -178,11 +195,15 @@ internal class MetadataManager(
|
|||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
backupType = backupType,
|
||||
system = packageInfo.isSystemApp()
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.state = packageState
|
||||
}
|
||||
|
@ -201,12 +222,22 @@ internal class MetadataManager(
|
|||
packageState: PackageState,
|
||||
) = modifyCachedMetadata {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
system = packageInfo.isSystemApp(),
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.state = packageState
|
||||
}.apply {
|
||||
state = packageState
|
||||
// update name, if none was set, yet (can happen while migrating to storing names)
|
||||
if (this.name == null) {
|
||||
this.name = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
|
||||
val metadataModule = module {
|
||||
single { MetadataManager(androidContext(), get(), get(), get(), get(), get()) }
|
||||
single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
|
||||
single<MetadataWriter> { MetadataWriterImpl(get()) }
|
||||
single<MetadataReader> { MetadataReaderImpl(get()) }
|
||||
}
|
||||
|
|
|
@ -126,6 +126,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
else -> null
|
||||
}
|
||||
val pSize = p.optLong(JSON_PACKAGE_SIZE, -1L)
|
||||
val pName = p.optString(JSON_PACKAGE_APP_NAME)
|
||||
val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
|
||||
val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
|
||||
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER)
|
||||
|
@ -143,7 +144,9 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
state = pState,
|
||||
backupType = pBackupType,
|
||||
size = if (pSize < 0L) null else pSize,
|
||||
name = if (pName == "") null else pName,
|
||||
system = pSystem,
|
||||
isLaunchableSystemApp = p.optBoolean(JSON_PACKAGE_SYSTEM_LAUNCHER, false),
|
||||
version = if (pVersion == 0L) null else pVersion,
|
||||
installer = if (pInstaller == "") null else pInstaller,
|
||||
splits = getSplits(p),
|
||||
|
|
|
@ -57,8 +57,14 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
|
|||
if (packageMetadata.size != null) {
|
||||
put(JSON_PACKAGE_SIZE, packageMetadata.size)
|
||||
}
|
||||
if (packageMetadata.name != null) {
|
||||
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
|
||||
}
|
||||
if (packageMetadata.system) {
|
||||
put(JSON_PACKAGE_SYSTEM, packageMetadata.system)
|
||||
put(JSON_PACKAGE_SYSTEM, true)
|
||||
}
|
||||
if (packageMetadata.isLaunchableSystemApp) {
|
||||
put(JSON_PACKAGE_SYSTEM_LAUNCHER, true)
|
||||
}
|
||||
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
|
||||
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
|
||||
|
|
|
@ -0,0 +1,348 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.app.backup.BackupTransport
|
||||
import android.app.backup.IBackupManager
|
||||
import android.app.backup.IRestoreObserver
|
||||
import android.app.backup.IRestoreSession
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Context
|
||||
import android.os.RemoteException
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.restore.install.isInstalled
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NO_DATA
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
|
||||
private val TAG = AppDataRestoreManager::class.simpleName
|
||||
|
||||
internal data class AppRestoreResult(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val state: AppBackupState,
|
||||
)
|
||||
|
||||
internal class AppDataRestoreManager(
|
||||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
) {
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
private val monitor = BackupMonitor()
|
||||
|
||||
private val mRestoreProgress = MutableLiveData(
|
||||
LinkedList<AppRestoreResult>().apply {
|
||||
add(
|
||||
AppRestoreResult(
|
||||
packageName = MAGIC_PACKAGE_MANAGER,
|
||||
name = getAppName(context, MAGIC_PACKAGE_MANAGER).toString(),
|
||||
state = IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
|
||||
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
|
||||
val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
|
||||
|
||||
@WorkerThread
|
||||
fun startRestore(restorableBackup: RestorableBackup) {
|
||||
val token = restorableBackup.token
|
||||
|
||||
Log.d(TAG, "Starting new restore session to restore backup $token")
|
||||
|
||||
// if we had no token before (i.e. restore from setup wizard),
|
||||
// use the token of the current restore set from now on
|
||||
if (settingsManager.getToken() == null) {
|
||||
settingsManager.setNewToken(token)
|
||||
}
|
||||
|
||||
// start a new restore session
|
||||
val session = try {
|
||||
getOrStartSession()
|
||||
} catch (e: RemoteException) {
|
||||
Log.e(TAG, "Error starting new session", e)
|
||||
mRestoreBackupResult.postValue(
|
||||
RestoreBackupResult(context.getString(R.string.restore_set_error))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val observer = RestoreObserver(
|
||||
restoreCoordinator = restoreCoordinator,
|
||||
restorableBackup = restorableBackup,
|
||||
session = session,
|
||||
// sort packages (reverse) alphabetically, since we move from bottom to top
|
||||
packages = restorableBackup.packageMetadataMap.packagesSortedByNameDescending,
|
||||
monitor = monitor,
|
||||
)
|
||||
|
||||
// We need to retrieve the restore sets before starting the restore.
|
||||
// Otherwise, restorePackages() won't work as they need the restore sets cached internally.
|
||||
if (session.getAvailableRestoreSets(observer, monitor) != 0) {
|
||||
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
|
||||
|
||||
mRestoreBackupResult.postValue(
|
||||
RestoreBackupResult(context.getString(R.string.restore_set_error))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(RemoteException::class)
|
||||
private fun getOrStartSession(): IRestoreSession {
|
||||
@Suppress("UNRESOLVED_REFERENCE")
|
||||
val session = this.session
|
||||
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
|
||||
?: throw RemoteException("beginRestoreSessionForUser returned null")
|
||||
this.session = session
|
||||
return session
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
// this should be called one package at a time and never concurrently for different packages
|
||||
private fun onRestoreStarted(packageName: String, backup: RestorableBackup) {
|
||||
// list is never null and always has at least one package
|
||||
val list = mRestoreProgress.value!!
|
||||
|
||||
// check previous package first and change status
|
||||
updateLatestPackage(list, backup)
|
||||
|
||||
// add current package
|
||||
val name = getAppName(
|
||||
context = context,
|
||||
packageName = packageName,
|
||||
fallback = backup.packageMetadataMap[packageName]?.name?.toString() ?: packageName,
|
||||
)
|
||||
list.addFirst(AppRestoreResult(packageName, name.toString(), IN_PROGRESS))
|
||||
mRestoreProgress.postValue(list)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateLatestPackage(list: LinkedList<AppRestoreResult>, backup: RestorableBackup) {
|
||||
val latestResult = list[0]
|
||||
if (restoreCoordinator.isFailedPackage(latestResult.packageName)) {
|
||||
list[0] = latestResult.copy(state = getFailedStatus(latestResult.packageName, backup))
|
||||
} else {
|
||||
list[0] = latestResult.copy(state = SUCCEEDED)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFailedStatus(packageName: String, backup: RestorableBackup): AppBackupState {
|
||||
val metadata = backup.packageMetadataMap[packageName] ?: return FAILED
|
||||
return when (metadata.state) {
|
||||
PackageState.NO_DATA -> FAILED_NO_DATA
|
||||
PackageState.WAS_STOPPED -> NOT_YET_BACKED_UP
|
||||
PackageState.NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
||||
PackageState.QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
||||
PackageState.UNKNOWN_ERROR -> FAILED
|
||||
PackageState.APK_AND_DATA -> if (context.packageManager.isInstalled(packageName)) {
|
||||
FAILED
|
||||
} else FAILED_NOT_INSTALLED
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun onRestoreComplete(result: RestoreBackupResult, backup: RestorableBackup) {
|
||||
// update status of latest package
|
||||
val list = mRestoreProgress.value!!
|
||||
updateLatestPackage(list, backup)
|
||||
|
||||
// add missing packages as failed
|
||||
val seenPackages = list.map { it.packageName }.toSet()
|
||||
val expectedPackages =
|
||||
backup.packageMetadataMap.packagesSortedByNameDescending.toMutableSet()
|
||||
expectedPackages.removeAll(seenPackages)
|
||||
for (packageName in expectedPackages) {
|
||||
val failedStatus = getFailedStatus(packageName, backup)
|
||||
if (failedStatus == FAILED_NO_DATA &&
|
||||
backup.packageMetadataMap[packageName]?.isInternalSystem == true
|
||||
) {
|
||||
// don't add internal system apps that had NO_DATA to backup
|
||||
} else {
|
||||
val name = getAppName(
|
||||
context = context,
|
||||
packageName = packageName,
|
||||
fallback = backup.packageMetadataMap[packageName]?.name?.toString()
|
||||
?: packageName,
|
||||
)
|
||||
val appResult = AppRestoreResult(packageName, name.toString(), failedStatus)
|
||||
list.addFirst(appResult)
|
||||
}
|
||||
}
|
||||
mRestoreProgress.postValue(list)
|
||||
|
||||
mRestoreBackupResult.postValue(result)
|
||||
}
|
||||
|
||||
fun closeSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class RestoreObserver(
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val restorableBackup: RestorableBackup,
|
||||
private val session: IRestoreSession,
|
||||
private val packages: List<String>,
|
||||
private val monitor: BackupMonitor,
|
||||
) : IRestoreObserver.Stub() {
|
||||
|
||||
/**
|
||||
* The current package index.
|
||||
*
|
||||
* Used for splitting the packages into chunks.
|
||||
*/
|
||||
private var packageIndex: Int = 0
|
||||
|
||||
/**
|
||||
* Map of results for each chunk.
|
||||
*
|
||||
* The key is the chunk index, the value is the result.
|
||||
*/
|
||||
private val chunkResults = mutableMapOf<Int, Int>()
|
||||
|
||||
/**
|
||||
* Supply a list of the restore datasets available from the current transport.
|
||||
* This method is invoked as a callback following the application's use of the
|
||||
* [IRestoreSession.getAvailableRestoreSets] method.
|
||||
*
|
||||
* @param restoreSets An array of [RestoreSet] objects
|
||||
* describing all of the available datasets that are candidates for restoring to
|
||||
* the current device. If no applicable datasets exist, restoreSets will be null.
|
||||
*/
|
||||
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
|
||||
// this gets executed after we got the restore sets
|
||||
// now we can start the restore of all available packages
|
||||
restoreNextPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the next chunk of packages.
|
||||
*
|
||||
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
|
||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
|
||||
* transaction, causing the entire restoration to fail.
|
||||
*/
|
||||
private fun restoreNextPackages() {
|
||||
// Make sure metadata for selected backup is cached before starting each chunk.
|
||||
val backupMetadata = restorableBackup.backupMetadata
|
||||
restoreCoordinator.beforeStartRestore(backupMetadata)
|
||||
|
||||
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
|
||||
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||
packageIndex += packageChunk.size
|
||||
|
||||
val token = backupMetadata.token
|
||||
val result = session.restorePackages(token, this, packageChunk, monitor)
|
||||
|
||||
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
||||
if (result != BackupManager.SUCCESS) {
|
||||
Log.e(TAG, "restorePackages() returned non-zero value: $result")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has begun.
|
||||
*
|
||||
* @param numPackages The total number of packages
|
||||
* being processed in this restore operation.
|
||||
*/
|
||||
override fun restoreStarting(numPackages: Int) {
|
||||
// noop
|
||||
}
|
||||
|
||||
/**
|
||||
* An indication of which package is being restored currently,
|
||||
* out of the total number provided in the [restoreStarting] callback.
|
||||
* This method is not guaranteed to be called.
|
||||
*
|
||||
* @param nowBeingRestored The index, between 1 and the numPackages parameter
|
||||
* to the [restoreStarting] callback, of the package now being restored.
|
||||
* @param currentPackage The name of the package now being restored.
|
||||
*/
|
||||
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
|
||||
// nowBeingRestored reporting is buggy, so don't use it
|
||||
onRestoreStarted(currentPackage, restorableBackup)
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has completed.
|
||||
*
|
||||
* @param result Zero on success; a nonzero error code if the restore operation
|
||||
* as a whole failed.
|
||||
*/
|
||||
override fun restoreFinished(result: Int) {
|
||||
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
||||
chunkResults[chunkIndex] = result
|
||||
|
||||
// Restore next chunk if successful and there are more packages to restore.
|
||||
if (packageIndex < packages.size) {
|
||||
restoreNextPackages()
|
||||
return
|
||||
}
|
||||
|
||||
// Restore finished, time to get the result.
|
||||
onRestoreComplete(getRestoreResult(), restorableBackup)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
private fun getRestoreResult(): RestoreBackupResult {
|
||||
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
||||
val failedChunks = chunkResults
|
||||
.filter { it.value != BackupManager.SUCCESS }
|
||||
.map { "chunk ${it.key} failed with error ${it.value}" }
|
||||
|
||||
return if (failedChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Restore failed: $failedChunks")
|
||||
|
||||
return RestoreBackupResult(
|
||||
errorMsg = context.getString(R.string.restore_finished_error)
|
||||
)
|
||||
} else {
|
||||
RestoreBackupResult(errorMsg = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PackageMetadataMap.packagesSortedByNameDescending: List<String>
|
||||
get() {
|
||||
return asIterable().sortedByDescending { (packageName, metadata) ->
|
||||
// sort packages (reverse) alphabetically, since we move from bottom to top
|
||||
(metadata.name?.toString() ?: packageName).lowercase(Locale.getDefault())
|
||||
}.mapNotNull {
|
||||
// don't try to restore this helper package, as it doesn't really exist
|
||||
if (it.key == NO_DATA_END_SENTINEL) null else it.key
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView.ScaleType.CENTER
|
||||
import android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import androidx.recyclerview.widget.RecyclerView.VISIBLE
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface AppSelectionItem
|
||||
|
||||
internal class AppSelectionSection(@StringRes val titleRes: Int) : AppSelectionItem
|
||||
|
||||
internal data class SelectableAppItem(
|
||||
val packageName: String,
|
||||
val metadata: PackageMetadata,
|
||||
val selected: Boolean,
|
||||
val hasIcon: Boolean? = null,
|
||||
) : AppSelectionItem {
|
||||
val name: String get() = metadata.name?.toString() ?: packageName
|
||||
}
|
||||
|
||||
internal class AppSelectionAdapter(
|
||||
val scope: CoroutineScope,
|
||||
val iconLoader: suspend (SelectableAppItem, (Drawable) -> Unit) -> Unit,
|
||||
val listener: (SelectableAppItem) -> Unit,
|
||||
) : Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private val diffCallback = object : ItemCallback<AppSelectionItem>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AppSelectionItem,
|
||||
newItem: AppSelectionItem,
|
||||
): Boolean {
|
||||
return if (oldItem is AppSelectionSection && newItem is AppSelectionSection) {
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
} else if (oldItem is SelectableAppItem && newItem is SelectableAppItem) {
|
||||
oldItem.packageName == newItem.packageName
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
old: AppSelectionItem,
|
||||
new: AppSelectionItem,
|
||||
): Boolean {
|
||||
return if (old is AppSelectionSection && new is AppSelectionSection) {
|
||||
true
|
||||
} else if (old is SelectableAppItem && new is SelectableAppItem) {
|
||||
old.selected == new.selected && old.hasIcon == new.hasIcon
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
private val differ = AsyncListDiffer(this, diffCallback)
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long = position.toLong() // items never get added/removed
|
||||
|
||||
override fun getItemViewType(position: Int): Int = when (differ.currentList[position]) {
|
||||
is SelectableAppItem -> 0
|
||||
is AppSelectionSection -> 1
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
0 -> {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.list_item_app_status, parent, false)
|
||||
SelectableAppViewHolder(v)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.list_item_app_section_title, parent, false)
|
||||
AppSelectionSectionViewHolder(v)
|
||||
}
|
||||
|
||||
else -> throw AssertionError("unknown view type")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is SelectableAppViewHolder -> {
|
||||
holder.bind(differ.currentList[position] as SelectableAppItem)
|
||||
}
|
||||
|
||||
is AppSelectionSectionViewHolder -> {
|
||||
holder.bind(differ.currentList[position] as AppSelectionSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitList(items: List<AppSelectionItem>) {
|
||||
val itemsWithSections = items.toMutableList().apply {
|
||||
val i = indexOfLast {
|
||||
it as SelectableAppItem
|
||||
it.packageName == PACKAGE_NAME_SYSTEM
|
||||
}
|
||||
add(i + 1, AppSelectionSection(R.string.backup_section_user))
|
||||
add(0, AppSelectionSection(R.string.backup_section_system))
|
||||
}
|
||||
differ.submitList(itemsWithSections)
|
||||
}
|
||||
|
||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
||||
if (holder is SelectableAppViewHolder) holder.iconJob?.cancel()
|
||||
}
|
||||
|
||||
class AppSelectionSectionViewHolder(v: View) : RecyclerView.ViewHolder(v) {
|
||||
private val titleView: TextView = v as TextView
|
||||
fun bind(item: AppSelectionSection) {
|
||||
titleView.setText(item.titleRes)
|
||||
}
|
||||
}
|
||||
|
||||
internal inner class SelectableAppViewHolder(v: View) : AppViewHolder(v) {
|
||||
|
||||
var iconJob: Job? = null
|
||||
|
||||
fun bind(item: SelectableAppItem) {
|
||||
v.background = clickableBackground
|
||||
v.setOnClickListener {
|
||||
checkBox.toggle()
|
||||
}
|
||||
|
||||
checkBox.setOnCheckedChangeListener(null)
|
||||
checkBox.isChecked = item.selected
|
||||
checkBox.setOnCheckedChangeListener { _, _ ->
|
||||
listener(item)
|
||||
}
|
||||
checkBox.visibility = if (item.hasIcon == null) INVISIBLE else VISIBLE
|
||||
progressBar.visibility = if (item.hasIcon == null) VISIBLE else INVISIBLE
|
||||
|
||||
val isSpecial = item.metadata.isInternalSystem
|
||||
appIcon.scaleType = FIT_CENTER
|
||||
appIcon.setImageResource(R.drawable.ic_launcher_default)
|
||||
appIcon.scaleType = if (isSpecial) CENTER else FIT_CENTER
|
||||
if (item.hasIcon == null && !isSpecial) {
|
||||
appIcon.alpha = 0.5f
|
||||
} else if (item.hasIcon == true || isSpecial) {
|
||||
appIcon.alpha = 0.5f
|
||||
iconJob = scope.launch {
|
||||
iconLoader(item) { bitmap ->
|
||||
appIcon.scaleType = if (isSpecial) CENTER else FIT_CENTER
|
||||
appIcon.setImageDrawable(bitmap)
|
||||
appIcon.alpha = 1f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appIcon.alpha = 1f
|
||||
}
|
||||
appName.text = item.name
|
||||
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(
|
||||
item.metadata.time,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.HOUR_IN_MILLIS,
|
||||
DateUtils.FORMAT_ABBREV_RELATIVE,
|
||||
) else v.context.getString(R.string.settings_backup_last_backup_never)
|
||||
val size = if (item.metadata.size == null) "" else "(" + Formatter.formatShortFileSize(
|
||||
v.context,
|
||||
item.metadata.size ?: 0
|
||||
) + ")"
|
||||
appInfo.text =
|
||||
v.context.getString(R.string.settings_backup_status_summary, "$time $size")
|
||||
appInfo.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import com.stevesoltys.seedvault.R
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class AppSelectionFragment : Fragment() {
|
||||
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private val layoutManager = LinearLayoutManager(context)
|
||||
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
|
||||
viewModel.onAppSelected(item)
|
||||
}
|
||||
|
||||
private lateinit var backupNameView: TextView
|
||||
private lateinit var toggleAllTextView: TextView
|
||||
private lateinit var toggleAllView: MaterialCheckBox
|
||||
private lateinit var appList: RecyclerView
|
||||
private lateinit var button: Button
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val v: View = inflater.inflate(R.layout.fragment_restore_app_selection, container, false)
|
||||
|
||||
backupNameView = v.requireViewById(R.id.backupNameView)
|
||||
toggleAllTextView = v.requireViewById(R.id.toggleAllTextView)
|
||||
toggleAllView = v.requireViewById(R.id.toggleAllView)
|
||||
appList = v.requireViewById(R.id.appList)
|
||||
button = v.requireViewById(R.id.button)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
toggleAllTextView.setOnClickListener {
|
||||
viewModel.onCheckAllAppsClicked()
|
||||
}
|
||||
toggleAllView.setOnClickListener {
|
||||
viewModel.onCheckAllAppsClicked()
|
||||
}
|
||||
|
||||
appList.apply {
|
||||
layoutManager = this@AppSelectionFragment.layoutManager
|
||||
adapter = this@AppSelectionFragment.adapter
|
||||
}
|
||||
button.setOnClickListener { viewModel.onNextClickedAfterSelectingApps() }
|
||||
|
||||
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup ->
|
||||
backupNameView.text = restorableBackup.name
|
||||
}
|
||||
viewModel.selectedApps.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(state.apps)
|
||||
toggleAllView.isChecked = state.allSelected
|
||||
// enable toggle all views only after icons have loaded
|
||||
toggleAllView.isEnabled = state.iconsLoaded
|
||||
toggleAllTextView.isEnabled = state.iconsLoaded
|
||||
button.isEnabled = state.iconsLoaded
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadIcon(item: SelectableAppItem, callback: (Drawable) -> Unit) {
|
||||
viewModel.loadIcon(item, callback)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||
import com.stevesoltys.seedvault.worker.IconManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
internal class SelectedAppsState(
|
||||
val apps: List<SelectableAppItem>,
|
||||
val allSelected: Boolean,
|
||||
val iconsLoaded: Boolean,
|
||||
)
|
||||
|
||||
private val TAG = AppSelectionManager::class.simpleName
|
||||
|
||||
internal class AppSelectionManager(
|
||||
private val context: Context,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val iconManager: IconManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
private val initialState = SelectedAppsState(
|
||||
emptyList(),
|
||||
allSelected = true,
|
||||
iconsLoaded = false,
|
||||
)
|
||||
private val selectedApps = MutableStateFlow(initialState)
|
||||
val selectedAppsFlow = selectedApps.asStateFlow()
|
||||
val selectedAppsLiveData: LiveData<SelectedAppsState> = selectedApps.asLiveData()
|
||||
|
||||
fun onRestoreSetChosen(restorableBackup: RestorableBackup) {
|
||||
// filter and sort app items for display
|
||||
val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
|
||||
if (metadata.time == 0L && !metadata.hasApk()) null
|
||||
else if (metadata.isInternalSystem) null
|
||||
else SelectableAppItem(packageName, metadata, true)
|
||||
}.sortedBy {
|
||||
it.name.lowercase(Locale.getDefault())
|
||||
}.toMutableList()
|
||||
val systemDataItems = systemData.mapNotNull { (packageName, data) ->
|
||||
val metadata = restorableBackup.packageMetadataMap[packageName]
|
||||
?: return@mapNotNull null
|
||||
if (metadata.time == 0L && !metadata.hasApk()) return@mapNotNull null
|
||||
val name = context.getString(data.nameRes)
|
||||
SelectableAppItem(packageName, metadata.copy(name = name), true)
|
||||
}
|
||||
val systemItem = SelectableAppItem(
|
||||
packageName = PACKAGE_NAME_SYSTEM,
|
||||
metadata = PackageMetadata(
|
||||
time = restorableBackup.packageMetadataMap.values.maxOf {
|
||||
if (it.system) it.time else -1
|
||||
},
|
||||
size = restorableBackup.packageMetadataMap.values.sumOf {
|
||||
if (it.system) it.size ?: 0L else 0L
|
||||
},
|
||||
system = true,
|
||||
name = context.getString(R.string.backup_system_apps),
|
||||
),
|
||||
selected = true,
|
||||
)
|
||||
items.add(0, systemItem)
|
||||
items.addAll(0, systemDataItems)
|
||||
selectedApps.value =
|
||||
SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false)
|
||||
// download icons
|
||||
coroutineScope.launch(workDispatcher) {
|
||||
val plugin = pluginManager.appPlugin
|
||||
val token = restorableBackup.token
|
||||
val packagesWithIcons = try {
|
||||
plugin.getInputStream(token, FILE_BACKUP_ICONS).use {
|
||||
iconManager.downloadIcons(restorableBackup.version, token, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading icons:", e)
|
||||
emptySet()
|
||||
} + systemData.keys + setOf(PACKAGE_NAME_SYSTEM) // special apps have built-in icons
|
||||
// update state, so it knows that icons have loaded
|
||||
val updatedItems = items.map { item ->
|
||||
item.copy(hasIcon = item.packageName in packagesWithIcons)
|
||||
}
|
||||
selectedApps.value =
|
||||
SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCheckAllAppsClicked() {
|
||||
val apps = selectedApps.value.apps
|
||||
val allSelected = apps.all { it.selected }
|
||||
if (allSelected) {
|
||||
// unselect all
|
||||
val newApps = apps.map { if (it.selected) it.copy(selected = false) else it }
|
||||
selectedApps.value = SelectedAppsState(newApps, false, iconsLoaded = true)
|
||||
} else {
|
||||
// select all
|
||||
val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it }
|
||||
selectedApps.value = SelectedAppsState(newApps, true, iconsLoaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAppSelected(item: SelectableAppItem) {
|
||||
val apps = selectedApps.value.apps.toMutableList()
|
||||
val iterator = apps.listIterator()
|
||||
var allSelected = true
|
||||
while (iterator.hasNext()) {
|
||||
val app = iterator.next()
|
||||
if (app.packageName == item.packageName) {
|
||||
iterator.set(item.copy(selected = !item.selected))
|
||||
allSelected = allSelected && !item.selected
|
||||
} else {
|
||||
allSelected = allSelected && app.selected
|
||||
}
|
||||
}
|
||||
selectedApps.value = SelectedAppsState(apps, allSelected, iconsLoaded = true)
|
||||
}
|
||||
|
||||
fun onAppSelectionFinished(backup: RestorableBackup): RestorableBackup {
|
||||
// map packages names to selection state
|
||||
val apps = selectedApps.value.apps.associate {
|
||||
Pair(it.packageName, it.selected)
|
||||
}
|
||||
// filter out unselected packages
|
||||
// Attention: This code is complicated and hard to test, proceed with plenty of care!
|
||||
val restoreSystemApps = apps[PACKAGE_NAME_SYSTEM] != false
|
||||
val packages = backup.packageMetadataMap.filter { (packageName, metadata) ->
|
||||
val isSelected = apps[packageName]
|
||||
@Suppress("IfThenToElvis") // the code is more readable like this
|
||||
if (isSelected == null) { // was not in list
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER) true // @pm@ is essential for restore
|
||||
else if (packageName == NO_DATA_END_SENTINEL) false // @end@ is not real
|
||||
// internal system apps were not in the list and are controlled by meta item,
|
||||
// so allow them only if meta item was selected
|
||||
else if (metadata.isInternalSystem) restoreSystemApps
|
||||
else true // non-system packages that weren't found, won't get filtered
|
||||
} else { // was in list and either selected or not
|
||||
isSelected
|
||||
}
|
||||
} as PackageMetadataMap
|
||||
// replace original chosen backup with unselected packages removed
|
||||
return backup.copy(
|
||||
backupMetadata = backup.backupMetadata.copy(packageMetadataMap = packages),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
|||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS
|
||||
import com.stevesoltys.seedvault.restore.install.InstallProgressFragment
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
|
@ -28,15 +29,16 @@ class RestoreActivity : RequireProvisioningActivity() {
|
|||
|
||||
setContentView(R.layout.activity_fragment_container)
|
||||
|
||||
viewModel.displayFragment.observeEvent(this, { fragment ->
|
||||
viewModel.displayFragment.observeEvent(this) { fragment ->
|
||||
when (fragment) {
|
||||
SELECT_APPS -> showFragment(AppSelectionFragment())
|
||||
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
||||
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
||||
RESTORE_FILES -> showFragment(RestoreFilesFragment())
|
||||
RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment())
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
showFragment(RestoreSetFragment())
|
||||
|
|
|
@ -6,21 +6,40 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil.ItemCallback
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.LinkedList
|
||||
|
||||
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
||||
internal class RestoreProgressAdapter(
|
||||
val scope: CoroutineScope,
|
||||
val iconLoader: suspend (AppRestoreResult, (Drawable) -> Unit) -> Unit,
|
||||
) : Adapter<PackageViewHolder>() {
|
||||
|
||||
private val items = LinkedList<AppRestoreResult>()
|
||||
private val diffCallback = object : ItemCallback<AppRestoreResult>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: AppRestoreResult,
|
||||
newItem: AppRestoreResult,
|
||||
): Boolean {
|
||||
return oldItem.packageName == newItem.packageName
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(old: AppRestoreResult, new: AppRestoreResult): Boolean {
|
||||
return old.name == new.name && old.state == new.state
|
||||
}
|
||||
}
|
||||
private val differ = AsyncListDiffer(this, diffCallback)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
|
@ -28,37 +47,24 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
|||
return PackageViewHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onBindViewHolder(holder: PackageViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
holder.bind(differ.currentList[position])
|
||||
}
|
||||
|
||||
fun update(newItems: LinkedList<AppRestoreResult>) {
|
||||
val diffResult = DiffUtil.calculateDiff(Diff(items, newItems))
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
diffResult.dispatchUpdatesTo(this)
|
||||
fun update(newItems: LinkedList<AppRestoreResult>, callback: Runnable) {
|
||||
// add .toList(), because [AppDataRestoreManager] still re-uses the same list,
|
||||
// but AsyncListDiffer needs a new one.
|
||||
differ.submitList(newItems.toList(), callback)
|
||||
}
|
||||
|
||||
private class Diff(
|
||||
private val oldItems: LinkedList<AppRestoreResult>,
|
||||
private val newItems: LinkedList<AppRestoreResult>,
|
||||
) : DiffUtil.Callback() {
|
||||
|
||||
override fun getOldListSize() = oldItems.size
|
||||
override fun getNewListSize() = newItems.size
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldItems[oldItemPosition] == newItems[newItemPosition]
|
||||
}
|
||||
override fun onViewRecycled(holder: PackageViewHolder) {
|
||||
holder.iconJob?.cancel()
|
||||
}
|
||||
|
||||
class PackageViewHolder(v: View) : AppViewHolder(v) {
|
||||
inner class PackageViewHolder(v: View) : AppViewHolder(v) {
|
||||
var iconJob: Job? = null
|
||||
fun bind(item: AppRestoreResult) {
|
||||
appName.text = item.name
|
||||
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
|
||||
|
@ -67,7 +73,11 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
|||
try {
|
||||
appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName))
|
||||
} catch (e: NameNotFoundException) {
|
||||
appIcon.setImageResource(R.drawable.ic_launcher_default)
|
||||
iconJob = scope.launch {
|
||||
iconLoader(item) { bitmap ->
|
||||
appIcon.setImageDrawable(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(item.state, true)
|
||||
|
@ -75,9 +85,3 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
internal data class AppRestoreResult(
|
||||
val packageName: String,
|
||||
val name: CharSequence,
|
||||
val state: AppBackupState,
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -16,6 +17,7 @@ import android.widget.TextView
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat.getColor
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
@ -27,7 +29,7 @@ class RestoreProgressFragment : Fragment() {
|
|||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private val layoutManager = LinearLayoutManager(context)
|
||||
private val adapter = RestoreProgressAdapter()
|
||||
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
|
||||
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var titleView: TextView
|
||||
|
@ -67,17 +69,20 @@ class RestoreProgressFragment : Fragment() {
|
|||
// decryption will fail when the device is locked, so keep the screen on to prevent locking
|
||||
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, { restorableBackup ->
|
||||
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup ->
|
||||
backupNameView.text = restorableBackup.name
|
||||
progressBar.max = restorableBackup.packageMetadataMap.size
|
||||
})
|
||||
}
|
||||
|
||||
viewModel.restoreProgress.observe(viewLifecycleOwner, { list ->
|
||||
stayScrolledAtTop { adapter.update(list) }
|
||||
viewModel.restoreProgress.observe(viewLifecycleOwner) { list ->
|
||||
progressBar.progress = list.size
|
||||
})
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
adapter.update(list) {
|
||||
if (position == 0) layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.restoreBackupResult.observe(viewLifecycleOwner, { finished ->
|
||||
viewModel.restoreBackupResult.observe(viewLifecycleOwner) { finished ->
|
||||
button.isEnabled = true
|
||||
if (finished.hasError()) {
|
||||
backupNameView.text = finished.errorMsg
|
||||
|
@ -87,7 +92,7 @@ class RestoreProgressFragment : Fragment() {
|
|||
onRestoreFinished()
|
||||
}
|
||||
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreFinished() {
|
||||
|
@ -103,10 +108,8 @@ class RestoreProgressFragment : Fragment() {
|
|||
.show()
|
||||
}
|
||||
|
||||
private fun stayScrolledAtTop(add: () -> Unit) {
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
add.invoke()
|
||||
if (position == 0) layoutManager.scrollToPosition(0)
|
||||
private suspend fun loadIcon(item: AppRestoreResult, callback: (Drawable) -> Unit) {
|
||||
viewModel.loadIcon(item.packageName, callback)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,73 +6,47 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupManager
|
||||
import android.app.backup.BackupTransport
|
||||
import android.app.backup.IBackupManager
|
||||
import android.app.backup.IRestoreObserver
|
||||
import android.app.backup.IRestoreSession
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Intent
|
||||
import android.os.RemoteException
|
||||
import android.os.UserHandle
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.switchMap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS
|
||||
import com.stevesoltys.seedvault.restore.install.ApkRestore
|
||||
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
|
||||
import com.stevesoltys.seedvault.restore.install.InstallResult
|
||||
import com.stevesoltys.seedvault.restore.install.isInstalled
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.storage.StorageRestoreService
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NO_DATA
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import com.stevesoltys.seedvault.worker.IconManager
|
||||
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
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.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
|
||||
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
|
||||
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.LinkedList
|
||||
|
||||
private val TAG = RestoreViewModel::class.java.simpleName
|
||||
|
@ -83,9 +57,10 @@ internal class RestoreViewModel(
|
|||
app: Application,
|
||||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
private val backupManager: IBackupManager,
|
||||
backupManager: IBackupManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val apkRestore: ApkRestore,
|
||||
private val iconManager: IconManager,
|
||||
storageBackup: StorageBackup,
|
||||
pluginManager: StoragePluginManager,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
|
@ -94,8 +69,10 @@ internal class RestoreViewModel(
|
|||
|
||||
override val isRestoreOperation = true
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
private val monitor = BackupMonitor()
|
||||
private val appSelectionManager =
|
||||
AppSelectionManager(app, pluginManager, iconManager, viewModelScope)
|
||||
private val appDataRestoreManager =
|
||||
AppDataRestoreManager(app, backupManager, settingsManager, restoreCoordinator)
|
||||
|
||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
|
||||
|
@ -106,43 +83,21 @@ internal class RestoreViewModel(
|
|||
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
|
||||
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
|
||||
|
||||
internal val installResult: LiveData<InstallResult> =
|
||||
mChosenRestorableBackup.switchMap { backup ->
|
||||
getInstallResult(backup)
|
||||
}
|
||||
internal val selectedApps: LiveData<SelectedAppsState> =
|
||||
appSelectionManager.selectedAppsLiveData
|
||||
|
||||
internal val installResult: LiveData<InstallResult> = apkRestore.installResult.asLiveData()
|
||||
|
||||
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
|
||||
|
||||
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false }
|
||||
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
|
||||
internal val restoreProgress: LiveData<LinkedList<AppRestoreResult>>
|
||||
get() = appDataRestoreManager.restoreProgress
|
||||
|
||||
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
|
||||
value = LinkedList<AppRestoreResult>().apply {
|
||||
add(
|
||||
AppRestoreResult(
|
||||
packageName = MAGIC_PACKAGE_MANAGER,
|
||||
name = getAppName(app, MAGIC_PACKAGE_MANAGER),
|
||||
state = IN_PROGRESS
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
internal val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
|
||||
|
||||
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
|
||||
internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
|
||||
internal val restoreBackupResult: LiveData<RestoreBackupResult>
|
||||
get() = appDataRestoreManager.restoreBackupResult
|
||||
|
||||
override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
|
||||
|
||||
@Throws(RemoteException::class)
|
||||
private fun getOrStartSession(): IRestoreSession {
|
||||
@Suppress("UNRESOLVED_REFERENCE")
|
||||
val session = this.session
|
||||
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
|
||||
?: throw RemoteException("beginRestoreSessionForUser returned null")
|
||||
this.session = session
|
||||
return session
|
||||
}
|
||||
|
||||
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
||||
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
||||
when (metadata.time) {
|
||||
|
@ -164,282 +119,57 @@ internal class RestoreViewModel(
|
|||
|
||||
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
|
||||
mChosenRestorableBackup.value = restorableBackup
|
||||
appSelectionManager.onRestoreSetChosen(restorableBackup)
|
||||
mDisplayFragment.setEvent(SELECT_APPS)
|
||||
}
|
||||
|
||||
suspend fun loadIcon(item: SelectableAppItem, callback: (Drawable) -> Unit) {
|
||||
if (item.packageName == PACKAGE_NAME_SYSTEM) {
|
||||
val drawable = getDrawable(app, R.drawable.ic_app_settings)!!
|
||||
callback(drawable)
|
||||
} else if (item.metadata.isInternalSystem && item.packageName in systemData.keys) {
|
||||
val drawable = getDrawable(app, systemData[item.packageName]!!.iconRes)!!
|
||||
callback(drawable)
|
||||
} else {
|
||||
iconManager.loadIcon(item.packageName, callback)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadIcon(packageName: String, callback: (Drawable) -> Unit) {
|
||||
iconManager.loadIcon(packageName, callback)
|
||||
}
|
||||
|
||||
fun onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked()
|
||||
fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item)
|
||||
|
||||
internal fun onNextClickedAfterSelectingApps() {
|
||||
val backup = chosenRestorableBackup.value ?: error("No chosen backup")
|
||||
// replace original chosen backup with unselected packages removed
|
||||
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
|
||||
mChosenRestorableBackup.value = filteredBackup
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
apkRestore.restore(filteredBackup)
|
||||
}
|
||||
// tell UI to move to InstallFragment
|
||||
mDisplayFragment.setEvent(RESTORE_APPS)
|
||||
}
|
||||
|
||||
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> {
|
||||
@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)
|
||||
}
|
||||
fun reCheckFailedPackage(packageName: String) = apkRestore.reCheckFailedPackage(packageName)
|
||||
|
||||
internal fun onNextClickedAfterInstallingApps() {
|
||||
mDisplayFragment.postEvent(RESTORE_BACKUP)
|
||||
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
startRestore()
|
||||
val backup = chosenRestorableBackup.value ?: error("No Backup chosen")
|
||||
appDataRestoreManager.startRestore(backup)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun startRestore() {
|
||||
val token = mChosenRestorableBackup.value?.token
|
||||
?: throw IllegalStateException("No chosen backup")
|
||||
|
||||
Log.d(TAG, "Starting new restore session to restore backup $token")
|
||||
|
||||
// if we had no token before (i.e. restore from setup wizard),
|
||||
// use the token of the current restore set from now on
|
||||
if (settingsManager.getToken() == null) {
|
||||
settingsManager.setNewToken(token)
|
||||
}
|
||||
|
||||
// start a new restore session
|
||||
val session = try {
|
||||
getOrStartSession()
|
||||
} catch (e: RemoteException) {
|
||||
Log.e(TAG, "Error starting new session", e)
|
||||
mRestoreBackupResult.postValue(
|
||||
RestoreBackupResult(app.getString(R.string.restore_set_error))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val restorableBackup = mChosenRestorableBackup.value
|
||||
val packages = restorableBackup?.packageMetadataMap?.keys?.toList()
|
||||
?: run {
|
||||
Log.e(TAG, "Chosen backup has empty package metadata map")
|
||||
mRestoreBackupResult.postValue(
|
||||
RestoreBackupResult(app.getString(R.string.restore_set_error))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val observer = RestoreObserver(
|
||||
restoreCoordinator = restoreCoordinator,
|
||||
restorableBackup = restorableBackup,
|
||||
session = session,
|
||||
packages = packages,
|
||||
monitor = monitor
|
||||
)
|
||||
|
||||
// We need to retrieve the restore sets before starting the restore.
|
||||
// Otherwise, restorePackages() won't work as they need the restore sets cached internally.
|
||||
if (session.getAvailableRestoreSets(observer, monitor) != 0) {
|
||||
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
|
||||
|
||||
mRestoreBackupResult.postValue(
|
||||
RestoreBackupResult(app.getString(R.string.restore_set_error))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
// this should be called one package at a time and never concurrently for different packages
|
||||
private fun onRestoreStarted(packageName: String) {
|
||||
// list is never null and always has at least one package
|
||||
val list = mRestoreProgress.value!!
|
||||
|
||||
// check previous package first and change status
|
||||
updateLatestPackage(list)
|
||||
|
||||
// add current package
|
||||
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), IN_PROGRESS))
|
||||
mRestoreProgress.postValue(list)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateLatestPackage(list: LinkedList<AppRestoreResult>) {
|
||||
val latestResult = list[0]
|
||||
if (restoreCoordinator.isFailedPackage(latestResult.packageName)) {
|
||||
list[0] = latestResult.copy(state = getFailedStatus(latestResult.packageName))
|
||||
} else {
|
||||
list[0] = latestResult.copy(state = SUCCEEDED)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getFailedStatus(
|
||||
packageName: String,
|
||||
restorableBackup: RestorableBackup = chosenRestorableBackup.value!!,
|
||||
): AppBackupState {
|
||||
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
|
||||
return when (metadata.state) {
|
||||
NO_DATA -> FAILED_NO_DATA
|
||||
WAS_STOPPED -> NOT_YET_BACKED_UP
|
||||
NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
||||
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
||||
UNKNOWN_ERROR -> FAILED
|
||||
APK_AND_DATA -> {
|
||||
if (app.packageManager.isInstalled(packageName)) FAILED else FAILED_NOT_INSTALLED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun onRestoreComplete(result: RestoreBackupResult) {
|
||||
// update status of latest package
|
||||
val list = mRestoreProgress.value!!
|
||||
updateLatestPackage(list)
|
||||
|
||||
// add missing packages as failed
|
||||
val seenPackages = list.map { it.packageName }
|
||||
val restorableBackup = chosenRestorableBackup.value!!
|
||||
val expectedPackages = restorableBackup.packageMetadataMap.keys
|
||||
expectedPackages.removeAll(seenPackages)
|
||||
for (packageName: String in expectedPackages) {
|
||||
// TODO don't add if it was a NO_DATA system app
|
||||
val failedStatus = getFailedStatus(packageName, restorableBackup)
|
||||
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), failedStatus))
|
||||
}
|
||||
mRestoreProgress.postValue(list)
|
||||
|
||||
mRestoreBackupResult.postValue(result)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
closeSession()
|
||||
}
|
||||
|
||||
private fun closeSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class RestoreObserver(
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val restorableBackup: RestorableBackup,
|
||||
private val session: IRestoreSession,
|
||||
private val packages: List<String>,
|
||||
private val monitor: BackupMonitor,
|
||||
) : IRestoreObserver.Stub() {
|
||||
|
||||
/**
|
||||
* The current package index.
|
||||
*
|
||||
* Used for splitting the packages into chunks.
|
||||
*/
|
||||
private var packageIndex: Int = 0
|
||||
|
||||
/**
|
||||
* Map of results for each chunk.
|
||||
*
|
||||
* The key is the chunk index, the value is the result.
|
||||
*/
|
||||
private val chunkResults = mutableMapOf<Int, Int>()
|
||||
|
||||
/**
|
||||
* Supply a list of the restore datasets available from the current transport.
|
||||
* This method is invoked as a callback following the application's use of the
|
||||
* [IRestoreSession.getAvailableRestoreSets] method.
|
||||
*
|
||||
* @param restoreSets An array of [RestoreSet] objects
|
||||
* describing all of the available datasets that are candidates for restoring to
|
||||
* the current device. If no applicable datasets exist, restoreSets will be null.
|
||||
*/
|
||||
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
|
||||
// this gets executed after we got the restore sets
|
||||
// now we can start the restore of all available packages
|
||||
restoreNextPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the next chunk of packages.
|
||||
*
|
||||
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
|
||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
|
||||
* transaction, causing the entire restoration to fail.
|
||||
*/
|
||||
private fun restoreNextPackages() {
|
||||
// Make sure metadata for selected backup is cached before starting each chunk.
|
||||
val backupMetadata = restorableBackup.backupMetadata
|
||||
restoreCoordinator.beforeStartRestore(backupMetadata)
|
||||
|
||||
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
|
||||
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||
packageIndex += packageChunk.size
|
||||
|
||||
val token = backupMetadata.token
|
||||
val result = session.restorePackages(token, this, packageChunk, monitor)
|
||||
|
||||
if (result != BackupManager.SUCCESS) {
|
||||
Log.e(TAG, "restorePackages() returned non-zero value: $result")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has begun.
|
||||
*
|
||||
* @param numPackages The total number of packages
|
||||
* being processed in this restore operation.
|
||||
*/
|
||||
override fun restoreStarting(numPackages: Int) {
|
||||
// noop
|
||||
}
|
||||
|
||||
/**
|
||||
* An indication of which package is being restored currently,
|
||||
* out of the total number provided in the [restoreStarting] callback.
|
||||
* This method is not guaranteed to be called.
|
||||
*
|
||||
* @param nowBeingRestored The index, between 1 and the numPackages parameter
|
||||
* to the [restoreStarting] callback, of the package now being restored.
|
||||
* @param currentPackage The name of the package now being restored.
|
||||
*/
|
||||
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
|
||||
// nowBeingRestored reporting is buggy, so don't use it
|
||||
onRestoreStarted(currentPackage)
|
||||
}
|
||||
|
||||
/**
|
||||
* The restore operation has completed.
|
||||
*
|
||||
* @param result Zero on success; a nonzero error code if the restore operation
|
||||
* as a whole failed.
|
||||
*/
|
||||
override fun restoreFinished(result: Int) {
|
||||
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
||||
chunkResults[chunkIndex] = result
|
||||
|
||||
// Restore next chunk if successful and there are more packages to restore.
|
||||
if (packageIndex < packages.size) {
|
||||
restoreNextPackages()
|
||||
return
|
||||
}
|
||||
|
||||
// Restore finished, time to get the result.
|
||||
onRestoreComplete(getRestoreResult())
|
||||
closeSession()
|
||||
}
|
||||
|
||||
private fun getRestoreResult(): RestoreBackupResult {
|
||||
val failedChunks = chunkResults
|
||||
.filter { it.value != BackupManager.SUCCESS }
|
||||
.map { "chunk ${it.key} failed with error ${it.value}" }
|
||||
|
||||
return if (failedChunks.isNotEmpty()) {
|
||||
Log.e(TAG, "Restore failed: $failedChunks")
|
||||
|
||||
return RestoreBackupResult(
|
||||
errorMsg = app.getString(R.string.restore_finished_error)
|
||||
)
|
||||
} else {
|
||||
RestoreBackupResult(errorMsg = null)
|
||||
}
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
|
||||
appDataRestoreManager.closeSession()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
|
@ -475,5 +205,5 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
|
|||
}
|
||||
|
||||
internal enum class DisplayFragment {
|
||||
RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
|
||||
SELECT_APPS, RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
|
||||
}
|
||||
|
|
|
@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) {
|
|||
cachedApks: List<File>,
|
||||
packageName: String,
|
||||
installerPackageName: String?,
|
||||
installResult: MutableInstallResult,
|
||||
) = suspendCancellableCoroutine<InstallResult> { cont ->
|
||||
installResult: InstallResult,
|
||||
) = suspendCancellableCoroutine { cont ->
|
||||
val broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, i: Intent) {
|
||||
if (i.action != BROADCAST_ACTION) return
|
||||
|
@ -110,7 +110,7 @@ internal class ApkInstaller(private val context: Context) {
|
|||
i: Intent,
|
||||
expectedPackageName: String,
|
||||
cachedApks: List<File>,
|
||||
installResult: MutableInstallResult,
|
||||
installResult: InstallResult,
|
||||
): InstallResult {
|
||||
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
|
||||
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_SIGNING_CERTIFICATES
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
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.getSignatures
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
private val TAG = ApkRestore::class.java.simpleName
|
||||
|
||||
|
@ -47,70 +50,69 @@ internal class ApkRestore(
|
|||
private val pm = context.packageManager
|
||||
private val storagePlugin get() = pluginManager.appPlugin
|
||||
|
||||
fun restore(backup: RestorableBackup) = flow {
|
||||
// we don't filter out apps without APK, so the user can manually install them
|
||||
val packages = backup.packageMetadataMap.filter {
|
||||
private val mInstallResult = MutableStateFlow(InstallResult())
|
||||
val installResult = mInstallResult.asStateFlow()
|
||||
|
||||
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.
|
||||
// Otherwise, it gets killed when we install it, terminating our restoration.
|
||||
it.key != storagePlugin.providerPackageName
|
||||
}
|
||||
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks()
|
||||
val total = packages.size
|
||||
var progress = 0
|
||||
|
||||
// queue all packages and emit LiveData
|
||||
val installResult = MutableInstallResult(total)
|
||||
packages.forEach { (packageName, metadata) ->
|
||||
progress++
|
||||
installResult[packageName] = ApkInstallResult(
|
||||
if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
|
||||
// The @pm@ package needs to be included in [backup], but can't be installed like an app
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
|
||||
// we don't filter out apps without APK, so the user can manually install them
|
||||
// exception is system apps without APK, as those can usually not be installed manually
|
||||
if (metadata.system && !metadata.hasApk()) return@mapNotNull null
|
||||
// apps that made it here get a state class for tracking
|
||||
ApkInstallResult(
|
||||
packageName = packageName,
|
||||
progress = progress,
|
||||
state = if (isAllowedToInstallApks) QUEUED else FAILED,
|
||||
installerPackageName = metadata.installer
|
||||
metadata = metadata,
|
||||
)
|
||||
}.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) {
|
||||
emit(installResult)
|
||||
} else {
|
||||
installResult.isFinished = true
|
||||
emit(installResult)
|
||||
return@flow
|
||||
if (!isAllowedToInstallApks) { // not allowed to install, so return list with all failed
|
||||
mInstallResult.value = InstallResult(packages, true)
|
||||
return
|
||||
}
|
||||
mInstallResult.value = InstallResult(packages)
|
||||
|
||||
// re-install individual packages and emit updates
|
||||
for ((packageName, metadata) in packages) {
|
||||
// re-install individual packages and emit updates (start from last and work your way up)
|
||||
for ((packageName, apkInstallResult) in packages.asIterable().reversed()) {
|
||||
try {
|
||||
if (metadata.hasApk()) {
|
||||
restore(this, backup, packageName, metadata, installResult)
|
||||
if (apkInstallResult.metadata.hasApk()) {
|
||||
restore(backup, packageName, apkInstallResult.metadata)
|
||||
} else {
|
||||
emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security error re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
}
|
||||
}
|
||||
installResult.isFinished = true
|
||||
emit(installResult)
|
||||
mInstallResult.update { it.copy(isFinished = true) }
|
||||
}
|
||||
|
||||
@Suppress("ThrowsCount")
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private suspend fun restore(
|
||||
collector: FlowCollector<InstallResult>,
|
||||
backup: RestorableBackup,
|
||||
packageName: String,
|
||||
metadata: PackageMetadata,
|
||||
installResult: MutableInstallResult,
|
||||
) {
|
||||
// cache the APK and get its hash
|
||||
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
||||
|
@ -153,34 +155,32 @@ internal class ApkRestore(
|
|||
publicSourceDir = cachedApk.absolutePath
|
||||
}
|
||||
val icon = appInfo?.loadIcon(pm)
|
||||
val name = appInfo?.let { pm.getApplicationLabel(it) }
|
||||
val name = appInfo?.let { pm.getApplicationLabel(it).toString() }
|
||||
|
||||
installResult.update(packageName) { result ->
|
||||
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
|
||||
if (metadata.system) {
|
||||
shouldInstallSystemApp(packageName, metadata, installResult)?.let {
|
||||
collector.emit(it)
|
||||
return
|
||||
mInstallResult.update {
|
||||
it.update(packageName) { result ->
|
||||
result.copy(state = IN_PROGRESS, name = name, icon = icon)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure system apps are actually already installed and newer system apps as well
|
||||
if (metadata.system) shouldInstallSystemApp(packageName, metadata)?.let {
|
||||
mInstallResult.value = it
|
||||
return
|
||||
}
|
||||
|
||||
// process further APK splits, if available
|
||||
val cachedApks =
|
||||
cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
|
||||
val cachedApks = cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
|
||||
if (cachedApks == null) {
|
||||
Log.w(TAG, "Not installing $packageName because of incompatible splits.")
|
||||
collector.emit(installResult.fail(packageName))
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
return
|
||||
}
|
||||
|
||||
// install APK and emit updates from it
|
||||
val result =
|
||||
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult)
|
||||
collector.emit(result)
|
||||
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult.value)
|
||||
mInstallResult.value = result
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -239,7 +239,6 @@ internal class ApkRestore(
|
|||
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 = if (version == 0.toByte()) {
|
||||
@Suppress("Deprecation")
|
||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
||||
} else {
|
||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
||||
|
@ -256,26 +255,38 @@ internal class ApkRestore(
|
|||
private fun shouldInstallSystemApp(
|
||||
packageName: String,
|
||||
metadata: PackageMetadata,
|
||||
installResult: MutableInstallResult,
|
||||
): InstallResult? {
|
||||
val installedPackageInfo = try {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Not installing system app $packageName because not installed here.")
|
||||
// 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
|
||||
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
|
||||
return if (isOlder) {
|
||||
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()) {
|
||||
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 {
|
||||
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
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
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.restore.install.ApkInstallState.FAILED
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||
|
@ -22,35 +23,33 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
|||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal interface InstallItemListener {
|
||||
fun onFailedItemClicked(item: ApkInstallResult)
|
||||
}
|
||||
|
||||
internal class InstallProgressAdapter(
|
||||
private val scope: CoroutineScope,
|
||||
private val iconLoader: suspend (ApkInstallResult, (Drawable) -> Unit) -> Unit,
|
||||
private val listener: InstallItemListener,
|
||||
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
|
||||
|
||||
private var finished = false
|
||||
private val finishedComparator = FailedFirstComparator()
|
||||
private val items = SortedList(
|
||||
ApkInstallResult::class.java,
|
||||
object : SortedListAdapterCallback<ApkInstallResult>(this) {
|
||||
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
|
||||
item1.packageName == item2.packageName
|
||||
|
||||
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
|
||||
// update failed items when finished
|
||||
return if (finished) new.state != FAILED && old == new
|
||||
else old == new
|
||||
}
|
||||
private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
|
||||
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean =
|
||||
item1.packageName == item2.packageName
|
||||
|
||||
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int {
|
||||
return if (finished) finishedComparator.compare(item1, item2)
|
||||
else item1.compareTo(item2)
|
||||
}
|
||||
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
|
||||
// update failed items when finished
|
||||
return if (finished) new.state != FAILED && old == new
|
||||
else old == new
|
||||
}
|
||||
)
|
||||
}
|
||||
private val differ = AsyncListDiffer(this, diffCallback)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
|
@ -58,27 +57,33 @@ internal class InstallProgressAdapter(
|
|||
return AppInstallViewHolder(v)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size()
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
holder.bind(differ.currentList[position])
|
||||
}
|
||||
|
||||
fun update(items: Collection<ApkInstallResult>) {
|
||||
this.items.replaceAll(items)
|
||||
fun update(items: List<ApkInstallResult>, block: Runnable) {
|
||||
differ.submitList(items, block)
|
||||
}
|
||||
|
||||
fun setFinished() {
|
||||
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) {
|
||||
v.setOnClickListener(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())
|
||||
appInfo.visibility = GONE
|
||||
when (item.state) {
|
||||
|
|
|
@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore.install
|
|||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -20,6 +21,7 @@ import android.widget.Toast.LENGTH_LONG
|
|||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
@ -31,7 +33,8 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
|||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
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 titleView: TextView
|
||||
|
@ -72,35 +75,27 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
|||
viewModel.installResult.observe(viewLifecycleOwner) { result ->
|
||||
onInstallResult(result)
|
||||
}
|
||||
|
||||
viewModel.nextButtonEnabled.observe(viewLifecycleOwner) { enabled ->
|
||||
button.isEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInstallResult(installResult: InstallResult) {
|
||||
// skip this screen, if there are no apps to install
|
||||
if (installResult.isFinished && installResult.isEmpty) {
|
||||
if (installResult.hasNoAppsToInstall) {
|
||||
viewModel.onNextClickedAfterInstallingApps()
|
||||
} else {
|
||||
// update progress bar
|
||||
progressBar.progress = installResult.progress
|
||||
progressBar.max = installResult.total
|
||||
|
||||
// just update adapter, or perform final action, if finished
|
||||
if (installResult.isFinished) onFinished(installResult)
|
||||
else updateAdapter(installResult.list)
|
||||
}
|
||||
|
||||
// if finished, treat all still queued apps as failed and resort/redisplay adapter items
|
||||
if (installResult.isFinished) {
|
||||
installResult.queuedToFailed()
|
||||
adapter.setFinished()
|
||||
}
|
||||
|
||||
// update progress bar
|
||||
progressBar.progress = installResult.progress
|
||||
progressBar.max = installResult.total
|
||||
|
||||
// just update adapter, or perform final action, if finished
|
||||
if (installResult.isFinished) onFinished(installResult)
|
||||
else updateAdapter(installResult.getNotQueued())
|
||||
}
|
||||
|
||||
private fun onFinished(installResult: InstallResult) {
|
||||
if (installResult.hasFailed) {
|
||||
adapter.setFinished()
|
||||
button.isEnabled = true
|
||||
if (!hasShownFailDialog && installResult.hasFailed) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setTitle(R.string.restore_installing_error_title)
|
||||
|
@ -109,18 +104,20 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
|
|||
dialog.dismiss()
|
||||
}
|
||||
.setOnDismissListener {
|
||||
updateAdapter(installResult.getNotQueued())
|
||||
hasShownFailDialog = true
|
||||
updateAdapter(installResult.list)
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
updateAdapter(installResult.getNotQueued())
|
||||
updateAdapter(installResult.list)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAdapter(items: Collection<ApkInstallResult>) {
|
||||
private fun updateAdapter(items: List<ApkInstallResult>) {
|
||||
val position = layoutManager.findFirstVisibleItemPosition()
|
||||
adapter.update(items)
|
||||
if (position == 0) layoutManager.scrollToPosition(0)
|
||||
adapter.update(items) {
|
||||
if (position == 0) layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailedItemClicked(item: ApkInstallResult) {
|
||||
|
@ -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 ->
|
||||
val result = viewModel.installResult.value ?: return@registerForActivityResult
|
||||
if (result.isFinished) {
|
||||
val changed = result.reCheckFailedPackage(
|
||||
requireContext().packageManager,
|
||||
packageName.toString()
|
||||
)
|
||||
if (changed) adapter.update(result.getNotQueued())
|
||||
viewModel.reCheckFailedPackage(packageName.toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,136 +5,90 @@
|
|||
|
||||
package com.stevesoltys.seedvault.restore.install
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
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.IN_PROGRESS
|
||||
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
|
||||
* or because an unexpected error happened along the way.
|
||||
* 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.
|
||||
*/
|
||||
val hasFailed: Boolean
|
||||
|
||||
/**
|
||||
* 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" }
|
||||
}
|
||||
val hasFailed: Boolean = installResults.any { it.value.state == FAILED }
|
||||
|
||||
fun update(
|
||||
packageName: String,
|
||||
updateFun: (ApkInstallResult) -> ApkInstallResult,
|
||||
): MutableInstallResult {
|
||||
val result = get(packageName)
|
||||
): InstallResult {
|
||||
val results = installResults.toMutableMap()
|
||||
val result = results[packageName]
|
||||
check(result != null) { "ApkRestoreResult for $packageName does not exist." }
|
||||
installResults[packageName] = updateFun(result)
|
||||
return this
|
||||
results[packageName] = updateFun(result)
|
||||
return copy(installResults = results)
|
||||
}
|
||||
|
||||
fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult {
|
||||
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(
|
||||
val packageName: CharSequence,
|
||||
val progress: Int,
|
||||
val packageName: String,
|
||||
val state: ApkInstallState,
|
||||
val name: CharSequence? = null,
|
||||
val metadata: PackageMetadata,
|
||||
val name: String? = metadata.name?.toString(),
|
||||
val icon: Drawable? = null,
|
||||
val installerPackageName: CharSequence? = null,
|
||||
) : Comparable<ApkInstallResult> {
|
||||
override fun compareTo(other: ApkInstallResult): Int {
|
||||
return other.progress.compareTo(progress)
|
||||
}
|
||||
) {
|
||||
val installerPackageName: CharSequence? get() = metadata.installer
|
||||
}
|
||||
|
||||
internal class FailedFirstComparator : Comparator<ApkInstallResult> {
|
||||
override fun compare(a1: ApkInstallResult, a2: ApkInstallResult): Int {
|
||||
return (if (a1.state == FAILED && a2.state != FAILED) -1
|
||||
return if (a1.state == FAILED && a2.state != FAILED) -1
|
||||
else if (a2.state == FAILED && a1.state != FAILED) 1
|
||||
else a1.compareTo(a2))
|
||||
else {
|
||||
val str = a1.name ?: a1.packageName
|
||||
val otherStr = a2.name ?: a2.packageName
|
||||
str.compareTo(otherStr, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import android.content.pm.PackageManager
|
|||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
|
@ -25,16 +25,13 @@ import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED
|
|||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS
|
||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import java.util.Locale
|
||||
|
||||
private const val TAG = "AppListRetriever"
|
||||
|
||||
private const val PACKAGE_NAME_SMS = "com.android.providers.telephony"
|
||||
private const val PACKAGE_NAME_SETTINGS = "com.android.providers.settings"
|
||||
private const val PACKAGE_NAME_CALL_LOG = "com.android.calllogbackup"
|
||||
private const val PACKAGE_NAME_CONTACTS = "org.calyxos.backup.contacts"
|
||||
|
||||
sealed class AppListItem
|
||||
|
||||
data class AppStatus(
|
||||
|
@ -64,7 +61,7 @@ internal class AppListRetriever(
|
|||
|
||||
val appListSections = linkedMapOf(
|
||||
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
|
||||
AppSectionTitle(R.string.backup_section_user) to getUserApps(),
|
||||
AppSectionTitle(R.string.backup_section_user) to getApps(),
|
||||
AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
|
||||
).filter { it.value.isNotEmpty() }
|
||||
|
||||
|
@ -74,13 +71,7 @@ internal class AppListRetriever(
|
|||
}
|
||||
|
||||
private fun getSpecialApps(): List<AppListItem> {
|
||||
val specialPackages = listOf(
|
||||
Pair(PACKAGE_NAME_SMS, R.string.backup_sms),
|
||||
Pair(PACKAGE_NAME_SETTINGS, R.string.backup_settings),
|
||||
Pair(PACKAGE_NAME_CALL_LOG, R.string.backup_call_log),
|
||||
Pair(PACKAGE_NAME_CONTACTS, R.string.backup_contacts)
|
||||
)
|
||||
return specialPackages.map { (packageName, stringId) ->
|
||||
return systemData.map { (packageName, data) ->
|
||||
val metadata = metadataManager.getPackageMetadata(packageName)
|
||||
val status = if (packageName == PACKAGE_NAME_CONTACTS && metadata?.state == null) {
|
||||
// handle local contacts backup specially as it might not be installed
|
||||
|
@ -90,38 +81,52 @@ internal class AppListRetriever(
|
|||
AppStatus(
|
||||
packageName = packageName,
|
||||
enabled = settingsManager.isBackupEnabled(packageName),
|
||||
icon = getIcon(packageName),
|
||||
name = context.getString(stringId),
|
||||
icon = data.iconRes?.let { getDrawable(context, it) }
|
||||
?: getIconFromPackageManager(packageName),
|
||||
name = context.getString(data.nameRes),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
status = status,
|
||||
isSpecial = true
|
||||
isSpecial = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUserApps(): List<AppStatus> {
|
||||
val locale = Locale.getDefault()
|
||||
return packageService.userApps.map {
|
||||
private fun getApps(): List<AppStatus> {
|
||||
val userPackages = mutableSetOf<String>()
|
||||
val userApps = packageService.userApps.map {
|
||||
userPackages.add(it.packageName)
|
||||
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
||||
val time = metadata?.time ?: 0
|
||||
val status = metadata?.state.toAppBackupState()
|
||||
if (status == NOT_YET_BACKED_UP) {
|
||||
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
||||
}
|
||||
if (metadata?.hasApk() == false) {
|
||||
Log.w(TAG, "No APK stored for: ${it.packageName}")
|
||||
}
|
||||
AppStatus(
|
||||
packageName = it.packageName,
|
||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||
icon = getIcon(it.packageName),
|
||||
icon = getIconFromPackageManager(it.packageName),
|
||||
name = getAppName(context, it.packageName).toString(),
|
||||
time = time,
|
||||
size = metadata?.size,
|
||||
status = status
|
||||
status = status,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase(locale) }
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
return (userApps + packageService.launchableSystemApps.mapNotNull {
|
||||
val packageName = it.activityInfo.packageName
|
||||
if (packageName in userPackages) return@mapNotNull null
|
||||
val metadata = metadataManager.getPackageMetadata(packageName)
|
||||
AppStatus(
|
||||
packageName = packageName,
|
||||
enabled = settingsManager.isBackupEnabled(packageName),
|
||||
icon = getIconFromPackageManager(packageName),
|
||||
name = it.loadLabel(context.packageManager).toString(),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
status = metadata?.state.toAppBackupState(),
|
||||
)
|
||||
}).sortedBy { it.name.lowercase(locale) }
|
||||
}
|
||||
|
||||
private fun getNotAllowedApps(): List<AppStatus> {
|
||||
|
@ -130,28 +135,19 @@ internal class AppListRetriever(
|
|||
AppStatus(
|
||||
packageName = it.packageName,
|
||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||
icon = getIcon(it.packageName),
|
||||
icon = getIconFromPackageManager(it.packageName),
|
||||
name = getAppName(context, it.packageName).toString(),
|
||||
time = 0,
|
||||
size = null,
|
||||
status = FAILED_NOT_ALLOWED
|
||||
status = FAILED_NOT_ALLOWED,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase(locale) }
|
||||
}
|
||||
|
||||
private fun getIcon(packageName: String): Drawable = when (packageName) {
|
||||
MAGIC_PACKAGE_MANAGER -> context.getDrawable(R.drawable.ic_launcher_default)!!
|
||||
PACKAGE_NAME_SMS -> context.getDrawable(R.drawable.ic_message)!!
|
||||
PACKAGE_NAME_SETTINGS -> context.getDrawable(R.drawable.ic_settings)!!
|
||||
PACKAGE_NAME_CALL_LOG -> context.getDrawable(R.drawable.ic_call)!!
|
||||
PACKAGE_NAME_CONTACTS -> context.getDrawable(R.drawable.ic_contacts)!!
|
||||
else -> getIconFromPackageManager(packageName)
|
||||
}
|
||||
|
||||
private fun getIconFromPackageManager(packageName: String): Drawable = try {
|
||||
pm.getApplicationIcon(packageName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
context.getDrawable(R.drawable.ic_launcher_default)!!
|
||||
getDrawable(context, R.drawable.ic_launcher_default)!!
|
||||
}
|
||||
|
||||
private fun PackageState?.toAppBackupState(): AppBackupState = when (this) {
|
||||
|
|
|
@ -96,15 +96,15 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
|||
v.background = clickableBackground
|
||||
if (editMode) {
|
||||
v.setOnClickListener {
|
||||
switchView.toggle()
|
||||
item.enabled = switchView.isChecked
|
||||
checkBox.toggle()
|
||||
item.enabled = checkBox.isChecked
|
||||
toggleListener.onAppStatusToggled(item)
|
||||
}
|
||||
appInfo.visibility = GONE
|
||||
appStatus.visibility = INVISIBLE
|
||||
progressBar.visibility = INVISIBLE
|
||||
switchView.visibility = VISIBLE
|
||||
switchView.isChecked = item.enabled
|
||||
checkBox.visibility = VISIBLE
|
||||
checkBox.isChecked = item.enabled
|
||||
} else {
|
||||
v.setOnClickListener(null)
|
||||
v.setOnLongClickListener {
|
||||
|
@ -130,7 +130,7 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
|||
}
|
||||
appInfo.visibility = VISIBLE
|
||||
}
|
||||
switchView.visibility = INVISIBLE
|
||||
checkBox.visibility = INVISIBLE
|
||||
}
|
||||
// show disabled items differently
|
||||
showEnabled(item.enabled)
|
||||
|
|
|
@ -61,10 +61,10 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
|
|||
}
|
||||
|
||||
progressBar.visibility = VISIBLE
|
||||
viewModel.appStatusList.observe(viewLifecycleOwner, { result ->
|
||||
viewModel.appStatusList.observe(viewLifecycleOwner) { result ->
|
||||
adapter.update(result.appStatusList, result.diff)
|
||||
progressBar.visibility = INVISIBLE
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
|
@ -73,10 +73,10 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
|
|||
appEditMenuItem = menu.findItem(R.id.edit_app_blacklist)
|
||||
|
||||
// observe edit mode changes here where we are sure to have the MenuItem
|
||||
viewModel.appEditMode.observe(viewLifecycleOwner, { enabled ->
|
||||
viewModel.appEditMode.observe(viewLifecycleOwner) { enabled ->
|
||||
appEditMenuItem.isChecked = enabled
|
||||
adapter.setEditMode(enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
|
|
|
@ -69,7 +69,7 @@ internal class SettingsViewModel(
|
|||
app: Application,
|
||||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
pluginManager: StoragePluginManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val appListRetriever: AppListRetriever,
|
||||
private val storageBackup: StorageBackup,
|
||||
|
@ -97,6 +97,9 @@ internal class SettingsViewModel(
|
|||
|
||||
private val mAppStatusList = lastBackupTime.switchMap {
|
||||
// updates app list when lastBackupTime changes
|
||||
// FIXME: Since we are currently updating that time a lot,
|
||||
// re-fetching everything on each change hammers the system hard
|
||||
// which can cause android.os.DeadObjectException
|
||||
getAppStatusResult()
|
||||
}
|
||||
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
|
||||
|
|
|
@ -7,6 +7,9 @@ package com.stevesoltys.seedvault.transport.backup
|
|||
|
||||
import android.app.backup.IBackupManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_MAIN
|
||||
import android.content.Intent.CATEGORY_LAUNCHER
|
||||
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
|
||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
|
@ -16,6 +19,8 @@ import android.content.pm.PackageInfo
|
|||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.GET_INSTRUMENTATION
|
||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||
import android.content.pm.PackageManager.MATCH_SYSTEM_ONLY
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.os.RemoteException
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
|
@ -147,6 +152,16 @@ internal class PackageService(
|
|||
}
|
||||
}
|
||||
|
||||
val launchableSystemApps: List<ResolveInfo>
|
||||
@WorkerThread
|
||||
get() {
|
||||
// filter intent for apps with a launcher activity
|
||||
val i = Intent(ACTION_MAIN).apply {
|
||||
addCategory(CATEGORY_LAUNCHER)
|
||||
}
|
||||
return packageManager.queryIntentActivities(i, MATCH_SYSTEM_ONLY)
|
||||
}
|
||||
|
||||
fun getVersionName(packageName: String): String? = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
|
|
|
@ -15,7 +15,7 @@ import android.widget.ImageView
|
|||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
|
||||
|
@ -32,7 +32,7 @@ internal abstract class AppViewHolder(protected val v: View) : RecyclerView.View
|
|||
protected val appInfo: TextView = v.requireViewById(R.id.appInfo)
|
||||
protected val appStatus: ImageView = v.requireViewById(R.id.appStatus)
|
||||
protected val progressBar: ProgressBar = v.requireViewById(R.id.progressBar)
|
||||
protected val switchView: SwitchMaterial = v.requireViewById(R.id.switchView)
|
||||
protected val checkBox: MaterialCheckBox = v.requireViewById(R.id.checkboxView)
|
||||
|
||||
init {
|
||||
// don't use clickable background by default
|
||||
|
|
|
@ -15,7 +15,7 @@ abstract class RequireProvisioningViewModel(
|
|||
protected val app: Application,
|
||||
protected val settingsManager: SettingsManager,
|
||||
protected val keyManager: KeyManager,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
protected val pluginManager: StoragePluginManager,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
abstract val isRestoreOperation: Boolean
|
||||
|
|
28
app/src/main/java/com/stevesoltys/seedvault/ui/SystemData.kt
Normal file
28
app/src/main/java/com/stevesoltys/seedvault/ui/SystemData.kt
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
||||
internal const val PACKAGE_NAME_SMS = "com.android.providers.telephony"
|
||||
internal const val PACKAGE_NAME_SETTINGS = "com.android.providers.settings"
|
||||
internal const val PACKAGE_NAME_CALL_LOG = "com.android.calllogbackup"
|
||||
internal const val PACKAGE_NAME_CONTACTS = "org.calyxos.backup.contacts"
|
||||
internal const val PACKAGE_NAME_SYSTEM = "@org.calyxos.system@"
|
||||
|
||||
val systemData = mapOf(
|
||||
PACKAGE_NAME_SMS to SystemData(R.string.backup_sms, R.drawable.ic_message),
|
||||
PACKAGE_NAME_SETTINGS to SystemData(R.string.backup_settings, R.drawable.ic_settings),
|
||||
PACKAGE_NAME_CALL_LOG to SystemData(R.string.backup_call_log, R.drawable.ic_call),
|
||||
PACKAGE_NAME_CONTACTS to SystemData(R.string.backup_contacts, R.drawable.ic_contacts),
|
||||
)
|
||||
|
||||
data class SystemData(
|
||||
@StringRes val nameRes: Int,
|
||||
@DrawableRes val iconRes: Int,
|
||||
)
|
|
@ -167,14 +167,18 @@ internal class NotificationBackupObserver(
|
|||
|
||||
}
|
||||
|
||||
fun getAppName(context: Context, packageId: String): CharSequence {
|
||||
if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) {
|
||||
fun getAppName(
|
||||
context: Context,
|
||||
packageName: String,
|
||||
fallback: String = packageName,
|
||||
): CharSequence {
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER || packageName.startsWith("@")) {
|
||||
return context.getString(R.string.restore_magic_package)
|
||||
}
|
||||
return try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
|
||||
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
|
||||
context.packageManager.getApplicationLabel(appInfo)
|
||||
} catch (e: NameNotFoundException) {
|
||||
packageId
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ internal class ApkBackupManager(
|
|||
private val settingsManager: SettingsManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val packageService: PackageService,
|
||||
private val iconManager: IconManager,
|
||||
private val apkBackup: ApkBackup,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val nm: BackupNotificationManager,
|
||||
|
@ -44,6 +45,8 @@ internal class ApkBackupManager(
|
|||
// Since an APK backup does not change the [packageState], we first record it for all
|
||||
// packages that don't get backed up.
|
||||
recordNotBackedUpPackages()
|
||||
// Upload current icons, so we can show them to user before restore
|
||||
uploadIcons()
|
||||
// Now, if APK backups are enabled by the user, we back those up.
|
||||
if (settingsManager.backupApks()) {
|
||||
backUpApks()
|
||||
|
@ -77,6 +80,7 @@ internal class ApkBackupManager(
|
|||
nm.onAppsNotBackedUp()
|
||||
packageService.notBackedUpPackages.forEach { packageInfo ->
|
||||
val packageName = packageInfo.packageName
|
||||
if (!settingsManager.isBackupEnabled(packageName)) return@forEach
|
||||
try {
|
||||
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
|
||||
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
||||
|
@ -94,6 +98,17 @@ internal class ApkBackupManager(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadIcons() {
|
||||
try {
|
||||
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
||||
pluginManager.appPlugin.getOutputStream(token, FILE_BACKUP_ICONS).use {
|
||||
iconManager.uploadIcons(token, it)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error uploading icons: ", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs up an APK for the given [PackageInfo].
|
||||
*
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap.CompressFormat.WEBP_LOSSY
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.crypto.TYPE_ICONS
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.zip.Deflater.BEST_SPEED
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
internal const val FILE_BACKUP_ICONS = ".backup.icons"
|
||||
private const val ICON_SIZE = 128
|
||||
private const val ICON_QUALITY = 75
|
||||
private const val CACHE_FOLDER = "restore-icons"
|
||||
private val TAG = IconManager::class.simpleName
|
||||
|
||||
internal class IconManager(
|
||||
private val context: Context,
|
||||
private val packageService: PackageService,
|
||||
private val crypto: Crypto,
|
||||
) {
|
||||
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
fun uploadIcons(token: Long, outputStream: OutputStream) {
|
||||
Log.d(TAG, "Start uploading icons")
|
||||
val packageManager = context.packageManager
|
||||
crypto.newEncryptingStream(outputStream, getAD(VERSION, token)).use { cryptoStream ->
|
||||
ZipOutputStream(cryptoStream).use { zip ->
|
||||
zip.setLevel(BEST_SPEED)
|
||||
val entries = mutableSetOf<String>()
|
||||
packageService.allUserPackages.forEach {
|
||||
val applicationInfo = it.applicationInfo ?: return@forEach
|
||||
val drawable = packageManager.getApplicationIcon(applicationInfo)
|
||||
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
|
||||
val entry = ZipEntry(it.packageName)
|
||||
zip.putNextEntry(entry)
|
||||
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip)
|
||||
entries.add(it.packageName)
|
||||
zip.closeEntry()
|
||||
}
|
||||
packageService.launchableSystemApps.forEach {
|
||||
val drawable = it.loadIcon(packageManager)
|
||||
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
|
||||
// check for duplicates (e.g. updated launchable system app)
|
||||
if (it.activityInfo.packageName in entries) return@forEach
|
||||
val entry = ZipEntry(it.activityInfo.packageName)
|
||||
zip.putNextEntry(entry)
|
||||
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Finished uploading icons")
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads icons file from given [inputStream].
|
||||
* @return a set of package names for which icons were found
|
||||
*/
|
||||
@Throws(IOException::class, SecurityException::class, GeneralSecurityException::class)
|
||||
fun downloadIcons(version: Byte, token: Long, inputStream: InputStream): Set<String> {
|
||||
Log.d(TAG, "Start downloading icons")
|
||||
val folder = File(context.cacheDir, CACHE_FOLDER)
|
||||
if (!folder.isDirectory && !folder.mkdirs())
|
||||
throw IOException("Can't create cache folder for icons")
|
||||
val set = mutableSetOf<String>()
|
||||
crypto.newDecryptingStream(inputStream, getAD(version, token)).use { cryptoStream ->
|
||||
ZipInputStream(cryptoStream).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
File(folder, entry.name).outputStream().use { outputStream ->
|
||||
zip.copyTo(outputStream)
|
||||
}
|
||||
set.add(entry.name)
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Finished downloading icons")
|
||||
return set
|
||||
}
|
||||
|
||||
private val defaultIcon by lazy {
|
||||
getDrawable(context, R.drawable.ic_launcher_default)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load the icons for the given [packageName]
|
||||
* that was downloaded before with [downloadIcons].
|
||||
* Calls [callback] on the UiThread with the loaded [Drawable] or the default icon.
|
||||
*/
|
||||
suspend fun loadIcon(packageName: String, callback: (Drawable) -> Unit) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val folder = File(context.cacheDir, CACHE_FOLDER)
|
||||
val file = File(folder, packageName)
|
||||
file.inputStream().use { inputStream ->
|
||||
val drawable =
|
||||
BitmapFactory.decodeStream(inputStream).toDrawable(context.resources)
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(drawable)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading icon for $packageName", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
callback(defaultIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun removeIcons() {
|
||||
val folder = File(context.cacheDir, CACHE_FOLDER)
|
||||
val result = folder.deleteRecursively()
|
||||
Log.e(TAG, "Could delete icons: $result")
|
||||
}
|
||||
|
||||
private fun getAD(version: Byte, token: Long) = ByteBuffer.allocate(2 + 8)
|
||||
.put(version)
|
||||
.put(TYPE_ICONS)
|
||||
.put(token.toByteArray())
|
||||
.array()
|
||||
|
||||
}
|
|
@ -16,6 +16,13 @@ val workerModule = module {
|
|||
packageService = get(),
|
||||
)
|
||||
}
|
||||
factory {
|
||||
IconManager(
|
||||
context = androidContext(),
|
||||
packageService = get(),
|
||||
crypto = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
ApkBackup(
|
||||
pm = androidContext().packageManager,
|
||||
|
@ -31,6 +38,7 @@ val workerModule = module {
|
|||
metadataManager = get(),
|
||||
packageService = get(),
|
||||
apkBackup = get(),
|
||||
iconManager = get(),
|
||||
pluginManager = get(),
|
||||
nm = get()
|
||||
)
|
||||
|
|
12
app/src/main/res/drawable/ic_app_settings.xml
Normal file
12
app/src/main/res/drawable/ic_app_settings.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M21.81,12.74l-0.82,-0.63v-0.22l0.8,-0.63c0.16,-0.12 0.2,-0.34 0.1,-0.51l-0.85,-1.48c-0.07,-0.13 -0.21,-0.2 -0.35,-0.2 -0.05,0 -0.1,0.01 -0.15,0.03l-0.95,0.38c-0.08,-0.05 -0.11,-0.07 -0.19,-0.11l-0.15,-1.01c-0.03,-0.21 -0.2,-0.36 -0.4,-0.36h-1.71c-0.2,0 -0.37,0.15 -0.4,0.34l-0.14,1.01c-0.03,0.02 -0.07,0.03 -0.1,0.05l-0.09,0.06 -0.95,-0.38c-0.05,-0.02 -0.1,-0.03 -0.15,-0.03 -0.14,0 -0.27,0.07 -0.35,0.2l-0.85,1.48c-0.1,0.17 -0.06,0.39 0.1,0.51l0.8,0.63v0.23l-0.8,0.63c-0.16,0.12 -0.2,0.34 -0.1,0.51l0.85,1.48c0.07,0.13 0.21,0.2 0.35,0.2 0.05,0 0.1,-0.01 0.15,-0.03l0.95,-0.37c0.08,0.05 0.12,0.07 0.2,0.11l0.15,1.01c0.03,0.2 0.2,0.34 0.4,0.34h1.71c0.2,0 0.37,-0.15 0.4,-0.34l0.15,-1.01c0.03,-0.02 0.07,-0.03 0.1,-0.05l0.09,-0.06 0.95,0.38c0.05,0.02 0.1,0.03 0.15,0.03 0.14,0 0.27,-0.07 0.35,-0.2l0.85,-1.48c0.1,-0.17 0.06,-0.39 -0.1,-0.51zM18,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,17h2v4c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3c0,-1.1 0.9,-2 2,-2h10c1.1,0 2,0.9 2,2v4h-2V6H7v12h10v-1z" />
|
||||
|
||||
</vector>
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
|
|
129
app/src/main/res/layout/fragment_restore_app_selection.xml
Normal file
129
app/src/main/res/layout/fragment_restore_app_selection.xml
Normal file
|
@ -0,0 +1,129 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.CollapsingToolbarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
style="@style/SudHeaderIcon"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_cloud_download"
|
||||
app:tint="?android:colorAccent"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
style="@style/SudHeaderTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/restore_select_packages"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/backupNameView"
|
||||
style="@style/SudDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
tools:text="Pixel 2 XL - Owner of the device" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/toggleAllTextView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="0dp"
|
||||
android:background="?selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="40dp"
|
||||
android:text="@string/restore_select_packages_all"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/toggleAllView"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/toggleAllView"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:gravity="center_vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
|
||||
tools:checked="true" />
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="40dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:dividerColor="?attr/colorControlNormal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/toggleAllView" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</com.google.android.material.appbar.CollapsingToolbarLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/appList"
|
||||
style="@style/SudContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginEnd="0dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
app:layout_constraintBottom_toTopOf="@+id/button"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
|
||||
tools:listitem="@layout/list_item_app_status" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button"
|
||||
style="@style/SudPrimaryButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/restore_backup_button"
|
||||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/appList" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -8,9 +8,9 @@
|
|||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="40dp"
|
||||
android:layout_marginEnd="40dp"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:screenReaderFocusable="true">
|
||||
|
@ -35,7 +35,7 @@
|
|||
android:layout_marginEnd="16dp"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
app:layout_constraintBottom_toTopOf="@+id/appInfo"
|
||||
app:layout_constraintEnd_toStartOf="@+id/switchView"
|
||||
app:layout_constraintEnd_toStartOf="@+id/checkboxView"
|
||||
app:layout_constraintStart_toEndOf="@+id/appIcon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Seedvault Backup" />
|
||||
|
@ -72,8 +72,8 @@
|
|||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchView"
|
||||
<com.google.android.material.checkbox.MaterialCheckBox
|
||||
android:id="@+id/checkboxView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="false"
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<color name="statusBarColor">@color/primary</color>
|
||||
<!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#69 -->
|
||||
<!-- private resource, access it from colorError attribute instead -->
|
||||
<color name="red">?android:attr/colorError</color>
|
||||
<color name="red">@*android:color/error_color_device_default_dark</color>
|
||||
<!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#35 -->
|
||||
<color name="ic_launcher_background">@color/accent</color>
|
||||
|
||||
|
|
|
@ -177,12 +177,13 @@
|
|||
|
||||
<!-- App Backup and Restore State -->
|
||||
|
||||
<string name="backup_section_system">System apps</string>
|
||||
<string name="backup_section_system">System data</string>
|
||||
<string name="backup_sms">SMS text messages</string>
|
||||
<string name="backup_settings">Device settings</string>
|
||||
<string name="backup_call_log">Call history</string>
|
||||
<string name="backup_contacts">Local contacts</string>
|
||||
<string name="backup_section_user">Installed apps</string>
|
||||
<string name="backup_system_apps">System apps</string>
|
||||
<string name="backup_section_user">Apps</string>
|
||||
<!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet -->
|
||||
<string name="backup_app_not_yet_backed_up">Waiting to back up…</string>
|
||||
<string name="restore_app_not_yet_backed_up">Was not yet backed up</string>
|
||||
|
@ -209,6 +210,8 @@
|
|||
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
|
||||
<string name="restore_set_error">An error occurred while loading the backups.</string>
|
||||
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
|
||||
<string name="restore_select_packages">Select apps to restore</string>
|
||||
<string name="restore_select_packages_all">All of the following apps</string>
|
||||
<string name="restore_installing_packages">Re-installing apps</string>
|
||||
<string name="restore_app_status_installing">Re-installing</string>
|
||||
<string name="restore_app_status_installed">Re-installed</string>
|
||||
|
|
|
@ -16,8 +16,10 @@ import com.stevesoltys.seedvault.metadata.metadataModule
|
|||
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
|
||||
import com.stevesoltys.seedvault.restore.install.installModule
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||
import io.mockk.mockk
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.KoinApplication
|
||||
import org.koin.core.context.startKoin
|
||||
|
@ -33,9 +35,11 @@ class TestApp : App() {
|
|||
single<KeyManager> { KeyManagerTestImpl() }
|
||||
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
||||
}
|
||||
private val packageService: PackageService = mockk()
|
||||
private val appModule = module {
|
||||
single { Clock() }
|
||||
single { SettingsManager(this@TestApp) }
|
||||
single<PackageService> { packageService }
|
||||
}
|
||||
|
||||
override fun startKoin(): KoinApplication {
|
||||
|
|
|
@ -7,11 +7,13 @@ package com.stevesoltys.seedvault.metadata
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.os.UserManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
|
@ -27,6 +29,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
|||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
|
@ -54,7 +57,6 @@ import kotlin.random.Random
|
|||
@Suppress("DEPRECATION")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(
|
||||
sdk = [33], // robolectric does not support 34, yet
|
||||
application = TestApp::class
|
||||
)
|
||||
class MetadataManagerTest {
|
||||
|
@ -64,6 +66,7 @@ class MetadataManagerTest {
|
|||
private val crypto: Crypto = mockk()
|
||||
private val metadataWriter: MetadataWriter = mockk()
|
||||
private val metadataReader: MetadataReader = mockk()
|
||||
private val packageService: PackageService = mockk()
|
||||
private val settingsManager: SettingsManager = mockk()
|
||||
|
||||
private val manager = MetadataManager(
|
||||
|
@ -72,9 +75,12 @@ class MetadataManagerTest {
|
|||
crypto = crypto,
|
||||
metadataWriter = metadataWriter,
|
||||
metadataReader = metadataReader,
|
||||
settingsManager = settingsManager
|
||||
packageService = packageService,
|
||||
settingsManager = settingsManager,
|
||||
)
|
||||
|
||||
private val packageManager: PackageManager = mockk()
|
||||
|
||||
private val time = 42L
|
||||
private val token = Random.nextLong()
|
||||
private val packageName = getRandomString()
|
||||
|
@ -162,6 +168,7 @@ class MetadataManagerTest {
|
|||
signatures = listOf("sig")
|
||||
)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
|
@ -185,12 +192,23 @@ class MetadataManagerTest {
|
|||
signatures = listOf("sig")
|
||||
)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
every { packageService.launchableSystemApps } returns listOf(
|
||||
ResolveInfo().apply {
|
||||
activityInfo = ActivityInfo().apply {
|
||||
packageName = this@MetadataManagerTest.packageName
|
||||
}
|
||||
}
|
||||
)
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
|
||||
assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName))
|
||||
assertEquals(
|
||||
packageMetadata.copy(system = true, isLaunchableSystemApp = true),
|
||||
manager.getPackageMetadata(packageName),
|
||||
)
|
||||
|
||||
verify {
|
||||
cacheInputStream.close()
|
||||
|
@ -214,6 +232,7 @@ class MetadataManagerTest {
|
|||
signatures = listOf("sig foo")
|
||||
)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(initialMetadata)
|
||||
|
||||
|
@ -236,6 +255,7 @@ class MetadataManagerTest {
|
|||
signatures = listOf("sig")
|
||||
)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(initialMetadata)
|
||||
val oldState = UNKNOWN_ERROR
|
||||
|
@ -295,6 +315,7 @@ class MetadataManagerTest {
|
|||
signatures = listOf("sig")
|
||||
)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
|
||||
assertNull(manager.getPackageMetadata(packageName))
|
||||
|
@ -330,6 +351,8 @@ class MetadataManagerTest {
|
|||
val packageMetadata = PackageMetadata(time)
|
||||
updatedMetadata.packageMetadataMap[packageName] = packageMetadata
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
every { packageService.launchableSystemApps } returns emptyList()
|
||||
expectReadFromCache()
|
||||
every { clock.time() } returns time
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
@ -342,6 +365,7 @@ class MetadataManagerTest {
|
|||
backupType = BackupType.FULL,
|
||||
size = size,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
manager.getPackageMetadata(packageName)
|
||||
)
|
||||
|
@ -361,6 +385,7 @@ class MetadataManagerTest {
|
|||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
every { settingsManager.d2dBackupsEnabled() } returns true
|
||||
every { context.packageManager } returns packageManager
|
||||
|
||||
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
|
||||
assertTrue(initialMetadata.d2dBackup)
|
||||
|
@ -382,6 +407,7 @@ class MetadataManagerTest {
|
|||
updatedMetadata.packageMetadataMap[packageName] =
|
||||
PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
every { clock.time() } returns updateTime
|
||||
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
|
||||
|
@ -414,6 +440,7 @@ class MetadataManagerTest {
|
|||
PackageMetadata(time, state = APK_AND_DATA)
|
||||
|
||||
expectReadFromCache()
|
||||
every { context.packageManager } returns packageManager
|
||||
every { clock.time() } returns time
|
||||
expectModifyMetadata(updatedMetadata)
|
||||
|
||||
|
@ -437,6 +464,7 @@ class MetadataManagerTest {
|
|||
val updatedMetadata = initialMetadata.copy()
|
||||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(updatedMetadata)
|
||||
|
||||
|
@ -454,6 +482,7 @@ class MetadataManagerTest {
|
|||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
|
||||
initialMetadata.packageMetadataMap.remove(packageName)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(updatedMetadata)
|
||||
|
||||
|
@ -482,6 +511,7 @@ class MetadataManagerTest {
|
|||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
|
||||
initialMetadata.packageMetadataMap.remove(packageName)
|
||||
|
||||
every { context.packageManager } returns packageManager
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(updatedMetadata)
|
||||
|
||||
|
|
|
@ -63,6 +63,10 @@ internal class MetadataWriterDecoderTest {
|
|||
time = Random.nextLong(),
|
||||
state = APK_AND_DATA,
|
||||
backupType = BackupType.FULL,
|
||||
size = Random.nextLong(0, Long.MAX_VALUE),
|
||||
name = getRandomString(),
|
||||
system = Random.nextBoolean(),
|
||||
isLaunchableSystemApp = Random.nextBoolean(),
|
||||
version = Random.nextLong(),
|
||||
installer = getRandomString(),
|
||||
splits = listOf(
|
||||
|
@ -94,6 +98,7 @@ internal class MetadataWriterDecoderTest {
|
|||
time = Random.nextLong(),
|
||||
state = QUOTA_EXCEEDED,
|
||||
backupType = BackupType.FULL,
|
||||
name = null,
|
||||
size = Random.nextLong(0..Long.MAX_VALUE),
|
||||
system = Random.nextBoolean(),
|
||||
version = Random.nextLong(),
|
||||
|
@ -108,6 +113,7 @@ internal class MetadataWriterDecoderTest {
|
|||
state = NO_DATA,
|
||||
backupType = BackupType.KV,
|
||||
size = null,
|
||||
name = getRandomString(),
|
||||
system = Random.nextBoolean(),
|
||||
version = Random.nextLong(),
|
||||
installer = getRandomString(),
|
||||
|
@ -121,6 +127,7 @@ internal class MetadataWriterDecoderTest {
|
|||
state = NOT_ALLOWED,
|
||||
size = 0,
|
||||
system = Random.nextBoolean(),
|
||||
isLaunchableSystemApp = Random.nextBoolean(),
|
||||
version = Random.nextLong(),
|
||||
installer = getRandomString(),
|
||||
sha256 = getRandomString(),
|
||||
|
@ -138,10 +145,11 @@ internal class MetadataWriterDecoderTest {
|
|||
private fun getMetadata(
|
||||
packageMetadata: HashMap<String, PackageMetadata> = HashMap(),
|
||||
): BackupMetadata {
|
||||
val version = Random.nextBytes(1)[0]
|
||||
return BackupMetadata(
|
||||
version = Random.nextBytes(1)[0],
|
||||
version = version,
|
||||
token = Random.nextLong(),
|
||||
salt = getRandomBase64(32),
|
||||
salt = if (version != 0.toByte()) getRandomBase64(32) else "",
|
||||
time = Random.nextLong(),
|
||||
androidVersion = Random.nextInt(),
|
||||
androidIncremental = getRandomString(),
|
||||
|
|
|
@ -24,7 +24,6 @@ import org.robolectric.annotation.Config
|
|||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(
|
||||
sdk = [33], // robolectric does not support 34, yet
|
||||
application = TestApp::class
|
||||
)
|
||||
internal class DocumentFileTest {
|
||||
|
|
|
@ -28,7 +28,6 @@ import kotlin.random.Random
|
|||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(
|
||||
sdk = [33], // robolectric does not support 34, yet
|
||||
application = TestApp::class
|
||||
)
|
||||
internal class WebDavStoragePluginTest : TransportTest() {
|
||||
|
|
|
@ -0,0 +1,409 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||
import com.stevesoltys.seedvault.worker.IconManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.fail
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class AppSelectionManagerTest : TransportTest() {
|
||||
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
private val iconManager: IconManager = mockk()
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val scope = TestScope(testDispatcher)
|
||||
|
||||
private val packageName1 = "org.example.1"
|
||||
private val packageName2 = "org.example.2"
|
||||
private val packageName3 = "org.example.3"
|
||||
private val packageName4 = "org.example.4"
|
||||
private val backupMetadata = BackupMetadata(
|
||||
token = Random.nextLong(),
|
||||
salt = getRandomString(),
|
||||
)
|
||||
|
||||
private val appSelectionManager = AppSelectionManager(
|
||||
context = context,
|
||||
pluginManager = storagePluginManager,
|
||||
iconManager = iconManager,
|
||||
coroutineScope = scope,
|
||||
workDispatcher = testDispatcher,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `apps without backup and APK, as well as system apps are filtered out`() = runTest {
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
val initialState = awaitItem()
|
||||
assertEquals(emptyList<SelectableAppItem>(), initialState.apps)
|
||||
assertTrue(initialState.allSelected)
|
||||
assertFalse(initialState.iconsLoaded)
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
PACKAGE_NAME_SETTINGS to PackageMetadata(), // no backup and no APK
|
||||
packageName1 to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
val initialApps = awaitItem()
|
||||
// only the meta system app item remains
|
||||
assertEquals(1, initialApps.apps.size)
|
||||
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
|
||||
assertTrue(initialApps.allSelected)
|
||||
assertFalse(initialApps.iconsLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apps get sorted by name, special items on top`() = runTest {
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
packageName1 to PackageMetadata(
|
||||
time = 23L,
|
||||
name = "B",
|
||||
),
|
||||
packageName2 to PackageMetadata(
|
||||
time = 42L,
|
||||
name = "A",
|
||||
),
|
||||
PACKAGE_NAME_SETTINGS to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
val initialApps = awaitItem()
|
||||
assertEquals(4, initialApps.apps.size)
|
||||
assertEquals(PACKAGE_NAME_SETTINGS, initialApps.apps[0].packageName)
|
||||
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName)
|
||||
assertEquals(packageName2, initialApps.apps[2].packageName)
|
||||
assertEquals(packageName1, initialApps.apps[3].packageName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test app selection`() = runTest {
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
packageName1 to PackageMetadata(time = 23L),
|
||||
packageName2 to PackageMetadata(time = 42L),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
// first all are selected
|
||||
val initialApps = awaitItem()
|
||||
assertEquals(3, initialApps.apps.size)
|
||||
initialApps.apps.forEach { assertTrue(it.selected) }
|
||||
assertTrue(initialApps.allSelected)
|
||||
|
||||
// deselect last app in list
|
||||
appSelectionManager.onAppSelected(initialApps.apps[2])
|
||||
val oneDeselected = awaitItem()
|
||||
oneDeselected.apps.forEach {
|
||||
if (it.packageName == packageName2) assertFalse(it.selected)
|
||||
else assertTrue(it.selected)
|
||||
}
|
||||
assertFalse(oneDeselected.allSelected)
|
||||
|
||||
// select all apps
|
||||
appSelectionManager.onCheckAllAppsClicked()
|
||||
val allSelected = awaitItem()
|
||||
allSelected.apps.forEach { assertTrue(it.selected) }
|
||||
assertTrue(allSelected.allSelected)
|
||||
|
||||
// de-select all apps
|
||||
appSelectionManager.onCheckAllAppsClicked()
|
||||
val noneSelected = awaitItem()
|
||||
noneSelected.apps.forEach { assertFalse(it.selected) }
|
||||
assertFalse(noneSelected.allSelected)
|
||||
|
||||
// re-select first (meta) app
|
||||
appSelectionManager.onAppSelected(noneSelected.apps[0])
|
||||
val firstSelected = awaitItem()
|
||||
firstSelected.apps.forEach {
|
||||
if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.selected)
|
||||
else assertFalse(it.selected)
|
||||
}
|
||||
assertFalse(firstSelected.allSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test icon loading`() = scope.runTest {
|
||||
expectIconLoading(setOf(packageName1)) // only icons found for packageName1
|
||||
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
packageName1 to PackageMetadata(time = 23),
|
||||
packageName2 to PackageMetadata(time = 42L),
|
||||
PACKAGE_NAME_SETTINGS to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
// all apps (except special ones) have an unknown item state initially
|
||||
val initialApps = awaitItem()
|
||||
assertEquals(4, initialApps.apps.size)
|
||||
initialApps.apps.forEach {
|
||||
assertNull(it.hasIcon)
|
||||
}
|
||||
|
||||
// all apps except packageName2 have icons now
|
||||
val itemsWithIcons = awaitItem()
|
||||
itemsWithIcons.apps.forEach {
|
||||
if (it.packageName == packageName2) assertFalse(it.hasIcon ?: fail())
|
||||
else assertTrue(it.hasIcon ?: fail())
|
||||
}
|
||||
assertTrue(itemsWithIcons.iconsLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test icon loading fails`() = scope.runTest {
|
||||
val appPlugin: StoragePlugin<*> = mockk()
|
||||
every { storagePluginManager.appPlugin } returns appPlugin
|
||||
coEvery {
|
||||
appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
|
||||
} throws IOException()
|
||||
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
packageName1 to PackageMetadata(time = 23),
|
||||
packageName2 to PackageMetadata(time = 42L),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
val initialApps = awaitItem()
|
||||
assertEquals(3, initialApps.apps.size)
|
||||
|
||||
// no apps have icons now (except special system app), but their state is known
|
||||
val itemsWithoutIcons = awaitItem()
|
||||
itemsWithoutIcons.apps.forEach {
|
||||
if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.hasIcon ?: fail())
|
||||
else assertFalse(it.hasIcon ?: fail())
|
||||
}
|
||||
assertTrue(itemsWithoutIcons.iconsLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing selection filters unselected apps, leaves system apps`() = runTest {
|
||||
testFiltering { backup ->
|
||||
val itemsWithIcons = awaitItem()
|
||||
|
||||
// unselect app1 and contacts app
|
||||
val app1 = itemsWithIcons.apps.find { it.packageName == packageName1 } ?: fail()
|
||||
val contacts = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_CONTACTS }
|
||||
?: fail()
|
||||
appSelectionManager.onAppSelected(app1)
|
||||
awaitItem()
|
||||
appSelectionManager.onAppSelected(contacts)
|
||||
|
||||
// assert that both apps are unselected
|
||||
val finalSelection = awaitItem()
|
||||
// we have 6 real apps (two are hidden) plus system meta item, makes 5
|
||||
assertEquals(5, finalSelection.apps.size)
|
||||
finalSelection.apps.forEach {
|
||||
if (it.packageName in listOf(packageName1, PACKAGE_NAME_CONTACTS)) {
|
||||
assertFalse(it.selected)
|
||||
} else {
|
||||
assertTrue(it.selected)
|
||||
}
|
||||
}
|
||||
|
||||
// 4 apps should survive: app2, app3 (system app), app4 (hidden) and settings
|
||||
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
|
||||
assertEquals(4, filteredBackup.packageMetadataMap.size)
|
||||
assertEquals(
|
||||
setOf(packageName2, packageName3, packageName4, PACKAGE_NAME_SETTINGS),
|
||||
filteredBackup.packageMetadataMap.keys,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing selection without system apps only removes non-special system apps`() = runTest {
|
||||
testFiltering { backup ->
|
||||
val itemsWithIcons = awaitItem()
|
||||
|
||||
// unselect all system apps and settings, contacts should stay
|
||||
val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM }
|
||||
?: fail()
|
||||
val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS }
|
||||
?: fail()
|
||||
appSelectionManager.onAppSelected(systemMeta)
|
||||
awaitItem()
|
||||
appSelectionManager.onAppSelected(settings)
|
||||
|
||||
// assert that both apps are unselected
|
||||
val finalSelection = awaitItem()
|
||||
// we have 6 real apps (two are hidden) plus system meta item, makes 5
|
||||
assertEquals(5, finalSelection.apps.size)
|
||||
finalSelection.apps.forEach {
|
||||
if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) {
|
||||
assertFalse(it.selected)
|
||||
} else {
|
||||
assertTrue(it.selected)
|
||||
}
|
||||
}
|
||||
|
||||
// 4 apps should survive: app1, app2, app4 (hidden) and contacts
|
||||
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
|
||||
assertEquals(4, filteredBackup.packageMetadataMap.size)
|
||||
assertEquals(
|
||||
setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS),
|
||||
filteredBackup.packageMetadataMap.keys,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `@pm@ doesn't get filtered out`() = runTest {
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mutableMapOf(
|
||||
MAGIC_PACKAGE_MANAGER to PackageMetadata(
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
// only system apps meta item in list
|
||||
val initialApps = awaitItem()
|
||||
assertEquals(1, initialApps.apps.size)
|
||||
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
|
||||
|
||||
// actual filtered backup includes @pm@ only
|
||||
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
|
||||
assertEquals(1, filteredBackup.packageMetadataMap.size)
|
||||
assertEquals(
|
||||
setOf(MAGIC_PACKAGE_MANAGER),
|
||||
filteredBackup.packageMetadataMap.keys,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRestorableBackup(map: Map<String, PackageMetadata>): RestorableBackup {
|
||||
return RestorableBackup(backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap))
|
||||
}
|
||||
|
||||
private suspend fun testFiltering(
|
||||
block: suspend TurbineTestContext<SelectedAppsState>.(RestorableBackup) -> Unit,
|
||||
) {
|
||||
expectIconLoading()
|
||||
appSelectionManager.selectedAppsFlow.test {
|
||||
awaitItem()
|
||||
|
||||
val backup = getRestorableBackup(
|
||||
mapOf(
|
||||
packageName1 to PackageMetadata(time = 23L),
|
||||
packageName2 to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = true,
|
||||
),
|
||||
packageName3 to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
packageName4 to PackageMetadata(), // no backup and no APK
|
||||
PACKAGE_NAME_CONTACTS to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
PACKAGE_NAME_SETTINGS to PackageMetadata(
|
||||
time = 42L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = false,
|
||||
),
|
||||
)
|
||||
)
|
||||
appSelectionManager.onRestoreSetChosen(backup)
|
||||
|
||||
val initialApps = awaitItem()
|
||||
// we have 6 real apps (two are hidden) plus system meta item, makes 5
|
||||
assertEquals(5, initialApps.apps.size)
|
||||
block(backup)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectIconLoading(icons: Set<String> = setOf(packageName1, packageName2)) {
|
||||
val appPlugin: StoragePlugin<*> = mockk()
|
||||
val inputStream = ByteArrayInputStream(Random.nextBytes(42))
|
||||
every { storagePluginManager.appPlugin } returns appPlugin
|
||||
coEvery {
|
||||
appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
|
||||
} returns inputStream
|
||||
every {
|
||||
iconManager.downloadIcons(backupMetadata.version, backupMetadata.token, inputStream)
|
||||
} returns icons
|
||||
}
|
||||
|
||||
}
|
|
@ -10,6 +10,7 @@ import android.content.pm.PackageManager
|
|||
import android.content.pm.Signature
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.PackageUtils
|
||||
import app.cash.turbine.test
|
||||
import com.stevesoltys.seedvault.assertReadEquals
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
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.StoragePluginManager
|
||||
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.worker.ApkBackup
|
||||
import io.mockk.coEvery
|
||||
|
@ -27,12 +31,12 @@ import io.mockk.mockk
|
|||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
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.io.TempDir
|
||||
import java.io.ByteArrayInputStream
|
||||
|
@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
}
|
||||
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||
private val storagePlugin: StoragePlugin<*> = mockk()
|
||||
|
@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
} returns true
|
||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
||||
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
|
||||
val resultMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
state = SUCCEEDED,
|
||||
metadata = packageMetadataMap[packageName] ?: fail(),
|
||||
)
|
||||
)
|
||||
coEvery {
|
||||
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
|
||||
} returns MutableInstallResult(1).apply {
|
||||
set(
|
||||
packageName, ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = ApkInstallState.SUCCEEDED
|
||||
)
|
||||
)
|
||||
}
|
||||
} returns InstallResult(resultMap)
|
||||
|
||||
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertFalse(value.hasFailed)
|
||||
assertEquals(1, value.total)
|
||||
if (i == 3) assertTrue(value.isFinished)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
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)
|
||||
|
|
|
@ -12,6 +12,8 @@ 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 app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.stevesoltys.seedvault.getRandomBase64
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
|
@ -32,13 +34,11 @@ import io.mockk.coEvery
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collectIndexed
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
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.io.TempDir
|
||||
import java.io.ByteArrayInputStream
|
||||
|
@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
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 { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedFailFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
} throws SecurityException()
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressFailFinished()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
|
||||
val installResult = MutableInstallResult(1).apply {
|
||||
set(
|
||||
packageName, ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
)
|
||||
val packagesMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
state = SUCCEEDED,
|
||||
metadata = PackageMetadata(),
|
||||
)
|
||||
}
|
||||
)
|
||||
val installResult = InstallResult(packagesMap)
|
||||
|
||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||
cacheBaseApkAndGetInfo(tmpDir)
|
||||
|
@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
} returns installResult
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressSuccessFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressSuccessFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
// This is a legacy backup with version 0
|
||||
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
|
||||
// Install will be successful
|
||||
val installResult = MutableInstallResult(1).apply {
|
||||
set(
|
||||
packageName, ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
)
|
||||
val packagesMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
state = SUCCEEDED,
|
||||
metadata = PackageMetadata(),
|
||||
)
|
||||
}
|
||||
)
|
||||
val installResult = InstallResult(packagesMap)
|
||||
|
||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||
@Suppress("Deprecation")
|
||||
coEvery {
|
||||
legacyStoragePlugin.getApkInputStream(token, packageName, "")
|
||||
} returns apkInputStream
|
||||
|
@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
} returns installResult
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressSuccessFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressSuccessFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
|
||||
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
|
||||
if (isSystemApp) { // if the installed app is not a system app, we don't install
|
||||
val installResult = MutableInstallResult(1).apply {
|
||||
set(
|
||||
val packagesMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
ApkInstallResult(packageName, progress = 1, state = SUCCEEDED)
|
||||
state = SUCCEEDED,
|
||||
metadata = PackageMetadata(),
|
||||
)
|
||||
}
|
||||
)
|
||||
val installResult = InstallResult(packagesMap)
|
||||
coEvery {
|
||||
apkInstaller.install(
|
||||
match { it.size == 1 },
|
||||
|
@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
awaitQueuedItem()
|
||||
awaitInProgressItem()
|
||||
awaitItem().also { systemItem ->
|
||||
val result = systemItem[packageName]
|
||||
if (willFail) {
|
||||
assertEquals(FAILED_SYSTEM_APP, result.state)
|
||||
} else {
|
||||
assertEquals(SUCCEEDED, result.state)
|
||||
}
|
||||
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) {
|
||||
assertEquals(FAILED_SYSTEM_APP, result.state)
|
||||
} else {
|
||||
assertEquals(SUCCEEDED, result.state)
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
}
|
||||
awaitItem().also { finishedItem ->
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -297,8 +296,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
} returns false
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressFailFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
} returns ByteArrayInputStream(getRandomByteArray())
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressFailFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressFailFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressFailFinished()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -385,60 +390,84 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
val resultMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
state = SUCCEEDED,
|
||||
metadata = PackageMetadata(),
|
||||
)
|
||||
)
|
||||
coEvery {
|
||||
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
|
||||
} returns MutableInstallResult(1).apply {
|
||||
set(
|
||||
packageName, ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
)
|
||||
)
|
||||
}
|
||||
} returns InstallResult(resultMap)
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
assertQueuedProgressSuccessFinished(i, value)
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
assertQueuedProgressSuccessFinished()
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
// set the storage provider package name to match our current package name,
|
||||
// and ensure that the current package is therefore skipped.
|
||||
every { storagePlugin.providerPackageName } returns packageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
assertFalse(value.isFinished)
|
||||
}
|
||||
1 -> {
|
||||
// the only package provided should have been filtered, leaving 0 packages.
|
||||
assertEquals(0, value.total)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
awaitItem().also { finishedItem ->
|
||||
// the only package provided should have been filtered, leaving 0 packages.
|
||||
assertEquals(0, finishedItem.total)
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no apks get installed when blocked by policy`(@TempDir tmpDir: Path) = runBlocking {
|
||||
fun `system app without APK get filtered out`() = runBlocking {
|
||||
// only backed up package is a system app without an APK
|
||||
packageMetadataMap[packageName] = PackageMetadata(
|
||||
time = 23L,
|
||||
system = true,
|
||||
isLaunchableSystemApp = Random.nextBoolean(),
|
||||
).also { assertFalse(it.hasApk()) }
|
||||
|
||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
|
||||
awaitItem().also { finishedItem ->
|
||||
println(finishedItem.installResults.values.toList())
|
||||
// the only package provided should have been filtered, leaving 0 packages.
|
||||
assertEquals(0, finishedItem.total)
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no apks get installed when blocked by policy`() = runBlocking {
|
||||
every { installRestriction.isAllowedToInstallApks() } returns false
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkRestore.restore(backup).collectIndexed { i, value ->
|
||||
when (i) {
|
||||
0 -> {
|
||||
// single package fails without attempting to install it
|
||||
assertEquals(1, value.total)
|
||||
assertEquals(FAILED, value[packageName].state)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
awaitItem().also { queuedItem ->
|
||||
// single package fails without attempting to install it
|
||||
assertEquals(1, queuedItem.total)
|
||||
assertEquals(FAILED, queuedItem[packageName].state)
|
||||
assertTrue(queuedItem.isFinished)
|
||||
}
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -456,74 +485,78 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
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]
|
||||
private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
|
||||
awaitQueuedItem()
|
||||
awaitItem().also { failedItem ->
|
||||
val result = failedItem[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
assertFalse(value.isFinished)
|
||||
assertTrue(failedItem.hasFailed)
|
||||
assertFalse(failedItem.isFinished)
|
||||
}
|
||||
2 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
awaitItem().also { finishedItem ->
|
||||
assertTrue(finishedItem.hasFailed)
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> assertQueuedProgress(step, value)
|
||||
1 -> assertQueuedProgress(step, value)
|
||||
2 -> {
|
||||
val result = value[packageName]
|
||||
private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressSuccessFinished() {
|
||||
awaitQueuedItem()
|
||||
awaitInProgressItem()
|
||||
awaitItem().also { successItem ->
|
||||
val result = successItem[packageName]
|
||||
assertEquals(SUCCEEDED, result.state)
|
||||
}
|
||||
3 -> {
|
||||
assertFalse(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
awaitItem().also { finishedItem ->
|
||||
assertFalse(finishedItem.hasFailed)
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) {
|
||||
0 -> assertQueuedProgress(step, value)
|
||||
1 -> assertQueuedProgress(step, value)
|
||||
2 -> {
|
||||
private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressFailFinished() {
|
||||
awaitQueuedItem()
|
||||
awaitInProgressItem()
|
||||
awaitItem().also { failedItem ->
|
||||
// app install has failed
|
||||
val result = value[packageName]
|
||||
val result = failedItem[packageName]
|
||||
assertEquals(FAILED, result.state)
|
||||
assertTrue(value.hasFailed)
|
||||
assertFalse(value.isFinished)
|
||||
assertTrue(failedItem.hasFailed)
|
||||
assertFalse(failedItem.isFinished)
|
||||
}
|
||||
3 -> {
|
||||
assertTrue(value.hasFailed)
|
||||
assertTrue(value.isFinished)
|
||||
awaitItem().also { finishedItem ->
|
||||
assertTrue(finishedItem.hasFailed)
|
||||
assertTrue(finishedItem.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
|
||||
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 suspend fun TurbineTestContext<InstallResult>.awaitQueuedItem(): InstallResult {
|
||||
val item = awaitItem()
|
||||
// single package gets queued
|
||||
val result = item[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(installerName, result.installerPackageName)
|
||||
assertEquals(1, item.total)
|
||||
assertEquals(0, item.list.size) // all items still queued
|
||||
return item
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<InstallResult>.awaitInProgressItem(): InstallResult {
|
||||
val item = awaitItem()
|
||||
// name and icon are available now
|
||||
val result = item[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
assertFalse(item.hasFailed)
|
||||
assertEquals(1, item.total)
|
||||
assertEquals(1, item.list.size)
|
||||
return item
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -27,7 +27,6 @@ import kotlin.random.Random
|
|||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(
|
||||
sdk = [33], // robolectric does not support 34, yet
|
||||
application = TestApp::class
|
||||
)
|
||||
internal class DeviceInfoTest {
|
||||
|
|
|
@ -31,6 +31,7 @@ import io.mockk.verify
|
|||
import io.mockk.verifyAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
|
@ -38,6 +39,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
|
||||
private val packageService: PackageService = mockk()
|
||||
private val apkBackup: ApkBackup = mockk()
|
||||
private val iconManager: IconManager = mockk()
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
private val plugin: StoragePlugin<*> = mockk()
|
||||
private val nm: BackupNotificationManager = mockk()
|
||||
|
@ -48,6 +50,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
metadataManager = metadataManager,
|
||||
packageService = packageService,
|
||||
apkBackup = apkBackup,
|
||||
iconManager = iconManager,
|
||||
pluginManager = storagePluginManager,
|
||||
nm = nm,
|
||||
)
|
||||
|
@ -63,6 +66,9 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
|
@ -86,6 +92,9 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
fun `Package state of app gets recorded even if no previous state`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
|
@ -115,6 +124,9 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
|
@ -138,6 +150,9 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
fun `Package state only updated when changed`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
|
@ -155,6 +170,25 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Package state only updated if not excluded`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns false
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every { settingsManager.backupApks() } returns false
|
||||
expectFinalUpload()
|
||||
every { nm.onApkBackupDone() } just Runs
|
||||
|
||||
apkBackupManager.backup()
|
||||
|
||||
verifyAll(inverse = true) {
|
||||
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two packages get backed up, one their APK uploaded`() = runBlocking {
|
||||
val notAllowedPackages = listOf(
|
||||
|
@ -167,7 +201,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
)
|
||||
|
||||
expectUploadIcons()
|
||||
expectAllAppsWillGetBackedUp()
|
||||
every { settingsManager.backupApks() } returns true
|
||||
|
||||
|
@ -206,6 +240,9 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
fun `we keep trying to upload metadata at the end`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
|
||||
|
||||
expectUploadIcons()
|
||||
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
|
@ -233,6 +270,13 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun expectUploadIcons() {
|
||||
every { settingsManager.getToken() } returns token
|
||||
val stream = ByteArrayOutputStream()
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_ICONS) } returns stream
|
||||
every { iconManager.uploadIcons(token, stream) } just Runs
|
||||
}
|
||||
|
||||
private fun expectAllAppsWillGetBackedUp() {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
every { packageService.notBackedUpPackages } returns emptyList()
|
||||
|
|
|
@ -11,12 +11,12 @@ ktlint = "11.5.0"
|
|||
|
||||
# Android SDK versions
|
||||
compileSdk = "34"
|
||||
minSdk = "33"
|
||||
minSdk = "34"
|
||||
targetSdk = "34"
|
||||
|
||||
# Test versions
|
||||
junit4 = "4.13.2"
|
||||
junit5 = "5.10.0" # careful, upgrading this can change a Cipher's IV size in tests!?
|
||||
junit5 = "5.10.2" # careful, upgrading this can change a Cipher's IV size in tests!?
|
||||
mockk = "1.13.4" # newer versions require kotlin > 1.8.10
|
||||
espresso = "3.4.0"
|
||||
|
||||
|
|
Loading…
Reference in a new issue