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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_app_status.xml b/app/src/main/res/layout/list_item_app_status.xml
index 8ff13961..29d288b6 100644
--- a/app/src/main/res/layout/list_item_app_status.xml
+++ b/app/src/main/res/layout/list_item_app_status.xml
@@ -8,9 +8,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginStart="40dp"
- android:layout_marginEnd="40dp"
+ android:layout_marginHorizontal="16dp"
android:background="?android:selectableItemBackground"
+ android:paddingHorizontal="24dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:screenReaderFocusable="true">
@@ -35,7 +35,7 @@
android:layout_marginEnd="16dp"
android:textColor="?android:textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/appInfo"
- app:layout_constraintEnd_toStartOf="@+id/switchView"
+ app:layout_constraintEnd_toStartOf="@+id/checkboxView"
app:layout_constraintStart_toEndOf="@+id/appIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Seedvault Backup" />
@@ -72,8 +72,8 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
- @color/primary
- ?android:attr/colorError
+ @*android:color/error_color_device_default_dark
@color/accent
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 170386e0..5936dc2e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -177,12 +177,13 @@
- System apps
+ System data
SMS text messages
Device settings
Call history
Local contacts
- Installed apps
+ System apps
+ Apps
Waiting to back up…
Was not yet backed up
@@ -209,6 +210,8 @@
We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.
An error occurred while loading the backups.
No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.
+ Select apps to restore
+ All of the following apps
Re-installing apps
Re-installing
Re-installed
diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
index 160dee86..78e9333a 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
@@ -16,8 +16,10 @@ import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule
+import io.mockk.mockk
import org.koin.android.ext.koin.androidContext
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
@@ -33,9 +35,11 @@ class TestApp : App() {
single { KeyManagerTestImpl() }
single { CryptoImpl(get(), get(), get()) }
}
+ private val packageService: PackageService = mockk()
private val appModule = module {
single { Clock() }
single { SettingsManager(this@TestApp) }
+ single { packageService }
}
override fun startKoin(): KoinApplication {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
index 0b1ff816..f049a827 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
@@ -7,11 +7,13 @@ package com.stevesoltys.seedvault.metadata
import android.content.Context
import android.content.Context.MODE_PRIVATE
+import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
import android.os.UserManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock
@@ -27,6 +29,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.settings.SettingsManager
+import com.stevesoltys.seedvault.transport.backup.PackageService
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@@ -54,7 +57,6 @@ import kotlin.random.Random
@Suppress("DEPRECATION")
@RunWith(AndroidJUnit4::class)
@Config(
- sdk = [33], // robolectric does not support 34, yet
application = TestApp::class
)
class MetadataManagerTest {
@@ -64,6 +66,7 @@ class MetadataManagerTest {
private val crypto: Crypto = mockk()
private val metadataWriter: MetadataWriter = mockk()
private val metadataReader: MetadataReader = mockk()
+ private val packageService: PackageService = mockk()
private val settingsManager: SettingsManager = mockk()
private val manager = MetadataManager(
@@ -72,9 +75,12 @@ class MetadataManagerTest {
crypto = crypto,
metadataWriter = metadataWriter,
metadataReader = metadataReader,
- settingsManager = settingsManager
+ packageService = packageService,
+ settingsManager = settingsManager,
)
+ private val packageManager: PackageManager = mockk()
+
private val time = 42L
private val token = Random.nextLong()
private val packageName = getRandomString()
@@ -162,6 +168,7 @@ class MetadataManagerTest {
signatures = listOf("sig")
)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectModifyMetadata(initialMetadata)
@@ -185,12 +192,23 @@ class MetadataManagerTest {
signatures = listOf("sig")
)
+ every { context.packageManager } returns packageManager
+ every { packageService.launchableSystemApps } returns listOf(
+ ResolveInfo().apply {
+ activityInfo = ActivityInfo().apply {
+ packageName = this@MetadataManagerTest.packageName
+ }
+ }
+ )
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata)
- assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName))
+ assertEquals(
+ packageMetadata.copy(system = true, isLaunchableSystemApp = true),
+ manager.getPackageMetadata(packageName),
+ )
verify {
cacheInputStream.close()
@@ -214,6 +232,7 @@ class MetadataManagerTest {
signatures = listOf("sig foo")
)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectWriteToCache(initialMetadata)
@@ -236,6 +255,7 @@ class MetadataManagerTest {
signatures = listOf("sig")
)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectWriteToCache(initialMetadata)
val oldState = UNKNOWN_ERROR
@@ -295,6 +315,7 @@ class MetadataManagerTest {
signatures = listOf("sig")
)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
assertNull(manager.getPackageMetadata(packageName))
@@ -330,6 +351,8 @@ class MetadataManagerTest {
val packageMetadata = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = packageMetadata
+ every { context.packageManager } returns packageManager
+ every { packageService.launchableSystemApps } returns emptyList()
expectReadFromCache()
every { clock.time() } returns time
expectModifyMetadata(initialMetadata)
@@ -342,6 +365,7 @@ class MetadataManagerTest {
backupType = BackupType.FULL,
size = size,
system = true,
+ isLaunchableSystemApp = false,
),
manager.getPackageMetadata(packageName)
)
@@ -361,6 +385,7 @@ class MetadataManagerTest {
expectModifyMetadata(initialMetadata)
every { settingsManager.d2dBackupsEnabled() } returns true
+ every { context.packageManager } returns packageManager
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
assertTrue(initialMetadata.d2dBackup)
@@ -382,6 +407,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] =
PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
every { clock.time() } returns updateTime
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
@@ -414,6 +440,7 @@ class MetadataManagerTest {
PackageMetadata(time, state = APK_AND_DATA)
expectReadFromCache()
+ every { context.packageManager } returns packageManager
every { clock.time() } returns time
expectModifyMetadata(updatedMetadata)
@@ -437,6 +464,7 @@ class MetadataManagerTest {
val updatedMetadata = initialMetadata.copy()
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectWriteToCache(updatedMetadata)
@@ -454,6 +482,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
initialMetadata.packageMetadataMap.remove(packageName)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectWriteToCache(updatedMetadata)
@@ -482,6 +511,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
initialMetadata.packageMetadataMap.remove(packageName)
+ every { context.packageManager } returns packageManager
expectReadFromCache()
expectModifyMetadata(updatedMetadata)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
index 24aad1cb..b1b1cb99 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
@@ -63,6 +63,10 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(),
state = APK_AND_DATA,
backupType = BackupType.FULL,
+ size = Random.nextLong(0, Long.MAX_VALUE),
+ name = getRandomString(),
+ system = Random.nextBoolean(),
+ isLaunchableSystemApp = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
splits = listOf(
@@ -94,6 +98,7 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(),
state = QUOTA_EXCEEDED,
backupType = BackupType.FULL,
+ name = null,
size = Random.nextLong(0..Long.MAX_VALUE),
system = Random.nextBoolean(),
version = Random.nextLong(),
@@ -108,6 +113,7 @@ internal class MetadataWriterDecoderTest {
state = NO_DATA,
backupType = BackupType.KV,
size = null,
+ name = getRandomString(),
system = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
@@ -121,6 +127,7 @@ internal class MetadataWriterDecoderTest {
state = NOT_ALLOWED,
size = 0,
system = Random.nextBoolean(),
+ isLaunchableSystemApp = Random.nextBoolean(),
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
@@ -138,10 +145,11 @@ internal class MetadataWriterDecoderTest {
private fun getMetadata(
packageMetadata: HashMap = HashMap(),
): BackupMetadata {
+ val version = Random.nextBytes(1)[0]
return BackupMetadata(
- version = Random.nextBytes(1)[0],
+ version = version,
token = Random.nextLong(),
- salt = getRandomBase64(32),
+ salt = if (version != 0.toByte()) getRandomBase64(32) else "",
time = Random.nextLong(),
androidVersion = Random.nextInt(),
androidIncremental = getRandomString(),
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
index ce19d907..2aead4e6 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
@@ -24,7 +24,6 @@ import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(
- sdk = [33], // robolectric does not support 34, yet
application = TestApp::class
)
internal class DocumentFileTest {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt
index dbbc7cc4..8472fe5a 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePluginTest.kt
@@ -28,7 +28,6 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@Config(
- sdk = [33], // robolectric does not support 34, yet
application = TestApp::class
)
internal class WebDavStoragePluginTest : TransportTest() {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt
new file mode 100644
index 00000000..ec7e2019
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt
@@ -0,0 +1,409 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault.restore
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.cash.turbine.TurbineTestContext
+import app.cash.turbine.test
+import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
+import com.stevesoltys.seedvault.getRandomString
+import com.stevesoltys.seedvault.metadata.BackupMetadata
+import com.stevesoltys.seedvault.metadata.PackageMetadata
+import com.stevesoltys.seedvault.metadata.PackageMetadataMap
+import com.stevesoltys.seedvault.plugins.StoragePlugin
+import com.stevesoltys.seedvault.plugins.StoragePluginManager
+import com.stevesoltys.seedvault.transport.TransportTest
+import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS
+import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS
+import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
+import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
+import com.stevesoltys.seedvault.worker.IconManager
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Assertions.fail
+import org.junit.runner.RunWith
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import kotlin.random.Random
+
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class AppSelectionManagerTest : TransportTest() {
+
+ private val storagePluginManager: StoragePluginManager = mockk()
+ private val iconManager: IconManager = mockk()
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val scope = TestScope(testDispatcher)
+
+ private val packageName1 = "org.example.1"
+ private val packageName2 = "org.example.2"
+ private val packageName3 = "org.example.3"
+ private val packageName4 = "org.example.4"
+ private val backupMetadata = BackupMetadata(
+ token = Random.nextLong(),
+ salt = getRandomString(),
+ )
+
+ private val appSelectionManager = AppSelectionManager(
+ context = context,
+ pluginManager = storagePluginManager,
+ iconManager = iconManager,
+ coroutineScope = scope,
+ workDispatcher = testDispatcher,
+ )
+
+ @Test
+ fun `apps without backup and APK, as well as system apps are filtered out`() = runTest {
+ appSelectionManager.selectedAppsFlow.test {
+ val initialState = awaitItem()
+ assertEquals(emptyList(), initialState.apps)
+ assertTrue(initialState.allSelected)
+ assertFalse(initialState.iconsLoaded)
+
+ val backup = getRestorableBackup(
+ mapOf(
+ PACKAGE_NAME_SETTINGS to PackageMetadata(), // no backup and no APK
+ packageName1 to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ val initialApps = awaitItem()
+ // only the meta system app item remains
+ assertEquals(1, initialApps.apps.size)
+ assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
+ assertTrue(initialApps.allSelected)
+ assertFalse(initialApps.iconsLoaded)
+ }
+ }
+
+ @Test
+ fun `apps get sorted by name, special items on top`() = runTest {
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mapOf(
+ packageName1 to PackageMetadata(
+ time = 23L,
+ name = "B",
+ ),
+ packageName2 to PackageMetadata(
+ time = 42L,
+ name = "A",
+ ),
+ PACKAGE_NAME_SETTINGS to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ val initialApps = awaitItem()
+ assertEquals(4, initialApps.apps.size)
+ assertEquals(PACKAGE_NAME_SETTINGS, initialApps.apps[0].packageName)
+ assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName)
+ assertEquals(packageName2, initialApps.apps[2].packageName)
+ assertEquals(packageName1, initialApps.apps[3].packageName)
+ }
+ }
+
+ @Test
+ fun `test app selection`() = runTest {
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mapOf(
+ packageName1 to PackageMetadata(time = 23L),
+ packageName2 to PackageMetadata(time = 42L),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ // first all are selected
+ val initialApps = awaitItem()
+ assertEquals(3, initialApps.apps.size)
+ initialApps.apps.forEach { assertTrue(it.selected) }
+ assertTrue(initialApps.allSelected)
+
+ // deselect last app in list
+ appSelectionManager.onAppSelected(initialApps.apps[2])
+ val oneDeselected = awaitItem()
+ oneDeselected.apps.forEach {
+ if (it.packageName == packageName2) assertFalse(it.selected)
+ else assertTrue(it.selected)
+ }
+ assertFalse(oneDeselected.allSelected)
+
+ // select all apps
+ appSelectionManager.onCheckAllAppsClicked()
+ val allSelected = awaitItem()
+ allSelected.apps.forEach { assertTrue(it.selected) }
+ assertTrue(allSelected.allSelected)
+
+ // de-select all apps
+ appSelectionManager.onCheckAllAppsClicked()
+ val noneSelected = awaitItem()
+ noneSelected.apps.forEach { assertFalse(it.selected) }
+ assertFalse(noneSelected.allSelected)
+
+ // re-select first (meta) app
+ appSelectionManager.onAppSelected(noneSelected.apps[0])
+ val firstSelected = awaitItem()
+ firstSelected.apps.forEach {
+ if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.selected)
+ else assertFalse(it.selected)
+ }
+ assertFalse(firstSelected.allSelected)
+ }
+ }
+
+ @Test
+ fun `test icon loading`() = scope.runTest {
+ expectIconLoading(setOf(packageName1)) // only icons found for packageName1
+
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mapOf(
+ packageName1 to PackageMetadata(time = 23),
+ packageName2 to PackageMetadata(time = 42L),
+ PACKAGE_NAME_SETTINGS to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ // all apps (except special ones) have an unknown item state initially
+ val initialApps = awaitItem()
+ assertEquals(4, initialApps.apps.size)
+ initialApps.apps.forEach {
+ assertNull(it.hasIcon)
+ }
+
+ // all apps except packageName2 have icons now
+ val itemsWithIcons = awaitItem()
+ itemsWithIcons.apps.forEach {
+ if (it.packageName == packageName2) assertFalse(it.hasIcon ?: fail())
+ else assertTrue(it.hasIcon ?: fail())
+ }
+ assertTrue(itemsWithIcons.iconsLoaded)
+ }
+ }
+
+ @Test
+ fun `test icon loading fails`() = scope.runTest {
+ val appPlugin: StoragePlugin<*> = mockk()
+ every { storagePluginManager.appPlugin } returns appPlugin
+ coEvery {
+ appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
+ } throws IOException()
+
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mapOf(
+ packageName1 to PackageMetadata(time = 23),
+ packageName2 to PackageMetadata(time = 42L),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ val initialApps = awaitItem()
+ assertEquals(3, initialApps.apps.size)
+
+ // no apps have icons now (except special system app), but their state is known
+ val itemsWithoutIcons = awaitItem()
+ itemsWithoutIcons.apps.forEach {
+ if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.hasIcon ?: fail())
+ else assertFalse(it.hasIcon ?: fail())
+ }
+ assertTrue(itemsWithoutIcons.iconsLoaded)
+ }
+ }
+
+ @Test
+ fun `finishing selection filters unselected apps, leaves system apps`() = runTest {
+ testFiltering { backup ->
+ val itemsWithIcons = awaitItem()
+
+ // unselect app1 and contacts app
+ val app1 = itemsWithIcons.apps.find { it.packageName == packageName1 } ?: fail()
+ val contacts = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_CONTACTS }
+ ?: fail()
+ appSelectionManager.onAppSelected(app1)
+ awaitItem()
+ appSelectionManager.onAppSelected(contacts)
+
+ // assert that both apps are unselected
+ val finalSelection = awaitItem()
+ // we have 6 real apps (two are hidden) plus system meta item, makes 5
+ assertEquals(5, finalSelection.apps.size)
+ finalSelection.apps.forEach {
+ if (it.packageName in listOf(packageName1, PACKAGE_NAME_CONTACTS)) {
+ assertFalse(it.selected)
+ } else {
+ assertTrue(it.selected)
+ }
+ }
+
+ // 4 apps should survive: app2, app3 (system app), app4 (hidden) and settings
+ val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
+ assertEquals(4, filteredBackup.packageMetadataMap.size)
+ assertEquals(
+ setOf(packageName2, packageName3, packageName4, PACKAGE_NAME_SETTINGS),
+ filteredBackup.packageMetadataMap.keys,
+ )
+ }
+ }
+
+ @Test
+ fun `finishing selection without system apps only removes non-special system apps`() = runTest {
+ testFiltering { backup ->
+ val itemsWithIcons = awaitItem()
+
+ // unselect all system apps and settings, contacts should stay
+ val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM }
+ ?: fail()
+ val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS }
+ ?: fail()
+ appSelectionManager.onAppSelected(systemMeta)
+ awaitItem()
+ appSelectionManager.onAppSelected(settings)
+
+ // assert that both apps are unselected
+ val finalSelection = awaitItem()
+ // we have 6 real apps (two are hidden) plus system meta item, makes 5
+ assertEquals(5, finalSelection.apps.size)
+ finalSelection.apps.forEach {
+ if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) {
+ assertFalse(it.selected)
+ } else {
+ assertTrue(it.selected)
+ }
+ }
+
+ // 4 apps should survive: app1, app2, app4 (hidden) and contacts
+ val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
+ assertEquals(4, filteredBackup.packageMetadataMap.size)
+ assertEquals(
+ setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS),
+ filteredBackup.packageMetadataMap.keys,
+ )
+ }
+ }
+
+ @Test
+ fun `@pm@ doesn't get filtered out`() = runTest {
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mutableMapOf(
+ MAGIC_PACKAGE_MANAGER to PackageMetadata(
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ // only system apps meta item in list
+ val initialApps = awaitItem()
+ assertEquals(1, initialApps.apps.size)
+ assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
+
+ // actual filtered backup includes @pm@ only
+ val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
+ assertEquals(1, filteredBackup.packageMetadataMap.size)
+ assertEquals(
+ setOf(MAGIC_PACKAGE_MANAGER),
+ filteredBackup.packageMetadataMap.keys,
+ )
+ }
+ }
+
+ private fun getRestorableBackup(map: Map): RestorableBackup {
+ return RestorableBackup(backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap))
+ }
+
+ private suspend fun testFiltering(
+ block: suspend TurbineTestContext.(RestorableBackup) -> Unit,
+ ) {
+ expectIconLoading()
+ appSelectionManager.selectedAppsFlow.test {
+ awaitItem()
+
+ val backup = getRestorableBackup(
+ mapOf(
+ packageName1 to PackageMetadata(time = 23L),
+ packageName2 to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = true,
+ ),
+ packageName3 to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ packageName4 to PackageMetadata(), // no backup and no APK
+ PACKAGE_NAME_CONTACTS to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ PACKAGE_NAME_SETTINGS to PackageMetadata(
+ time = 42L,
+ system = true,
+ isLaunchableSystemApp = false,
+ ),
+ )
+ )
+ appSelectionManager.onRestoreSetChosen(backup)
+
+ val initialApps = awaitItem()
+ // we have 6 real apps (two are hidden) plus system meta item, makes 5
+ assertEquals(5, initialApps.apps.size)
+ block(backup)
+ }
+ }
+
+ private fun expectIconLoading(icons: Set = setOf(packageName1, packageName2)) {
+ val appPlugin: StoragePlugin<*> = mockk()
+ val inputStream = ByteArrayInputStream(Random.nextBytes(42))
+ every { storagePluginManager.appPlugin } returns appPlugin
+ coEvery {
+ appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
+ } returns inputStream
+ every {
+ iconManager.downloadIcons(backupMetadata.version, backupMetadata.token, inputStream)
+ } returns icons
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
index 6b33c969..0fb2f2b9 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.Signature
import android.graphics.drawable.Drawable
import android.util.PackageUtils
+import app.cash.turbine.test
import com.stevesoltys.seedvault.assertReadEquals
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit
@@ -19,6 +20,9 @@ import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup
+import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
+import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
+import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.coEvery
@@ -27,12 +31,12 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
@@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
}
private val storagePluginManager: StoragePluginManager = mockk()
+
@Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val storagePlugin: StoragePlugin<*> = mockk()
@@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() {
} returns true
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
+ val resultMap = mapOf(
+ packageName to ApkInstallResult(
+ packageName,
+ state = SUCCEEDED,
+ metadata = packageMetadataMap[packageName] ?: fail(),
+ )
+ )
coEvery {
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
- } returns MutableInstallResult(1).apply {
- set(
- packageName, ApkInstallResult(
- packageName,
- progress = 1,
- state = ApkInstallState.SUCCEEDED
- )
- )
- }
+ } returns InstallResult(resultMap)
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertFalse(value.hasFailed)
- assertEquals(1, value.total)
- if (i == 3) assertTrue(value.isFinished)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ awaitItem().also {
+ assertFalse(it.hasFailed)
+ assertEquals(1, it.total)
+ assertEquals(0, it.list.size)
+ assertEquals(QUEUED, it.installResults[packageName]?.state)
+ assertFalse(it.isFinished)
+ }
+ awaitItem().also {
+ assertFalse(it.hasFailed)
+ assertEquals(1, it.total)
+ assertEquals(1, it.list.size)
+ assertEquals(IN_PROGRESS, it.installResults[packageName]?.state)
+ assertFalse(it.isFinished)
+ }
+ awaitItem().also {
+ assertFalse(it.hasFailed)
+ assertEquals(1, it.total)
+ assertEquals(1, it.list.size)
+ assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
+ assertFalse(it.isFinished)
+ }
+ awaitItem().also {
+ assertFalse(it.hasFailed)
+ assertEquals(1, it.total)
+ assertEquals(1, it.list.size)
+ assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
+ assertTrue(it.isFinished)
+ }
+ ensureAllEventsConsumed()
}
val apkFile = File(apkPath.captured)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt
index c58dbbe2..2cb74bd5 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt
@@ -12,6 +12,8 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
+import app.cash.turbine.TurbineTestContext
+import app.cash.turbine.test
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
@@ -32,13 +34,11 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
-import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
@@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedFailFinished()
}
}
@@ -126,8 +128,10 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedFailFinished()
}
}
@@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() {
} throws SecurityException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressFailFinished()
}
}
@Test
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
- val installResult = MutableInstallResult(1).apply {
- set(
- packageName, ApkInstallResult(
- packageName,
- progress = 1,
- state = SUCCEEDED
- )
+ val packagesMap = mapOf(
+ packageName to ApkInstallResult(
+ packageName,
+ state = SUCCEEDED,
+ metadata = PackageMetadata(),
)
- }
+ )
+ val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true
cacheBaseApkAndGetInfo(tmpDir)
@@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressSuccessFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressSuccessFinished()
}
}
@@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() {
// This is a legacy backup with version 0
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
// Install will be successful
- val installResult = MutableInstallResult(1).apply {
- set(
- packageName, ApkInstallResult(
- packageName,
- progress = 1,
- state = SUCCEEDED
- )
+ val packagesMap = mapOf(
+ packageName to ApkInstallResult(
+ packageName,
+ state = SUCCEEDED,
+ metadata = PackageMetadata(),
)
- }
+ )
+ val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true
every { strictContext.cacheDir } returns File(tmpDir.toString())
- @Suppress("Deprecation")
coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream
@@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressSuccessFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressSuccessFinished()
}
}
@@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
if (isSystemApp) { // if the installed app is not a system app, we don't install
- val installResult = MutableInstallResult(1).apply {
- set(
+ val packagesMap = mapOf(
+ packageName to ApkInstallResult(
packageName,
- ApkInstallResult(packageName, progress = 1, state = SUCCEEDED)
+ state = SUCCEEDED,
+ metadata = PackageMetadata(),
)
- }
+ )
+ val installResult = InstallResult(packagesMap)
coEvery {
apkInstaller.install(
match { it.size == 1 },
@@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() {
}
}
- apkRestore.restore(backup).collectIndexed { i, value ->
- when (i) {
- 0 -> {
- val result = value[packageName]
- assertEquals(QUEUED, result.state)
- assertEquals(1, result.progress)
- assertEquals(1, value.total)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ awaitQueuedItem()
+ awaitInProgressItem()
+ awaitItem().also { systemItem ->
+ val result = systemItem[packageName]
+ if (willFail) {
+ assertEquals(FAILED_SYSTEM_APP, result.state)
+ } else {
+ assertEquals(SUCCEEDED, result.state)
}
- 1 -> {
- val result = value[packageName]
- assertEquals(IN_PROGRESS, result.state)
- assertEquals(appName, result.name)
- assertEquals(icon, result.icon)
- }
- 2 -> {
- val result = value[packageName]
- if (willFail) {
- assertEquals(FAILED_SYSTEM_APP, result.state)
- } else {
- assertEquals(SUCCEEDED, result.state)
- }
- }
- 3 -> {
- assertTrue(value.isFinished)
- }
- else -> fail("more values emitted")
}
+ awaitItem().also { finishedItem ->
+ assertTrue(finishedItem.isFinished)
+ }
+ ensureAllEventsConsumed()
}
}
@@ -297,8 +296,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressFailFinished()
}
}
@@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns ByteArrayInputStream(getRandomByteArray())
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressFailFinished()
}
}
@@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressFailFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressFailFinished()
}
}
@@ -385,60 +390,84 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName
+ val resultMap = mapOf(
+ packageName to ApkInstallResult(
+ packageName,
+ state = SUCCEEDED,
+ metadata = PackageMetadata(),
+ )
+ )
coEvery {
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
- } returns MutableInstallResult(1).apply {
- set(
- packageName, ApkInstallResult(
- packageName,
- progress = 1,
- state = SUCCEEDED
- )
- )
- }
+ } returns InstallResult(resultMap)
- apkRestore.restore(backup).collectIndexed { i, value ->
- assertQueuedProgressSuccessFinished(i, value)
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ assertQueuedProgressSuccessFinished()
}
}
@Test
- fun `storage provider app does not get reinstalled`(@TempDir tmpDir: Path) = runBlocking {
+ fun `storage provider app does not get reinstalled`() = runBlocking {
every { installRestriction.isAllowedToInstallApks() } returns true
// set the storage provider package name to match our current package name,
// and ensure that the current package is therefore skipped.
every { storagePlugin.providerPackageName } returns packageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- when (i) {
- 0 -> {
- assertFalse(value.isFinished)
- }
- 1 -> {
- // the only package provided should have been filtered, leaving 0 packages.
- assertEquals(0, value.total)
- assertTrue(value.isFinished)
- }
- else -> fail("more values emitted")
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ awaitItem().also { finishedItem ->
+ // the only package provided should have been filtered, leaving 0 packages.
+ assertEquals(0, finishedItem.total)
+ assertTrue(finishedItem.isFinished)
}
+ ensureAllEventsConsumed()
}
}
@Test
- fun `no apks get installed when blocked by policy`(@TempDir tmpDir: Path) = runBlocking {
+ fun `system app without APK get filtered out`() = runBlocking {
+ // only backed up package is a system app without an APK
+ packageMetadataMap[packageName] = PackageMetadata(
+ time = 23L,
+ system = true,
+ isLaunchableSystemApp = Random.nextBoolean(),
+ ).also { assertFalse(it.hasApk()) }
+
+ every { installRestriction.isAllowedToInstallApks() } returns true
+ every { storagePlugin.providerPackageName } returns storageProviderPackageName
+
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+
+ awaitItem().also { finishedItem ->
+ println(finishedItem.installResults.values.toList())
+ // the only package provided should have been filtered, leaving 0 packages.
+ assertEquals(0, finishedItem.total)
+ assertTrue(finishedItem.isFinished)
+ }
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ fun `no apks get installed when blocked by policy`() = runBlocking {
every { installRestriction.isAllowedToInstallApks() } returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName
- apkRestore.restore(backup).collectIndexed { i, value ->
- when (i) {
- 0 -> {
- // single package fails without attempting to install it
- assertEquals(1, value.total)
- assertEquals(FAILED, value[packageName].state)
- assertTrue(value.isFinished)
- }
- else -> fail("more values emitted")
+ apkRestore.installResult.test {
+ awaitItem() // initial empty state
+ apkRestore.restore(backup)
+ awaitItem().also { queuedItem ->
+ // single package fails without attempting to install it
+ assertEquals(1, queuedItem.total)
+ assertEquals(FAILED, queuedItem[packageName].state)
+ assertTrue(queuedItem.isFinished)
}
+ ensureAllEventsConsumed()
}
}
@@ -456,74 +485,78 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
}
- private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) {
- 0 -> assertQueuedProgress(step, value)
- 1 -> {
- val result = value[packageName]
+ private suspend fun TurbineTestContext.assertQueuedFailFinished() {
+ awaitQueuedItem()
+ awaitItem().also { failedItem ->
+ val result = failedItem[packageName]
assertEquals(FAILED, result.state)
- assertTrue(value.hasFailed)
- assertFalse(value.isFinished)
+ assertTrue(failedItem.hasFailed)
+ assertFalse(failedItem.isFinished)
}
- 2 -> {
- assertTrue(value.hasFailed)
- assertTrue(value.isFinished)
+ awaitItem().also { finishedItem ->
+ assertTrue(finishedItem.hasFailed)
+ assertTrue(finishedItem.isFinished)
}
- else -> fail("more values emitted")
+ ensureAllEventsConsumed()
}
- private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) {
- 0 -> assertQueuedProgress(step, value)
- 1 -> assertQueuedProgress(step, value)
- 2 -> {
- val result = value[packageName]
+ private suspend fun TurbineTestContext.assertQueuedProgressSuccessFinished() {
+ awaitQueuedItem()
+ awaitInProgressItem()
+ awaitItem().also { successItem ->
+ val result = successItem[packageName]
assertEquals(SUCCEEDED, result.state)
}
- 3 -> {
- assertFalse(value.hasFailed)
- assertTrue(value.isFinished)
+ awaitItem().also { finishedItem ->
+ assertFalse(finishedItem.hasFailed)
+ assertTrue(finishedItem.isFinished)
}
- else -> fail("more values emitted")
+ ensureAllEventsConsumed()
}
- private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) {
- 0 -> assertQueuedProgress(step, value)
- 1 -> assertQueuedProgress(step, value)
- 2 -> {
+ private suspend fun TurbineTestContext.assertQueuedProgressFailFinished() {
+ awaitQueuedItem()
+ awaitInProgressItem()
+ awaitItem().also { failedItem ->
// app install has failed
- val result = value[packageName]
+ val result = failedItem[packageName]
assertEquals(FAILED, result.state)
- assertTrue(value.hasFailed)
- assertFalse(value.isFinished)
+ assertTrue(failedItem.hasFailed)
+ assertFalse(failedItem.isFinished)
}
- 3 -> {
- assertTrue(value.hasFailed)
- assertTrue(value.isFinished)
+ awaitItem().also { finishedItem ->
+ assertTrue(finishedItem.hasFailed)
+ assertTrue(finishedItem.isFinished)
}
- else -> fail("more values emitted")
+ ensureAllEventsConsumed()
}
- private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) {
- 0 -> {
- // single package gets queued
- val result = value[packageName]
- assertEquals(QUEUED, result.state)
- assertEquals(installerName, result.installerPackageName)
- assertEquals(1, result.progress)
- assertEquals(1, value.total)
- }
- 1 -> {
- // name and icon are available now
- val result = value[packageName]
- assertEquals(IN_PROGRESS, result.state)
- assertEquals(appName, result.name)
- assertEquals(icon, result.icon)
- assertFalse(value.hasFailed)
- }
- else -> fail("more values emitted")
+ private suspend fun TurbineTestContext.awaitQueuedItem(): InstallResult {
+ val item = awaitItem()
+ // single package gets queued
+ val result = item[packageName]
+ assertEquals(QUEUED, result.state)
+ assertEquals(installerName, result.installerPackageName)
+ assertEquals(1, item.total)
+ assertEquals(0, item.list.size) // all items still queued
+ return item
+ }
+
+ private suspend fun TurbineTestContext.awaitInProgressItem(): InstallResult {
+ val item = awaitItem()
+ // name and icon are available now
+ val result = item[packageName]
+ assertEquals(IN_PROGRESS, result.state)
+ assertEquals(appName, result.name)
+ assertEquals(icon, result.icon)
+ assertFalse(item.hasFailed)
+ assertEquals(1, item.total)
+ assertEquals(1, item.list.size)
+ return item
}
}
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
- return (this as MutableInstallResult)[packageName] ?: Assertions.fail("$packageName not found")
+ return this.installResults[packageName] ?: Assertions.fail("$packageName not found")
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
index 2f4346c3..66d36c78 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/DeviceInfoTest.kt
@@ -27,7 +27,6 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@Config(
- sdk = [33], // robolectric does not support 34, yet
application = TestApp::class
)
internal class DeviceInfoTest {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt
index dc32063f..5a7652eb 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt
@@ -31,6 +31,7 @@ import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
+import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
@@ -38,6 +39,7 @@ internal class ApkBackupManagerTest : TransportTest() {
private val packageService: PackageService = mockk()
private val apkBackup: ApkBackup = mockk()
+ private val iconManager: IconManager = mockk()
private val storagePluginManager: StoragePluginManager = mockk()
private val plugin: StoragePlugin<*> = mockk()
private val nm: BackupNotificationManager = mockk()
@@ -48,6 +50,7 @@ internal class ApkBackupManagerTest : TransportTest() {
metadataManager = metadataManager,
packageService = packageService,
apkBackup = apkBackup,
+ iconManager = iconManager,
pluginManager = storagePluginManager,
nm = nm,
)
@@ -63,6 +66,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
+
+ expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
@@ -86,6 +92,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `Package state of app gets recorded even if no previous state`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
+
+ expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
@@ -115,6 +124,9 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
+
+ expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
@@ -138,6 +150,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `Package state only updated when changed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
+
+ expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
@@ -155,6 +170,25 @@ internal class ApkBackupManagerTest : TransportTest() {
}
}
+ @Test
+ fun `Package state only updated if not excluded`() = runBlocking {
+ every { nm.onAppsNotBackedUp() } just Runs
+ every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns false
+
+ expectUploadIcons()
+
+ every { settingsManager.backupApks() } returns false
+ expectFinalUpload()
+ every { nm.onApkBackupDone() } just Runs
+
+ apkBackupManager.backup()
+
+ verifyAll(inverse = true) {
+ metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
+ }
+ }
+
@Test
fun `two packages get backed up, one their APK uploaded`() = runBlocking {
val notAllowedPackages = listOf(
@@ -167,7 +201,7 @@ internal class ApkBackupManagerTest : TransportTest() {
}
}
)
-
+ expectUploadIcons()
expectAllAppsWillGetBackedUp()
every { settingsManager.backupApks() } returns true
@@ -206,6 +240,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `we keep trying to upload metadata at the end`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
+ every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
+
+ expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
@@ -233,6 +270,13 @@ internal class ApkBackupManagerTest : TransportTest() {
}
}
+ private suspend fun expectUploadIcons() {
+ every { settingsManager.getToken() } returns token
+ val stream = ByteArrayOutputStream()
+ coEvery { plugin.getOutputStream(token, FILE_BACKUP_ICONS) } returns stream
+ every { iconManager.uploadIcons(token, stream) } just Runs
+ }
+
private fun expectAllAppsWillGetBackedUp() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns emptyList()
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b170fc07..d0b7f901 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,12 +11,12 @@ ktlint = "11.5.0"
# Android SDK versions
compileSdk = "34"
-minSdk = "33"
+minSdk = "34"
targetSdk = "34"
# Test versions
junit4 = "4.13.2"
-junit5 = "5.10.0" # careful, upgrading this can change a Cipher's IV size in tests!?
+junit5 = "5.10.2" # careful, upgrading this can change a Cipher's IV size in tests!?
mockk = "1.13.4" # newer versions require kotlin > 1.8.10
espresso = "3.4.0"