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