Merge pull request #670 from grote/309-restore-choose-apps

Allow choosing which apps will get restored
This commit is contained in:
Torsten Grote 2024-06-19 09:21:59 -03:00 committed by GitHub
commit 22ca2550c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2398 additions and 897 deletions

View file

@ -3,21 +3,12 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# #
adb root echo "Disable auto-restore"
sleep 5 adb shell bmgr autorestore false
adb remount
echo "Installing Seedvault app..." echo "Installing Seedvault app..."
adb shell mkdir -p /system/priv-app/Seedvault ./gradlew --stacktrace :app:installDebugAndroidTest
adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk sleep 60
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
D2D_BACKUP_TEST=$1 D2D_BACKUP_TEST=$1

View file

@ -7,6 +7,7 @@
<w>ejectable</w> <w>ejectable</w>
<w>hasher</w> <w>hasher</w>
<w>hkdf</w> <w>hkdf</w>
<w>launchable</w>
<w>restorable</w> <w>restorable</w>
<w>seedvault</w> <w>seedvault</w>
<w>snowden</w> <w>snowden</w>

View file

@ -176,11 +176,15 @@ dependencies {
// anything less than 'implementation' fails tests run with gradlew // anything less than 'implementation' fails tests run with gradlew
testImplementation(aospLibs) testImplementation(aospLibs)
testImplementation("androidx.test.ext:junit:1.1.5") 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.hamcrest:hamcrest:2.2")
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}") testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
testImplementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}"
)
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2") testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")

View file

@ -11,10 +11,9 @@ if [ -z "$ANDROID_HOME" ]; then
fi fi
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
DEVELOPMENT_DIR=$SCRIPT_DIR/..
ROOT_PROJECT_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 if [ -z "$EMULATOR_DEVICE_NAME" ]; then
echo "Emulator device name not found" echo "Emulator device name not found"
@ -29,13 +28,9 @@ $ADB remount # remount /system as writable
echo "Installing Seedvault app..." echo "Installing Seedvault app..."
$ADB shell mkdir -p /system/priv-app/Seedvault $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..." 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"/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 push "$ROOT_PROJECT_DIR"/allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
$ADB shell am force-stop com.stevesoltys.seedvault
$ADB shell am broadcast -a android.intent.action.BOOT_COMPLETED $ADB shell am broadcast -a android.intent.action.BOOT_COMPLETED
echo "Setting Seedvault transport..."
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport

View file

@ -20,25 +20,23 @@ EMULATOR_NAME=$1
SYSTEM_IMAGE=$2 SYSTEM_IMAGE=$2
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
DEVELOPMENT_DIR=$SCRIPT_DIR/..
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
echo "Downloading system image..." 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 # 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." echo "AVD already exists. Skipping creation."
else else
echo "Creating AVD..." 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 sleep 1
fi 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 if [ -z "$EMULATOR_DEVICE_NAME" ]; then
$SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME" "$SCRIPT_DIR"/start_emulator.sh "$EMULATOR_NAME"
fi fi
# wait for emulator device to appear with 180 second timeout # 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 for i in {1..180}; do
if [ -z "$EMULATOR_DEVICE_NAME" ]; then if [ -z "$EMULATOR_DEVICE_NAME" ]; then
sleep 1 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 else
break break
fi 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;' $ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
echo "Provisioning emulator for Seedvault..." echo "Provisioning emulator for Seedvault..."
$SCRIPT_DIR/install_app.sh "$SCRIPT_DIR"/install_app.sh
echo "Rebooting emulator..." echo "Rebooting emulator..."
$ADB reboot $ADB reboot
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' $ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
echo "Setting backup transport to Seedvault..." echo "Disabling backup..."
$ADB shell bmgr enable true $ADB shell bmgr enable false
sleep 5
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
echo "Downloading and extracting test backup to '/sdcard/seedvault_baseline'..." echo "Downloading and extracting test backup to '/sdcard/seedvault_baseline'..."

View file

@ -46,7 +46,19 @@ class KoinInstrumentationTestApp : App() {
viewModel { viewModel {
currentRestoreViewModel = 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!! currentRestoreViewModel!!
} }

View file

@ -72,6 +72,10 @@ internal interface LargeRestoreTestBase : LargeTestBase {
backupListItem.clickAndWaitForNewWindow() backupListItem.clickAndWaitForNewWindow()
waitUntilIdle() waitUntilIdle()
waitForAppSelectionLoaded()
// just tap next in app selection
appsSelectedButton.clickAndWaitForNewWindow()
waitForInstallResult() waitForInstallResult()
if (someAppsNotInstalledText.exists()) { if (someAppsNotInstalledText.exists()) {
@ -104,13 +108,22 @@ internal interface LargeRestoreTestBase : LargeTestBase {
spyOnKVRestoreData(result) 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 { private fun waitForInstallResult() = runBlocking {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
withTimeout(RESTORE_TIMEOUT) { withTimeout(RESTORE_TIMEOUT) {
while (spyRestoreViewModel.installResult.value == null || while (spyRestoreViewModel.installResult.value?.isFinished != true) {
spyRestoreViewModel.nextButtonEnabled.value == false
) {
delay(100) delay(100)
} }
} }

View file

@ -113,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
} }
fun testResultFilename(testName: String): String { 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 simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time) val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
return "${timeStamp}_${testName.replace(" ", "_")}" return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)

View file

@ -16,6 +16,7 @@ import org.junit.rules.TestName
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import java.io.File import java.io.File
import java.lang.Thread.sleep
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -44,6 +45,11 @@ internal abstract class SeedvaultLargeTest :
resetApplicationState() resetApplicationState()
clearTestBackups() clearTestBackups()
runCommand("bmgr enable true")
sleep(60_000)
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
sleep(60_000)
startRecordingTest(keepRecordingScreen, name.methodName) startRecordingTest(keepRecordingScreen, name.methodName)
restoreBaselineBackup() restoreBaselineBackup()

View file

@ -6,6 +6,7 @@
package com.stevesoltys.seedvault.e2e.impl package com.stevesoltys.seedvault.e2e.impl
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
@ -127,17 +128,19 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
) { ) {
// Assert all "key/value" restored data matches the backup data. // Assert all "key/value" restored data matches the backup data.
restore.kv.forEach { (pkg, kvData) -> restore.kv.forEach { (pkg, kvData) ->
assert(backup.kv.containsKey(pkg)) { if (pkg != MAGIC_PACKAGE_MANAGER) {
"KV data for $pkg missing from backup." 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."
} }
assert(value.contentEquals(backup.kv[pkg]!![key]!!)) { kvData.forEach { (key, value) ->
"KV data for $pkg/$key does not match." 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."
}
} }
} }
} }

View file

@ -11,6 +11,8 @@ object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
val backupListItem = findObject { textContains("Last backup") } val backupListItem = findObject { textContains("Last backup") }
val appsSelectedButton = findObject { text("Restore backup") }
val nextButton = findObject { text("Next") } val nextButton = findObject { text("Next") }
val finishButton = findObject { text("Finish") } val finishButton = findObject { text("Finish") }

View file

@ -95,7 +95,19 @@ open class App : Application() {
) )
} }
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) } 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()) } viewModel { FileSelectionViewModel(this@App, get()) }
} }
@ -189,6 +201,7 @@ open class App : Application() {
const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL
const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
const val NO_DATA_END_SENTINEL = "@end@"
const val GLOBAL_METADATA_KEY = "@meta@" const val GLOBAL_METADATA_KEY = "@meta@"
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED

View file

@ -120,6 +120,7 @@ internal interface Crypto {
internal const val TYPE_METADATA: Byte = 0x00 internal const val TYPE_METADATA: Byte = 0x00
internal const val TYPE_BACKUP_KV: Byte = 0x01 internal const val TYPE_BACKUP_KV: Byte = 0x01
internal const val TYPE_BACKUP_FULL: Byte = 0x02 internal const val TYPE_BACKUP_FULL: Byte = 0x02
internal const val TYPE_ICONS: Byte = 0x03
internal class CryptoImpl( internal class CryptoImpl(
private val keyManager: KeyManager, private val keyManager: KeyManager,

View file

@ -26,7 +26,7 @@ data class BackupMetadata(
internal var d2dBackup: Boolean = false, internal var d2dBackup: Boolean = false,
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(), internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
) { ) {
val size: Long? val size: Long
get() = packageMetadataMap.values.sumOf { m -> get() = packageMetadataMap.values.sumOf { m ->
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L) (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 state: PackageState = UNKNOWN_ERROR,
internal var backupType: BackupType? = null, internal var backupType: BackupType? = null,
internal var size: Long? = null, internal var size: Long? = null,
internal var name: CharSequence? = null,
internal val system: Boolean = false, internal val system: Boolean = false,
internal val isLaunchableSystemApp: Boolean = false,
internal val version: Long? = null, internal val version: Long? = null,
internal val installer: String? = null, internal val installer: String? = null,
internal val splits: List<ApkSplit>? = null, internal val splits: List<ApkSplit>? = null,
internal val sha256: String? = null, internal val sha256: String? = null,
internal val signatures: List<String>? = null, internal val signatures: List<String>? = null,
) { ) {
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
fun hasApk(): Boolean { fun hasApk(): Boolean {
return version != null && sha256 != null && signatures != null 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_BACKUP_TYPE = "backupType"
internal const val JSON_PACKAGE_STATE = "state" internal const val JSON_PACKAGE_STATE = "state"
internal const val JSON_PACKAGE_SIZE = "size" 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 = "system"
internal const val JSON_PACKAGE_SYSTEM_LAUNCHER = "systemLauncher"
internal const val JSON_PACKAGE_VERSION = "version" internal const val JSON_PACKAGE_VERSION = "version"
internal const val JSON_PACKAGE_INSTALLER = "installer" internal const val JSON_PACKAGE_INSTALLER = "installer"
internal const val JSON_PACKAGE_SPLITS = "splits" internal const val JSON_PACKAGE_SPLITS = "splits"

View file

@ -23,6 +23,7 @@ import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
@ -41,6 +42,7 @@ internal class MetadataManager(
private val crypto: Crypto, private val crypto: Crypto,
private val metadataWriter: MetadataWriter, private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader, private val metadataReader: MetadataReader,
private val packageService: PackageService,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
) { ) {
@ -63,7 +65,11 @@ internal class MetadataManager(
return field 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. * Call this when initializing a new device.
@ -111,8 +117,11 @@ internal class MetadataManager(
val oldPackageMetadata = metadata.packageMetadataMap[packageName] val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata() ?: PackageMetadata()
modifyCachedMetadata { modifyCachedMetadata {
val isSystemApp = packageInfo.isSystemApp()
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy( metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
system = packageInfo.isSystemApp(), name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName),
version = packageMetadata.version, version = packageMetadata.version,
installer = packageMetadata.installer, installer = packageMetadata.installer,
splits = packageMetadata.splits, splits = packageMetadata.splits,
@ -144,12 +153,16 @@ internal class MetadataManager(
metadata.time = now metadata.time = now
metadata.d2dBackup = settingsManager.d2dBackupsEnabled() metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
metadata.packageMetadataMap.getOrPut(packageName) { metadata.packageMetadataMap.getOrPut(packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = now, time = now,
state = APK_AND_DATA, state = APK_AND_DATA,
backupType = type, backupType = type,
size = size, size = size,
system = packageInfo.isSystemApp(), name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp &&
launchableSystemApps.contains(packageName),
) )
}.apply { }.apply {
time = now time = now
@ -157,6 +170,10 @@ internal class MetadataManager(
backupType = type backupType = type
// don't override a previous K/V size, if there were no K/V changes // don't override a previous K/V size, if there were no K/V changes
if (size != null) this.size = size 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." } check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
modifyMetadata(metadataOutputStream) { modifyMetadata(metadataOutputStream) {
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = 0L, time = 0L,
state = packageState, state = packageState,
backupType = backupType, backupType = backupType,
system = packageInfo.isSystemApp() name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp &&
launchableSystemApps.contains(packageInfo.packageName),
) )
}.state = packageState }.state = packageState
} }
@ -201,12 +222,22 @@ internal class MetadataManager(
packageState: PackageState, packageState: PackageState,
) = modifyCachedMetadata { ) = modifyCachedMetadata {
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = 0L, time = 0L,
state = packageState, 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)
}
}
} }
/** /**

View file

@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val metadataModule = module { val metadataModule = module {
single { MetadataManager(androidContext(), get(), get(), get(), get(), get()) } single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
single<MetadataWriter> { MetadataWriterImpl(get()) } single<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) } single<MetadataReader> { MetadataReaderImpl(get()) }
} }

View file

@ -126,6 +126,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
else -> null else -> null
} }
val pSize = p.optLong(JSON_PACKAGE_SIZE, -1L) 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 pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L) val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER) val pInstaller = p.optString(JSON_PACKAGE_INSTALLER)
@ -143,7 +144,9 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
state = pState, state = pState,
backupType = pBackupType, backupType = pBackupType,
size = if (pSize < 0L) null else pSize, size = if (pSize < 0L) null else pSize,
name = if (pName == "") null else pName,
system = pSystem, system = pSystem,
isLaunchableSystemApp = p.optBoolean(JSON_PACKAGE_SYSTEM_LAUNCHER, false),
version = if (pVersion == 0L) null else pVersion, version = if (pVersion == 0L) null else pVersion,
installer = if (pInstaller == "") null else pInstaller, installer = if (pInstaller == "") null else pInstaller,
splits = getSplits(p), splits = getSplits(p),

View file

@ -57,8 +57,14 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
if (packageMetadata.size != null) { if (packageMetadata.size != null) {
put(JSON_PACKAGE_SIZE, packageMetadata.size) put(JSON_PACKAGE_SIZE, packageMetadata.size)
} }
if (packageMetadata.name != null) {
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
}
if (packageMetadata.system) { 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.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) } packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }

View file

@ -0,0 +1,348 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import android.app.backup.BackupManager
import android.app.backup.BackupTransport
import android.app.backup.IBackupManager
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.content.Context
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NO_DATA
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
import com.stevesoltys.seedvault.ui.notification.getAppName
import java.util.LinkedList
import java.util.Locale
private val TAG = AppDataRestoreManager::class.simpleName
internal data class AppRestoreResult(
val packageName: String,
val name: String,
val state: AppBackupState,
)
internal class AppDataRestoreManager(
private val context: Context,
private val backupManager: IBackupManager,
private val settingsManager: SettingsManager,
private val restoreCoordinator: RestoreCoordinator,
) {
private var session: IRestoreSession? = null
private val monitor = BackupMonitor()
private val mRestoreProgress = MutableLiveData(
LinkedList<AppRestoreResult>().apply {
add(
AppRestoreResult(
packageName = MAGIC_PACKAGE_MANAGER,
name = getAppName(context, MAGIC_PACKAGE_MANAGER).toString(),
state = IN_PROGRESS,
)
)
}
)
val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
@WorkerThread
fun startRestore(restorableBackup: RestorableBackup) {
val token = restorableBackup.token
Log.d(TAG, "Starting new restore session to restore backup $token")
// if we had no token before (i.e. restore from setup wizard),
// use the token of the current restore set from now on
if (settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
// start a new restore session
val session = try {
getOrStartSession()
} catch (e: RemoteException) {
Log.e(TAG, "Error starting new session", e)
mRestoreBackupResult.postValue(
RestoreBackupResult(context.getString(R.string.restore_set_error))
)
return
}
val observer = RestoreObserver(
restoreCoordinator = restoreCoordinator,
restorableBackup = restorableBackup,
session = session,
// sort packages (reverse) alphabetically, since we move from bottom to top
packages = restorableBackup.packageMetadataMap.packagesSortedByNameDescending,
monitor = monitor,
)
// We need to retrieve the restore sets before starting the restore.
// Otherwise, restorePackages() won't work as they need the restore sets cached internally.
if (session.getAvailableRestoreSets(observer, monitor) != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreBackupResult.postValue(
RestoreBackupResult(context.getString(R.string.restore_set_error))
)
}
}
@Throws(RemoteException::class)
private fun getOrStartSession(): IRestoreSession {
@Suppress("UNRESOLVED_REFERENCE")
val session = this.session
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
?: throw RemoteException("beginRestoreSessionForUser returned null")
this.session = session
return session
}
@WorkerThread
// this should be called one package at a time and never concurrently for different packages
private fun onRestoreStarted(packageName: String, backup: RestorableBackup) {
// list is never null and always has at least one package
val list = mRestoreProgress.value!!
// check previous package first and change status
updateLatestPackage(list, backup)
// add current package
val name = getAppName(
context = context,
packageName = packageName,
fallback = backup.packageMetadataMap[packageName]?.name?.toString() ?: packageName,
)
list.addFirst(AppRestoreResult(packageName, name.toString(), IN_PROGRESS))
mRestoreProgress.postValue(list)
}
@WorkerThread
private fun updateLatestPackage(list: LinkedList<AppRestoreResult>, backup: RestorableBackup) {
val latestResult = list[0]
if (restoreCoordinator.isFailedPackage(latestResult.packageName)) {
list[0] = latestResult.copy(state = getFailedStatus(latestResult.packageName, backup))
} else {
list[0] = latestResult.copy(state = SUCCEEDED)
}
}
@WorkerThread
private fun getFailedStatus(packageName: String, backup: RestorableBackup): AppBackupState {
val metadata = backup.packageMetadataMap[packageName] ?: return FAILED
return when (metadata.state) {
PackageState.NO_DATA -> FAILED_NO_DATA
PackageState.WAS_STOPPED -> NOT_YET_BACKED_UP
PackageState.NOT_ALLOWED -> FAILED_NOT_ALLOWED
PackageState.QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
PackageState.UNKNOWN_ERROR -> FAILED
PackageState.APK_AND_DATA -> if (context.packageManager.isInstalled(packageName)) {
FAILED
} else FAILED_NOT_INSTALLED
}
}
@WorkerThread
private fun onRestoreComplete(result: RestoreBackupResult, backup: RestorableBackup) {
// update status of latest package
val list = mRestoreProgress.value!!
updateLatestPackage(list, backup)
// add missing packages as failed
val seenPackages = list.map { it.packageName }.toSet()
val expectedPackages =
backup.packageMetadataMap.packagesSortedByNameDescending.toMutableSet()
expectedPackages.removeAll(seenPackages)
for (packageName in expectedPackages) {
val failedStatus = getFailedStatus(packageName, backup)
if (failedStatus == FAILED_NO_DATA &&
backup.packageMetadataMap[packageName]?.isInternalSystem == true
) {
// don't add internal system apps that had NO_DATA to backup
} else {
val name = getAppName(
context = context,
packageName = packageName,
fallback = backup.packageMetadataMap[packageName]?.name?.toString()
?: packageName,
)
val appResult = AppRestoreResult(packageName, name.toString(), failedStatus)
list.addFirst(appResult)
}
}
mRestoreProgress.postValue(list)
mRestoreBackupResult.postValue(result)
}
fun closeSession() {
session?.endRestoreSession()
session = null
}
@WorkerThread
private inner class RestoreObserver(
private val restoreCoordinator: RestoreCoordinator,
private val restorableBackup: RestorableBackup,
private val session: IRestoreSession,
private val packages: List<String>,
private val monitor: BackupMonitor,
) : IRestoreObserver.Stub() {
/**
* The current package index.
*
* Used for splitting the packages into chunks.
*/
private var packageIndex: Int = 0
/**
* Map of results for each chunk.
*
* The key is the chunk index, the value is the result.
*/
private val chunkResults = mutableMapOf<Int, Int>()
/**
* Supply a list of the restore datasets available from the current transport.
* This method is invoked as a callback following the application's use of the
* [IRestoreSession.getAvailableRestoreSets] method.
*
* @param restoreSets An array of [RestoreSet] objects
* describing all of the available datasets that are candidates for restoring to
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
// this gets executed after we got the restore sets
// now we can start the restore of all available packages
restoreNextPackages()
}
/**
* Restore the next chunk of packages.
*
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
* transaction, causing the entire restoration to fail.
*/
private fun restoreNextPackages() {
// Make sure metadata for selected backup is cached before starting each chunk.
val backupMetadata = restorableBackup.backupMetadata
restoreCoordinator.beforeStartRestore(backupMetadata)
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
packageIndex += packageChunk.size
val token = backupMetadata.token
val result = session.restorePackages(token, this, packageChunk, monitor)
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
if (result != BackupManager.SUCCESS) {
Log.e(TAG, "restorePackages() returned non-zero value: $result")
}
}
/**
* The restore operation has begun.
*
* @param numPackages The total number of packages
* being processed in this restore operation.
*/
override fun restoreStarting(numPackages: Int) {
// noop
}
/**
* An indication of which package is being restored currently,
* out of the total number provided in the [restoreStarting] callback.
* This method is not guaranteed to be called.
*
* @param nowBeingRestored The index, between 1 and the numPackages parameter
* to the [restoreStarting] callback, of the package now being restored.
* @param currentPackage The name of the package now being restored.
*/
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
// nowBeingRestored reporting is buggy, so don't use it
onRestoreStarted(currentPackage, restorableBackup)
}
/**
* The restore operation has completed.
*
* @param result Zero on success; a nonzero error code if the restore operation
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
chunkResults[chunkIndex] = result
// Restore next chunk if successful and there are more packages to restore.
if (packageIndex < packages.size) {
restoreNextPackages()
return
}
// Restore finished, time to get the result.
onRestoreComplete(getRestoreResult(), restorableBackup)
closeSession()
}
private fun getRestoreResult(): RestoreBackupResult {
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
val failedChunks = chunkResults
.filter { it.value != BackupManager.SUCCESS }
.map { "chunk ${it.key} failed with error ${it.value}" }
return if (failedChunks.isNotEmpty()) {
Log.e(TAG, "Restore failed: $failedChunks")
return RestoreBackupResult(
errorMsg = context.getString(R.string.restore_finished_error)
)
} else {
RestoreBackupResult(errorMsg = null)
}
}
}
private val PackageMetadataMap.packagesSortedByNameDescending: List<String>
get() {
return asIterable().sortedByDescending { (packageName, metadata) ->
// sort packages (reverse) alphabetically, since we move from bottom to top
(metadata.name?.toString() ?: packageName).lowercase(Locale.getDefault())
}.mapNotNull {
// don't try to restore this helper package, as it doesn't really exist
if (it.key == NO_DATA_END_SENTINEL) null else it.key
}
}
}

View file

@ -0,0 +1,194 @@
package com.stevesoltys.seedvault.restore
import android.graphics.drawable.Drawable
import android.text.format.DateUtils
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.ViewGroup
import android.widget.ImageView.ScaleType.CENTER
import android.widget.ImageView.ScaleType.FIT_CENTER
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.VISIBLE
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
sealed interface AppSelectionItem
internal class AppSelectionSection(@StringRes val titleRes: Int) : AppSelectionItem
internal data class SelectableAppItem(
val packageName: String,
val metadata: PackageMetadata,
val selected: Boolean,
val hasIcon: Boolean? = null,
) : AppSelectionItem {
val name: String get() = metadata.name?.toString() ?: packageName
}
internal class AppSelectionAdapter(
val scope: CoroutineScope,
val iconLoader: suspend (SelectableAppItem, (Drawable) -> Unit) -> Unit,
val listener: (SelectableAppItem) -> Unit,
) : Adapter<RecyclerView.ViewHolder>() {
private val diffCallback = object : ItemCallback<AppSelectionItem>() {
override fun areItemsTheSame(
oldItem: AppSelectionItem,
newItem: AppSelectionItem,
): Boolean {
return if (oldItem is AppSelectionSection && newItem is AppSelectionSection) {
oldItem.titleRes == newItem.titleRes
} else if (oldItem is SelectableAppItem && newItem is SelectableAppItem) {
oldItem.packageName == newItem.packageName
} else {
false
}
}
override fun areContentsTheSame(
old: AppSelectionItem,
new: AppSelectionItem,
): Boolean {
return if (old is AppSelectionSection && new is AppSelectionSection) {
true
} else if (old is SelectableAppItem && new is SelectableAppItem) {
old.selected == new.selected && old.hasIcon == new.hasIcon
} else {
false
}
}
}
private val differ = AsyncListDiffer(this, diffCallback)
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long = position.toLong() // items never get added/removed
override fun getItemViewType(position: Int): Int = when (differ.currentList[position]) {
is SelectableAppItem -> 0
is AppSelectionSection -> 1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
0 -> {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_app_status, parent, false)
SelectableAppViewHolder(v)
}
1 -> {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_app_section_title, parent, false)
AppSelectionSectionViewHolder(v)
}
else -> throw AssertionError("unknown view type")
}
}
override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is SelectableAppViewHolder -> {
holder.bind(differ.currentList[position] as SelectableAppItem)
}
is AppSelectionSectionViewHolder -> {
holder.bind(differ.currentList[position] as AppSelectionSection)
}
}
}
fun submitList(items: List<AppSelectionItem>) {
val itemsWithSections = items.toMutableList().apply {
val i = indexOfLast {
it as SelectableAppItem
it.packageName == PACKAGE_NAME_SYSTEM
}
add(i + 1, AppSelectionSection(R.string.backup_section_user))
add(0, AppSelectionSection(R.string.backup_section_system))
}
differ.submitList(itemsWithSections)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is SelectableAppViewHolder) holder.iconJob?.cancel()
}
class AppSelectionSectionViewHolder(v: View) : RecyclerView.ViewHolder(v) {
private val titleView: TextView = v as TextView
fun bind(item: AppSelectionSection) {
titleView.setText(item.titleRes)
}
}
internal inner class SelectableAppViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null
fun bind(item: SelectableAppItem) {
v.background = clickableBackground
v.setOnClickListener {
checkBox.toggle()
}
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = item.selected
checkBox.setOnCheckedChangeListener { _, _ ->
listener(item)
}
checkBox.visibility = if (item.hasIcon == null) INVISIBLE else VISIBLE
progressBar.visibility = if (item.hasIcon == null) VISIBLE else INVISIBLE
val isSpecial = item.metadata.isInternalSystem
appIcon.scaleType = FIT_CENTER
appIcon.setImageResource(R.drawable.ic_launcher_default)
appIcon.scaleType = if (isSpecial) CENTER else FIT_CENTER
if (item.hasIcon == null && !isSpecial) {
appIcon.alpha = 0.5f
} else if (item.hasIcon == true || isSpecial) {
appIcon.alpha = 0.5f
iconJob = scope.launch {
iconLoader(item) { bitmap ->
appIcon.scaleType = if (isSpecial) CENTER else FIT_CENTER
appIcon.setImageDrawable(bitmap)
appIcon.alpha = 1f
}
}
} else {
appIcon.alpha = 1f
}
appName.text = item.name
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(
item.metadata.time,
System.currentTimeMillis(),
DateUtils.HOUR_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
) else v.context.getString(R.string.settings_backup_last_backup_never)
val size = if (item.metadata.size == null) "" else "(" + Formatter.formatShortFileSize(
v.context,
item.metadata.size ?: 0
) + ")"
appInfo.text =
v.context.getString(R.string.settings_backup_status_summary, "$time $size")
appInfo.visibility = VISIBLE
}
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,168 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
import com.stevesoltys.seedvault.ui.systemData
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.worker.IconManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.Locale
internal class SelectedAppsState(
val apps: List<SelectableAppItem>,
val allSelected: Boolean,
val iconsLoaded: Boolean,
)
private val TAG = AppSelectionManager::class.simpleName
internal class AppSelectionManager(
private val context: Context,
private val pluginManager: StoragePluginManager,
private val iconManager: IconManager,
private val coroutineScope: CoroutineScope,
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val initialState = SelectedAppsState(
emptyList(),
allSelected = true,
iconsLoaded = false,
)
private val selectedApps = MutableStateFlow(initialState)
val selectedAppsFlow = selectedApps.asStateFlow()
val selectedAppsLiveData: LiveData<SelectedAppsState> = selectedApps.asLiveData()
fun onRestoreSetChosen(restorableBackup: RestorableBackup) {
// filter and sort app items for display
val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
if (metadata.time == 0L && !metadata.hasApk()) null
else if (metadata.isInternalSystem) null
else SelectableAppItem(packageName, metadata, true)
}.sortedBy {
it.name.lowercase(Locale.getDefault())
}.toMutableList()
val systemDataItems = systemData.mapNotNull { (packageName, data) ->
val metadata = restorableBackup.packageMetadataMap[packageName]
?: return@mapNotNull null
if (metadata.time == 0L && !metadata.hasApk()) return@mapNotNull null
val name = context.getString(data.nameRes)
SelectableAppItem(packageName, metadata.copy(name = name), true)
}
val systemItem = SelectableAppItem(
packageName = PACKAGE_NAME_SYSTEM,
metadata = PackageMetadata(
time = restorableBackup.packageMetadataMap.values.maxOf {
if (it.system) it.time else -1
},
size = restorableBackup.packageMetadataMap.values.sumOf {
if (it.system) it.size ?: 0L else 0L
},
system = true,
name = context.getString(R.string.backup_system_apps),
),
selected = true,
)
items.add(0, systemItem)
items.addAll(0, systemDataItems)
selectedApps.value =
SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false)
// download icons
coroutineScope.launch(workDispatcher) {
val plugin = pluginManager.appPlugin
val token = restorableBackup.token
val packagesWithIcons = try {
plugin.getInputStream(token, FILE_BACKUP_ICONS).use {
iconManager.downloadIcons(restorableBackup.version, token, it)
}
} catch (e: Exception) {
Log.e(TAG, "Error loading icons:", e)
emptySet()
} + systemData.keys + setOf(PACKAGE_NAME_SYSTEM) // special apps have built-in icons
// update state, so it knows that icons have loaded
val updatedItems = items.map { item ->
item.copy(hasIcon = item.packageName in packagesWithIcons)
}
selectedApps.value =
SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true)
}
}
fun onCheckAllAppsClicked() {
val apps = selectedApps.value.apps
val allSelected = apps.all { it.selected }
if (allSelected) {
// unselect all
val newApps = apps.map { if (it.selected) it.copy(selected = false) else it }
selectedApps.value = SelectedAppsState(newApps, false, iconsLoaded = true)
} else {
// select all
val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it }
selectedApps.value = SelectedAppsState(newApps, true, iconsLoaded = true)
}
}
fun onAppSelected(item: SelectableAppItem) {
val apps = selectedApps.value.apps.toMutableList()
val iterator = apps.listIterator()
var allSelected = true
while (iterator.hasNext()) {
val app = iterator.next()
if (app.packageName == item.packageName) {
iterator.set(item.copy(selected = !item.selected))
allSelected = allSelected && !item.selected
} else {
allSelected = allSelected && app.selected
}
}
selectedApps.value = SelectedAppsState(apps, allSelected, iconsLoaded = true)
}
fun onAppSelectionFinished(backup: RestorableBackup): RestorableBackup {
// map packages names to selection state
val apps = selectedApps.value.apps.associate {
Pair(it.packageName, it.selected)
}
// filter out unselected packages
// Attention: This code is complicated and hard to test, proceed with plenty of care!
val restoreSystemApps = apps[PACKAGE_NAME_SYSTEM] != false
val packages = backup.packageMetadataMap.filter { (packageName, metadata) ->
val isSelected = apps[packageName]
@Suppress("IfThenToElvis") // the code is more readable like this
if (isSelected == null) { // was not in list
if (packageName == MAGIC_PACKAGE_MANAGER) true // @pm@ is essential for restore
else if (packageName == NO_DATA_END_SENTINEL) false // @end@ is not real
// internal system apps were not in the list and are controlled by meta item,
// so allow them only if meta item was selected
else if (metadata.isInternalSystem) restoreSystemApps
else true // non-system packages that weren't found, won't get filtered
} else { // was in list and either selected or not
isSelected
}
} as PackageMetadataMap
// replace original chosen backup with unselected packages removed
return backup.copy(
backupMetadata = backup.backupMetadata.copy(packageMetadataMap = packages),
)
}
}

View file

@ -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_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED 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.restore.install.InstallProgressFragment
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
@ -28,15 +29,16 @@ class RestoreActivity : RequireProvisioningActivity() {
setContentView(R.layout.activity_fragment_container) setContentView(R.layout.activity_fragment_container)
viewModel.displayFragment.observeEvent(this, { fragment -> viewModel.displayFragment.observeEvent(this) { fragment ->
when (fragment) { when (fragment) {
SELECT_APPS -> showFragment(AppSelectionFragment())
RESTORE_APPS -> showFragment(InstallProgressFragment()) RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment()) RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
RESTORE_FILES -> showFragment(RestoreFilesFragment()) RESTORE_FILES -> showFragment(RestoreFilesFragment())
RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment()) RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment())
else -> throw AssertionError() else -> throw AssertionError()
} }
}) }
if (savedInstanceState == null) { if (savedInstanceState == null) {
showFragment(RestoreSetFragment()) showFragment(RestoreSetFragment())

View file

@ -6,21 +6,40 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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 androidx.recyclerview.widget.RecyclerView.Adapter
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.LinkedList import java.util.LinkedList
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() { internal class RestoreProgressAdapter(
val scope: CoroutineScope,
val iconLoader: suspend (AppRestoreResult, (Drawable) -> Unit) -> Unit,
) : Adapter<PackageViewHolder>() {
private val items = LinkedList<AppRestoreResult>() private val diffCallback = object : ItemCallback<AppRestoreResult>() {
override fun areItemsTheSame(
oldItem: AppRestoreResult,
newItem: AppRestoreResult,
): Boolean {
return oldItem.packageName == newItem.packageName
}
override fun areContentsTheSame(old: AppRestoreResult, new: AppRestoreResult): Boolean {
return old.name == new.name && old.state == new.state
}
}
private val differ = AsyncListDiffer(this, diffCallback)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder {
val v = LayoutInflater.from(parent.context) val v = LayoutInflater.from(parent.context)
@ -28,37 +47,24 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
return PackageViewHolder(v) return PackageViewHolder(v)
} }
override fun getItemCount() = items.size override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: PackageViewHolder, position: Int) { override fun onBindViewHolder(holder: PackageViewHolder, position: Int) {
holder.bind(items[position]) holder.bind(differ.currentList[position])
} }
fun update(newItems: LinkedList<AppRestoreResult>) { fun update(newItems: LinkedList<AppRestoreResult>, callback: Runnable) {
val diffResult = DiffUtil.calculateDiff(Diff(items, newItems)) // add .toList(), because [AppDataRestoreManager] still re-uses the same list,
items.clear() // but AsyncListDiffer needs a new one.
items.addAll(newItems) differ.submitList(newItems.toList(), callback)
diffResult.dispatchUpdatesTo(this)
} }
private class Diff( override fun onViewRecycled(holder: PackageViewHolder) {
private val oldItems: LinkedList<AppRestoreResult>, holder.iconJob?.cancel()
private val newItems: LinkedList<AppRestoreResult>,
) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition] == newItems[newItemPosition]
}
} }
class PackageViewHolder(v: View) : AppViewHolder(v) { inner class PackageViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null
fun bind(item: AppRestoreResult) { fun bind(item: AppRestoreResult) {
appName.text = item.name appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) { if (item.packageName == MAGIC_PACKAGE_MANAGER) {
@ -67,7 +73,11 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
try { try {
appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName)) appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName))
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
appIcon.setImageResource(R.drawable.ic_launcher_default) iconJob = scope.launch {
iconLoader(item) { bitmap ->
appIcon.setImageDrawable(bitmap)
}
}
} }
} }
setState(item.state, true) setState(item.state, true)
@ -75,9 +85,3 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
} }
} }
internal data class AppRestoreResult(
val packageName: String,
val name: CharSequence,
val state: AppBackupState,
)

View file

@ -5,6 +5,7 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -16,6 +17,7 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getColor import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -27,7 +29,7 @@ class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter() private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView 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 // decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON) requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, { restorableBackup -> viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup ->
backupNameView.text = restorableBackup.name backupNameView.text = restorableBackup.name
progressBar.max = restorableBackup.packageMetadataMap.size progressBar.max = restorableBackup.packageMetadataMap.size
}) }
viewModel.restoreProgress.observe(viewLifecycleOwner, { list -> viewModel.restoreProgress.observe(viewLifecycleOwner) { list ->
stayScrolledAtTop { adapter.update(list) }
progressBar.progress = list.size 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 button.isEnabled = true
if (finished.hasError()) { if (finished.hasError()) {
backupNameView.text = finished.errorMsg backupNameView.text = finished.errorMsg
@ -87,7 +92,7 @@ class RestoreProgressFragment : Fragment() {
onRestoreFinished() onRestoreFinished()
} }
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON) activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
}) }
} }
private fun onRestoreFinished() { private fun onRestoreFinished() {
@ -103,10 +108,8 @@ class RestoreProgressFragment : Fragment() {
.show() .show()
} }
private fun stayScrolledAtTop(add: () -> Unit) { private suspend fun loadIcon(item: AppRestoreResult, callback: (Drawable) -> Unit) {
val position = layoutManager.findFirstVisibleItemPosition() viewModel.loadIcon(item.packageName, callback)
add.invoke()
if (position == 0) layoutManager.scrollToPosition(0)
} }
} }

View file

@ -6,73 +6,47 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.app.Application import android.app.Application
import android.app.backup.BackupManager
import android.app.backup.BackupTransport
import android.app.backup.IBackupManager 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.content.Intent
import android.os.RemoteException import android.graphics.drawable.Drawable
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager 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.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED 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.ApkRestore
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
import com.stevesoltys.seedvault.restore.install.InstallResult import com.stevesoltys.seedvault.restore.install.InstallResult
import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageRestoreService 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.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.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent 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.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.CoroutineDispatcher
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START 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.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.lang.IllegalStateException
import java.util.LinkedList import java.util.LinkedList
private val TAG = RestoreViewModel::class.java.simpleName private val TAG = RestoreViewModel::class.java.simpleName
@ -83,9 +57,10 @@ internal class RestoreViewModel(
app: Application, app: Application,
settingsManager: SettingsManager, settingsManager: SettingsManager,
keyManager: KeyManager, keyManager: KeyManager,
private val backupManager: IBackupManager, backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator, private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore, private val apkRestore: ApkRestore,
private val iconManager: IconManager,
storageBackup: StorageBackup, storageBackup: StorageBackup,
pluginManager: StoragePluginManager, pluginManager: StoragePluginManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@ -94,8 +69,10 @@ internal class RestoreViewModel(
override val isRestoreOperation = true override val isRestoreOperation = true
private var session: IRestoreSession? = null private val appSelectionManager =
private val monitor = BackupMonitor() AppSelectionManager(app, pluginManager, iconManager, viewModelScope)
private val appDataRestoreManager =
AppDataRestoreManager(app, backupManager, settingsManager, restoreCoordinator)
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>() private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
@ -106,43 +83,21 @@ internal class RestoreViewModel(
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>() private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
internal val installResult: LiveData<InstallResult> = internal val selectedApps: LiveData<SelectedAppsState> =
mChosenRestorableBackup.switchMap { backup -> appSelectionManager.selectedAppsLiveData
getInstallResult(backup)
} internal val installResult: LiveData<InstallResult> = apkRestore.installResult.asLiveData()
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) } internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false } internal val restoreProgress: LiveData<LinkedList<AppRestoreResult>>
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled get() = appDataRestoreManager.restoreProgress
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply { internal val restoreBackupResult: LiveData<RestoreBackupResult>
value = LinkedList<AppRestoreResult>().apply { get() = appDataRestoreManager.restoreBackupResult
add(
AppRestoreResult(
packageName = MAGIC_PACKAGE_MANAGER,
name = getAppName(app, MAGIC_PACKAGE_MANAGER),
state = IN_PROGRESS
)
)
}
}
internal val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher) 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) { internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) -> val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
when (metadata.time) { when (metadata.time) {
@ -164,282 +119,57 @@ internal class RestoreViewModel(
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) { override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
mChosenRestorableBackup.value = 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) mDisplayFragment.setEvent(RESTORE_APPS)
} }
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> { fun reCheckFailedPackage(packageName: String) = apkRestore.reCheckFailedPackage(packageName)
@Suppress("EXPERIMENTAL_API_USAGE")
return apkRestore.restore(backup)
.onStart {
Log.d(TAG, "Start InstallResult Flow")
}.catch { e ->
Log.d(TAG, "Exception in InstallResult Flow", e)
}.onCompletion { e ->
Log.d(TAG, "Completed InstallResult Flow", e)
mNextButtonEnabled.postValue(true)
}
.flowOn(ioDispatcher)
// collect on the same thread, so concurrency issues don't mess up live data updates
// e.g. InstallResult#isFinished isn't reported too early.
.asLiveData(ioDispatcher)
}
internal fun onNextClickedAfterInstallingApps() { internal fun onNextClickedAfterInstallingApps() {
mDisplayFragment.postEvent(RESTORE_BACKUP) mDisplayFragment.postEvent(RESTORE_BACKUP)
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
startRestore() val backup = chosenRestorableBackup.value ?: error("No Backup chosen")
appDataRestoreManager.startRestore(backup)
} }
} }
@WorkerThread
private fun startRestore() {
val token = mChosenRestorableBackup.value?.token
?: throw IllegalStateException("No chosen backup")
Log.d(TAG, "Starting new restore session to restore backup $token")
// if we had no token before (i.e. restore from setup wizard),
// use the token of the current restore set from now on
if (settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
// start a new restore session
val session = try {
getOrStartSession()
} catch (e: RemoteException) {
Log.e(TAG, "Error starting new session", e)
mRestoreBackupResult.postValue(
RestoreBackupResult(app.getString(R.string.restore_set_error))
)
return
}
val restorableBackup = mChosenRestorableBackup.value
val packages = restorableBackup?.packageMetadataMap?.keys?.toList()
?: run {
Log.e(TAG, "Chosen backup has empty package metadata map")
mRestoreBackupResult.postValue(
RestoreBackupResult(app.getString(R.string.restore_set_error))
)
return
}
val observer = RestoreObserver(
restoreCoordinator = restoreCoordinator,
restorableBackup = restorableBackup,
session = session,
packages = packages,
monitor = monitor
)
// We need to retrieve the restore sets before starting the restore.
// Otherwise, restorePackages() won't work as they need the restore sets cached internally.
if (session.getAvailableRestoreSets(observer, monitor) != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreBackupResult.postValue(
RestoreBackupResult(app.getString(R.string.restore_set_error))
)
}
}
@WorkerThread
// this should be called one package at a time and never concurrently for different packages
private fun onRestoreStarted(packageName: String) {
// list is never null and always has at least one package
val list = mRestoreProgress.value!!
// check previous package first and change status
updateLatestPackage(list)
// add current package
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), IN_PROGRESS))
mRestoreProgress.postValue(list)
}
@WorkerThread
private fun updateLatestPackage(list: LinkedList<AppRestoreResult>) {
val latestResult = list[0]
if (restoreCoordinator.isFailedPackage(latestResult.packageName)) {
list[0] = latestResult.copy(state = getFailedStatus(latestResult.packageName))
} else {
list[0] = latestResult.copy(state = SUCCEEDED)
}
}
@WorkerThread
private fun getFailedStatus(
packageName: String,
restorableBackup: RestorableBackup = chosenRestorableBackup.value!!,
): AppBackupState {
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
return when (metadata.state) {
NO_DATA -> FAILED_NO_DATA
WAS_STOPPED -> NOT_YET_BACKED_UP
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> {
if (app.packageManager.isInstalled(packageName)) FAILED else FAILED_NOT_INSTALLED
}
}
}
@WorkerThread
private fun onRestoreComplete(result: RestoreBackupResult) {
// update status of latest package
val list = mRestoreProgress.value!!
updateLatestPackage(list)
// add missing packages as failed
val seenPackages = list.map { it.packageName }
val restorableBackup = chosenRestorableBackup.value!!
val expectedPackages = restorableBackup.packageMetadataMap.keys
expectedPackages.removeAll(seenPackages)
for (packageName: String in expectedPackages) {
// TODO don't add if it was a NO_DATA system app
val failedStatus = getFailedStatus(packageName, restorableBackup)
list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), failedStatus))
}
mRestoreProgress.postValue(list)
mRestoreBackupResult.postValue(result)
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
closeSession() @OptIn(DelicateCoroutinesApi::class)
} GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
appDataRestoreManager.closeSession()
private fun closeSession() {
session?.endRestoreSession()
session = null
}
@WorkerThread
private inner class RestoreObserver(
private val restoreCoordinator: RestoreCoordinator,
private val restorableBackup: RestorableBackup,
private val session: IRestoreSession,
private val packages: List<String>,
private val monitor: BackupMonitor,
) : IRestoreObserver.Stub() {
/**
* The current package index.
*
* Used for splitting the packages into chunks.
*/
private var packageIndex: Int = 0
/**
* Map of results for each chunk.
*
* The key is the chunk index, the value is the result.
*/
private val chunkResults = mutableMapOf<Int, Int>()
/**
* Supply a list of the restore datasets available from the current transport.
* This method is invoked as a callback following the application's use of the
* [IRestoreSession.getAvailableRestoreSets] method.
*
* @param restoreSets An array of [RestoreSet] objects
* describing all of the available datasets that are candidates for restoring to
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
// this gets executed after we got the restore sets
// now we can start the restore of all available packages
restoreNextPackages()
}
/**
* Restore the next chunk of packages.
*
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
* transaction, causing the entire restoration to fail.
*/
private fun restoreNextPackages() {
// Make sure metadata for selected backup is cached before starting each chunk.
val backupMetadata = restorableBackup.backupMetadata
restoreCoordinator.beforeStartRestore(backupMetadata)
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
packageIndex += packageChunk.size
val token = backupMetadata.token
val result = session.restorePackages(token, this, packageChunk, monitor)
if (result != BackupManager.SUCCESS) {
Log.e(TAG, "restorePackages() returned non-zero value: $result")
}
}
/**
* The restore operation has begun.
*
* @param numPackages The total number of packages
* being processed in this restore operation.
*/
override fun restoreStarting(numPackages: Int) {
// noop
}
/**
* An indication of which package is being restored currently,
* out of the total number provided in the [restoreStarting] callback.
* This method is not guaranteed to be called.
*
* @param nowBeingRestored The index, between 1 and the numPackages parameter
* to the [restoreStarting] callback, of the package now being restored.
* @param currentPackage The name of the package now being restored.
*/
override fun onUpdate(nowBeingRestored: Int, currentPackage: String) {
// nowBeingRestored reporting is buggy, so don't use it
onRestoreStarted(currentPackage)
}
/**
* The restore operation has completed.
*
* @param result Zero on success; a nonzero error code if the restore operation
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
chunkResults[chunkIndex] = result
// Restore next chunk if successful and there are more packages to restore.
if (packageIndex < packages.size) {
restoreNextPackages()
return
}
// Restore finished, time to get the result.
onRestoreComplete(getRestoreResult())
closeSession()
}
private fun getRestoreResult(): RestoreBackupResult {
val failedChunks = chunkResults
.filter { it.value != BackupManager.SUCCESS }
.map { "chunk ${it.key} failed with error ${it.value}" }
return if (failedChunks.isNotEmpty()) {
Log.e(TAG, "Restore failed: $failedChunks")
return RestoreBackupResult(
errorMsg = app.getString(R.string.restore_finished_error)
)
} else {
RestoreBackupResult(errorMsg = null)
}
}
} }
@UiThread @UiThread
@ -475,5 +205,5 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
} }
internal enum class DisplayFragment { internal enum class DisplayFragment {
RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED SELECT_APPS, RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
} }

View file

@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) {
cachedApks: List<File>, cachedApks: List<File>,
packageName: String, packageName: String,
installerPackageName: String?, installerPackageName: String?,
installResult: MutableInstallResult, installResult: InstallResult,
) = suspendCancellableCoroutine<InstallResult> { cont -> ) = suspendCancellableCoroutine { cont ->
val broadcastReceiver = object : BroadcastReceiver() { val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, i: Intent) { override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return if (i.action != BROADCAST_ACTION) return
@ -110,7 +110,7 @@ internal class ApkInstaller(private val context: Context) {
i: Intent, i: Intent,
expectedPackageName: String, expectedPackageName: String,
cachedApks: List<File>, cachedApks: List<File>,
installResult: MutableInstallResult, installResult: InstallResult,
): InstallResult { ): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!! val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
@ -26,10 +27,12 @@ import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
import com.stevesoltys.seedvault.worker.getSignatures import com.stevesoltys.seedvault.worker.getSignatures
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Locale
private val TAG = ApkRestore::class.java.simpleName private val TAG = ApkRestore::class.java.simpleName
@ -47,70 +50,69 @@ internal class ApkRestore(
private val pm = context.packageManager private val pm = context.packageManager
private val storagePlugin get() = pluginManager.appPlugin private val storagePlugin get() = pluginManager.appPlugin
fun restore(backup: RestorableBackup) = flow { private val mInstallResult = MutableStateFlow(InstallResult())
// we don't filter out apps without APK, so the user can manually install them val installResult = mInstallResult.asStateFlow()
val packages = backup.packageMetadataMap.filter {
suspend fun restore(backup: RestorableBackup) {
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks()
// assemble all apps in a list and sort it by name, than transform it back to a (sorted) map
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
// We need to exclude the DocumentsProvider used to retrieve backup data. // We need to exclude the DocumentsProvider used to retrieve backup data.
// Otherwise, it gets killed when we install it, terminating our restoration. // Otherwise, it gets killed when we install it, terminating our restoration.
it.key != storagePlugin.providerPackageName if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
} // The @pm@ package needs to be included in [backup], but can't be installed like an app
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks() if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
val total = packages.size // we don't filter out apps without APK, so the user can manually install them
var progress = 0 // exception is system apps without APK, as those can usually not be installed manually
if (metadata.system && !metadata.hasApk()) return@mapNotNull null
// queue all packages and emit LiveData // apps that made it here get a state class for tracking
val installResult = MutableInstallResult(total) ApkInstallResult(
packages.forEach { (packageName, metadata) ->
progress++
installResult[packageName] = ApkInstallResult(
packageName = packageName, packageName = packageName,
progress = progress,
state = if (isAllowedToInstallApks) QUEUED else FAILED, state = if (isAllowedToInstallApks) QUEUED else FAILED,
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) { if (!isAllowedToInstallApks) { // not allowed to install, so return list with all failed
emit(installResult) mInstallResult.value = InstallResult(packages, true)
} else { return
installResult.isFinished = true
emit(installResult)
return@flow
} }
mInstallResult.value = InstallResult(packages)
// re-install individual packages and emit updates // re-install individual packages and emit updates (start from last and work your way up)
for ((packageName, metadata) in packages) { for ((packageName, apkInstallResult) in packages.asIterable().reversed()) {
try { try {
if (metadata.hasApk()) { if (apkInstallResult.metadata.hasApk()) {
restore(this, backup, packageName, metadata, installResult) restore(backup, packageName, apkInstallResult.metadata)
} else { } else {
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security error re-installing APK for $packageName.", e) Log.e(TAG, "Security error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} }
} }
installResult.isFinished = true mInstallResult.update { it.copy(isFinished = true) }
emit(installResult)
} }
@Suppress("ThrowsCount") @Suppress("ThrowsCount")
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>,
backup: RestorableBackup, backup: RestorableBackup,
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult,
) { ) {
// cache the APK and get its hash // cache the APK and get its hash
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName) val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
@ -153,34 +155,32 @@ internal class ApkRestore(
publicSourceDir = cachedApk.absolutePath publicSourceDir = cachedApk.absolutePath
} }
val icon = appInfo?.loadIcon(pm) val icon = appInfo?.loadIcon(pm)
val name = appInfo?.let { pm.getApplicationLabel(it) } val name = appInfo?.let { pm.getApplicationLabel(it).toString() }
installResult.update(packageName) { result -> mInstallResult.update {
result.copy(state = IN_PROGRESS, name = name, icon = icon) it.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
} }
} }
// 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 // process further APK splits, if available
val cachedApks = val cachedApks = cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
if (cachedApks == null) { if (cachedApks == null) {
Log.w(TAG, "Not installing $packageName because of incompatible splits.") Log.w(TAG, "Not installing $packageName because of incompatible splits.")
collector.emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
return return
} }
// install APK and emit updates from it // install APK and emit updates from it
val result = val result =
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult) apkInstaller.install(cachedApks, packageName, metadata.installer, installResult.value)
collector.emit(result) mInstallResult.value = result
} }
/** /**
@ -239,7 +239,6 @@ internal class ApkRestore(
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it // copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = if (version == 0.toByte()) { val inputStream = if (version == 0.toByte()) {
@Suppress("Deprecation")
legacyStoragePlugin.getApkInputStream(token, packageName, suffix) legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
} else { } else {
val name = crypto.getNameForApk(salt, packageName, suffix) val name = crypto.getNameForApk(salt, packageName, suffix)
@ -256,26 +255,38 @@ internal class ApkRestore(
private fun shouldInstallSystemApp( private fun shouldInstallSystemApp(
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult,
): InstallResult? { ): InstallResult? {
val installedPackageInfo = try { val installedPackageInfo = try {
pm.getPackageInfo(packageName, 0) pm.getPackageInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "Not installing system app $packageName because not installed here.") Log.w(TAG, "Not installing system app $packageName because not installed here.")
// we report a different FAILED status here to prevent manual installs // we report a different FAILED status here to prevent manual installs
return installResult.fail(packageName, FAILED_SYSTEM_APP) return installResult.value.fail(packageName, FAILED_SYSTEM_APP)
} }
// metadata.version is not null, because here hasApk() must be true // metadata.version is not null, because here hasApk() must be true
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
return if (isOlder) { return if (isOlder) {
Log.w(TAG, "Not installing $packageName because ours is older.") Log.w(TAG, "Not installing $packageName because ours is older.")
installResult.update(packageName) { it.copy(state = SUCCEEDED) } installResult.value.update(packageName) { it.copy(state = SUCCEEDED) }
} else if (!installedPackageInfo.isSystemApp()) { } else if (!installedPackageInfo.isSystemApp()) {
Log.w(TAG, "Not installing $packageName because not a system app here.") Log.w(TAG, "Not installing $packageName because not a system app here.")
installResult.update(packageName) { it.copy(state = SUCCEEDED) } installResult.value.update(packageName) { it.copy(state = SUCCEEDED) }
} else { } else {
null // everything is good, we can re-install this null // everything is good, we can re-install this
} }
} }
/**
* Once [InstallResult.isFinished] is true,
* this can be called to re-check a package in state [FAILED].
* If it is now installed, the state will be changed to [SUCCEEDED].
*/
fun reCheckFailedPackage(packageName: String) {
check(installResult.value.isFinished) {
"re-checking failed packages only allowed when finished"
}
if (context.packageManager.isInstalled(packageName)) mInstallResult.update { result ->
result.update(packageName) { it.copy(state = SUCCEEDED) }
}
}
} }

View file

@ -5,15 +5,16 @@
package com.stevesoltys.seedvault.restore.install package com.stevesoltys.seedvault.restore.install
import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -22,35 +23,33 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
internal interface InstallItemListener { internal interface InstallItemListener {
fun onFailedItemClicked(item: ApkInstallResult) fun onFailedItemClicked(item: ApkInstallResult)
} }
internal class InstallProgressAdapter( internal class InstallProgressAdapter(
private val scope: CoroutineScope,
private val iconLoader: suspend (ApkInstallResult, (Drawable) -> Unit) -> Unit,
private val listener: InstallItemListener, private val listener: InstallItemListener,
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() { ) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
private var finished = false private var finished = false
private val finishedComparator = FailedFirstComparator()
private val items = SortedList(
ApkInstallResult::class.java,
object : SortedListAdapterCallback<ApkInstallResult>(this) {
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
item1.packageName == item2.packageName
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean { private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
// update failed items when finished override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean =
return if (finished) new.state != FAILED && old == new item1.packageName == item2.packageName
else old == new
}
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int { override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
return if (finished) finishedComparator.compare(item1, item2) // update failed items when finished
else item1.compareTo(item2) 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
val v = LayoutInflater.from(parent.context) val v = LayoutInflater.from(parent.context)
@ -58,27 +57,33 @@ internal class InstallProgressAdapter(
return AppInstallViewHolder(v) return AppInstallViewHolder(v)
} }
override fun getItemCount() = items.size() override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) { override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
holder.bind(items[position]) holder.bind(differ.currentList[position])
} }
fun update(items: Collection<ApkInstallResult>) { fun update(items: List<ApkInstallResult>, block: Runnable) {
this.items.replaceAll(items) differ.submitList(items, block)
} }
fun setFinished() { fun setFinished() {
finished = true finished = true
} }
internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) { override fun onViewRecycled(holder: AppInstallViewHolder) {
holder.iconJob?.cancel()
}
internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null
fun bind(item: ApkInstallResult) { fun bind(item: ApkInstallResult) {
v.setOnClickListener(null) v.setOnClickListener(null)
v.background = null v.background = null
appIcon.setImageDrawable(item.icon) if (item.icon == null) iconJob = scope.launch {
iconLoader(item, appIcon::setImageDrawable)
} else appIcon.setImageDrawable(item.icon)
appName.text = item.name ?: getAppName(v.context, item.packageName.toString()) appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
appInfo.visibility = GONE appInfo.visibility = GONE
when (item.state) { when (item.state) {

View file

@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore.install
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -20,6 +21,7 @@ import android.widget.Toast.LENGTH_LONG
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -31,7 +33,8 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter(this) private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
private var hasShownFailDialog = false
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView private lateinit var titleView: TextView
@ -72,35 +75,27 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
viewModel.installResult.observe(viewLifecycleOwner) { result -> viewModel.installResult.observe(viewLifecycleOwner) { result ->
onInstallResult(result) onInstallResult(result)
} }
viewModel.nextButtonEnabled.observe(viewLifecycleOwner) { enabled ->
button.isEnabled = enabled
}
} }
private fun onInstallResult(installResult: InstallResult) { private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install // skip this screen, if there are no apps to install
if (installResult.isFinished && installResult.isEmpty) { if (installResult.hasNoAppsToInstall) {
viewModel.onNextClickedAfterInstallingApps() viewModel.onNextClickedAfterInstallingApps()
} else {
// 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) { private fun onFinished(installResult: InstallResult) {
if (installResult.hasFailed) { adapter.setFinished()
button.isEnabled = true
if (!hasShownFailDialog && installResult.hasFailed) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_warning) .setIcon(R.drawable.ic_warning)
.setTitle(R.string.restore_installing_error_title) .setTitle(R.string.restore_installing_error_title)
@ -109,18 +104,20 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
dialog.dismiss() dialog.dismiss()
} }
.setOnDismissListener { .setOnDismissListener {
updateAdapter(installResult.getNotQueued()) hasShownFailDialog = true
updateAdapter(installResult.list)
} }
.show() .show()
} else { } else {
updateAdapter(installResult.getNotQueued()) updateAdapter(installResult.list)
} }
} }
private fun updateAdapter(items: Collection<ApkInstallResult>) { private fun updateAdapter(items: List<ApkInstallResult>) {
val position = layoutManager.findFirstVisibleItemPosition() val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(items) adapter.update(items) {
if (position == 0) layoutManager.scrollToPosition(0) if (position == 0) layoutManager.scrollToPosition(0)
}
} }
override fun onFailedItemClicked(item: ApkInstallResult) { override fun onFailedItemClicked(item: ApkInstallResult) {
@ -131,14 +128,14 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
} }
} }
private suspend fun loadIcon(item: ApkInstallResult, callback: (Drawable) -> Unit) {
viewModel.loadIcon(item.packageName, callback)
}
private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName -> private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName ->
val result = viewModel.installResult.value ?: return@registerForActivityResult val result = viewModel.installResult.value ?: return@registerForActivityResult
if (result.isFinished) { if (result.isFinished) {
val changed = result.reCheckFailedPackage( viewModel.reCheckFailedPackage(packageName.toString())
requireContext().packageManager,
packageName.toString()
)
if (changed) adapter.update(result.getNotQueued())
} }
} }

View file

@ -5,136 +5,90 @@
package com.stevesoltys.seedvault.restore.install package com.stevesoltys.seedvault.restore.install
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import java.util.concurrent.ConcurrentHashMap
internal interface InstallResult {
/**
* The number of packages already processed.
*/
val progress: Int
/**
* The total number of packages to be considered for re-install.
*/
val total: Int
/**
* Is true, if there is no packages to install and false otherwise.
*/
val isEmpty: Boolean
internal data class InstallResult(
@get:VisibleForTesting
val installResults: Map<String, ApkInstallResult> = mapOf(),
/** /**
* Is true, if the installation is finished, either because all packages were processed * Is true, if the installation is finished, either because all packages were processed
* or because an unexpected error happened along the way. * or because an unexpected error happened along the way.
* Is false, if the installation is still ongoing. * Is false, if the installation is still ongoing.
*/ */
val isFinished: Boolean val isFinished: Boolean = false,
) {
/**
* The number of packages already processed.
*/
val progress: Int = installResults.count {
val state = it.value.state
state != QUEUED && state != IN_PROGRESS
}
/**
* The total number of packages to be considered for re-install.
*/
val total: Int = installResults.size
/**
* A list of all [ApkInstallResult]s that are not in state [QUEUED].
*/
val list: List<ApkInstallResult> = installResults.filterValues { result ->
result.state != QUEUED
}.values.run {
if (isFinished) sortedWith(FailedFirstComparator()) else this
}.toList()
/**
* Is true, if there is no packages to install and false otherwise.
*/
val hasNoAppsToInstall: Boolean = installResults.isEmpty() && isFinished
/** /**
* Is true when one or more packages failed to install. * Is true when one or more packages failed to install.
*/ */
val hasFailed: Boolean val hasFailed: Boolean = installResults.any { it.value.state == FAILED }
/**
* Get all [ApkInstallResult]s that are not in state [QUEUED].
*/
fun getNotQueued(): Collection<ApkInstallResult>
/**
* Set the set of all [ApkInstallResult]s that are still [QUEUED] to [FAILED].
* This is useful after [isFinished] is true due to an error
* and we need to treat all packages as failed that haven't been processed.
*/
fun queuedToFailed()
/**
* Once [isFinished] is true, this can be called to re-check a package in state [FAILED].
* If it is now installed, the state will be changed to [SUCCEEDED] and true returned.
*/
fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean
}
internal class MutableInstallResult(override val total: Int) : InstallResult {
private val installResults = ConcurrentHashMap<String, ApkInstallResult>(total)
override val isEmpty get() = installResults.isEmpty()
@Volatile
override var isFinished = false
override val progress
get() = installResults.count {
val state = it.value.state
state != QUEUED && state != IN_PROGRESS
}
override val hasFailed get() = installResults.any { it.value.state == FAILED }
override fun getNotQueued(): Collection<ApkInstallResult> {
return installResults.filterValues { result -> result.state != QUEUED }.values
}
override fun queuedToFailed() {
installResults.forEach { entry ->
val result = entry.value
if (result.state == QUEUED) installResults[entry.key] = result.copy(state = FAILED)
}
}
operator fun get(packageName: String) = installResults[packageName]
operator fun set(packageName: String, installResult: ApkInstallResult) {
installResults[packageName] = installResult
check(installResults.size <= total) { "Attempting to add more packages than total" }
}
fun update( fun update(
packageName: String, packageName: String,
updateFun: (ApkInstallResult) -> ApkInstallResult, updateFun: (ApkInstallResult) -> ApkInstallResult,
): MutableInstallResult { ): InstallResult {
val result = get(packageName) val results = installResults.toMutableMap()
val result = results[packageName]
check(result != null) { "ApkRestoreResult for $packageName does not exist." } check(result != null) { "ApkRestoreResult for $packageName does not exist." }
installResults[packageName] = updateFun(result) results[packageName] = updateFun(result)
return this return copy(installResults = results)
} }
fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult { fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult {
return update(packageName) { it.copy(state = state) } return update(packageName) { it.copy(state = state) }
} }
override fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean {
check(isFinished) { "re-checking failed packages only allowed when finished" }
if (pm.isInstalled(packageName)) {
update(packageName) { it.copy(state = SUCCEEDED) }
return true
}
return false
}
} }
data class ApkInstallResult( data class ApkInstallResult(
val packageName: CharSequence, val packageName: String,
val progress: Int,
val state: ApkInstallState, val state: ApkInstallState,
val name: CharSequence? = null, val metadata: PackageMetadata,
val name: String? = metadata.name?.toString(),
val icon: Drawable? = null, val icon: Drawable? = null,
val installerPackageName: CharSequence? = null, ) {
) : Comparable<ApkInstallResult> { val installerPackageName: CharSequence? get() = metadata.installer
override fun compareTo(other: ApkInstallResult): Int {
return other.progress.compareTo(progress)
}
} }
internal class FailedFirstComparator : Comparator<ApkInstallResult> { internal class FailedFirstComparator : Comparator<ApkInstallResult> {
override fun compare(a1: ApkInstallResult, a2: ApkInstallResult): Int { 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 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)
}
} }
} }

View file

@ -11,7 +11,7 @@ import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread 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.R
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState 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.FAILED_WAS_STOPPED
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED 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.notification.getAppName
import com.stevesoltys.seedvault.ui.systemData
import java.util.Locale import java.util.Locale
private const val TAG = "AppListRetriever" 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 sealed class AppListItem
data class AppStatus( data class AppStatus(
@ -64,7 +61,7 @@ internal class AppListRetriever(
val appListSections = linkedMapOf( val appListSections = linkedMapOf(
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(), 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() AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
).filter { it.value.isNotEmpty() } ).filter { it.value.isNotEmpty() }
@ -74,13 +71,7 @@ internal class AppListRetriever(
} }
private fun getSpecialApps(): List<AppListItem> { private fun getSpecialApps(): List<AppListItem> {
val specialPackages = listOf( return systemData.map { (packageName, data) ->
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) ->
val metadata = metadataManager.getPackageMetadata(packageName) val metadata = metadataManager.getPackageMetadata(packageName)
val status = if (packageName == PACKAGE_NAME_CONTACTS && metadata?.state == null) { val status = if (packageName == PACKAGE_NAME_CONTACTS && metadata?.state == null) {
// handle local contacts backup specially as it might not be installed // handle local contacts backup specially as it might not be installed
@ -90,38 +81,52 @@ internal class AppListRetriever(
AppStatus( AppStatus(
packageName = packageName, packageName = packageName,
enabled = settingsManager.isBackupEnabled(packageName), enabled = settingsManager.isBackupEnabled(packageName),
icon = getIcon(packageName), icon = data.iconRes?.let { getDrawable(context, it) }
name = context.getString(stringId), ?: getIconFromPackageManager(packageName),
name = context.getString(data.nameRes),
time = metadata?.time ?: 0, time = metadata?.time ?: 0,
size = metadata?.size, size = metadata?.size,
status = status, status = status,
isSpecial = true isSpecial = true,
) )
} }
} }
private fun getUserApps(): List<AppStatus> { private fun getApps(): List<AppStatus> {
val locale = Locale.getDefault() val userPackages = mutableSetOf<String>()
return packageService.userApps.map { val userApps = packageService.userApps.map {
userPackages.add(it.packageName)
val metadata = metadataManager.getPackageMetadata(it.packageName) val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0 val time = metadata?.time ?: 0
val status = metadata?.state.toAppBackupState() val status = metadata?.state.toAppBackupState()
if (status == NOT_YET_BACKED_UP) { if (status == NOT_YET_BACKED_UP) {
Log.w(TAG, "No metadata available for: ${it.packageName}") Log.w(TAG, "No metadata available for: ${it.packageName}")
} }
if (metadata?.hasApk() == false) {
Log.w(TAG, "No APK stored for: ${it.packageName}")
}
AppStatus( AppStatus(
packageName = it.packageName, packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName), enabled = settingsManager.isBackupEnabled(it.packageName),
icon = getIcon(it.packageName), icon = getIconFromPackageManager(it.packageName),
name = getAppName(context, it.packageName).toString(), name = getAppName(context, it.packageName).toString(),
time = time, time = time,
size = metadata?.size, size = metadata?.size,
status = status status = status,
) )
}.sortedBy { it.name.lowercase(locale) } }
val locale = Locale.getDefault()
return (userApps + packageService.launchableSystemApps.mapNotNull {
val packageName = it.activityInfo.packageName
if (packageName in userPackages) return@mapNotNull null
val metadata = metadataManager.getPackageMetadata(packageName)
AppStatus(
packageName = packageName,
enabled = settingsManager.isBackupEnabled(packageName),
icon = getIconFromPackageManager(packageName),
name = it.loadLabel(context.packageManager).toString(),
time = metadata?.time ?: 0,
size = metadata?.size,
status = metadata?.state.toAppBackupState(),
)
}).sortedBy { it.name.lowercase(locale) }
} }
private fun getNotAllowedApps(): List<AppStatus> { private fun getNotAllowedApps(): List<AppStatus> {
@ -130,28 +135,19 @@ internal class AppListRetriever(
AppStatus( AppStatus(
packageName = it.packageName, packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName), enabled = settingsManager.isBackupEnabled(it.packageName),
icon = getIcon(it.packageName), icon = getIconFromPackageManager(it.packageName),
name = getAppName(context, it.packageName).toString(), name = getAppName(context, it.packageName).toString(),
time = 0, time = 0,
size = null, size = null,
status = FAILED_NOT_ALLOWED status = FAILED_NOT_ALLOWED,
) )
}.sortedBy { it.name.lowercase(locale) } }.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 { private fun getIconFromPackageManager(packageName: String): Drawable = try {
pm.getApplicationIcon(packageName) pm.getApplicationIcon(packageName)
} catch (e: PackageManager.NameNotFoundException) { } 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) { private fun PackageState?.toAppBackupState(): AppBackupState = when (this) {

View file

@ -96,15 +96,15 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
v.background = clickableBackground v.background = clickableBackground
if (editMode) { if (editMode) {
v.setOnClickListener { v.setOnClickListener {
switchView.toggle() checkBox.toggle()
item.enabled = switchView.isChecked item.enabled = checkBox.isChecked
toggleListener.onAppStatusToggled(item) toggleListener.onAppStatusToggled(item)
} }
appInfo.visibility = GONE appInfo.visibility = GONE
appStatus.visibility = INVISIBLE appStatus.visibility = INVISIBLE
progressBar.visibility = INVISIBLE progressBar.visibility = INVISIBLE
switchView.visibility = VISIBLE checkBox.visibility = VISIBLE
switchView.isChecked = item.enabled checkBox.isChecked = item.enabled
} else { } else {
v.setOnClickListener(null) v.setOnClickListener(null)
v.setOnLongClickListener { v.setOnLongClickListener {
@ -130,7 +130,7 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
} }
appInfo.visibility = VISIBLE appInfo.visibility = VISIBLE
} }
switchView.visibility = INVISIBLE checkBox.visibility = INVISIBLE
} }
// show disabled items differently // show disabled items differently
showEnabled(item.enabled) showEnabled(item.enabled)

View file

@ -61,10 +61,10 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
} }
progressBar.visibility = VISIBLE progressBar.visibility = VISIBLE
viewModel.appStatusList.observe(viewLifecycleOwner, { result -> viewModel.appStatusList.observe(viewLifecycleOwner) { result ->
adapter.update(result.appStatusList, result.diff) adapter.update(result.appStatusList, result.diff)
progressBar.visibility = INVISIBLE progressBar.visibility = INVISIBLE
}) }
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -73,10 +73,10 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
appEditMenuItem = menu.findItem(R.id.edit_app_blacklist) appEditMenuItem = menu.findItem(R.id.edit_app_blacklist)
// observe edit mode changes here where we are sure to have the MenuItem // 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 appEditMenuItem.isChecked = enabled
adapter.setEditMode(enabled) adapter.setEditMode(enabled)
}) }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {

View file

@ -69,7 +69,7 @@ internal class SettingsViewModel(
app: Application, app: Application,
settingsManager: SettingsManager, settingsManager: SettingsManager,
keyManager: KeyManager, keyManager: KeyManager,
private val pluginManager: StoragePluginManager, pluginManager: StoragePluginManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever, private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
@ -97,6 +97,9 @@ internal class SettingsViewModel(
private val mAppStatusList = lastBackupTime.switchMap { private val mAppStatusList = lastBackupTime.switchMap {
// updates app list when lastBackupTime changes // 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() getAppStatusResult()
} }
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList

View file

@ -7,6 +7,9 @@ package com.stevesoltys.seedvault.transport.backup
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context 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_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.ApplicationInfo.FLAG_SYSTEM 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
import android.content.pm.PackageManager.GET_INSTRUMENTATION import android.content.pm.PackageManager.GET_INSTRUMENTATION
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES 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.RemoteException
import android.os.UserHandle import android.os.UserHandle
import android.util.Log import android.util.Log
@ -147,6 +152,16 @@ internal class PackageService(
} }
} }
val launchableSystemApps: List<ResolveInfo>
@WorkerThread
get() {
// filter intent for apps with a launcher activity
val i = Intent(ACTION_MAIN).apply {
addCategory(CATEGORY_LAUNCHER)
}
return packageManager.queryIntentActivities(i, MATCH_SYSTEM_ONLY)
}
fun getVersionName(packageName: String): String? = try { fun getVersionName(packageName: String): String? = try {
packageManager.getPackageInfo(packageName, 0).versionName packageManager.getPackageInfo(packageName, 0).versionName
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {

View file

@ -15,7 +15,7 @@ import android.widget.ImageView
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView 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.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS 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 appInfo: TextView = v.requireViewById(R.id.appInfo)
protected val appStatus: ImageView = v.requireViewById(R.id.appStatus) protected val appStatus: ImageView = v.requireViewById(R.id.appStatus)
protected val progressBar: ProgressBar = v.requireViewById(R.id.progressBar) 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 { init {
// don't use clickable background by default // don't use clickable background by default

View file

@ -15,7 +15,7 @@ abstract class RequireProvisioningViewModel(
protected val app: Application, protected val app: Application,
protected val settingsManager: SettingsManager, protected val settingsManager: SettingsManager,
protected val keyManager: KeyManager, protected val keyManager: KeyManager,
private val pluginManager: StoragePluginManager, protected val pluginManager: StoragePluginManager,
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
abstract val isRestoreOperation: Boolean abstract val isRestoreOperation: Boolean

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.stevesoltys.seedvault.R
internal const val PACKAGE_NAME_SMS = "com.android.providers.telephony"
internal const val PACKAGE_NAME_SETTINGS = "com.android.providers.settings"
internal const val PACKAGE_NAME_CALL_LOG = "com.android.calllogbackup"
internal const val PACKAGE_NAME_CONTACTS = "org.calyxos.backup.contacts"
internal const val PACKAGE_NAME_SYSTEM = "@org.calyxos.system@"
val systemData = mapOf(
PACKAGE_NAME_SMS to SystemData(R.string.backup_sms, R.drawable.ic_message),
PACKAGE_NAME_SETTINGS to SystemData(R.string.backup_settings, R.drawable.ic_settings),
PACKAGE_NAME_CALL_LOG to SystemData(R.string.backup_call_log, R.drawable.ic_call),
PACKAGE_NAME_CONTACTS to SystemData(R.string.backup_contacts, R.drawable.ic_contacts),
)
data class SystemData(
@StringRes val nameRes: Int,
@DrawableRes val iconRes: Int,
)

View file

@ -167,14 +167,18 @@ internal class NotificationBackupObserver(
} }
fun getAppName(context: Context, packageId: String): CharSequence { fun getAppName(
if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) { context: Context,
packageName: String,
fallback: String = packageName,
): CharSequence {
if (packageName == MAGIC_PACKAGE_MANAGER || packageName.startsWith("@")) {
return context.getString(R.string.restore_magic_package) return context.getString(R.string.restore_magic_package)
} }
return try { return try {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0) val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(appInfo) context.packageManager.getApplicationLabel(appInfo)
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
packageId fallback
} }
} }

View file

@ -29,6 +29,7 @@ internal class ApkBackupManager(
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val packageService: PackageService, private val packageService: PackageService,
private val iconManager: IconManager,
private val apkBackup: ApkBackup, private val apkBackup: ApkBackup,
private val pluginManager: StoragePluginManager, private val pluginManager: StoragePluginManager,
private val nm: BackupNotificationManager, 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 // Since an APK backup does not change the [packageState], we first record it for all
// packages that don't get backed up. // packages that don't get backed up.
recordNotBackedUpPackages() 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. // Now, if APK backups are enabled by the user, we back those up.
if (settingsManager.backupApks()) { if (settingsManager.backupApks()) {
backUpApks() backUpApks()
@ -77,6 +80,7 @@ internal class ApkBackupManager(
nm.onAppsNotBackedUp() nm.onAppsNotBackedUp()
packageService.notBackedUpPackages.forEach { packageInfo -> packageService.notBackedUpPackages.forEach { packageInfo ->
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (!settingsManager.isBackupEnabled(packageName)) return@forEach
try { try {
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
val packageMetadata = metadataManager.getPackageMetadata(packageName) 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]. * Backs up an APK for the given [PackageInfo].
* *

View file

@ -0,0 +1,151 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.content.Context
import android.graphics.Bitmap.CompressFormat.WEBP_LOSSY
import android.graphics.BitmapFactory
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.TYPE_ICONS
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.transport.backup.PackageService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.security.GeneralSecurityException
import java.util.zip.Deflater.BEST_SPEED
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
internal const val FILE_BACKUP_ICONS = ".backup.icons"
private const val ICON_SIZE = 128
private const val ICON_QUALITY = 75
private const val CACHE_FOLDER = "restore-icons"
private val TAG = IconManager::class.simpleName
internal class IconManager(
private val context: Context,
private val packageService: PackageService,
private val crypto: Crypto,
) {
@Throws(IOException::class, GeneralSecurityException::class)
fun uploadIcons(token: Long, outputStream: OutputStream) {
Log.d(TAG, "Start uploading icons")
val packageManager = context.packageManager
crypto.newEncryptingStream(outputStream, getAD(VERSION, token)).use { cryptoStream ->
ZipOutputStream(cryptoStream).use { zip ->
zip.setLevel(BEST_SPEED)
val entries = mutableSetOf<String>()
packageService.allUserPackages.forEach {
val applicationInfo = it.applicationInfo ?: return@forEach
val drawable = packageManager.getApplicationIcon(applicationInfo)
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
val entry = ZipEntry(it.packageName)
zip.putNextEntry(entry)
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip)
entries.add(it.packageName)
zip.closeEntry()
}
packageService.launchableSystemApps.forEach {
val drawable = it.loadIcon(packageManager)
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
// check for duplicates (e.g. updated launchable system app)
if (it.activityInfo.packageName in entries) return@forEach
val entry = ZipEntry(it.activityInfo.packageName)
zip.putNextEntry(entry)
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip)
zip.closeEntry()
}
}
}
Log.d(TAG, "Finished uploading icons")
}
/**
* Downloads icons file from given [inputStream].
* @return a set of package names for which icons were found
*/
@Throws(IOException::class, SecurityException::class, GeneralSecurityException::class)
fun downloadIcons(version: Byte, token: Long, inputStream: InputStream): Set<String> {
Log.d(TAG, "Start downloading icons")
val folder = File(context.cacheDir, CACHE_FOLDER)
if (!folder.isDirectory && !folder.mkdirs())
throw IOException("Can't create cache folder for icons")
val set = mutableSetOf<String>()
crypto.newDecryptingStream(inputStream, getAD(version, token)).use { cryptoStream ->
ZipInputStream(cryptoStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
File(folder, entry.name).outputStream().use { outputStream ->
zip.copyTo(outputStream)
}
set.add(entry.name)
entry = zip.nextEntry
}
}
}
Log.d(TAG, "Finished downloading icons")
return set
}
private val defaultIcon by lazy {
getDrawable(context, R.drawable.ic_launcher_default)!!
}
/**
* Tries to load the icons for the given [packageName]
* that was downloaded before with [downloadIcons].
* Calls [callback] on the UiThread with the loaded [Drawable] or the default icon.
*/
suspend fun loadIcon(packageName: String, callback: (Drawable) -> Unit) {
try {
withContext(Dispatchers.IO) {
val folder = File(context.cacheDir, CACHE_FOLDER)
val file = File(folder, packageName)
file.inputStream().use { inputStream ->
val drawable =
BitmapFactory.decodeStream(inputStream).toDrawable(context.resources)
withContext(Dispatchers.Main) {
callback(drawable)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading icon for $packageName", e)
withContext(Dispatchers.Main) {
callback(defaultIcon)
}
}
}
@WorkerThread
fun removeIcons() {
val folder = File(context.cacheDir, CACHE_FOLDER)
val result = folder.deleteRecursively()
Log.e(TAG, "Could delete icons: $result")
}
private fun getAD(version: Byte, token: Long) = ByteBuffer.allocate(2 + 8)
.put(version)
.put(TYPE_ICONS)
.put(token.toByteArray())
.array()
}

View file

@ -16,6 +16,13 @@ val workerModule = module {
packageService = get(), packageService = get(),
) )
} }
factory {
IconManager(
context = androidContext(),
packageService = get(),
crypto = get(),
)
}
single { single {
ApkBackup( ApkBackup(
pm = androidContext().packageManager, pm = androidContext().packageManager,
@ -31,6 +38,7 @@ val workerModule = module {
metadataManager = get(), metadataManager = get(),
packageService = get(), packageService = get(),
apkBackup = get(), apkBackup = get(),
iconManager = get(),
pluginManager = get(), pluginManager = get(),
nm = get() nm = get()
) )

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21.81,12.74l-0.82,-0.63v-0.22l0.8,-0.63c0.16,-0.12 0.2,-0.34 0.1,-0.51l-0.85,-1.48c-0.07,-0.13 -0.21,-0.2 -0.35,-0.2 -0.05,0 -0.1,0.01 -0.15,0.03l-0.95,0.38c-0.08,-0.05 -0.11,-0.07 -0.19,-0.11l-0.15,-1.01c-0.03,-0.21 -0.2,-0.36 -0.4,-0.36h-1.71c-0.2,0 -0.37,0.15 -0.4,0.34l-0.14,1.01c-0.03,0.02 -0.07,0.03 -0.1,0.05l-0.09,0.06 -0.95,-0.38c-0.05,-0.02 -0.1,-0.03 -0.15,-0.03 -0.14,0 -0.27,0.07 -0.35,0.2l-0.85,1.48c-0.1,0.17 -0.06,0.39 0.1,0.51l0.8,0.63v0.23l-0.8,0.63c-0.16,0.12 -0.2,0.34 -0.1,0.51l0.85,1.48c0.07,0.13 0.21,0.2 0.35,0.2 0.05,0 0.1,-0.01 0.15,-0.03l0.95,-0.37c0.08,0.05 0.12,0.07 0.2,0.11l0.15,1.01c0.03,0.2 0.2,0.34 0.4,0.34h1.71c0.2,0 0.37,-0.15 0.4,-0.34l0.15,-1.01c0.03,-0.02 0.07,-0.03 0.1,-0.05l0.09,-0.06 0.95,0.38c0.05,0.02 0.1,0.03 0.15,0.03 0.14,0 0.27,-0.07 0.35,-0.2l0.85,-1.48c0.1,-0.17 0.06,-0.39 -0.1,-0.51zM18,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,17h2v4c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3c0,-1.1 0.9,-2 2,-2h10c1.1,0 2,0.9 2,2v4h-2V6H7v12h10v-1z" />
</vector>

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0" android:viewportWidth="24.0"
android:viewportHeight="24.0"> android:viewportHeight="24.0">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0" android:viewportWidth="24.0"
android:viewportHeight="24.0"> android:viewportHeight="24.0">
<path <path

View file

@ -6,7 +6,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/colorControlNormal" android:tint="?attr/colorControlNormal"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageView"
style="@style/SudHeaderIcon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_cloud_download"
app:tint="?android:colorAccent"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
style="@style/SudHeaderTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_select_packages"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/backupNameView"
style="@style/SudDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="?android:textColorTertiary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Pixel 2 XL - Owner of the device" />
<TextView
android:id="@+id/toggleAllTextView"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="0dp"
android:background="?selectableItemBackground"
android:clickable="true"
android:gravity="center_vertical"
android:paddingStart="40dp"
android:text="@string/restore_select_packages_all"
android:textSize="16sp"
app:layout_constraintEnd_toStartOf="@+id/toggleAllView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:ignore="RtlSymmetry" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/toggleAllView"
style="@style/SudContent"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:checked="true" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="40dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:dividerColor="?attr/colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toggleAllView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
style="@style/SudContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="0dp"
android:layout_marginTop="0dp"
android:layout_marginEnd="0dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:listitem="@layout/list_item_app_status" />
<Button
android:id="@+id/button"
style="@style/SudPrimaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:text="@string/restore_backup_button"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appList" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -8,9 +8,9 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="40dp" android:layout_marginHorizontal="16dp"
android:layout_marginEnd="40dp"
android:background="?android:selectableItemBackground" android:background="?android:selectableItemBackground"
android:paddingHorizontal="24dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:screenReaderFocusable="true"> android:screenReaderFocusable="true">
@ -35,7 +35,7 @@
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:textColor="?android:textColorPrimary" android:textColor="?android:textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/appInfo" 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_constraintStart_toEndOf="@+id/appIcon"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="Seedvault Backup" /> tools:text="Seedvault Backup" />
@ -72,8 +72,8 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/switchView" android:id="@+id/checkboxView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="false" android:clickable="false"

View file

@ -19,7 +19,7 @@
<color name="statusBarColor">@color/primary</color> <color name="statusBarColor">@color/primary</color>
<!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#69 --> <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#69 -->
<!-- private resource, access it from colorError attribute instead --> <!-- private resource, access it from colorError attribute instead -->
<color name="red">?android:attr/colorError</color> <color name="red">@*android:color/error_color_device_default_dark</color>
<!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#35 --> <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#35 -->
<color name="ic_launcher_background">@color/accent</color> <color name="ic_launcher_background">@color/accent</color>

View file

@ -177,12 +177,13 @@
<!-- App Backup and Restore State --> <!-- App Backup and Restore State -->
<string name="backup_section_system">System apps</string> <string name="backup_section_system">System data</string>
<string name="backup_sms">SMS text messages</string> <string name="backup_sms">SMS text messages</string>
<string name="backup_settings">Device settings</string> <string name="backup_settings">Device settings</string>
<string name="backup_call_log">Call history</string> <string name="backup_call_log">Call history</string>
<string name="backup_contacts">Local contacts</string> <string name="backup_contacts">Local contacts</string>
<string name="backup_section_user">Installed apps</string> <string name="backup_system_apps">System apps</string>
<string name="backup_section_user">Apps</string>
<!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet --> <!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet -->
<string name="backup_app_not_yet_backed_up">Waiting to back up…</string> <string name="backup_app_not_yet_backed_up">Waiting to back up…</string>
<string name="restore_app_not_yet_backed_up">Was not yet backed up</string> <string name="restore_app_not_yet_backed_up">Was not yet backed up</string>
@ -209,6 +210,8 @@
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string> <string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string> <string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string> <string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_select_packages">Select apps to restore</string>
<string name="restore_select_packages_all">All of the following apps</string>
<string name="restore_installing_packages">Re-installing apps</string> <string name="restore_installing_packages">Re-installing apps</string>
<string name="restore_app_status_installing">Re-installing</string> <string name="restore_app_status_installing">Re-installing</string>
<string name="restore_app_status_installed">Re-installed</string> <string name="restore_app_status_installed">Re-installed</string>

View file

@ -16,8 +16,10 @@ import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.SettingsManager 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.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule import com.stevesoltys.seedvault.transport.restore.restoreModule
import io.mockk.mockk
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.KoinApplication import org.koin.core.KoinApplication
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
@ -33,9 +35,11 @@ class TestApp : App() {
single<KeyManager> { KeyManagerTestImpl() } single<KeyManager> { KeyManagerTestImpl() }
single<Crypto> { CryptoImpl(get(), get(), get()) } single<Crypto> { CryptoImpl(get(), get(), get()) }
} }
private val packageService: PackageService = mockk()
private val appModule = module { private val appModule = module {
single { Clock() } single { Clock() }
single { SettingsManager(this@TestApp) } single { SettingsManager(this@TestApp) }
single<PackageService> { packageService }
} }
override fun startKoin(): KoinApplication { override fun startKoin(): KoinApplication {

View file

@ -7,11 +7,13 @@ package com.stevesoltys.seedvault.metadata
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.UserManager import android.os.UserManager
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock 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.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import io.mockk.Runs import io.mockk.Runs
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
@ -54,7 +57,6 @@ import kotlin.random.Random
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config( @Config(
sdk = [33], // robolectric does not support 34, yet
application = TestApp::class application = TestApp::class
) )
class MetadataManagerTest { class MetadataManagerTest {
@ -64,6 +66,7 @@ class MetadataManagerTest {
private val crypto: Crypto = mockk() private val crypto: Crypto = mockk()
private val metadataWriter: MetadataWriter = mockk() private val metadataWriter: MetadataWriter = mockk()
private val metadataReader: MetadataReader = mockk() private val metadataReader: MetadataReader = mockk()
private val packageService: PackageService = mockk()
private val settingsManager: SettingsManager = mockk() private val settingsManager: SettingsManager = mockk()
private val manager = MetadataManager( private val manager = MetadataManager(
@ -72,9 +75,12 @@ class MetadataManagerTest {
crypto = crypto, crypto = crypto,
metadataWriter = metadataWriter, metadataWriter = metadataWriter,
metadataReader = metadataReader, metadataReader = metadataReader,
settingsManager = settingsManager packageService = packageService,
settingsManager = settingsManager,
) )
private val packageManager: PackageManager = mockk()
private val time = 42L private val time = 42L
private val token = Random.nextLong() private val token = Random.nextLong()
private val packageName = getRandomString() private val packageName = getRandomString()
@ -162,6 +168,7 @@ class MetadataManagerTest {
signatures = listOf("sig") signatures = listOf("sig")
) )
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
@ -185,12 +192,23 @@ class MetadataManagerTest {
signatures = listOf("sig") signatures = listOf("sig")
) )
every { context.packageManager } returns packageManager
every { packageService.launchableSystemApps } returns listOf(
ResolveInfo().apply {
activityInfo = ActivityInfo().apply {
packageName = this@MetadataManagerTest.packageName
}
}
)
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName)) assertEquals(
packageMetadata.copy(system = true, isLaunchableSystemApp = true),
manager.getPackageMetadata(packageName),
)
verify { verify {
cacheInputStream.close() cacheInputStream.close()
@ -214,6 +232,7 @@ class MetadataManagerTest {
signatures = listOf("sig foo") signatures = listOf("sig foo")
) )
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectWriteToCache(initialMetadata) expectWriteToCache(initialMetadata)
@ -236,6 +255,7 @@ class MetadataManagerTest {
signatures = listOf("sig") signatures = listOf("sig")
) )
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectWriteToCache(initialMetadata) expectWriteToCache(initialMetadata)
val oldState = UNKNOWN_ERROR val oldState = UNKNOWN_ERROR
@ -295,6 +315,7 @@ class MetadataManagerTest {
signatures = listOf("sig") signatures = listOf("sig")
) )
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
assertNull(manager.getPackageMetadata(packageName)) assertNull(manager.getPackageMetadata(packageName))
@ -330,6 +351,8 @@ class MetadataManagerTest {
val packageMetadata = PackageMetadata(time) val packageMetadata = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = packageMetadata updatedMetadata.packageMetadataMap[packageName] = packageMetadata
every { context.packageManager } returns packageManager
every { packageService.launchableSystemApps } returns emptyList()
expectReadFromCache() expectReadFromCache()
every { clock.time() } returns time every { clock.time() } returns time
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
@ -342,6 +365,7 @@ class MetadataManagerTest {
backupType = BackupType.FULL, backupType = BackupType.FULL,
size = size, size = size,
system = true, system = true,
isLaunchableSystemApp = false,
), ),
manager.getPackageMetadata(packageName) manager.getPackageMetadata(packageName)
) )
@ -361,6 +385,7 @@ class MetadataManagerTest {
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
every { settingsManager.d2dBackupsEnabled() } returns true every { settingsManager.d2dBackupsEnabled() } returns true
every { context.packageManager } returns packageManager
manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream) manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
assertTrue(initialMetadata.d2dBackup) assertTrue(initialMetadata.d2dBackup)
@ -382,6 +407,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] = updatedMetadata.packageMetadataMap[packageName] =
PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size) PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size)
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
every { clock.time() } returns updateTime every { clock.time() } returns updateTime
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException() every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
@ -414,6 +440,7 @@ class MetadataManagerTest {
PackageMetadata(time, state = APK_AND_DATA) PackageMetadata(time, state = APK_AND_DATA)
expectReadFromCache() expectReadFromCache()
every { context.packageManager } returns packageManager
every { clock.time() } returns time every { clock.time() } returns time
expectModifyMetadata(updatedMetadata) expectModifyMetadata(updatedMetadata)
@ -437,6 +464,7 @@ class MetadataManagerTest {
val updatedMetadata = initialMetadata.copy() val updatedMetadata = initialMetadata.copy()
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED) updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED)
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectWriteToCache(updatedMetadata) expectWriteToCache(updatedMetadata)
@ -454,6 +482,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED) updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
initialMetadata.packageMetadataMap.remove(packageName) initialMetadata.packageMetadataMap.remove(packageName)
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectWriteToCache(updatedMetadata) expectWriteToCache(updatedMetadata)
@ -482,6 +511,7 @@ class MetadataManagerTest {
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED) updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
initialMetadata.packageMetadataMap.remove(packageName) initialMetadata.packageMetadataMap.remove(packageName)
every { context.packageManager } returns packageManager
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(updatedMetadata) expectModifyMetadata(updatedMetadata)

View file

@ -63,6 +63,10 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(), time = Random.nextLong(),
state = APK_AND_DATA, state = APK_AND_DATA,
backupType = BackupType.FULL, backupType = BackupType.FULL,
size = Random.nextLong(0, Long.MAX_VALUE),
name = getRandomString(),
system = Random.nextBoolean(),
isLaunchableSystemApp = Random.nextBoolean(),
version = Random.nextLong(), version = Random.nextLong(),
installer = getRandomString(), installer = getRandomString(),
splits = listOf( splits = listOf(
@ -94,6 +98,7 @@ internal class MetadataWriterDecoderTest {
time = Random.nextLong(), time = Random.nextLong(),
state = QUOTA_EXCEEDED, state = QUOTA_EXCEEDED,
backupType = BackupType.FULL, backupType = BackupType.FULL,
name = null,
size = Random.nextLong(0..Long.MAX_VALUE), size = Random.nextLong(0..Long.MAX_VALUE),
system = Random.nextBoolean(), system = Random.nextBoolean(),
version = Random.nextLong(), version = Random.nextLong(),
@ -108,6 +113,7 @@ internal class MetadataWriterDecoderTest {
state = NO_DATA, state = NO_DATA,
backupType = BackupType.KV, backupType = BackupType.KV,
size = null, size = null,
name = getRandomString(),
system = Random.nextBoolean(), system = Random.nextBoolean(),
version = Random.nextLong(), version = Random.nextLong(),
installer = getRandomString(), installer = getRandomString(),
@ -121,6 +127,7 @@ internal class MetadataWriterDecoderTest {
state = NOT_ALLOWED, state = NOT_ALLOWED,
size = 0, size = 0,
system = Random.nextBoolean(), system = Random.nextBoolean(),
isLaunchableSystemApp = Random.nextBoolean(),
version = Random.nextLong(), version = Random.nextLong(),
installer = getRandomString(), installer = getRandomString(),
sha256 = getRandomString(), sha256 = getRandomString(),
@ -138,10 +145,11 @@ internal class MetadataWriterDecoderTest {
private fun getMetadata( private fun getMetadata(
packageMetadata: HashMap<String, PackageMetadata> = HashMap(), packageMetadata: HashMap<String, PackageMetadata> = HashMap(),
): BackupMetadata { ): BackupMetadata {
val version = Random.nextBytes(1)[0]
return BackupMetadata( return BackupMetadata(
version = Random.nextBytes(1)[0], version = version,
token = Random.nextLong(), token = Random.nextLong(),
salt = getRandomBase64(32), salt = if (version != 0.toByte()) getRandomBase64(32) else "",
time = Random.nextLong(), time = Random.nextLong(),
androidVersion = Random.nextInt(), androidVersion = Random.nextInt(),
androidIncremental = getRandomString(), androidIncremental = getRandomString(),

View file

@ -24,7 +24,6 @@ import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config( @Config(
sdk = [33], // robolectric does not support 34, yet
application = TestApp::class application = TestApp::class
) )
internal class DocumentFileTest { internal class DocumentFileTest {

View file

@ -28,7 +28,6 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config( @Config(
sdk = [33], // robolectric does not support 34, yet
application = TestApp::class application = TestApp::class
) )
internal class WebDavStoragePluginTest : TransportTest() { internal class WebDavStoragePluginTest : TransportTest() {

View file

@ -0,0 +1,409 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.worker.IconManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.runner.RunWith
import java.io.ByteArrayInputStream
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
internal class AppSelectionManagerTest : TransportTest() {
private val storagePluginManager: StoragePluginManager = mockk()
private val iconManager: IconManager = mockk()
private val testDispatcher = UnconfinedTestDispatcher()
private val scope = TestScope(testDispatcher)
private val packageName1 = "org.example.1"
private val packageName2 = "org.example.2"
private val packageName3 = "org.example.3"
private val packageName4 = "org.example.4"
private val backupMetadata = BackupMetadata(
token = Random.nextLong(),
salt = getRandomString(),
)
private val appSelectionManager = AppSelectionManager(
context = context,
pluginManager = storagePluginManager,
iconManager = iconManager,
coroutineScope = scope,
workDispatcher = testDispatcher,
)
@Test
fun `apps without backup and APK, as well as system apps are filtered out`() = runTest {
appSelectionManager.selectedAppsFlow.test {
val initialState = awaitItem()
assertEquals(emptyList<SelectableAppItem>(), initialState.apps)
assertTrue(initialState.allSelected)
assertFalse(initialState.iconsLoaded)
val backup = getRestorableBackup(
mapOf(
PACKAGE_NAME_SETTINGS to PackageMetadata(), // no backup and no APK
packageName1 to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
)
)
appSelectionManager.onRestoreSetChosen(backup)
val initialApps = awaitItem()
// only the meta system app item remains
assertEquals(1, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
assertTrue(initialApps.allSelected)
assertFalse(initialApps.iconsLoaded)
}
}
@Test
fun `apps get sorted by name, special items on top`() = runTest {
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mapOf(
packageName1 to PackageMetadata(
time = 23L,
name = "B",
),
packageName2 to PackageMetadata(
time = 42L,
name = "A",
),
PACKAGE_NAME_SETTINGS to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
)
)
appSelectionManager.onRestoreSetChosen(backup)
val initialApps = awaitItem()
assertEquals(4, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SETTINGS, initialApps.apps[0].packageName)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName)
assertEquals(packageName2, initialApps.apps[2].packageName)
assertEquals(packageName1, initialApps.apps[3].packageName)
}
}
@Test
fun `test app selection`() = runTest {
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mapOf(
packageName1 to PackageMetadata(time = 23L),
packageName2 to PackageMetadata(time = 42L),
)
)
appSelectionManager.onRestoreSetChosen(backup)
// first all are selected
val initialApps = awaitItem()
assertEquals(3, initialApps.apps.size)
initialApps.apps.forEach { assertTrue(it.selected) }
assertTrue(initialApps.allSelected)
// deselect last app in list
appSelectionManager.onAppSelected(initialApps.apps[2])
val oneDeselected = awaitItem()
oneDeselected.apps.forEach {
if (it.packageName == packageName2) assertFalse(it.selected)
else assertTrue(it.selected)
}
assertFalse(oneDeselected.allSelected)
// select all apps
appSelectionManager.onCheckAllAppsClicked()
val allSelected = awaitItem()
allSelected.apps.forEach { assertTrue(it.selected) }
assertTrue(allSelected.allSelected)
// de-select all apps
appSelectionManager.onCheckAllAppsClicked()
val noneSelected = awaitItem()
noneSelected.apps.forEach { assertFalse(it.selected) }
assertFalse(noneSelected.allSelected)
// re-select first (meta) app
appSelectionManager.onAppSelected(noneSelected.apps[0])
val firstSelected = awaitItem()
firstSelected.apps.forEach {
if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.selected)
else assertFalse(it.selected)
}
assertFalse(firstSelected.allSelected)
}
}
@Test
fun `test icon loading`() = scope.runTest {
expectIconLoading(setOf(packageName1)) // only icons found for packageName1
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mapOf(
packageName1 to PackageMetadata(time = 23),
packageName2 to PackageMetadata(time = 42L),
PACKAGE_NAME_SETTINGS to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
)
)
appSelectionManager.onRestoreSetChosen(backup)
// all apps (except special ones) have an unknown item state initially
val initialApps = awaitItem()
assertEquals(4, initialApps.apps.size)
initialApps.apps.forEach {
assertNull(it.hasIcon)
}
// all apps except packageName2 have icons now
val itemsWithIcons = awaitItem()
itemsWithIcons.apps.forEach {
if (it.packageName == packageName2) assertFalse(it.hasIcon ?: fail())
else assertTrue(it.hasIcon ?: fail())
}
assertTrue(itemsWithIcons.iconsLoaded)
}
}
@Test
fun `test icon loading fails`() = scope.runTest {
val appPlugin: StoragePlugin<*> = mockk()
every { storagePluginManager.appPlugin } returns appPlugin
coEvery {
appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
} throws IOException()
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mapOf(
packageName1 to PackageMetadata(time = 23),
packageName2 to PackageMetadata(time = 42L),
)
)
appSelectionManager.onRestoreSetChosen(backup)
val initialApps = awaitItem()
assertEquals(3, initialApps.apps.size)
// no apps have icons now (except special system app), but their state is known
val itemsWithoutIcons = awaitItem()
itemsWithoutIcons.apps.forEach {
if (it.packageName == PACKAGE_NAME_SYSTEM) assertTrue(it.hasIcon ?: fail())
else assertFalse(it.hasIcon ?: fail())
}
assertTrue(itemsWithoutIcons.iconsLoaded)
}
}
@Test
fun `finishing selection filters unselected apps, leaves system apps`() = runTest {
testFiltering { backup ->
val itemsWithIcons = awaitItem()
// unselect app1 and contacts app
val app1 = itemsWithIcons.apps.find { it.packageName == packageName1 } ?: fail()
val contacts = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_CONTACTS }
?: fail()
appSelectionManager.onAppSelected(app1)
awaitItem()
appSelectionManager.onAppSelected(contacts)
// assert that both apps are unselected
val finalSelection = awaitItem()
// we have 6 real apps (two are hidden) plus system meta item, makes 5
assertEquals(5, finalSelection.apps.size)
finalSelection.apps.forEach {
if (it.packageName in listOf(packageName1, PACKAGE_NAME_CONTACTS)) {
assertFalse(it.selected)
} else {
assertTrue(it.selected)
}
}
// 4 apps should survive: app2, app3 (system app), app4 (hidden) and settings
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
assertEquals(4, filteredBackup.packageMetadataMap.size)
assertEquals(
setOf(packageName2, packageName3, packageName4, PACKAGE_NAME_SETTINGS),
filteredBackup.packageMetadataMap.keys,
)
}
}
@Test
fun `finishing selection without system apps only removes non-special system apps`() = runTest {
testFiltering { backup ->
val itemsWithIcons = awaitItem()
// unselect all system apps and settings, contacts should stay
val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM }
?: fail()
val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS }
?: fail()
appSelectionManager.onAppSelected(systemMeta)
awaitItem()
appSelectionManager.onAppSelected(settings)
// assert that both apps are unselected
val finalSelection = awaitItem()
// we have 6 real apps (two are hidden) plus system meta item, makes 5
assertEquals(5, finalSelection.apps.size)
finalSelection.apps.forEach {
if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) {
assertFalse(it.selected)
} else {
assertTrue(it.selected)
}
}
// 4 apps should survive: app1, app2, app4 (hidden) and contacts
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
assertEquals(4, filteredBackup.packageMetadataMap.size)
assertEquals(
setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS),
filteredBackup.packageMetadataMap.keys,
)
}
}
@Test
fun `@pm@ doesn't get filtered out`() = runTest {
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mutableMapOf(
MAGIC_PACKAGE_MANAGER to PackageMetadata(
system = true,
isLaunchableSystemApp = false,
),
)
)
appSelectionManager.onRestoreSetChosen(backup)
// only system apps meta item in list
val initialApps = awaitItem()
assertEquals(1, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
// actual filtered backup includes @pm@ only
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
assertEquals(1, filteredBackup.packageMetadataMap.size)
assertEquals(
setOf(MAGIC_PACKAGE_MANAGER),
filteredBackup.packageMetadataMap.keys,
)
}
}
private fun getRestorableBackup(map: Map<String, PackageMetadata>): RestorableBackup {
return RestorableBackup(backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap))
}
private suspend fun testFiltering(
block: suspend TurbineTestContext<SelectedAppsState>.(RestorableBackup) -> Unit,
) {
expectIconLoading()
appSelectionManager.selectedAppsFlow.test {
awaitItem()
val backup = getRestorableBackup(
mapOf(
packageName1 to PackageMetadata(time = 23L),
packageName2 to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = true,
),
packageName3 to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
packageName4 to PackageMetadata(), // no backup and no APK
PACKAGE_NAME_CONTACTS to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
PACKAGE_NAME_SETTINGS to PackageMetadata(
time = 42L,
system = true,
isLaunchableSystemApp = false,
),
)
)
appSelectionManager.onRestoreSetChosen(backup)
val initialApps = awaitItem()
// we have 6 real apps (two are hidden) plus system meta item, makes 5
assertEquals(5, initialApps.apps.size)
block(backup)
}
}
private fun expectIconLoading(icons: Set<String> = setOf(packageName1, packageName2)) {
val appPlugin: StoragePlugin<*> = mockk()
val inputStream = ByteArrayInputStream(Random.nextBytes(42))
every { storagePluginManager.appPlugin } returns appPlugin
coEvery {
appPlugin.getInputStream(backupMetadata.token, FILE_BACKUP_ICONS)
} returns inputStream
every {
iconManager.downloadIcons(backupMetadata.version, backupMetadata.token, inputStream)
} returns icons
}
}

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.Signature import android.content.pm.Signature
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.PackageUtils import android.util.PackageUtils
import app.cash.turbine.test
import com.stevesoltys.seedvault.assertReadEquals import com.stevesoltys.seedvault.assertReadEquals
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
@ -19,6 +20,9 @@ import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.coEvery import io.mockk.coEvery
@ -27,12 +31,12 @@ import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
} }
private val storagePluginManager: StoragePluginManager = mockk() private val storagePluginManager: StoragePluginManager = mockk()
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val storagePlugin: StoragePlugin<*> = mockk() private val storagePlugin: StoragePlugin<*> = mockk()
@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() {
} returns true } returns true
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
val resultMap = mapOf(
packageName to ApkInstallResult(
packageName,
state = SUCCEEDED,
metadata = packageMetadataMap[packageName] ?: fail(),
)
)
coEvery { coEvery {
apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
} returns MutableInstallResult(1).apply { } returns InstallResult(resultMap)
set(
packageName, ApkInstallResult(
packageName,
progress = 1,
state = ApkInstallState.SUCCEEDED
)
)
}
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertFalse(value.hasFailed) awaitItem() // initial empty state
assertEquals(1, value.total) apkRestore.restore(backup)
if (i == 3) assertTrue(value.isFinished) awaitItem().also {
assertFalse(it.hasFailed)
assertEquals(1, it.total)
assertEquals(0, it.list.size)
assertEquals(QUEUED, it.installResults[packageName]?.state)
assertFalse(it.isFinished)
}
awaitItem().also {
assertFalse(it.hasFailed)
assertEquals(1, it.total)
assertEquals(1, it.list.size)
assertEquals(IN_PROGRESS, it.installResults[packageName]?.state)
assertFalse(it.isFinished)
}
awaitItem().also {
assertFalse(it.hasFailed)
assertEquals(1, it.total)
assertEquals(1, it.list.size)
assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
assertFalse(it.isFinished)
}
awaitItem().also {
assertFalse(it.hasFailed)
assertEquals(1, it.total)
assertEquals(1, it.list.size)
assertEquals(SUCCEEDED, it.installResults[packageName]?.state)
assertTrue(it.isFinished)
}
ensureAllEventsConsumed()
} }
val apkFile = File(apkPath.captured) val apkFile = File(apkPath.captured)

View file

@ -12,6 +12,8 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.stevesoltys.seedvault.getRandomBase64 import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
@ -32,13 +34,11 @@ import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedFailFinished()
} }
} }
@ -126,8 +128,10 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedFailFinished()
} }
} }
@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() {
} throws SecurityException() } throws SecurityException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@Test @Test
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking { fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, ApkInstallResult( packageName,
packageName, state = SUCCEEDED,
progress = 1, metadata = PackageMetadata(),
state = SUCCEEDED
)
) )
} )
val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult } returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() {
// This is a legacy backup with version 0 // This is a legacy backup with version 0
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0)) val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
// Install will be successful // Install will be successful
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, ApkInstallResult( packageName,
packageName, state = SUCCEEDED,
progress = 1, metadata = PackageMetadata(),
state = SUCCEEDED
)
) )
} )
val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { strictContext.cacheDir } returns File(tmpDir.toString()) every { strictContext.cacheDir } returns File(tmpDir.toString())
@Suppress("Deprecation")
coEvery { coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "") legacyStoragePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream } returns apkInputStream
@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult } returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1 every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
if (isSystemApp) { // if the installed app is not a system app, we don't install if (isSystemApp) { // if the installed app is not a system app, we don't install
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, packageName,
ApkInstallResult(packageName, progress = 1, state = SUCCEEDED) state = SUCCEEDED,
metadata = PackageMetadata(),
) )
} )
val installResult = InstallResult(packagesMap)
coEvery { coEvery {
apkInstaller.install( apkInstaller.install(
match { it.size == 1 }, match { it.size == 1 },
@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() {
} }
} }
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
val result = value[packageName] awaitQueuedItem()
assertEquals(QUEUED, result.state) awaitInProgressItem()
assertEquals(1, result.progress) awaitItem().also { systemItem ->
assertEquals(1, value.total) val result = systemItem[packageName]
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 } returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns ByteArrayInputStream(getRandomByteArray()) } returns ByteArrayInputStream(getRandomByteArray())
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException() coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -385,60 +390,84 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
val resultMap = mapOf(
packageName to ApkInstallResult(
packageName,
state = SUCCEEDED,
metadata = PackageMetadata(),
)
)
coEvery { coEvery {
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
} returns MutableInstallResult(1).apply { } returns InstallResult(resultMap)
set(
packageName, ApkInstallResult(
packageName,
progress = 1,
state = SUCCEEDED
)
)
}
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@Test @Test
fun `storage provider app does not get reinstalled`(@TempDir tmpDir: Path) = runBlocking { fun `storage provider app does not get reinstalled`() = runBlocking {
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
// set the storage provider package name to match our current package name, // set the storage provider package name to match our current package name,
// and ensure that the current package is therefore skipped. // and ensure that the current package is therefore skipped.
every { storagePlugin.providerPackageName } returns packageName every { storagePlugin.providerPackageName } returns packageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
assertFalse(value.isFinished) awaitItem().also { finishedItem ->
} // the only package provided should have been filtered, leaving 0 packages.
1 -> { assertEquals(0, finishedItem.total)
// the only package provided should have been filtered, leaving 0 packages. assertTrue(finishedItem.isFinished)
assertEquals(0, value.total)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
} }
ensureAllEventsConsumed()
} }
} }
@Test @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 { installRestriction.isAllowedToInstallApks() } returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
// single package fails without attempting to install it awaitItem().also { queuedItem ->
assertEquals(1, value.total) // single package fails without attempting to install it
assertEquals(FAILED, value[packageName].state) assertEquals(1, queuedItem.total)
assertTrue(value.isFinished) assertEquals(FAILED, queuedItem[packageName].state)
} assertTrue(queuedItem.isFinished)
else -> fail("more values emitted")
} }
ensureAllEventsConsumed()
} }
} }
@ -456,74 +485,78 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
} }
private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> { awaitItem().also { failedItem ->
val result = value[packageName] val result = failedItem[packageName]
assertEquals(FAILED, result.state) assertEquals(FAILED, result.state)
assertTrue(value.hasFailed) assertTrue(failedItem.hasFailed)
assertFalse(value.isFinished) assertFalse(failedItem.isFinished)
} }
2 -> { awaitItem().also { finishedItem ->
assertTrue(value.hasFailed) assertTrue(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressSuccessFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> assertQueuedProgress(step, value) awaitInProgressItem()
2 -> { awaitItem().also { successItem ->
val result = value[packageName] val result = successItem[packageName]
assertEquals(SUCCEEDED, result.state) assertEquals(SUCCEEDED, result.state)
} }
3 -> { awaitItem().also { finishedItem ->
assertFalse(value.hasFailed) assertFalse(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressFailFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> assertQueuedProgress(step, value) awaitInProgressItem()
2 -> { awaitItem().also { failedItem ->
// app install has failed // app install has failed
val result = value[packageName] val result = failedItem[packageName]
assertEquals(FAILED, result.state) assertEquals(FAILED, result.state)
assertTrue(value.hasFailed) assertTrue(failedItem.hasFailed)
assertFalse(value.isFinished) assertFalse(failedItem.isFinished)
} }
3 -> { awaitItem().also { finishedItem ->
assertTrue(value.hasFailed) assertTrue(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.awaitQueuedItem(): InstallResult {
0 -> { val item = awaitItem()
// single package gets queued // single package gets queued
val result = value[packageName] val result = item[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(installerName, result.installerPackageName) assertEquals(installerName, result.installerPackageName)
assertEquals(1, result.progress) assertEquals(1, item.total)
assertEquals(1, value.total) assertEquals(0, item.list.size) // all items still queued
} return item
1 -> { }
// name and icon are available now
val result = value[packageName] private suspend fun TurbineTestContext<InstallResult>.awaitInProgressItem(): InstallResult {
assertEquals(IN_PROGRESS, result.state) val item = awaitItem()
assertEquals(appName, result.name) // name and icon are available now
assertEquals(icon, result.icon) val result = item[packageName]
assertFalse(value.hasFailed) assertEquals(IN_PROGRESS, result.state)
} assertEquals(appName, result.name)
else -> fail("more values emitted") 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 { 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")
} }

View file

@ -27,7 +27,6 @@ import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Config( @Config(
sdk = [33], // robolectric does not support 34, yet
application = TestApp::class application = TestApp::class
) )
internal class DeviceInfoTest { internal class DeviceInfoTest {

View file

@ -31,6 +31,7 @@ import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -38,6 +39,7 @@ internal class ApkBackupManagerTest : TransportTest() {
private val packageService: PackageService = mockk() private val packageService: PackageService = mockk()
private val apkBackup: ApkBackup = mockk() private val apkBackup: ApkBackup = mockk()
private val iconManager: IconManager = mockk()
private val storagePluginManager: StoragePluginManager = mockk() private val storagePluginManager: StoragePluginManager = mockk()
private val plugin: StoragePlugin<*> = mockk() private val plugin: StoragePlugin<*> = mockk()
private val nm: BackupNotificationManager = mockk() private val nm: BackupNotificationManager = mockk()
@ -48,6 +50,7 @@ internal class ApkBackupManagerTest : TransportTest() {
metadataManager = metadataManager, metadataManager = metadataManager,
packageService = packageService, packageService = packageService,
apkBackup = apkBackup, apkBackup = apkBackup,
iconManager = iconManager,
pluginManager = storagePluginManager, pluginManager = storagePluginManager,
nm = nm, 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 { fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo) every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
expectUploadIcons()
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) 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 { fun `Package state of app gets recorded even if no previous state`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo) every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
expectUploadIcons()
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) metadataManager.getPackageMetadata(packageInfo.packageName)
@ -115,6 +124,9 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo) every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
expectUploadIcons()
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) metadataManager.getPackageMetadata(packageInfo.packageName)
@ -138,6 +150,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `Package state only updated when changed`() = runBlocking { fun `Package state only updated when changed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo) every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
expectUploadIcons()
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) 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 @Test
fun `two packages get backed up, one their APK uploaded`() = runBlocking { fun `two packages get backed up, one their APK uploaded`() = runBlocking {
val notAllowedPackages = listOf( val notAllowedPackages = listOf(
@ -167,7 +201,7 @@ internal class ApkBackupManagerTest : TransportTest() {
} }
} }
) )
expectUploadIcons()
expectAllAppsWillGetBackedUp() expectAllAppsWillGetBackedUp()
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
@ -206,6 +240,9 @@ internal class ApkBackupManagerTest : TransportTest() {
fun `we keep trying to upload metadata at the end`() = runBlocking { fun `we keep trying to upload metadata at the end`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo) every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every { settingsManager.isBackupEnabled(packageInfo.packageName) } returns true
expectUploadIcons()
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) 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() { private fun expectAllAppsWillGetBackedUp() {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns emptyList() every { packageService.notBackedUpPackages } returns emptyList()

View file

@ -11,12 +11,12 @@ ktlint = "11.5.0"
# Android SDK versions # Android SDK versions
compileSdk = "34" compileSdk = "34"
minSdk = "33" minSdk = "34"
targetSdk = "34" targetSdk = "34"
# Test versions # Test versions
junit4 = "4.13.2" 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 mockk = "1.13.4" # newer versions require kotlin > 1.8.10
espresso = "3.4.0" espresso = "3.4.0"