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
#
adb root
sleep 5
adb remount
echo "Disable auto-restore"
adb shell bmgr autorestore false
echo "Installing Seedvault app..."
adb shell mkdir -p /system/priv-app/Seedvault
adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
echo "Installing Seedvault permissions..."
adb push permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
adb push allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
echo "Setting Seedvault transport..."
sleep 10
adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
./gradlew --stacktrace :app:installDebugAndroidTest
sleep 60
D2D_BACKUP_TEST=$1

View file

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

View file

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

View file

@ -11,10 +11,9 @@ if [ -z "$ANDROID_HOME" ]; then
fi
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
DEVELOPMENT_DIR=$SCRIPT_DIR/..
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
echo "Emulator device name not found"
@ -29,13 +28,9 @@ $ADB remount # remount /system as writable
echo "Installing Seedvault app..."
$ADB shell mkdir -p /system/priv-app/Seedvault
$ADB push $ROOT_PROJECT_DIR/app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
$ADB push "$ROOT_PROJECT_DIR"/app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk
echo "Installing Seedvault permissions..."
$ADB push $ROOT_PROJECT_DIR/permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
$ADB push $ROOT_PROJECT_DIR/allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
$ADB shell am force-stop com.stevesoltys.seedvault
$ADB push "$ROOT_PROJECT_DIR"/permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml
$ADB push "$ROOT_PROJECT_DIR"/allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml
$ADB shell am broadcast -a android.intent.action.BOOT_COMPLETED
echo "Setting Seedvault transport..."
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport

View file

@ -20,25 +20,23 @@ EMULATOR_NAME=$1
SYSTEM_IMAGE=$2
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
DEVELOPMENT_DIR=$SCRIPT_DIR/..
ROOT_PROJECT_DIR=$SCRIPT_DIR/../../..
echo "Downloading system image..."
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --install "$SYSTEM_IMAGE"
# create AVD if it doesn't exist
if $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
if "$ANDROID_HOME"/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$EMULATOR_NAME"; then
echo "AVD already exists. Skipping creation."
else
echo "Creating AVD..."
echo 'no' | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
echo 'no' | "$ANDROID_HOME"/cmdline-tools/latest/bin/avdmanager create avd -n "$EMULATOR_NAME" -k "$SYSTEM_IMAGE"
sleep 1
fi
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
$SCRIPT_DIR/start_emulator.sh "$EMULATOR_NAME"
"$SCRIPT_DIR"/start_emulator.sh "$EMULATOR_NAME"
fi
# wait for emulator device to appear with 180 second timeout
@ -47,7 +45,7 @@ echo "Waiting for emulator device..."
for i in {1..180}; do
if [ -z "$EMULATOR_DEVICE_NAME" ]; then
sleep 1
EMULATOR_DEVICE_NAME=$($ANDROID_HOME/platform-tools/adb devices | grep emulator | cut -f1)
EMULATOR_DEVICE_NAME=$("$ANDROID_HOME"/platform-tools/adb devices | grep emulator | cut -f1)
else
break
fi
@ -73,16 +71,14 @@ $ADB reboot # need to reboot first time we remount
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
echo "Provisioning emulator for Seedvault..."
$SCRIPT_DIR/install_app.sh
"$SCRIPT_DIR"/install_app.sh
echo "Rebooting emulator..."
$ADB reboot
$ADB wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
echo "Setting backup transport to Seedvault..."
$ADB shell bmgr enable true
sleep 5
$ADB shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
echo "Disabling backup..."
$ADB shell bmgr enable false
echo "Downloading and extracting test backup to '/sdcard/seedvault_baseline'..."

View file

@ -46,7 +46,19 @@ class KoinInstrumentationTestApp : App() {
viewModel {
currentRestoreViewModel =
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
spyk(
RestoreViewModel(
app = context,
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
)
)
currentRestoreViewModel!!
}

View file

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

View file

@ -113,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
}
fun testResultFilename(testName: String): String {
val arguments = InstrumentationRegistry.getArguments()
val d2d = if (arguments.getString("d2d_backup_test") == "true") "d2d" else ""
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
return "${timeStamp}_${testName.replace(" ", "_")}"
return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
}
@OptIn(DelicateCoroutinesApi::class)

View file

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

View file

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

View file

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

View file

@ -95,7 +95,19 @@ open class App : Application() {
)
}
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
viewModel {
RestoreViewModel(
app = this@App,
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
)
}
viewModel { FileSelectionViewModel(this@App, get()) }
}
@ -189,6 +201,7 @@ open class App : Application() {
const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL
const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
const val NO_DATA_END_SENTINEL = "@end@"
const val GLOBAL_METADATA_KEY = "@meta@"
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -57,8 +57,14 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
if (packageMetadata.size != null) {
put(JSON_PACKAGE_SIZE, packageMetadata.size)
}
if (packageMetadata.name != null) {
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
}
if (packageMetadata.system) {
put(JSON_PACKAGE_SYSTEM, packageMetadata.system)
put(JSON_PACKAGE_SYSTEM, true)
}
if (packageMetadata.isLaunchableSystemApp) {
put(JSON_PACKAGE_SYSTEM_LAUNCHER, true)
}
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,7 +69,7 @@ internal class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val pluginManager: StoragePluginManager,
pluginManager: StoragePluginManager,
private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup,
@ -97,6 +97,9 @@ internal class SettingsViewModel(
private val mAppStatusList = lastBackupTime.switchMap {
// updates app list when lastBackupTime changes
// FIXME: Since we are currently updating that time a lot,
// re-fetching everything on each change hammers the system hard
// which can cause android.os.DeadObjectException
getAppStatusResult()
}
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList

View file

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

View file

@ -15,7 +15,7 @@ import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.checkbox.MaterialCheckBox
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
@ -32,7 +32,7 @@ internal abstract class AppViewHolder(protected val v: View) : RecyclerView.View
protected val appInfo: TextView = v.requireViewById(R.id.appInfo)
protected val appStatus: ImageView = v.requireViewById(R.id.appStatus)
protected val progressBar: ProgressBar = v.requireViewById(R.id.progressBar)
protected val switchView: SwitchMaterial = v.requireViewById(R.id.switchView)
protected val checkBox: MaterialCheckBox = v.requireViewById(R.id.checkboxView)
init {
// don't use clickable background by default

View file

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

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 {
if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) {
fun getAppName(
context: Context,
packageName: String,
fallback: String = packageName,
): CharSequence {
if (packageName == MAGIC_PACKAGE_MANAGER || packageName.startsWith("@")) {
return context.getString(R.string.restore_magic_package)
}
return try {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(appInfo)
} catch (e: NameNotFoundException) {
packageId
fallback
}
}

View file

@ -29,6 +29,7 @@ internal class ApkBackupManager(
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val packageService: PackageService,
private val iconManager: IconManager,
private val apkBackup: ApkBackup,
private val pluginManager: StoragePluginManager,
private val nm: BackupNotificationManager,
@ -44,6 +45,8 @@ internal class ApkBackupManager(
// Since an APK backup does not change the [packageState], we first record it for all
// packages that don't get backed up.
recordNotBackedUpPackages()
// Upload current icons, so we can show them to user before restore
uploadIcons()
// Now, if APK backups are enabled by the user, we back those up.
if (settingsManager.backupApks()) {
backUpApks()
@ -77,6 +80,7 @@ internal class ApkBackupManager(
nm.onAppsNotBackedUp()
packageService.notBackedUpPackages.forEach { packageInfo ->
val packageName = packageInfo.packageName
if (!settingsManager.isBackupEnabled(packageName)) return@forEach
try {
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
val packageMetadata = metadataManager.getPackageMetadata(packageName)
@ -94,6 +98,17 @@ internal class ApkBackupManager(
}
}
private suspend fun uploadIcons() {
try {
val token = settingsManager.getToken() ?: throw IOException("no current token")
pluginManager.appPlugin.getOutputStream(token, FILE_BACKUP_ICONS).use {
iconManager.uploadIcons(token, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error uploading icons: ", e)
}
}
/**
* Backs up an APK for the given [PackageInfo].
*

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

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"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -11,12 +11,12 @@ ktlint = "11.5.0"
# Android SDK versions
compileSdk = "34"
minSdk = "33"
minSdk = "34"
targetSdk = "34"
# Test versions
junit4 = "4.13.2"
junit5 = "5.10.0" # careful, upgrading this can change a Cipher's IV size in tests!?
junit5 = "5.10.2" # careful, upgrading this can change a Cipher's IV size in tests!?
mockk = "1.13.4" # newer versions require kotlin > 1.8.10
espresso = "3.4.0"