diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh index 7b8d189d..358c197a 100755 --- a/.github/scripts/run_tests.sh +++ b/.github/scripts/run_tests.sh @@ -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 diff --git a/.idea/dictionaries/user.xml b/.idea/dictionaries/user.xml index 2317866a..db7d2d2c 100644 --- a/.idea/dictionaries/user.xml +++ b/.idea/dictionaries/user.xml @@ -7,6 +7,7 @@ ejectable hasher hkdf + launchable restorable seedvault snowden diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80387e26..b8ea99cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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()}") diff --git a/app/development/scripts/install_app.sh b/app/development/scripts/install_app.sh index e4f8cda3..cfd44d90 100755 --- a/app/development/scripts/install_app.sh +++ b/app/development/scripts/install_app.sh @@ -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 diff --git a/app/development/scripts/provision_emulator.sh b/app/development/scripts/provision_emulator.sh index 9219c9bf..6e0161cf 100755 --- a/app/development/scripts/provision_emulator.sh +++ b/app/development/scripts/provision_emulator.sh @@ -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'..." diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index f04319af..60d0ece9 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -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!! } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt index 6b9fdd1c..76a9f7d4 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt @@ -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) } } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt index af052e3a..ea648ddc 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt @@ -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) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt index 568bd1c2..ba66e3d7 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt @@ -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() diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt index f553b054..7de32560 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt @@ -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." + } } } } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt index 9808621c..e17e6d1e 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/screen/impl/RestoreScreen.kt @@ -11,6 +11,8 @@ object RestoreScreen : UiDeviceScreen() { val backupListItem = findObject { textContains("Last backup") } + val appsSelectedButton = findObject { text("Restore backup") } + val nextButton = findObject { text("Next") } val finishButton = findObject { text("Finish") } diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index dc6d8d2d..4391067f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt index 489e6c0a..acb6902e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt @@ -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, diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt index c36e00c1..362275ac 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt @@ -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? = null, internal val sha256: String? = null, internal val signatures: List? = 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" diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index daf7ebb0..c9c6dc39 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -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) + } + } } /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt index b0a10173..b5eaee76 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt @@ -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 { MetadataWriterImpl(get()) } single { MetadataReaderImpl(get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt index 8f77bcc4..98ffa9cb 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt @@ -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), diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt index dcfdbe7c..49e3c348 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt @@ -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) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt new file mode 100644 index 00000000..88caefc0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt @@ -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().apply { + add( + AppRestoreResult( + packageName = MAGIC_PACKAGE_MANAGER, + name = getAppName(context, MAGIC_PACKAGE_MANAGER).toString(), + state = IN_PROGRESS, + ) + ) + } + ) + val restoreProgress: LiveData> get() = mRestoreProgress + private val mRestoreBackupResult = MutableLiveData() + val restoreBackupResult: LiveData 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, 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, + 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() + + /** + * 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?) { + // 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 + 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 + } + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt new file mode 100644 index 00000000..499184e8 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt @@ -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() { + + private val diffCallback = object : ItemCallback() { + 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) { + 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 + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt new file mode 100644 index 00000000..d19cbacd --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt @@ -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) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt new file mode 100644 index 00000000..40ac9863 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt @@ -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, + 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 = 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), + ) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt index 4e8c1819..5b5c30bd 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt @@ -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()) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt index ae235703..fff602d3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt @@ -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() { +internal class RestoreProgressAdapter( + val scope: CoroutineScope, + val iconLoader: suspend (AppRestoreResult, (Drawable) -> Unit) -> Unit, +) : Adapter() { - private val items = LinkedList() + private val diffCallback = object : ItemCallback() { + 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() { 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) { - val diffResult = DiffUtil.calculateDiff(Diff(items, newItems)) - items.clear() - items.addAll(newItems) - diffResult.dispatchUpdatesTo(this) + fun update(newItems: LinkedList, 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, - private val newItems: LinkedList, - ) : 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() { 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() { } } - -internal data class AppRestoreResult( - val packageName: String, - val name: CharSequence, - val state: AppBackupState, -) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt index d6085843..21b2f228 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt @@ -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) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 5d8905d9..9aff3eec 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -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() internal val displayFragment: LiveEvent = mDisplayFragment @@ -106,43 +83,21 @@ internal class RestoreViewModel( private val mChosenRestorableBackup = MutableLiveData() internal val chosenRestorableBackup: LiveData get() = mChosenRestorableBackup - internal val installResult: LiveData = - mChosenRestorableBackup.switchMap { backup -> - getInstallResult(backup) - } + internal val selectedApps: LiveData = + appSelectionManager.selectedAppsLiveData + + internal val installResult: LiveData = apkRestore.installResult.asLiveData() + internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) } - private val mNextButtonEnabled = MutableLiveData().apply { value = false } - internal val nextButtonEnabled: LiveData = mNextButtonEnabled + internal val restoreProgress: LiveData> + get() = appDataRestoreManager.restoreProgress - private val mRestoreProgress = MutableLiveData>().apply { - value = LinkedList().apply { - add( - AppRestoreResult( - packageName = MAGIC_PACKAGE_MANAGER, - name = getAppName(app, MAGIC_PACKAGE_MANAGER), - state = IN_PROGRESS - ) - ) - } - } - internal val restoreProgress: LiveData> get() = mRestoreProgress - - private val mRestoreBackupResult = MutableLiveData() - internal val restoreBackupResult: LiveData get() = mRestoreBackupResult + internal val restoreBackupResult: LiveData + 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 { - @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) { - 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, - 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() - - /** - * 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?) { - // 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 } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt index eb03cd75..55707d6a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt @@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) { cachedApks: List, packageName: String, installerPackageName: String?, - installResult: MutableInstallResult, - ) = suspendCancellableCoroutine { 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, - installResult: MutableInstallResult, + installResult: InstallResult, ): InstallResult { val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!! val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 6fc01437..85013361 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -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, 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) } + } + } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt index a4413f41..e92a4f7b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt @@ -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() { private var finished = false - private val finishedComparator = FailedFirstComparator() - private val items = SortedList( - ApkInstallResult::class.java, - object : SortedListAdapterCallback(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() { + 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) { - this.items.replaceAll(items) + fun update(items: List, 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) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt index 28e64e40..926dab69 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt @@ -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) { + private fun updateAdapter(items: List) { 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()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt index bdfb96f9..39d92d9e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt @@ -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 = 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 = 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 - - /** - * 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(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 { - 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 { - override fun compareTo(other: ApkInstallResult): Int { - return other.progress.compareTo(progress) - } +) { + val installerPackageName: CharSequence? get() = metadata.installer } internal class FailedFirstComparator : Comparator { 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) + } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt index 5da1c7a9..6d9b156c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt @@ -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 { - 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 { - val locale = Locale.getDefault() - return packageService.userApps.map { + private fun getApps(): List { + val userPackages = mutableSetOf() + 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 { @@ -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) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt index 63401588..960fbf88 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt @@ -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) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt index eb4ac7d5..25c5aad1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt @@ -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) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 5be48bfd..f3a71fa0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -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 = mAppStatusList diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt index 39109bf2..c1fb8618 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt @@ -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 + @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) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt index 19f4f100..e5448d0f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt index b183948e..505f9944 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/SystemData.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/SystemData.kt new file mode 100644 index 00000000..546e452c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/SystemData.kt @@ -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, +) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index 2bbb08dc..d45dca91 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -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 } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt index 3dd6f10d..a36eafe7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -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]. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt new file mode 100644 index 00000000..629dd05e --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt @@ -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() + 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 { + 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() + 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() + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index b7ff36de..8c3d0c3e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -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() ) diff --git a/app/src/main/res/drawable/ic_app_settings.xml b/app/src/main/res/drawable/ic_app_settings.xml new file mode 100644 index 00000000..be2e27ef --- /dev/null +++ b/app/src/main/res/drawable/ic_app_settings.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml index 9df2d799..17ca9c5d 100644 --- a/app/src/main/res/drawable/ic_bug_report.xml +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -6,7 +6,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +