Merge pull request #750 from grote/app-backup-v2
App backup format v2 with compression and deduplication
This commit is contained in:
commit
5365ef3a5e
213 changed files with 10054 additions and 4090 deletions
4
.github/scripts/run_tests.sh
vendored
4
.github/scripts/run_tests.sh
vendored
|
@ -10,10 +10,8 @@ echo "Installing Seedvault app..."
|
|||
./gradlew --stacktrace :app:installDebugAndroidTest
|
||||
sleep 60
|
||||
|
||||
D2D_BACKUP_TEST=$1
|
||||
|
||||
large_test_exit_code=0
|
||||
./gradlew --stacktrace -Pinstrumented_test_size=large -Pd2d_backup_test="$D2D_BACKUP_TEST" :app:connectedAndroidTest || large_test_exit_code=$?
|
||||
./gradlew --stacktrace -Pinstrumented_test_size=large :app:connectedAndroidTest || large_test_exit_code=$?
|
||||
|
||||
adb pull /sdcard/seedvault_test_results
|
||||
|
||||
|
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
@ -20,7 +20,6 @@ jobs:
|
|||
matrix:
|
||||
android_target: [ 34 ]
|
||||
emulator_type: [ aosp_atd ]
|
||||
d2d_backup_test: [ true, false ]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
@ -53,7 +52,7 @@ jobs:
|
|||
disable-animations: true
|
||||
script: |
|
||||
./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64"
|
||||
./.github/scripts/run_tests.sh ${{ matrix.d2d_backup_test }}
|
||||
./.github/scripts/run_tests.sh
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
|
|
22
Android.bp
22
Android.bp
|
@ -8,12 +8,23 @@ android_app {
|
|||
srcs: [
|
||||
"app/src/main/java/**/*.kt",
|
||||
"app/src/main/java/**/*.java",
|
||||
"app/src/main/proto/*.proto",
|
||||
// as of Android 15, there is no way to pass --kotlin_out to aprotoc compiler
|
||||
"app/build/generated/source/proto/debug/kotlin/com/stevesoltys/seedvault/proto/*.kt",
|
||||
],
|
||||
resource_dirs: [
|
||||
"app/src/main/res",
|
||||
],
|
||||
asset_dirs: [
|
||||
"app/src/main/assets"
|
||||
],
|
||||
proto: {
|
||||
type: "lite",
|
||||
local_include_dirs: ["app/src/main/proto"],
|
||||
},
|
||||
static_libs: [
|
||||
"kotlin-stdlib-jdk8",
|
||||
"libprotobuf-java-lite",
|
||||
"androidx.core_core-ktx",
|
||||
"androidx.fragment_fragment-ktx",
|
||||
"androidx.activity_activity-ktx",
|
||||
|
@ -26,6 +37,13 @@ android_app {
|
|||
"com.google.android.material_material",
|
||||
"kotlinx-coroutines-android",
|
||||
"kotlinx-coroutines-core",
|
||||
"seedvault-lib-kotlin-logging-jvm",
|
||||
// app backup related libs
|
||||
"seedvault-lib-protobuf-kotlin-lite",
|
||||
"seedvault-logback-android",
|
||||
"seedvault-lib-chunker",
|
||||
"seedvault-lib-zstd-jni",
|
||||
"okio-lib",
|
||||
// our own gradle module libs
|
||||
"seedvault-lib-core",
|
||||
"seedvault-lib-storage",
|
||||
|
@ -34,10 +52,8 @@ android_app {
|
|||
"seedvault-lib-koin-android",
|
||||
// bip39
|
||||
"seedvault-lib-kotlin-bip39",
|
||||
// WebDAV
|
||||
"seedvault-lib-dav4jvm",
|
||||
"seedvault-lib-okhttp",
|
||||
],
|
||||
use_embedded_native_libs: true,
|
||||
manifest: "app/src/main/AndroidManifest.xml",
|
||||
|
||||
platform_apis: true,
|
||||
|
|
|
@ -3,12 +3,14 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
|
||||
import com.google.protobuf.gradle.id
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.google.protobuf)
|
||||
}
|
||||
|
||||
val gitDescribe = {
|
||||
|
@ -37,9 +39,6 @@ android {
|
|||
|
||||
testInstrumentationRunnerArguments["size"] = testSize
|
||||
}
|
||||
|
||||
val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
|
||||
testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
@ -93,6 +92,30 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = if ("aarch64" == System.getProperty("os.arch")) {
|
||||
// mac m1
|
||||
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}:osx-x86_64"
|
||||
} else {
|
||||
// other
|
||||
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
|
||||
}
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.plugins {
|
||||
id("java") {
|
||||
option("lite")
|
||||
}
|
||||
id("kotlin") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
|
||||
|
@ -132,7 +155,10 @@ dependencies {
|
|||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.google.material)
|
||||
|
||||
implementation(libs.google.protobuf.javalite)
|
||||
implementation(libs.google.tink.android)
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.squareup.okio)
|
||||
|
||||
/**
|
||||
* Storage Dependencies
|
||||
|
@ -151,9 +177,13 @@ dependencies {
|
|||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
|
||||
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||
|
||||
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||
implementation(
|
||||
fileTree("${rootProject.rootDir}/libs").include("protobuf-kotlin-lite-3.21.12.jar")
|
||||
)
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.8.jar"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("logback-android-3.0.0.aar"))
|
||||
|
||||
/**
|
||||
* Test Dependencies (do not concern the AOSP build)
|
||||
|
@ -163,6 +193,7 @@ dependencies {
|
|||
// anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation(aospLibs)
|
||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||
testImplementation("org.slf4j:slf4j-simple:2.0.3")
|
||||
testImplementation("org.robolectric:robolectric:4.12.2")
|
||||
testImplementation("org.hamcrest:hamcrest:2.2")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
||||
|
@ -173,6 +204,7 @@ dependencies {
|
|||
)
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
||||
testImplementation("com.github.luben:zstd-jni:1.5.6-5")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
||||
|
||||
|
|
|
@ -0,0 +1,950 @@
|
|||
//Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: snapshot.proto
|
||||
|
||||
package com.stevesoltys.seedvault.proto;
|
||||
|
||||
@kotlin.jvm.JvmName("-initializesnapshot")
|
||||
public inline fun snapshot(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.newBuilder()).apply { block() }._build()
|
||||
public object SnapshotKt {
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||
public class Dsl private constructor(
|
||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Builder
|
||||
) {
|
||||
public companion object {
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Builder): Dsl = Dsl(builder)
|
||||
}
|
||||
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot = _builder.build()
|
||||
|
||||
/**
|
||||
* <code>uint32 version = 1;</code>
|
||||
*/
|
||||
public var version: kotlin.Int
|
||||
@JvmName("getVersion")
|
||||
get() = _builder.getVersion()
|
||||
@JvmName("setVersion")
|
||||
set(value) {
|
||||
_builder.setVersion(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint32 version = 1;</code>
|
||||
*/
|
||||
public fun clearVersion() {
|
||||
_builder.clearVersion()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>uint64 token = 2;</code>
|
||||
*/
|
||||
public var token: kotlin.Long
|
||||
@JvmName("getToken")
|
||||
get() = _builder.getToken()
|
||||
@JvmName("setToken")
|
||||
set(value) {
|
||||
_builder.setToken(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint64 token = 2;</code>
|
||||
*/
|
||||
public fun clearToken() {
|
||||
_builder.clearToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string name = 3;</code>
|
||||
*/
|
||||
public var name: kotlin.String
|
||||
@JvmName("getName")
|
||||
get() = _builder.getName()
|
||||
@JvmName("setName")
|
||||
set(value) {
|
||||
_builder.setName(value)
|
||||
}
|
||||
/**
|
||||
* <code>string name = 3;</code>
|
||||
*/
|
||||
public fun clearName() {
|
||||
_builder.clearName()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string user = 4;</code>
|
||||
*/
|
||||
public var user: kotlin.String
|
||||
@JvmName("getUser")
|
||||
get() = _builder.getUser()
|
||||
@JvmName("setUser")
|
||||
set(value) {
|
||||
_builder.setUser(value)
|
||||
}
|
||||
/**
|
||||
* <code>string user = 4;</code>
|
||||
*/
|
||||
public fun clearUser() {
|
||||
_builder.clearUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string androidId = 5;</code>
|
||||
*/
|
||||
public var androidId: kotlin.String
|
||||
@JvmName("getAndroidId")
|
||||
get() = _builder.getAndroidId()
|
||||
@JvmName("setAndroidId")
|
||||
set(value) {
|
||||
_builder.setAndroidId(value)
|
||||
}
|
||||
/**
|
||||
* <code>string androidId = 5;</code>
|
||||
*/
|
||||
public fun clearAndroidId() {
|
||||
_builder.clearAndroidId()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>uint32 sdkInt = 6;</code>
|
||||
*/
|
||||
public var sdkInt: kotlin.Int
|
||||
@JvmName("getSdkInt")
|
||||
get() = _builder.getSdkInt()
|
||||
@JvmName("setSdkInt")
|
||||
set(value) {
|
||||
_builder.setSdkInt(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint32 sdkInt = 6;</code>
|
||||
*/
|
||||
public fun clearSdkInt() {
|
||||
_builder.clearSdkInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string androidIncremental = 7;</code>
|
||||
*/
|
||||
public var androidIncremental: kotlin.String
|
||||
@JvmName("getAndroidIncremental")
|
||||
get() = _builder.getAndroidIncremental()
|
||||
@JvmName("setAndroidIncremental")
|
||||
set(value) {
|
||||
_builder.setAndroidIncremental(value)
|
||||
}
|
||||
/**
|
||||
* <code>string androidIncremental = 7;</code>
|
||||
*/
|
||||
public fun clearAndroidIncremental() {
|
||||
_builder.clearAndroidIncremental()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>bool d2d = 8;</code>
|
||||
*/
|
||||
public var d2D: kotlin.Boolean
|
||||
@JvmName("getD2D")
|
||||
get() = _builder.getD2D()
|
||||
@JvmName("setD2D")
|
||||
set(value) {
|
||||
_builder.setD2D(value)
|
||||
}
|
||||
/**
|
||||
* <code>bool d2d = 8;</code>
|
||||
*/
|
||||
public fun clearD2D() {
|
||||
_builder.clearD2D()
|
||||
}
|
||||
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class AppsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
public val apps: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("getAppsMap")
|
||||
get() = com.google.protobuf.kotlin.DslMap(
|
||||
_builder.getAppsMap()
|
||||
)
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
@JvmName("putApps")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
||||
_builder.putApps(key, value)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("setApps")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
||||
put(key, value)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("removeApps")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
.remove(key: kotlin.String) {
|
||||
_builder.removeApps(key)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("putAllApps")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App>) {
|
||||
_builder.putAllApps(map)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("clearApps")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||
.clear() {
|
||||
_builder.clearApps()
|
||||
}
|
||||
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class IconChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
*/
|
||||
public val iconChunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
get() = com.google.protobuf.kotlin.DslList(
|
||||
_builder.getIconChunkIdsList()
|
||||
)
|
||||
/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
* @param value The iconChunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addIconChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||
_builder.addIconChunkIds(value)
|
||||
}/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
* @param value The iconChunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignIconChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||
add(value)
|
||||
}/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
* @param values The iconChunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addAllIconChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
_builder.addAllIconChunkIds(values)
|
||||
}/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
* @param values The iconChunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignAllIconChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
addAll(values)
|
||||
}/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
* @param index The index to set the value at.
|
||||
* @param value The iconChunkIds to set.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("setIconChunkIds")
|
||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||
_builder.setIconChunkIds(index, value)
|
||||
}/**
|
||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("clearIconChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.clear() {
|
||||
_builder.clearIconChunkIds()
|
||||
}
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class BlobsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
public val blobs: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("getBlobsMap")
|
||||
get() = com.google.protobuf.kotlin.DslMap(
|
||||
_builder.getBlobsMap()
|
||||
)
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
@JvmName("putBlobs")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
||||
_builder.putBlobs(key, value)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("setBlobs")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
||||
put(key, value)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("removeBlobs")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
.remove(key: kotlin.String) {
|
||||
_builder.removeBlobs(key)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("putAllBlobs")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob>) {
|
||||
_builder.putAllBlobs(map)
|
||||
}
|
||||
/**
|
||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@JvmName("clearBlobs")
|
||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||
.clear() {
|
||||
_builder.clearBlobs()
|
||||
}
|
||||
}
|
||||
@kotlin.jvm.JvmName("-initializeapp")
|
||||
public inline fun app(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.App.newBuilder()).apply { block() }._build()
|
||||
public object AppKt {
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||
public class Dsl private constructor(
|
||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder
|
||||
) {
|
||||
public companion object {
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder): Dsl = Dsl(builder)
|
||||
}
|
||||
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.App = _builder.build()
|
||||
|
||||
/**
|
||||
* <code>uint64 time = 1;</code>
|
||||
*/
|
||||
public var time: kotlin.Long
|
||||
@JvmName("getTime")
|
||||
get() = _builder.getTime()
|
||||
@JvmName("setTime")
|
||||
set(value) {
|
||||
_builder.setTime(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint64 time = 1;</code>
|
||||
*/
|
||||
public fun clearTime() {
|
||||
_builder.clearTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
||||
*/
|
||||
public var type: com.stevesoltys.seedvault.proto.Snapshot.BackupType
|
||||
@JvmName("getType")
|
||||
get() = _builder.getType()
|
||||
@JvmName("setType")
|
||||
set(value) {
|
||||
_builder.setType(value)
|
||||
}
|
||||
/**
|
||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
||||
*/
|
||||
public fun clearType() {
|
||||
_builder.clearType()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string name = 3;</code>
|
||||
*/
|
||||
public var name: kotlin.String
|
||||
@JvmName("getName")
|
||||
get() = _builder.getName()
|
||||
@JvmName("setName")
|
||||
set(value) {
|
||||
_builder.setName(value)
|
||||
}
|
||||
/**
|
||||
* <code>string name = 3;</code>
|
||||
*/
|
||||
public fun clearName() {
|
||||
_builder.clearName()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>bool system = 4;</code>
|
||||
*/
|
||||
public var system: kotlin.Boolean
|
||||
@JvmName("getSystem")
|
||||
get() = _builder.getSystem()
|
||||
@JvmName("setSystem")
|
||||
set(value) {
|
||||
_builder.setSystem(value)
|
||||
}
|
||||
/**
|
||||
* <code>bool system = 4;</code>
|
||||
*/
|
||||
public fun clearSystem() {
|
||||
_builder.clearSystem()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>bool launchableSystemApp = 5;</code>
|
||||
*/
|
||||
public var launchableSystemApp: kotlin.Boolean
|
||||
@JvmName("getLaunchableSystemApp")
|
||||
get() = _builder.getLaunchableSystemApp()
|
||||
@JvmName("setLaunchableSystemApp")
|
||||
set(value) {
|
||||
_builder.setLaunchableSystemApp(value)
|
||||
}
|
||||
/**
|
||||
* <code>bool launchableSystemApp = 5;</code>
|
||||
*/
|
||||
public fun clearLaunchableSystemApp() {
|
||||
_builder.clearLaunchableSystemApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
*/
|
||||
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
get() = com.google.protobuf.kotlin.DslList(
|
||||
_builder.getChunkIdsList()
|
||||
)
|
||||
/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
* @param value The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||
_builder.addChunkIds(value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
* @param value The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||
add(value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
* @param values The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addAllChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
_builder.addAllChunkIds(values)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
* @param values The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
addAll(values)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
* @param index The index to set the value at.
|
||||
* @param value The chunkIds to set.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("setChunkIds")
|
||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||
_builder.setChunkIds(index, value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 6;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("clearChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
||||
_builder.clearChunkIds()
|
||||
}
|
||||
/**
|
||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||
*/
|
||||
public var apk: com.stevesoltys.seedvault.proto.Snapshot.Apk
|
||||
@JvmName("getApk")
|
||||
get() = _builder.getApk()
|
||||
@JvmName("setApk")
|
||||
set(value) {
|
||||
_builder.setApk(value)
|
||||
}
|
||||
/**
|
||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||
*/
|
||||
public fun clearApk() {
|
||||
_builder.clearApk()
|
||||
}
|
||||
/**
|
||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||
* @return Whether the apk field is set.
|
||||
*/
|
||||
public fun hasApk(): kotlin.Boolean {
|
||||
return _builder.hasApk()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>uint64 size = 8;</code>
|
||||
*/
|
||||
public var size: kotlin.Long
|
||||
@JvmName("getSize")
|
||||
get() = _builder.getSize()
|
||||
@JvmName("setSize")
|
||||
set(value) {
|
||||
_builder.setSize(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint64 size = 8;</code>
|
||||
*/
|
||||
public fun clearSize() {
|
||||
_builder.clearSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
@kotlin.jvm.JvmName("-initializeapk")
|
||||
public inline fun apk(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Apk.newBuilder()).apply { block() }._build()
|
||||
public object ApkKt {
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||
public class Dsl private constructor(
|
||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder
|
||||
) {
|
||||
public companion object {
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder): Dsl = Dsl(builder)
|
||||
}
|
||||
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Apk = _builder.build()
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
**
|
||||
* Attention: Has default value of 0
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint64 versionCode = 1;</code>
|
||||
*/
|
||||
public var versionCode: kotlin.Long
|
||||
@JvmName("getVersionCode")
|
||||
get() = _builder.getVersionCode()
|
||||
@JvmName("setVersionCode")
|
||||
set(value) {
|
||||
_builder.setVersionCode(value)
|
||||
}
|
||||
/**
|
||||
* <pre>
|
||||
**
|
||||
* Attention: Has default value of 0
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint64 versionCode = 1;</code>
|
||||
*/
|
||||
public fun clearVersionCode() {
|
||||
_builder.clearVersionCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>string installer = 2;</code>
|
||||
*/
|
||||
public var installer: kotlin.String
|
||||
@JvmName("getInstaller")
|
||||
get() = _builder.getInstaller()
|
||||
@JvmName("setInstaller")
|
||||
set(value) {
|
||||
_builder.setInstaller(value)
|
||||
}
|
||||
/**
|
||||
* <code>string installer = 2;</code>
|
||||
*/
|
||||
public fun clearInstaller() {
|
||||
_builder.clearInstaller()
|
||||
}
|
||||
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class SignaturesProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
*/
|
||||
public val signatures: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
get() = com.google.protobuf.kotlin.DslList(
|
||||
_builder.getSignaturesList()
|
||||
)
|
||||
/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
* @param value The signatures to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addSignatures")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.add(value: com.google.protobuf.ByteString) {
|
||||
_builder.addSignatures(value)
|
||||
}/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
* @param value The signatures to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignSignatures")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||
add(value)
|
||||
}/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
* @param values The signatures to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addAllSignatures")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
_builder.addAllSignatures(values)
|
||||
}/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
* @param values The signatures to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignAllSignatures")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
addAll(values)
|
||||
}/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
* @param index The index to set the value at.
|
||||
* @param value The signatures to set.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("setSignatures")
|
||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||
_builder.setSignatures(index, value)
|
||||
}/**
|
||||
* <code>repeated bytes signatures = 3;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("clearSignatures")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.clear() {
|
||||
_builder.clearSignatures()
|
||||
}
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class SplitsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
*/
|
||||
public val splits: com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
get() = com.google.protobuf.kotlin.DslList(
|
||||
_builder.getSplitsList()
|
||||
)
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
* @param value The splits to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addSplits")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.add(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||
_builder.addSplits(value)
|
||||
}
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
* @param value The splits to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignSplits")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||
add(value)
|
||||
}
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
* @param values The splits to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addAllSplits")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.addAll(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
||||
_builder.addAllSplits(values)
|
||||
}
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
* @param values The splits to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignAllSplits")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
||||
addAll(values)
|
||||
}
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
* @param index The index to set the value at.
|
||||
* @param value The splits to set.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("setSplits")
|
||||
public operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.set(index: kotlin.Int, value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||
_builder.setSplits(index, value)
|
||||
}
|
||||
/**
|
||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("clearSplits")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.clear() {
|
||||
_builder.clearSplits()
|
||||
}
|
||||
}
|
||||
}
|
||||
@kotlin.jvm.JvmName("-initializesplit")
|
||||
public inline fun split(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Split.newBuilder()).apply { block() }._build()
|
||||
public object SplitKt {
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||
public class Dsl private constructor(
|
||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder
|
||||
) {
|
||||
public companion object {
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder): Dsl = Dsl(builder)
|
||||
}
|
||||
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Split = _builder.build()
|
||||
|
||||
/**
|
||||
* <code>string name = 1;</code>
|
||||
*/
|
||||
public var name: kotlin.String
|
||||
@JvmName("getName")
|
||||
get() = _builder.getName()
|
||||
@JvmName("setName")
|
||||
set(value) {
|
||||
_builder.setName(value)
|
||||
}
|
||||
/**
|
||||
* <code>string name = 1;</code>
|
||||
*/
|
||||
public fun clearName() {
|
||||
_builder.clearName()
|
||||
}
|
||||
|
||||
/**
|
||||
* An uninstantiable, behaviorless type to represent the field in
|
||||
* generics.
|
||||
*/
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||
/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
*/
|
||||
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
get() = com.google.protobuf.kotlin.DslList(
|
||||
_builder.getChunkIdsList()
|
||||
)
|
||||
/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
* @param value The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||
_builder.addChunkIds(value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
* @param value The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||
add(value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
* @param values The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("addAllChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
_builder.addAllChunkIds(values)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
* @param values The chunkIds to add.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||
addAll(values)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
* @param index The index to set the value at.
|
||||
* @param value The chunkIds to set.
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("setChunkIds")
|
||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||
_builder.setChunkIds(index, value)
|
||||
}/**
|
||||
* <code>repeated bytes chunkIds = 2;</code>
|
||||
*/
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.jvm.JvmName("clearChunkIds")
|
||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
||||
_builder.clearChunkIds()
|
||||
}}
|
||||
}
|
||||
@kotlin.jvm.JvmName("-initializeblob")
|
||||
public inline fun blob(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Blob.newBuilder()).apply { block() }._build()
|
||||
public object BlobKt {
|
||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||
public class Dsl private constructor(
|
||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder
|
||||
) {
|
||||
public companion object {
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder): Dsl = Dsl(builder)
|
||||
}
|
||||
|
||||
@kotlin.jvm.JvmSynthetic
|
||||
@kotlin.PublishedApi
|
||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Blob = _builder.build()
|
||||
|
||||
/**
|
||||
* <code>bytes id = 1;</code>
|
||||
*/
|
||||
public var id: com.google.protobuf.ByteString
|
||||
@JvmName("getId")
|
||||
get() = _builder.getId()
|
||||
@JvmName("setId")
|
||||
set(value) {
|
||||
_builder.setId(value)
|
||||
}
|
||||
/**
|
||||
* <code>bytes id = 1;</code>
|
||||
*/
|
||||
public fun clearId() {
|
||||
_builder.clearId()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>uint32 length = 2;</code>
|
||||
*/
|
||||
public var length: kotlin.Int
|
||||
@JvmName("getLength")
|
||||
get() = _builder.getLength()
|
||||
@JvmName("setLength")
|
||||
set(value) {
|
||||
_builder.setLength(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint32 length = 2;</code>
|
||||
*/
|
||||
public fun clearLength() {
|
||||
_builder.clearLength()
|
||||
}
|
||||
|
||||
/**
|
||||
* <code>uint32 uncompressedLength = 3;</code>
|
||||
*/
|
||||
public var uncompressedLength: kotlin.Int
|
||||
@JvmName("getUncompressedLength")
|
||||
get() = _builder.getUncompressedLength()
|
||||
@JvmName("setUncompressedLength")
|
||||
set(value) {
|
||||
_builder.setUncompressedLength(value)
|
||||
}
|
||||
/**
|
||||
* <code>uint32 uncompressedLength = 3;</code>
|
||||
*/
|
||||
public fun clearUncompressedLength() {
|
||||
_builder.clearUncompressedLength()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||
|
||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.App.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||
|
||||
public val com.stevesoltys.seedvault.proto.Snapshot.AppOrBuilder.apkOrNull: com.stevesoltys.seedvault.proto.Snapshot.Apk?
|
||||
get() = if (hasApk()) getApk() else null
|
||||
|
||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Apk.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||
|
||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Split.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||
|
||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Blob.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
||||
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||
|
|
@ -32,16 +32,16 @@ class KoinInstrumentationTestApp : App() {
|
|||
val testModule = module {
|
||||
val context = this@KoinInstrumentationTestApp
|
||||
|
||||
single { spyk(PackageService(context, get(), get(), get())) }
|
||||
single { spyk(PackageService(context, get(), get())) }
|
||||
single { spyk(SettingsManager(context)) }
|
||||
|
||||
single { spyk(BackupNotificationManager(context)) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get())) }
|
||||
single { spyk(KVBackup(get(), get(), get())) }
|
||||
single { spyk(InputFactory()) }
|
||||
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(OutputFactory()) }
|
||||
|
||||
viewModel {
|
||||
|
@ -53,6 +53,7 @@ class KoinInstrumentationTestApp : App() {
|
|||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
appBackupManager = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
|
|
|
@ -10,7 +10,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
||||
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin
|
||||
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
@ -92,59 +91,58 @@ class PluginTest : KoinComponent {
|
|||
@Test
|
||||
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||
// no backups available initially
|
||||
assertEquals(0, backend.getAvailableBackups()?.toList()?.size)
|
||||
assertEquals(0, backend.getAvailableBackupFileHandles().toList().size)
|
||||
|
||||
// prepare returned tokens requested when initializing device
|
||||
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||
every { mockedSettingsManager.token } returnsMany listOf(token, token + 1, token + 1)
|
||||
|
||||
// write metadata (needed for backup to be recognized)
|
||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||
.writeAndClose(getRandomByteArray())
|
||||
|
||||
// one backup available now
|
||||
assertEquals(1, backend.getAvailableBackups()?.toList()?.size)
|
||||
assertEquals(1, backend.getAvailableBackupFileHandles().toList().size)
|
||||
|
||||
// initializing again (with another restore set) does add a restore set
|
||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||
.writeAndClose(getRandomByteArray())
|
||||
assertEquals(2, backend.getAvailableBackups()?.toList()?.size)
|
||||
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size)
|
||||
|
||||
// initializing again (without new restore set) doesn't change number of restore sets
|
||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||
.writeAndClose(getRandomByteArray())
|
||||
assertEquals(2, backend.getAvailableBackups()?.toList()?.size)
|
||||
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||
every { mockedSettingsManager.getToken() } returns token
|
||||
every { mockedSettingsManager.token } returns token
|
||||
|
||||
// write metadata
|
||||
val metadata = getRandomByteArray()
|
||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
||||
|
||||
// get available backups, expect only one with our token and no error
|
||||
var availableBackups = backend.getAvailableBackups()?.toList()
|
||||
check(availableBackups != null)
|
||||
var availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||
assertEquals(1, availableBackups.size)
|
||||
assertEquals(token, availableBackups[0].token)
|
||||
var backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||
assertEquals(token, backupHandle.token)
|
||||
|
||||
// read metadata matches what was written earlier
|
||||
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
||||
assertReadEquals(metadata, backend.load(backupHandle))
|
||||
|
||||
// initializing again (without changing storage) keeps restore set with same token
|
||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
||||
availableBackups = backend.getAvailableBackups()?.toList()
|
||||
check(availableBackups != null)
|
||||
availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||
assertEquals(1, availableBackups.size)
|
||||
assertEquals(token, availableBackups[0].token)
|
||||
backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||
assertEquals(token, backupHandle.token)
|
||||
|
||||
// metadata hasn't changed
|
||||
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
||||
assertReadEquals(metadata, backend.load(backupHandle))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("Deprecation")
|
||||
fun v0testApkWriteRead() = runBlocking {
|
||||
// initialize storage with given token
|
||||
initStorage(token)
|
||||
|
@ -202,7 +200,7 @@ class PluginTest : KoinComponent {
|
|||
}
|
||||
|
||||
private fun initStorage(token: Long) = runBlocking {
|
||||
every { mockedSettingsManager.getToken() } returns token
|
||||
every { mockedSettingsManager.token } returns token
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
|||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.BackendTest
|
||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
@ -25,15 +24,8 @@ class SafBackendTest : BackendTest(), KoinComponent {
|
|||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val settingsManager by inject<SettingsManager>()
|
||||
private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage")
|
||||
private val safProperties = SafProperties(
|
||||
config = safStorage.config,
|
||||
name = safStorage.name,
|
||||
isUsb = safStorage.isUsb,
|
||||
requiresNetwork = safStorage.requiresNetwork,
|
||||
rootId = safStorage.rootId,
|
||||
)
|
||||
override val plugin: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
||||
private val safProperties = settingsManager.getSafProperties() ?: error("No SAF storage")
|
||||
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
||||
|
||||
@Test
|
||||
fun `test write list read rename delete`(): Unit = runBlocking {
|
||||
|
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.withTimeout
|
|||
import org.koin.core.component.get
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.test.fail
|
||||
|
||||
internal interface LargeBackupTestBase : LargeTestBase {
|
||||
|
||||
|
@ -74,7 +75,6 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
full = mutableMapOf(),
|
||||
kv = mutableMapOf(),
|
||||
userApps = packageService.userApps,
|
||||
userNotAllowedApps = packageService.userNotAllowedApps
|
||||
)
|
||||
|
||||
val completed = spyOnBackup(backupResult)
|
||||
|
@ -111,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
var data = mutableMapOf<String, ByteArray>()
|
||||
|
||||
coEvery {
|
||||
spyKVBackup.performBackup(any(), any(), any(), any(), any())
|
||||
spyKVBackup.performBackup(any(), any(), any())
|
||||
} answers {
|
||||
packageName = firstArg<PackageInfo>().packageName
|
||||
callOriginal()
|
||||
|
@ -157,7 +157,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
var dataIntercept = ByteArrayOutputStream()
|
||||
|
||||
coEvery {
|
||||
spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
|
||||
spyFullBackup.performFullBackup(any(), any(), any())
|
||||
} answers {
|
||||
packageName = firstArg<PackageInfo>().packageName
|
||||
callOriginal()
|
||||
|
@ -172,7 +172,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
)
|
||||
}
|
||||
|
||||
every {
|
||||
coEvery {
|
||||
spyFullBackup.finishBackup()
|
||||
} answers {
|
||||
val result = callOriginal()
|
||||
|
@ -190,7 +190,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
clearMocks(spyBackupNotificationManager)
|
||||
|
||||
every {
|
||||
spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
|
||||
spyBackupNotificationManager.onBackupSuccess(any(), any(), any())
|
||||
} answers {
|
||||
val success = firstArg<Boolean>()
|
||||
assert(success) { "Backup failed." }
|
||||
|
@ -198,6 +198,13 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
callOriginal()
|
||||
completed.set(true)
|
||||
}
|
||||
every {
|
||||
spyBackupNotificationManager.onBackupError()
|
||||
} answers {
|
||||
callOriginal()
|
||||
completed.set(true)
|
||||
fail("Backup failed.")
|
||||
}
|
||||
|
||||
return completed
|
||||
}
|
||||
|
|
|
@ -63,7 +63,6 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
full = mutableMapOf(),
|
||||
kv = mutableMapOf(),
|
||||
userApps = emptyList(), // will update everything below this after restore
|
||||
userNotAllowedApps = emptyList()
|
||||
)
|
||||
|
||||
spyOnRestoreData(result)
|
||||
|
@ -97,7 +96,6 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
|
||||
return result.copy(
|
||||
userApps = packageService.userApps,
|
||||
userNotAllowedApps = packageService.userNotAllowedApps
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -164,7 +162,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
clearMocks(spyKVRestore)
|
||||
|
||||
coEvery {
|
||||
spyKVRestore.initializeState(any(), any(), any(), any(), any())
|
||||
spyKVRestore.initializeState(any(), any(), any(), any())
|
||||
} answers {
|
||||
packageName = arg<PackageInfo>(3).packageName
|
||||
restoreResult.kv[packageName!!] = mutableMapOf()
|
||||
|
@ -189,7 +187,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
clearMocks(spyFullRestore)
|
||||
|
||||
coEvery {
|
||||
spyFullRestore.initializeState(any(), any(), any(), any())
|
||||
spyFullRestore.initializeState(any(), any(), any())
|
||||
} answers {
|
||||
packageName?.let {
|
||||
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
||||
|
|
|
@ -85,7 +85,6 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
fun resetApplicationState() {
|
||||
backupManager.setAutoRestore(false)
|
||||
settingsManager.setNewToken(null)
|
||||
|
||||
val sharedPreferences = permitDiskReads {
|
||||
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
||||
|
@ -113,11 +112,9 @@ internal interface LargeTestBase : KoinComponent {
|
|||
}
|
||||
|
||||
fun testResultFilename(testName: String): String {
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
val d2d = if (arguments.getString("d2d_backup_test") == "true") "d2d" else ""
|
||||
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
|
||||
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
|
||||
return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
|
||||
return "${timeStamp}_${testName.replace(" ", "_")}"
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
|
|
|
@ -7,7 +7,6 @@ package com.stevesoltys.seedvault.e2e
|
|||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
|
@ -52,17 +51,6 @@ internal abstract class SeedvaultLargeTest :
|
|||
|
||||
startRecordingTest(keepRecordingScreen, name.methodName)
|
||||
restoreBaselineBackup()
|
||||
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
|
||||
if (arguments.getString("d2d_backup_test") == "true") {
|
||||
println("Enabling D2D backups for test")
|
||||
settingsManager.setD2dBackupsEnabled(true)
|
||||
|
||||
} else {
|
||||
println("Disabling D2D backups for test")
|
||||
settingsManager.setD2dBackupsEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
|
|
|
@ -24,7 +24,6 @@ internal data class SeedvaultLargeTestResult(
|
|||
val full: MutableMap<String, String>,
|
||||
val kv: MutableMap<String, MutableMap<String, String>>,
|
||||
val userApps: List<PackageInfo>,
|
||||
val userNotAllowedApps: List<PackageInfo>,
|
||||
) {
|
||||
fun allUserApps() = userApps + userNotAllowedApps
|
||||
fun allUserApps() = userApps
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import android.app.backup.BackupDataInput
|
||||
import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.stevesoltys.seedvault.repo.BackupData
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class KvBackupInstrumentationTest : KoinComponent {
|
||||
|
||||
private val backupReceiver: BackupReceiver = mockk()
|
||||
private val inputFactory: InputFactory = mockk()
|
||||
private val dbManager: KvDbManager by inject()
|
||||
|
||||
private val backup = KVBackup(
|
||||
backupReceiver = backupReceiver,
|
||||
inputFactory = inputFactory,
|
||||
dbManager = dbManager,
|
||||
)
|
||||
|
||||
private val data = mockk<ParcelFileDescriptor>()
|
||||
private val dataInput = mockk<BackupDataInput>()
|
||||
private val key = "foo.bar"
|
||||
private val dataValue = Random.nextBytes(23)
|
||||
|
||||
@Test
|
||||
fun `test non-incremental backup with existing DB`() {
|
||||
val packageName = "com.example"
|
||||
val backupData = BackupData(emptyList(), emptyMap())
|
||||
|
||||
// create existing db
|
||||
dbManager.getDb(packageName).use { db ->
|
||||
db.put("foo", "bar".toByteArray())
|
||||
}
|
||||
|
||||
val packageInfo = PackageInfo().apply {
|
||||
this.packageName = packageName
|
||||
}
|
||||
|
||||
every { inputFactory.getBackupDataInput(data) } returns dataInput
|
||||
every { dataInput.readNextHeader() } returnsMany listOf(true, false)
|
||||
every { dataInput.key } returns key
|
||||
every { dataInput.dataSize } returns dataValue.size
|
||||
val slot = CapturingSlot<ByteArray>()
|
||||
every { dataInput.readEntityData(capture(slot), 0, dataValue.size) } answers {
|
||||
dataValue.copyInto(slot.captured)
|
||||
dataValue.size
|
||||
}
|
||||
every { data.close() } just Runs
|
||||
|
||||
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
|
||||
|
||||
coEvery { backupReceiver.readFromStream(any(), any()) } returns backupData
|
||||
|
||||
runBlocking {
|
||||
assertEquals(backupData, backup.finishBackup())
|
||||
}
|
||||
|
||||
dbManager.getDb(packageName).use { db ->
|
||||
assertNull(db.get("foo")) // existing data foo is gone
|
||||
assertArrayEquals(dataValue, db.get(key)) // new data got added
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.BackupData
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import com.stevesoltys.seedvault.repo.SnapshotCreatorFactory
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class IconManagerTest : KoinComponent {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val packageService by inject<PackageService>()
|
||||
private val backupReceiver = mockk<BackupReceiver>()
|
||||
private val loader = mockk<Loader>()
|
||||
private val appBackupManager = mockk<AppBackupManager>()
|
||||
private val snapshotCreatorFactory by inject<SnapshotCreatorFactory>()
|
||||
private val snapshotCreator = snapshotCreatorFactory.createSnapshotCreator()
|
||||
|
||||
private val iconManager = IconManager(
|
||||
context = context,
|
||||
packageService = packageService,
|
||||
crypto = mockk(),
|
||||
backupReceiver = backupReceiver,
|
||||
loader = loader,
|
||||
appBackupManager = appBackupManager,
|
||||
)
|
||||
|
||||
init {
|
||||
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test upload and then download`(): Unit = runBlocking {
|
||||
// prepare output data
|
||||
val output = slot<ByteArray>()
|
||||
val chunkId = Random.nextBytes(32).toHexString()
|
||||
val chunkList = listOf(chunkId)
|
||||
val blobId = Random.nextBytes(32).toHexString()
|
||||
val blob = blob { id = ByteString.fromHex(blobId) }
|
||||
|
||||
// upload icons and capture plaintext bytes
|
||||
coEvery { backupReceiver.addBytes(any(), capture(output)) } just Runs
|
||||
coEvery {
|
||||
backupReceiver.finalize(any())
|
||||
} returns BackupData(chunkList, mapOf(chunkId to blob))
|
||||
iconManager.uploadIcons()
|
||||
assertTrue(output.captured.isNotEmpty())
|
||||
|
||||
// @pm@ is needed
|
||||
val pmPackageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||
val backupData = BackupData(emptyList(), emptyMap())
|
||||
snapshotCreator.onPackageBackedUp(pmPackageInfo, BackupType.KV, backupData)
|
||||
|
||||
// get snapshot and assert it has icon chunks
|
||||
val snapshot = snapshotCreator.finalizeSnapshot()
|
||||
assertTrue(snapshot.iconChunkIdsCount > 0)
|
||||
|
||||
// prepare data for downloading icons
|
||||
val repoId = Random.nextBytes(32).toHexString()
|
||||
val inputStream = ByteArrayInputStream(output.captured)
|
||||
coEvery { loader.loadFile(AppBackupFileType.Blob(repoId, blobId)) } returns inputStream
|
||||
|
||||
// download icons and ensure we had an icon for at least one app
|
||||
val iconSet = iconManager.downloadIcons(repoId, snapshot)
|
||||
assertTrue(iconSet.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test upload produces deterministic output`(): Unit = runBlocking {
|
||||
val output1 = slot<ByteArray>()
|
||||
val output2 = slot<ByteArray>()
|
||||
|
||||
coEvery { backupReceiver.addBytes(any(), capture(output1)) } just Runs
|
||||
coEvery { backupReceiver.finalize(any()) } returns BackupData(emptyList(), emptyMap())
|
||||
iconManager.uploadIcons()
|
||||
assertTrue(output1.captured.isNotEmpty())
|
||||
|
||||
coEvery { backupReceiver.addBytes(any(), capture(output2)) } just Runs
|
||||
coEvery { backupReceiver.finalize(any()) } returns BackupData(emptyList(), emptyMap())
|
||||
iconManager.uploadIcons()
|
||||
assertTrue(output2.captured.isNotEmpty())
|
||||
|
||||
assertArrayEquals(output1.captured, output2.captured)
|
||||
|
||||
// print compressed and uncompressed size
|
||||
val size = output1.captured.size.toFloat() / 1024 / 1024
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
ZstdOutputStream(outputStream).use { it.write(output1.captured) }
|
||||
val compressedSize = outputStream.size().toFloat() / 1024 / 1024
|
||||
println("Icon size: $size MB, compressed $compressedSize MB")
|
||||
}
|
||||
|
||||
}
|
|
@ -101,7 +101,9 @@
|
|||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:exported="true"
|
||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS" />
|
||||
android:launchMode="singleTask"
|
||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.storage.StorageActivity"
|
||||
|
@ -114,12 +116,14 @@
|
|||
|
||||
<activity
|
||||
android:name=".ui.recoverycode.RecoveryCodeActivity"
|
||||
android:label="@string/recovery_code_title" />
|
||||
android:label="@string/recovery_code_title"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".restore.RestoreActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/restore_title"
|
||||
android:launchMode="singleTask"
|
||||
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP">
|
||||
<intent-filter>
|
||||
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
|
||||
|
|
23
app/src/main/assets/logback.xml
Normal file
23
app/src/main/assets/logback.xml
Normal file
|
@ -0,0 +1,23 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<configuration
|
||||
xmlns="https://tony19.github.io/logback-android/xml"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd"
|
||||
>
|
||||
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
|
||||
<tagEncoder>
|
||||
<pattern>%logger{12}</pattern>
|
||||
</tagEncoder>
|
||||
<encoder>
|
||||
<pattern>[%-20thread] %msg</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="logcat" />
|
||||
</root>
|
||||
</configuration>
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
package com.stevesoltys.seedvault
|
||||
|
||||
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.RunningAppProcessInfo
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupManager
|
||||
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
||||
|
@ -17,16 +19,18 @@ import android.os.ServiceManager.getService
|
|||
import android.os.StrictMode
|
||||
import android.os.UserHandle
|
||||
import android.os.UserManager
|
||||
import android.util.Log
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import androidx.work.WorkManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.stevesoltys.seedvault.MemoryLogger.getMemStr
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf
|
||||
import com.stevesoltys.seedvault.backend.webdav.storagePluginModuleWebDav
|
||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||
import com.stevesoltys.seedvault.header.headerModule
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||
import com.stevesoltys.seedvault.repo.repoModule
|
||||
import com.stevesoltys.seedvault.restore.install.installModule
|
||||
import com.stevesoltys.seedvault.restore.restoreUiModule
|
||||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||
|
@ -62,7 +66,7 @@ open class App : Application() {
|
|||
private val appModule = module {
|
||||
single { SettingsManager(this@App) }
|
||||
single { BackupNotificationManager(this@App) }
|
||||
single { BackendManager(this@App, get(), get()) }
|
||||
single { BackendManager(this@App, get(), get(), get()) }
|
||||
single {
|
||||
BackendFactory {
|
||||
// uses context of the device's main user to be able to access USB storage
|
||||
|
@ -82,15 +86,15 @@ open class App : Application() {
|
|||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backendManager = get(),
|
||||
metadataManager = get(),
|
||||
appListRetriever = get(),
|
||||
storageBackup = get(),
|
||||
backupManager = get(),
|
||||
backupInitializer = get(),
|
||||
backupStateManager = get(),
|
||||
)
|
||||
}
|
||||
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||
viewModel {
|
||||
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
|
||||
}
|
||||
viewModel {
|
||||
BackupStorageViewModel(
|
||||
app = this@App,
|
||||
|
@ -110,6 +114,7 @@ open class App : Application() {
|
|||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
startKoin()
|
||||
if (!isTest) migrateToOwnScheduling()
|
||||
if (isDebugBuild()) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
@ -125,10 +130,6 @@ open class App : Application() {
|
|||
.build()
|
||||
)
|
||||
}
|
||||
permitDiskReads {
|
||||
migrateTokenFromMetadataToSettingsManager()
|
||||
}
|
||||
if (!isTest) migrateToOwnScheduling()
|
||||
}
|
||||
|
||||
protected open fun startKoin() = startKoin {
|
||||
|
@ -147,29 +148,24 @@ open class App : Application() {
|
|||
restoreModule,
|
||||
installModule,
|
||||
storageModule,
|
||||
repoModule,
|
||||
workerModule,
|
||||
restoreUiModule,
|
||||
appModule
|
||||
)
|
||||
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val backupManager: IBackupManager by inject()
|
||||
private val backendManager: BackendManager by inject()
|
||||
private val backupStateManager: BackupStateManager by inject()
|
||||
|
||||
/**
|
||||
* The responsibility for the current token was moved to the [SettingsManager]
|
||||
* in the end of 2020.
|
||||
* This method migrates the token for existing installs and can be removed
|
||||
* after sufficient time has passed.
|
||||
*/
|
||||
private fun migrateTokenFromMetadataToSettingsManager() {
|
||||
@Suppress("DEPRECATION")
|
||||
val token = metadataManager.getBackupToken()
|
||||
if (token != 0L && settingsManager.getToken() == null) {
|
||||
settingsManager.setNewToken(token)
|
||||
}
|
||||
override fun onTrimMemory(level: Int) {
|
||||
Log.w("Seedvault", "onTrimMemory($level) ${getMemStr()}")
|
||||
val processInfo = RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(processInfo)
|
||||
Log.w("Seedvault", " lastTrimLevel: ${processInfo.lastTrimLevel}")
|
||||
Log.w("Seedvault", " importance: ${processInfo.importance}")
|
||||
super.onTrimMemory(level)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -200,6 +196,7 @@ const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
|
|||
const val NO_DATA_END_SENTINEL = "@end@"
|
||||
const val GLOBAL_METADATA_KEY = "@meta@"
|
||||
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED
|
||||
const val ERROR_BACKUP_NOT_ALLOWED: Int = BackupManager.ERROR_BACKUP_NOT_ALLOWED
|
||||
|
||||
// TODO this doesn't work for LineageOS as they do public debug builds
|
||||
fun isDebugBuild() = Build.TYPE == "userdebug"
|
||||
|
|
|
@ -17,18 +17,25 @@ import android.util.Log.DEBUG
|
|||
|
||||
private val TAG = BackupMonitor::class.java.name
|
||||
|
||||
class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||
open class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||
|
||||
override fun onEvent(bundle: Bundle) {
|
||||
val id = bundle.getInt(EXTRA_LOG_EVENT_ID)
|
||||
val packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")
|
||||
onEvent(
|
||||
id = bundle.getInt(EXTRA_LOG_EVENT_ID),
|
||||
category = bundle.getInt(EXTRA_LOG_EVENT_CATEGORY),
|
||||
packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME),
|
||||
bundle = bundle,
|
||||
)
|
||||
}
|
||||
|
||||
open fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
|
||||
if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) {
|
||||
val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1)
|
||||
Log.w(TAG, "Pre-flight error from $packageName: $preflightResult")
|
||||
}
|
||||
if (!Log.isLoggable(TAG, DEBUG)) return
|
||||
Log.d(TAG, "ID: $id")
|
||||
Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1))
|
||||
Log.d(TAG, "CATEGORY: $category")
|
||||
Log.d(TAG, "PACKAGE: $packageName")
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import androidx.work.WorkInfo.State.RUNNING
|
|||
import androidx.work.WorkManager
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
|
||||
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
@ -31,14 +32,18 @@ class BackupStateManager(
|
|||
flow = ConfigurableBackupTransportService.isRunning,
|
||||
flow2 = StorageBackupService.isRunning,
|
||||
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
|
||||
) { appBackupRunning, filesBackupRunning, workInfos ->
|
||||
val workInfoState = workInfos.getOrNull(0)?.state
|
||||
flow4 = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
|
||||
) { appBackupRunning, filesBackupRunning, workInfo1, workInfo2 ->
|
||||
val workInfoState1 = workInfo1.getOrNull(0)?.state
|
||||
val workInfoState2 = workInfo2.getOrNull(0)?.state
|
||||
Log.i(
|
||||
TAG, "appBackupRunning: $appBackupRunning, " +
|
||||
"filesBackupRunning: $filesBackupRunning, " +
|
||||
"workInfoState: ${workInfoState?.name}"
|
||||
"appBackupWorker: ${workInfoState1?.name}, " +
|
||||
"pruneBackupWorker: ${workInfoState2?.name}"
|
||||
)
|
||||
appBackupRunning || filesBackupRunning || workInfoState == RUNNING
|
||||
appBackupRunning || filesBackupRunning ||
|
||||
workInfoState1 == RUNNING || workInfoState2 == RUNNING
|
||||
}
|
||||
|
||||
val isAutoRestoreEnabled: Boolean
|
||||
|
|
24
app/src/main/java/com/stevesoltys/seedvault/MemoryLogger.kt
Normal file
24
app/src/main/java/com/stevesoltys/seedvault/MemoryLogger.kt
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault
|
||||
|
||||
import android.util.Log
|
||||
|
||||
object MemoryLogger {
|
||||
|
||||
fun log() {
|
||||
Log.d("MemoryLogger", getMemStr())
|
||||
}
|
||||
|
||||
fun getMemStr(): String {
|
||||
val r = Runtime.getRuntime()
|
||||
val total = r.totalMemory() / 1024 / 1024
|
||||
val free = r.freeMemory() / 1024 / 1024
|
||||
val max = r.maxMemory() / 1024 / 1024
|
||||
val used = total - free
|
||||
return "$free MiB free - $used of $total (max $max)"
|
||||
}
|
||||
}
|
|
@ -20,7 +20,6 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.provider.DocumentsContract
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||
|
@ -34,7 +33,6 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
|
||||
// using KoinComponent would crash robolectric tests :(
|
||||
private val settingsManager: SettingsManager by lazy { get().get() }
|
||||
private val metadataManager: MetadataManager by lazy { get().get() }
|
||||
private val backupManager: IBackupManager by lazy { get().get() }
|
||||
|
||||
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
|
||||
|
@ -44,14 +42,15 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
val attachedFlashDrive = FlashDrive.from(device)
|
||||
return if (savedFlashDrive == attachedFlashDrive) {
|
||||
Log.d(TAG, "Matches stored device, checking backup time...")
|
||||
val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
|
||||
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0
|
||||
val backupMillis = System.currentTimeMillis() - lastBackupTime
|
||||
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
|
||||
Log.d(TAG, "Last backup older than it should be, requesting a backup...")
|
||||
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||
Log.d(TAG, " ${Date(lastBackupTime)}")
|
||||
true
|
||||
} else {
|
||||
Log.d(TAG, "We have a recent backup, not requesting a new one.")
|
||||
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||
Log.d(TAG, " ${Date(lastBackupTime)}")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend
|
||||
|
||||
import android.util.Log
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
suspend fun Backend.getMetadataOutputStream(token: Long): OutputStream {
|
||||
return save(LegacyAppBackupFile.Metadata(token))
|
||||
}
|
||||
|
||||
suspend fun Backend.getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||
return try {
|
||||
// get all restore set tokens in root folder that have a metadata file
|
||||
val handles = ArrayList<LegacyAppBackupFile.Metadata>()
|
||||
list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
|
||||
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
|
||||
handles.add(handle)
|
||||
}
|
||||
val handleIterator = handles.iterator()
|
||||
return generateSequence {
|
||||
if (!handleIterator.hasNext()) return@generateSequence null // end sequence
|
||||
val handle = handleIterator.next()
|
||||
EncryptedMetadata(handle.token) {
|
||||
load(handle)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("SafBackend", "Error getting available backups: ", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Exception.isOutOfSpace(): Boolean {
|
||||
return when (this) {
|
||||
is IOException -> message?.contains("No space left on device") == true ||
|
||||
(cause as? HttpException)?.code == 507
|
||||
|
||||
is HttpException -> code == 507
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream)
|
|
@ -10,6 +10,7 @@ import android.util.Log
|
|||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.repo.BlobCache
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.settings.StoragePluginType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
|
@ -20,6 +21,7 @@ import org.calyxos.seedvault.core.backends.saf.SafBackend
|
|||
class BackendManager(
|
||||
private val context: Context,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val blobCache: BlobCache,
|
||||
backendFactory: BackendFactory,
|
||||
) {
|
||||
|
||||
|
@ -86,6 +88,8 @@ class BackendManager(
|
|||
settingsManager.setStorageBackend(backend)
|
||||
mBackend = backend
|
||||
mBackendProperties = storageProperties
|
||||
blobCache.clearLocalCache()
|
||||
// TODO not critical, but nice to have: clear also local snapshot cache
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,7 +29,6 @@ import org.calyxos.seedvault.core.backends.saf.SafProperties
|
|||
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Deprecated("")
|
||||
|
@ -51,7 +50,7 @@ internal class DocumentsStorage(
|
|||
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
|
||||
private val contentResolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
internal var rootBackupDir: DocumentFile? = null
|
||||
private var rootBackupDir: DocumentFile? = null
|
||||
get() = runBlocking {
|
||||
if (field == null) {
|
||||
val parent = safStorage.getDocumentFile(context)
|
||||
|
@ -94,16 +93,6 @@ internal class DocumentsStorage(
|
|||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getOutputStream(file: DocumentFile): OutputStream {
|
||||
return try {
|
||||
contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
|
||||
} catch (e: Exception) {
|
||||
// SAF can throw all sorts of exceptions, so wrap it in IOException
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -192,7 +181,7 @@ suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String)
|
|||
@Throws(IOException::class, TimeoutCancellationException::class)
|
||||
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
|
||||
withTimeout(timeout) {
|
||||
suspendCancellableCoroutine<Cursor> { cont ->
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val cursor = query() ?: throw IOException()
|
||||
cont.invokeOnCancellation { cursor.close() }
|
||||
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
|
||||
|
|
|
@ -15,7 +15,6 @@ import android.util.Log
|
|||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
||||
import com.stevesoltys.seedvault.isMassStorage
|
||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
@ -59,9 +58,8 @@ internal class SafHandler(
|
|||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun hasAppBackup(safProperties: SafProperties): Boolean {
|
||||
val appPlugin = backendFactory.createSafBackend(safProperties)
|
||||
val backups = appPlugin.getAvailableBackups()
|
||||
return backups != null && backups.iterator().hasNext()
|
||||
val backend = backendFactory.createSafBackend(safProperties)
|
||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||
}
|
||||
|
||||
fun save(safProperties: SafProperties) {
|
||||
|
|
|
@ -139,17 +139,6 @@ internal object StorageRootResolver {
|
|||
return if (index != -1) getInt(index) else 0
|
||||
}
|
||||
|
||||
private fun Cursor.getLong(columnName: String): Long? {
|
||||
val index = getColumnIndex(columnName)
|
||||
if (index == -1) return null
|
||||
val value = getString(index) ?: return null
|
||||
return try {
|
||||
java.lang.Long.parseLong(value)
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
||||
return getPackageIcon(context, authority, icon) ?: when {
|
||||
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
||||
|
|
|
@ -6,15 +6,14 @@
|
|||
package com.stevesoltys.seedvault.backend.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||
|
@ -43,7 +42,7 @@ internal class WebDavHandler(
|
|||
|
||||
companion object {
|
||||
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
||||
val host = config.url.toHttpUrl().host
|
||||
val host = Uri.parse(config.url).host
|
||||
return WebDavProperties(
|
||||
config = config,
|
||||
name = context.getString(R.string.storage_webdav_name, host),
|
||||
|
@ -81,8 +80,7 @@ internal class WebDavHandler(
|
|||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun hasAppBackup(backend: Backend): Boolean {
|
||||
val backups = backend.getAvailableBackups()
|
||||
return backups != null && backups.iterator().hasNext()
|
||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||
}
|
||||
|
||||
fun save(properties: WebDavProperties) {
|
||||
|
|
|
@ -5,23 +5,33 @@
|
|||
|
||||
package com.stevesoltys.seedvault.crypto
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.Secure.ANDROID_ID
|
||||
import com.google.crypto.tink.subtle.AesGcmHkdfStreaming
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.HeaderReader
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
|
||||
import com.stevesoltys.seedvault.header.SegmentHeader
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.VersionHeader
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto.deriveStreamKey
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.ALGORITHM_HMAC
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.deriveKey
|
||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
|
@ -47,13 +57,18 @@ internal interface Crypto {
|
|||
*/
|
||||
fun getRandomBytes(size: Int): ByteArray
|
||||
|
||||
fun getNameForPackage(salt: String, packageName: String): String
|
||||
/**
|
||||
* Returns the ID of the backup repository as a 64 char hex string.
|
||||
*/
|
||||
val repoId: String
|
||||
|
||||
/**
|
||||
* Returns the name that identifies an APK in the backup storage plugin.
|
||||
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
|
||||
* A secret key of size [KEY_SIZE_BYTES]
|
||||
* only used to create a gear table specific to each main key.
|
||||
*/
|
||||
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
||||
val gearTableKey: ByteArray
|
||||
|
||||
fun sha256(bytes: ByteArray): ByteArray
|
||||
|
||||
/**
|
||||
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
||||
|
@ -75,6 +90,29 @@ internal interface Crypto {
|
|||
associatedData: ByteArray,
|
||||
): InputStream
|
||||
|
||||
fun getAdForVersion(version: Byte = VERSION): ByteArray
|
||||
|
||||
@Deprecated("only for v1")
|
||||
fun getNameForPackage(salt: String, packageName: String): String
|
||||
|
||||
/**
|
||||
* Returns the name that identifies an APK in the backup storage plugin.
|
||||
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
|
||||
*/
|
||||
@Deprecated("only for v1")
|
||||
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
||||
|
||||
/**
|
||||
* Returns a [AesGcmHkdfStreaming] decrypting stream
|
||||
* that gets decrypted and authenticated the given associated data.
|
||||
*/
|
||||
@Deprecated("only for v1")
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
fun newDecryptingStreamV1(
|
||||
inputStream: InputStream,
|
||||
associatedData: ByteArray,
|
||||
): InputStream
|
||||
|
||||
/**
|
||||
* Reads and decrypts a [VersionHeader] from the given [InputStream]
|
||||
* and ensures that the expected version, package name and key match
|
||||
|
@ -122,30 +160,71 @@ internal const val TYPE_BACKUP_KV: Byte = 0x01
|
|||
internal const val TYPE_BACKUP_FULL: Byte = 0x02
|
||||
internal const val TYPE_ICONS: Byte = 0x03
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
internal class CryptoImpl(
|
||||
context: Context,
|
||||
private val keyManager: KeyManager,
|
||||
private val cipherFactory: CipherFactory,
|
||||
private val headerReader: HeaderReader,
|
||||
private val androidId: String = Settings.Secure.getString(context.contentResolver, ANDROID_ID),
|
||||
) : Crypto {
|
||||
|
||||
private val key: ByteArray by lazy {
|
||||
deriveStreamKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||
private val keyV1: ByteArray by lazy {
|
||||
deriveKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||
}
|
||||
private val secureRandom: SecureRandom by lazy { SecureRandom() }
|
||||
private val streamKey: ByteArray by lazy {
|
||||
deriveKey(keyManager.getMainKey(), "app backup stream key".toByteArray())
|
||||
}
|
||||
private val secureRandom: SecureRandom by lazy { SecureRandom.getInstanceStrong() }
|
||||
|
||||
override fun getRandomBytes(size: Int) = ByteArray(size).apply {
|
||||
secureRandom.nextBytes(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the backup repository tied to this user/device via [ANDROID_ID]
|
||||
* and the current [KeyManager.getMainKey].
|
||||
*
|
||||
* Attention: If the main key ever changes, we need to kill our process,
|
||||
* so all lazy values that depend on that key or the [gearTableKey] get reinitialized.
|
||||
*/
|
||||
override val repoId: String by lazy {
|
||||
val repoIdKey =
|
||||
deriveKey(keyManager.getMainKey(), "app backup repoId key".toByteArray())
|
||||
val hmacHasher: Mac = Mac.getInstance(ALGORITHM_HMAC).apply {
|
||||
init(SecretKeySpec(repoIdKey, ALGORITHM_HMAC))
|
||||
}
|
||||
hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString()
|
||||
}
|
||||
|
||||
override val gearTableKey: ByteArray
|
||||
get() = deriveKey(keyManager.getMainKey(), "app backup gear table key".toByteArray())
|
||||
|
||||
override fun newEncryptingStream(
|
||||
outputStream: OutputStream,
|
||||
associatedData: ByteArray,
|
||||
): OutputStream = CoreCrypto.newEncryptingStream(streamKey, outputStream, associatedData)
|
||||
|
||||
override fun newDecryptingStream(
|
||||
inputStream: InputStream,
|
||||
associatedData: ByteArray,
|
||||
): InputStream = CoreCrypto.newDecryptingStream(streamKey, inputStream, associatedData)
|
||||
|
||||
override fun getAdForVersion(version: Byte): ByteArray = ByteBuffer.allocate(1)
|
||||
.put(version)
|
||||
.array()
|
||||
|
||||
@Deprecated("only for v1")
|
||||
override fun getNameForPackage(salt: String, packageName: String): String {
|
||||
return sha256("$salt$packageName".toByteArray()).encodeBase64()
|
||||
}
|
||||
|
||||
@Deprecated("only for v1")
|
||||
override fun getNameForApk(salt: String, packageName: String, suffix: String): String {
|
||||
return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64()
|
||||
}
|
||||
|
||||
private fun sha256(bytes: ByteArray): ByteArray {
|
||||
override fun sha256(bytes: ByteArray): ByteArray {
|
||||
val messageDigest: MessageDigest = try {
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
|
@ -155,21 +234,12 @@ internal class CryptoImpl(
|
|||
return messageDigest.digest()
|
||||
}
|
||||
|
||||
@Deprecated("only for v1")
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
override fun newEncryptingStream(
|
||||
outputStream: OutputStream,
|
||||
associatedData: ByteArray,
|
||||
): OutputStream {
|
||||
return StreamCrypto.newEncryptingStream(key, outputStream, associatedData)
|
||||
}
|
||||
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
override fun newDecryptingStream(
|
||||
override fun newDecryptingStreamV1(
|
||||
inputStream: InputStream,
|
||||
associatedData: ByteArray,
|
||||
): InputStream {
|
||||
return StreamCrypto.newDecryptingStream(key, inputStream, associatedData)
|
||||
}
|
||||
): InputStream = CoreCrypto.newDecryptingStream(keyV1, inputStream, associatedData)
|
||||
|
||||
@Suppress("Deprecation")
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package com.stevesoltys.seedvault.crypto
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import java.security.KeyStore
|
||||
|
||||
|
@ -20,5 +21,5 @@ val cryptoModule = module {
|
|||
}
|
||||
KeyManagerImpl(keyStore)
|
||||
}
|
||||
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
||||
single<Crypto> { CryptoImpl(androidContext(), get(), get(), get()) }
|
||||
}
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.extensions
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
fun Activity.setupEdgeToEdge() {
|
||||
val rootView = window.decorView.rootView
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(insets.left, insets.top, insets.right, insets.bottom)
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
|
|||
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
internal const val VERSION: Byte = 1
|
||||
internal const val VERSION: Byte = 2
|
||||
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
||||
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
||||
internal const val MAX_VERSION_HEADER_SIZE =
|
||||
|
|
|
@ -8,8 +8,12 @@ package com.stevesoltys.seedvault.metadata
|
|||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||
import android.os.Build
|
||||
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.repo.hexFromProto
|
||||
import com.stevesoltys.seedvault.worker.BASE_SPLIT
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
|
@ -26,6 +30,23 @@ data class BackupMetadata(
|
|||
internal var d2dBackup: Boolean = false,
|
||||
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun fromSnapshot(s: Snapshot) = BackupMetadata(
|
||||
version = s.version.toByte(),
|
||||
token = s.token,
|
||||
salt = "",
|
||||
time = s.token,
|
||||
androidVersion = s.sdkInt,
|
||||
androidIncremental = s.androidIncremental,
|
||||
deviceName = "${s.name} - ${s.user}",
|
||||
d2dBackup = s.d2D,
|
||||
packageMetadataMap = s.appsMap.mapValues { (_, app) ->
|
||||
PackageMetadata.fromSnapshot(app)
|
||||
} as PackageMetadataMap
|
||||
)
|
||||
}
|
||||
|
||||
val size: Long
|
||||
get() = packageMetadataMap.values.sumOf { m ->
|
||||
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
|
||||
|
@ -91,12 +112,56 @@ data class PackageMetadata(
|
|||
internal val version: Long? = null,
|
||||
internal val installer: String? = null,
|
||||
internal val splits: List<ApkSplit>? = null,
|
||||
internal val baseApkChunkIds: List<String>? = null, // used for v2
|
||||
internal val chunkIds: List<String>? = null, // used for v2
|
||||
internal val sha256: String? = null,
|
||||
internal val signatures: List<String>? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun fromSnapshot(app: Snapshot.App) = PackageMetadata(
|
||||
time = app.time,
|
||||
backupType = app.type.toBackupType(),
|
||||
name = app.name,
|
||||
chunkIds = app.chunkIdsList.hexFromProto(),
|
||||
system = app.system,
|
||||
isLaunchableSystemApp = app.launchableSystemApp,
|
||||
version = app.apk.versionCode,
|
||||
installer = app.apk.installer.takeIf { it.isNotEmpty() },
|
||||
baseApkChunkIds = run {
|
||||
val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT }
|
||||
if (baseChunk == null || baseChunk.chunkIdsCount == 0) {
|
||||
null
|
||||
} else {
|
||||
baseChunk.chunkIdsList.hexFromProto()
|
||||
}
|
||||
},
|
||||
splits = app.apk.splitsList.filter { it.name != BASE_SPLIT }.map {
|
||||
ApkSplit(
|
||||
name = it.name,
|
||||
size = null,
|
||||
sha256 = "",
|
||||
chunkIds = if (it.chunkIdsCount == 0) null else it.chunkIdsList.hexFromProto()
|
||||
)
|
||||
}.takeIf { it.isNotEmpty() }, // expected null if there are no splits
|
||||
sha256 = null,
|
||||
signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }.takeIf {
|
||||
it.isNotEmpty()
|
||||
},
|
||||
)
|
||||
|
||||
fun Snapshot.BackupType.toBackupType() = when (this) {
|
||||
Snapshot.BackupType.FULL -> BackupType.FULL
|
||||
Snapshot.BackupType.KV -> BackupType.KV
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
||||
fun hasApk(): Boolean {
|
||||
return version != null && sha256 != null && signatures != null
|
||||
return version != null && // v2 doesn't use sha256 here
|
||||
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
|
||||
signatures != null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,6 +169,7 @@ data class ApkSplit(
|
|||
val name: String,
|
||||
val size: Long?,
|
||||
val sha256: String,
|
||||
val chunkIds: List<String>? = null, // used for v2
|
||||
// There's also a revisionCode, but it doesn't seem to be used just yet
|
||||
)
|
||||
|
||||
|
|
|
@ -8,23 +8,11 @@ package com.stevesoltys.seedvault.metadata
|
|||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
import android.os.UserManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
@ -39,130 +27,50 @@ internal const val METADATA_SALT_SIZE = 32
|
|||
internal class MetadataManager(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val crypto: Crypto,
|
||||
private val metadataWriter: MetadataWriter,
|
||||
private val metadataReader: MetadataReader,
|
||||
private val packageService: PackageService,
|
||||
private val settingsManager: SettingsManager,
|
||||
) {
|
||||
|
||||
private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
|
||||
private val uninitializedMetadata = BackupMetadata(token = -42L, salt = "foo bar")
|
||||
private var metadata: BackupMetadata = uninitializedMetadata
|
||||
get() {
|
||||
if (field == uninitializedMetadata) {
|
||||
field = try {
|
||||
getMetadataFromCache() ?: throw IOException()
|
||||
val m = getMetadataFromCache() ?: throw IOException()
|
||||
if (m == uninitializedMetadata) m.copy(salt = "initialized")
|
||||
else m
|
||||
} catch (e: IOException) {
|
||||
// This can happen if the storage location ran out of space
|
||||
// or the app process got killed while writing the file.
|
||||
// It is hard to recover from this, so we try as best as we can here:
|
||||
Log.e(TAG, "ERROR getting metadata cache, creating new file ", e)
|
||||
// This should cause requiresInit() return true
|
||||
uninitializedMetadata.copy(version = (-1).toByte())
|
||||
uninitializedMetadata.copy(salt = "initialized")
|
||||
}
|
||||
mLastBackupTime.postValue(field.time)
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Existing [BackupMetadata] will be cleared
|
||||
* and new metadata with the given [token] will be written to the internal cache
|
||||
* with a fresh salt.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun onDeviceInitialization(token: Long) {
|
||||
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
|
||||
modifyCachedMetadata {
|
||||
val userName = getUserName()
|
||||
metadata = BackupMetadata(
|
||||
token = token,
|
||||
salt = salt,
|
||||
deviceName = if (userName == null) {
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
} else {
|
||||
"${Build.MANUFACTURER} ${Build.MODEL} - $userName"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after a package's APK has been backed up successfully.
|
||||
*
|
||||
* It updates the packages' metadata to the internal cache.
|
||||
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun onApkBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
packageMetadata: PackageMetadata,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
metadata.packageMetadataMap[packageName]?.let {
|
||||
check(packageMetadata.version != null) {
|
||||
"APK backup returned version null"
|
||||
}
|
||||
}
|
||||
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
||||
?: PackageMetadata()
|
||||
modifyCachedMetadata {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName),
|
||||
version = packageMetadata.version,
|
||||
installer = packageMetadata.installer,
|
||||
splits = packageMetadata.splits,
|
||||
sha256 = packageMetadata.sha256,
|
||||
signatures = packageMetadata.signatures
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after a package has been backed up successfully.
|
||||
*
|
||||
* It updates the packages' metadata
|
||||
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
|
||||
*
|
||||
* Closing the [OutputStream] is the responsibility of the caller.
|
||||
* It updates the packages' metadata.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun onPackageBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
type: BackupType,
|
||||
type: BackupType?,
|
||||
size: Long?,
|
||||
metadataOutputStream: OutputStream,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
modifyCachedMetadata {
|
||||
val now = clock.time()
|
||||
metadata.time = now
|
||||
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||
metadata.packageMetadataMap.getOrPut(packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = now,
|
||||
state = APK_AND_DATA,
|
||||
backupType = type,
|
||||
size = size,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageName),
|
||||
)
|
||||
}.apply {
|
||||
time = now
|
||||
|
@ -170,10 +78,6 @@ internal class MetadataManager(
|
|||
backupType = type
|
||||
// don't override a previous K/V size, if there were no K/V changes
|
||||
if (size != null) this.size = size
|
||||
// update name, if none was set, yet (can happen while migrating to storing names)
|
||||
if (this.name == null) {
|
||||
this.name = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -189,21 +93,16 @@ internal class MetadataManager(
|
|||
internal fun onPackageBackupError(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState,
|
||||
metadataOutputStream: OutputStream,
|
||||
backupType: BackupType? = null,
|
||||
) {
|
||||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
modifyCachedMetadata {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
backupType = backupType,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.state = packageState
|
||||
}
|
||||
|
@ -213,7 +112,6 @@ internal class MetadataManager(
|
|||
* Call this for all packages we can not back up for some reason.
|
||||
*
|
||||
* It updates the packages' local metadata.
|
||||
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
|
@ -222,14 +120,10 @@ internal class MetadataManager(
|
|||
packageState: PackageState,
|
||||
) = modifyCachedMetadata {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.apply {
|
||||
state = packageState
|
||||
|
@ -240,86 +134,26 @@ internal class MetadataManager(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun uploadMetadata(metadataOutputStream: OutputStream) {
|
||||
metadataWriter.write(metadata, metadataOutputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
try {
|
||||
modFun.invoke()
|
||||
writeMetadataToCache()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing metadata to storage", e)
|
||||
// revert metadata and do not write it to cache
|
||||
metadata = oldMetadata
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
try {
|
||||
modFun.invoke()
|
||||
metadataWriter.write(metadata, metadataOutputStream)
|
||||
writeMetadataToCache()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing metadata to storage", e)
|
||||
// revert metadata and do not write it to cache
|
||||
metadata = oldMetadata
|
||||
throw IOException(e)
|
||||
}
|
||||
mLastBackupTime.postValue(metadata.time)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current backup token.
|
||||
*
|
||||
* If the token is 0L, it is not yet initialized and must not be used for anything.
|
||||
*/
|
||||
@Synchronized
|
||||
@Deprecated(
|
||||
"Responsibility for current token moved to SettingsManager",
|
||||
ReplaceWith("settingsManager.getToken()")
|
||||
)
|
||||
fun getBackupToken(): Long = metadata.token
|
||||
|
||||
/**
|
||||
* Returns the last backup time in unix epoch milli seconds.
|
||||
*
|
||||
* Note that this might be a blocking I/O call.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getLastBackupTime(): Long = mLastBackupTime.value ?: metadata.time
|
||||
|
||||
private val mLastBackupTime = MutableLiveData<Long>()
|
||||
internal val lastBackupTime: LiveData<Long> = mLastBackupTime.distinctUntilChanged()
|
||||
|
||||
internal val salt: String
|
||||
@Synchronized get() = metadata.salt
|
||||
|
||||
internal val requiresInit: Boolean
|
||||
@Synchronized get() = metadata == uninitializedMetadata || metadata.version < VERSION
|
||||
|
||||
@Synchronized
|
||||
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
||||
return metadata.packageMetadataMap[packageName]?.copy()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getPackagesBackupSize(): Long {
|
||||
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
|
||||
@Throws(IOException::class)
|
||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy(
|
||||
// copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
try {
|
||||
modFun.invoke()
|
||||
writeMetadataToCache()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing metadata to storage", e)
|
||||
// revert metadata and do not write it to cache
|
||||
metadata = oldMetadata
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
|
@ -347,12 +181,4 @@ internal class MetadataManager(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getUserName(): String? {
|
||||
val perm = "android.permission.QUERY_USERS"
|
||||
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
||||
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
||||
userManager.userName
|
||||
} else null
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
|
||||
val metadataModule = module {
|
||||
single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
|
||||
single<MetadataWriter> { MetadataWriterImpl(get()) }
|
||||
single { MetadataManager(androidContext(), get(), get(), get()) }
|
||||
single<MetadataWriter> { MetadataWriterImpl() }
|
||||
single<MetadataReader> { MetadataReaderImpl(get()) }
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
|
||||
|
||||
val metadataBytes = try {
|
||||
crypto.newDecryptingStream(inputStream, getAD(version, expectedToken)).readBytes()
|
||||
crypto.newDecryptingStreamV1(inputStream, getAD(version, expectedToken)).readBytes()
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw DecryptionFailedException(e)
|
||||
}
|
||||
|
@ -94,14 +94,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
val json = JSONObject(bytes.toString(Utf8))
|
||||
// get backup metadata and check expectations
|
||||
val meta = json.getJSONObject(JSON_METADATA)
|
||||
val version = meta.getInt(JSON_METADATA_VERSION).toByte()
|
||||
val version = meta.optInt(JSON_METADATA_VERSION, VERSION.toInt()).toByte()
|
||||
if (expectedVersion != null && version != expectedVersion) {
|
||||
throw SecurityException(
|
||||
"Invalid version '${version.toInt()}' in metadata," +
|
||||
"expected '${expectedVersion.toInt()}'."
|
||||
)
|
||||
}
|
||||
val token = meta.getLong(JSON_METADATA_TOKEN)
|
||||
val token = meta.optLong(JSON_METADATA_TOKEN, 0)
|
||||
if (expectedToken != null && token != expectedToken) throw SecurityException(
|
||||
"Invalid token '$token' in metadata, expected '$expectedToken'."
|
||||
)
|
||||
|
@ -157,11 +157,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
return BackupMetadata(
|
||||
version = version,
|
||||
token = token,
|
||||
salt = if (version == 0.toByte()) "" else meta.getString(JSON_METADATA_SALT),
|
||||
time = meta.getLong(JSON_METADATA_TIME),
|
||||
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
|
||||
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
|
||||
deviceName = meta.getString(JSON_METADATA_NAME),
|
||||
salt = if (version == 0.toByte()) "" else meta.optString(JSON_METADATA_SALT, ""),
|
||||
time = meta.optLong(JSON_METADATA_TIME, -1),
|
||||
androidVersion = meta.optInt(JSON_METADATA_SDK_INT, 0),
|
||||
androidIncremental = meta.optString(JSON_METADATA_INCREMENTAL),
|
||||
deviceName = meta.optString(JSON_METADATA_NAME),
|
||||
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
||||
packageMetadataMap = packageMetadataMap,
|
||||
)
|
||||
|
|
|
@ -6,42 +6,18 @@
|
|||
package com.stevesoltys.seedvault.metadata
|
||||
|
||||
import com.stevesoltys.seedvault.Utf8
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
interface MetadataWriter {
|
||||
@Throws(IOException::class)
|
||||
fun write(metadata: BackupMetadata, outputStream: OutputStream)
|
||||
|
||||
fun encode(metadata: BackupMetadata): ByteArray
|
||||
}
|
||||
|
||||
internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(metadata: BackupMetadata, outputStream: OutputStream) {
|
||||
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
|
||||
crypto.newEncryptingStream(outputStream, getAD(metadata.version, metadata.token)).use {
|
||||
it.write(encode(metadata))
|
||||
}
|
||||
}
|
||||
internal class MetadataWriterImpl : MetadataWriter {
|
||||
|
||||
override fun encode(metadata: BackupMetadata): ByteArray {
|
||||
val json = JSONObject().apply {
|
||||
put(JSON_METADATA, JSONObject().apply {
|
||||
put(JSON_METADATA_VERSION, metadata.version.toInt())
|
||||
put(JSON_METADATA_TOKEN, metadata.token)
|
||||
put(JSON_METADATA_SALT, metadata.salt)
|
||||
put(JSON_METADATA_TIME, metadata.time)
|
||||
put(JSON_METADATA_SDK_INT, metadata.androidVersion)
|
||||
put(JSON_METADATA_INCREMENTAL, metadata.androidIncremental)
|
||||
put(JSON_METADATA_NAME, metadata.deviceName)
|
||||
put(JSON_METADATA_D2D_BACKUP, metadata.d2dBackup)
|
||||
})
|
||||
put(JSON_METADATA, JSONObject())
|
||||
}
|
||||
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
|
||||
json.put(packageName, JSONObject().apply {
|
||||
|
@ -57,31 +33,8 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
|
|||
if (packageMetadata.size != null) {
|
||||
put(JSON_PACKAGE_SIZE, packageMetadata.size)
|
||||
}
|
||||
if (packageMetadata.name != null) {
|
||||
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
|
||||
}
|
||||
if (packageMetadata.system) {
|
||||
put(JSON_PACKAGE_SYSTEM, true)
|
||||
}
|
||||
if (packageMetadata.isLaunchableSystemApp) {
|
||||
put(JSON_PACKAGE_SYSTEM_LAUNCHER, true)
|
||||
}
|
||||
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
|
||||
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
|
||||
packageMetadata.splits?.let { splits ->
|
||||
put(JSON_PACKAGE_SPLITS, JSONArray().apply {
|
||||
for (split in splits) put(JSONObject().apply {
|
||||
put(JSON_PACKAGE_SPLIT_NAME, split.name)
|
||||
if (split.size != null) put(JSON_PACKAGE_SIZE, split.size)
|
||||
put(JSON_PACKAGE_SHA256, split.sha256)
|
||||
})
|
||||
})
|
||||
}
|
||||
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
|
||||
packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) }
|
||||
})
|
||||
}
|
||||
return json.toString().toByteArray(Utf8)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.MemoryLogger
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.delay
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Snapshot
|
||||
import org.calyxos.seedvault.core.backends.FileInfo
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Manages the process of app data backups, especially related to work that needs to happen
|
||||
* before and after a backup run.
|
||||
* See [beforeBackup] and [afterBackupFinished].
|
||||
*/
|
||||
internal class AppBackupManager(
|
||||
private val crypto: Crypto,
|
||||
private val blobCache: BlobCache,
|
||||
private val backendManager: BackendManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
private val snapshotCreatorFactory: SnapshotCreatorFactory,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* A temporary [SnapshotCreator] that has a lifetime only valid during the backup run.
|
||||
*/
|
||||
@Volatile
|
||||
var snapshotCreator: SnapshotCreator? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var startedViaAdb = false
|
||||
|
||||
/**
|
||||
* Call this method before doing any kind of backup work.
|
||||
* It will
|
||||
* * download the blobs available on the backend,
|
||||
* * assemble the chunk ID to blob mapping from previous snapshots and
|
||||
* * create a new instance of a [SnapshotCreator].
|
||||
*
|
||||
* @throws IOException or other exceptions.
|
||||
* These should be caught by the caller who may retry us on transient errors.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun beforeBackup() {
|
||||
log.info { "Loading existing snapshots and blobs..." }
|
||||
val blobInfos = mutableListOf<FileInfo>()
|
||||
val snapshotHandles = mutableListOf<Snapshot>()
|
||||
backendManager.backend.list(
|
||||
topLevelFolder = TopLevelFolder(crypto.repoId),
|
||||
Blob::class, Snapshot::class,
|
||||
) { fileInfo ->
|
||||
when (fileInfo.fileHandle) {
|
||||
is Blob -> blobInfos.add(fileInfo)
|
||||
is Snapshot -> snapshotHandles.add(fileInfo.fileHandle as Snapshot)
|
||||
else -> error("Unexpected FileHandle: $fileInfo")
|
||||
}
|
||||
}
|
||||
log.info { "Found ${snapshotHandles.size} existing snapshots." }
|
||||
val snapshots = snapshotManager.onSnapshotsLoaded(snapshotHandles)
|
||||
blobCache.populateCache(blobInfos, snapshots)
|
||||
snapshotCreator = snapshotCreatorFactory.createSnapshotCreator()
|
||||
}
|
||||
|
||||
/**
|
||||
* This must be called after the backup run has been completed.
|
||||
* It finalized the current snapshot and saves it to the backend.
|
||||
* Then, it clears up the [BlobCache] and the [SnapshotCreator].
|
||||
*
|
||||
* @param success true if the backup run was successful, false otherwise.
|
||||
*
|
||||
* @return the snapshot saved to the backend or null if there was an error saving it.
|
||||
*/
|
||||
@WorkerThread
|
||||
suspend fun afterBackupFinished(success: Boolean): com.stevesoltys.seedvault.proto.Snapshot? {
|
||||
MemoryLogger.log()
|
||||
log.info { "After backup finished. Success: $success" }
|
||||
// free up memory by clearing blobs cache
|
||||
blobCache.clear()
|
||||
return try {
|
||||
if (success) {
|
||||
// only save snapshot when backup was successful,
|
||||
// otherwise we'd have partial snapshots
|
||||
val snapshot = snapshotCreator?.finalizeSnapshot()
|
||||
?: error("Had no snapshotCreator")
|
||||
keepTrying { // TODO remove when we have auto-retrying backends
|
||||
// saving this is so important, we even keep trying
|
||||
snapshotManager.saveSnapshot(snapshot)
|
||||
}
|
||||
// save token and time of last backup
|
||||
settingsManager.onSuccessfulBackupCompleted(snapshot.token)
|
||||
// after snapshot was written, we can clear local cache as its info is in snapshot
|
||||
blobCache.clearLocalCache()
|
||||
snapshot
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error finishing backup" }
|
||||
null
|
||||
} finally {
|
||||
snapshotCreator = null
|
||||
MemoryLogger.log()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When doing backups with `adb shell bmgr backupnow`,
|
||||
* we don't get a chance to do our initialization in [beforeBackup],
|
||||
* so we use this opportunity to do it now.
|
||||
*/
|
||||
suspend fun ensureBackupPrepared() = if (snapshotCreator == null) {
|
||||
log.warn { "Backup not prepared. If not started via `adb shell bmgr` that's a bug" }
|
||||
startedViaAdb = true
|
||||
beforeBackup()
|
||||
} else Unit
|
||||
|
||||
/**
|
||||
* We don't get notified when backups ran from `adb shell bmgr backupnow` end,
|
||||
* so [afterBackupFinished] will not run, so we need to find a place
|
||||
*/
|
||||
suspend fun finalizeBackupIfNeeded() {
|
||||
if (startedViaAdb) {
|
||||
log.warn { "Backup not finalized. If not started via `adb shell bmgr` that's a bug" }
|
||||
startedViaAdb = false
|
||||
afterBackupFinished(true) // is there a way to know if success or not?
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the repo identified by [repoId] can be transferred to this device.
|
||||
* This is the case when it isn't the same as the current repoId and the version is latest.
|
||||
*/
|
||||
fun canRecycleBackupRepo(repoId: String?, version: Byte?): Boolean {
|
||||
if (repoId == null || version == null) return false
|
||||
return repoId != crypto.repoId && version == VERSION
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers the ownership of the backup repository identified by the [oldRepoId]
|
||||
* to the current user and device
|
||||
* by renaming the [TopLevelFolder] of the repo to the current repoId.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun recycleBackupRepo(oldRepoId: String) {
|
||||
val newRepoId = crypto.repoId
|
||||
if (oldRepoId == newRepoId) return
|
||||
val oldFolder = TopLevelFolder(oldRepoId)
|
||||
val newFolder = TopLevelFolder(newRepoId)
|
||||
backendManager.backend.rename(oldFolder, newFolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Careful, this removes the entire backup repository from the backend
|
||||
* and clears local blob cache.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun removeBackupRepo() {
|
||||
blobCache.clearLocalCache()
|
||||
// TODO not critical, but nice to have: clear also local snapshot cache
|
||||
backendManager.backend.remove(TopLevelFolder(crypto.repoId))
|
||||
}
|
||||
|
||||
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
|
||||
for (i in 1..n) {
|
||||
try {
|
||||
block()
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (i == n) throw e
|
||||
log.error(e) { "Error (#$i), we'll keep trying" }
|
||||
delay(1000 * i.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
|
||||
/**
|
||||
* Essential metadata returned when storing backup data.
|
||||
*
|
||||
* @param chunkIds an ordered(!) list of the chunk IDs required to re-assemble the backup data.
|
||||
* @param blobMap a mapping from chunk ID to [Blob] on the backend.
|
||||
* Needed for fetching blobs from the backend for re-assembly.
|
||||
*/
|
||||
data class BackupData(
|
||||
val chunkIds: List<String>,
|
||||
val blobMap: Map<String, Blob>,
|
||||
) {
|
||||
/**
|
||||
* The uncompressed plaintext size of all blobs.
|
||||
*/
|
||||
val size get() = blobMap.values.sumOf { it.uncompressedLength }.toLong()
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import org.calyxos.seedvault.chunker.Chunk
|
||||
import org.calyxos.seedvault.chunker.Chunker
|
||||
import org.calyxos.seedvault.chunker.GearTableCreator
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* The single point for receiving data for backup.
|
||||
* Data received will get split into smaller chunks, if needed.
|
||||
* [Chunk]s that don't have a corresponding [Blob] in the [blobCache]
|
||||
* will be passed to the [blobCreator] and have the new blob saved to the backend.
|
||||
*
|
||||
* Data can be received either via [addBytes] (requires matching call to [finalize])
|
||||
* or via [readFromStream].
|
||||
* This call is *not* thread-safe.
|
||||
*/
|
||||
internal class BackupReceiver(
|
||||
private val blobCache: BlobCache,
|
||||
private val blobCreator: BlobCreator,
|
||||
private val crypto: Crypto,
|
||||
private val replaceableChunker: Chunker? = null,
|
||||
) {
|
||||
|
||||
private val chunker: Chunker by lazy {
|
||||
// crypto.gearTableKey is not available at creation time, so use lazy instantiation
|
||||
replaceableChunker ?: Chunker(
|
||||
minSize = 1536 * 1024, // 1.5 MB
|
||||
avgSize = 3 * 1024 * 1024, // 3.0 MB
|
||||
maxSize = 7680 * 1024, // 7.5 MB
|
||||
normalization = 1,
|
||||
gearTable = GearTableCreator.create(crypto.gearTableKey),
|
||||
hashFunction = { bytes ->
|
||||
// this calculates the chunkId
|
||||
crypto.sha256(bytes).toHexString()
|
||||
},
|
||||
)
|
||||
}
|
||||
private val chunks = mutableListOf<String>()
|
||||
private val blobMap = mutableMapOf<String, Blob>()
|
||||
private var owner: String? = null
|
||||
|
||||
/**
|
||||
* Adds more [bytes] to be chunked and saved.
|
||||
* Must call [finalize] when done, even when an exception was thrown
|
||||
* to free up this re-usable instance of [BackupReceiver].
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun addBytes(owner: String, bytes: ByteArray) {
|
||||
checkOwner(owner)
|
||||
chunker.addBytes(bytes).forEach { chunk ->
|
||||
onNewChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads backup data from the given [inputStream] and returns [BackupData],
|
||||
* so a call to [finalize] isn't required.
|
||||
* The caller must close the [inputStream] when done.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun readFromStream(owner: String, inputStream: InputStream): BackupData {
|
||||
checkOwner(owner)
|
||||
try {
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = inputStream.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
if (bytes == buffer.size) {
|
||||
addBytes(owner, buffer)
|
||||
} else {
|
||||
addBytes(owner, buffer.copyOfRange(0, bytes))
|
||||
}
|
||||
bytes = inputStream.read(buffer)
|
||||
}
|
||||
return finalize(owner)
|
||||
} catch (e: Exception) {
|
||||
finalize(owner)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called after one or more calls to [addBytes] to finalize usage of this instance
|
||||
* and receive the [BackupData] for snapshotting.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun finalize(owner: String): BackupData {
|
||||
checkOwner(owner)
|
||||
try {
|
||||
chunker.finalize().forEach { chunk ->
|
||||
onNewChunk(chunk)
|
||||
}
|
||||
return BackupData(chunks.toList(), blobMap.toMap())
|
||||
} finally {
|
||||
chunks.clear()
|
||||
blobMap.clear()
|
||||
this.owner = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onNewChunk(chunk: Chunk) {
|
||||
chunks.add(chunk.hash)
|
||||
|
||||
val existingBlob = blobCache[chunk.hash]
|
||||
if (existingBlob == null) {
|
||||
val blob = blobCreator.createNewBlob(chunk)
|
||||
blobMap[chunk.hash] = blob
|
||||
blobCache.saveNewBlob(chunk.hash, blob)
|
||||
} else {
|
||||
blobMap[chunk.hash] = existingBlob
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkOwner(owner: String) {
|
||||
if (this.owner == null) this.owner = owner
|
||||
else check(this.owner == owner) { "Owned by ${this.owner}, but called from $owner" }
|
||||
}
|
||||
|
||||
}
|
166
app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
Normal file
166
app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
Normal file
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_APPEND
|
||||
import com.stevesoltys.seedvault.MemoryLogger
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.calyxos.seedvault.core.backends.FileInfo
|
||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
||||
private const val CACHE_FILE_NAME = "blobsCache"
|
||||
|
||||
/**
|
||||
* Responsible for caching blobs during a backup run,
|
||||
* so we can know that a blob for the given chunk ID already exists
|
||||
* and does not need to be uploaded again.
|
||||
*
|
||||
* It builds up its cache from snapshots available on the backend
|
||||
* and from the persistent cache that includes blobs that could not be added to a snapshot,
|
||||
* because the backup was aborted.
|
||||
*/
|
||||
class BlobCache(
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val blobMap = mutableMapOf<String, Blob>()
|
||||
|
||||
/**
|
||||
* This must be called before saving files to the backend to avoid uploading duplicate blobs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun populateCache(blobs: List<FileInfo>, snapshots: List<Snapshot>) {
|
||||
log.info { "Getting all blobs from backend..." }
|
||||
blobMap.clear()
|
||||
MemoryLogger.log()
|
||||
// create map of blobId to size of blob on backend
|
||||
val blobIds = blobs.associate {
|
||||
Pair(it.fileHandle.name, it.size.toInt())
|
||||
}
|
||||
// load local blob cache and include only blobs on backend
|
||||
loadPersistentBlobCache(blobIds)
|
||||
// build up mapping from chunkId to blob from available snapshots
|
||||
snapshots.forEach { snapshot ->
|
||||
onSnapshotLoaded(snapshot, blobIds)
|
||||
}
|
||||
MemoryLogger.log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Should only be called after [populateCache] has returned.
|
||||
*/
|
||||
operator fun get(chunkId: String): Blob? = blobMap[chunkId]
|
||||
|
||||
/**
|
||||
* Should get called for all new blobs as soon as they've been saved to the backend.
|
||||
*/
|
||||
fun saveNewBlob(chunkId: String, blob: Blob) {
|
||||
val previous = blobMap.put(chunkId, blob)
|
||||
if (previous == null) {
|
||||
// persist this new blob locally in case backup gets interrupted
|
||||
context.openFileOutput(CACHE_FILE_NAME, MODE_APPEND).use { outputStream ->
|
||||
outputStream.write(chunkId.toByteArrayFromHex())
|
||||
blob.writeDelimitedTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached blob mapping.
|
||||
* Should be called after a backup run to free up memory.
|
||||
*/
|
||||
fun clear() {
|
||||
log.info { "Clearing cache..." }
|
||||
blobMap.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the local cache.
|
||||
* Should get called after
|
||||
* * changing to a different backup to prevent usage of blobs that don't exist there
|
||||
* * uploading a new snapshot to prevent the persistent cache from growing indefinitely
|
||||
*/
|
||||
fun clearLocalCache() {
|
||||
log.info { "Clearing local cache..." }
|
||||
context.deleteFile(CACHE_FILE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads persistent cache from disk and adds blobs to [blobMap]
|
||||
* if available in [allowedBlobIds] with the right size.
|
||||
*/
|
||||
private fun loadPersistentBlobCache(allowedBlobIds: Map<String, Int>) {
|
||||
try {
|
||||
context.openFileInput(CACHE_FILE_NAME).use { inputStream ->
|
||||
val chunkIdBytes = ByteArray(32)
|
||||
while (true) {
|
||||
val bytesRead = inputStream.read(chunkIdBytes)
|
||||
if (bytesRead != 32) break
|
||||
val chunkId = chunkIdBytes.toHexString()
|
||||
// parse blob
|
||||
val blob = Blob.parseDelimitedFrom(inputStream)
|
||||
val blobId = blob.id.hexFromProto()
|
||||
// include blob only if size is equal to size on backend
|
||||
val sizeOnBackend = allowedBlobIds[blobId]
|
||||
if (sizeOnBackend == blob.length) {
|
||||
blobMap[chunkId] = blob
|
||||
} else log.warn {
|
||||
if (sizeOnBackend == null) {
|
||||
"Cached blob $blobId is missing from backend."
|
||||
} else {
|
||||
"Cached blob $blobId had different size on backend: $sizeOnBackend"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is FileNotFoundException) log.info { "No local blob cache found." }
|
||||
else {
|
||||
// If the local cache is corrupted, that's not the end of the world.
|
||||
// We can still continue normally,
|
||||
// but may be writing out duplicated blobs we can't re-use.
|
||||
// Those will get deleted again when pruning.
|
||||
// So swallow the exception.
|
||||
log.error(e) { "Error loading blobs cache: " }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for populating local [blobMap] cache.
|
||||
* Adds mapping from chunkId to [Blob], if it exists on backend, i.e. part of [blobIds]
|
||||
* and its size matches the one on backend, i.e. value of [blobIds].
|
||||
*/
|
||||
private fun onSnapshotLoaded(snapshot: Snapshot, blobIds: Map<String, Int>) {
|
||||
snapshot.blobsMap.forEach { (chunkId, blob) ->
|
||||
// check if referenced blob still exists on backend
|
||||
val blobId = blob.id.hexFromProto()
|
||||
val sizeOnBackend = blobIds[blobId]
|
||||
if (sizeOnBackend == blob.length) {
|
||||
// only add blob to our mapping, if it still exists
|
||||
blobMap.putIfAbsent(chunkId, blob)?.let { previous ->
|
||||
if (previous.id != blob.id) log.warn {
|
||||
"Chunk ID ${chunkId.substring(0..5)} had more than one blob."
|
||||
}
|
||||
}
|
||||
} else log.warn {
|
||||
if (sizeOnBackend == null) {
|
||||
"Blob $blobId in snapshot ${snapshot.token} is missing."
|
||||
} else {
|
||||
"Blob $blobId has unexpected size: $sizeOnBackend"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.MemoryLogger
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
|
||||
import com.stevesoltys.seedvault.repo.Padding.getPadTo
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.calyxos.seedvault.chunker.Chunk
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Creates and uploads new blobs to the current backend.
|
||||
*/
|
||||
internal class BlobCreator(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val payloadBuffer = Buffer()
|
||||
private val buffer = Buffer()
|
||||
|
||||
/**
|
||||
* Creates and returns a new [Blob] from the given [chunk] and uploads it to the backend.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun createNewBlob(chunk: Chunk): Blob {
|
||||
// ensure buffers are cleared
|
||||
payloadBuffer.clear()
|
||||
buffer.clear()
|
||||
|
||||
// compress payload and get size
|
||||
ZstdOutputStream(payloadBuffer.outputStream()).use { zstdOutputStream ->
|
||||
zstdOutputStream.write(chunk.data)
|
||||
}
|
||||
val payloadSize = payloadBuffer.size.toInt()
|
||||
val payloadSizeBytes = ByteBuffer.allocate(4).putInt(payloadSize).array()
|
||||
val paddingSize = getPadTo(payloadSize) - payloadSize
|
||||
|
||||
// encrypt compressed payload and assemble entire blob
|
||||
val bufferStream = buffer.outputStream()
|
||||
bufferStream.write(VERSION.toInt())
|
||||
crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream ->
|
||||
cryptoStream.write(payloadSizeBytes)
|
||||
payloadBuffer.writeTo(cryptoStream)
|
||||
// add padding
|
||||
// we could just write 0s, but because of defense in depth, we use random bytes
|
||||
cryptoStream.write(crypto.getRandomBytes(paddingSize))
|
||||
}
|
||||
MemoryLogger.log()
|
||||
payloadBuffer.clear()
|
||||
|
||||
// compute hash and save blob
|
||||
val sha256ByteString = buffer.sha256()
|
||||
val handle = AppBackupFileType.Blob(crypto.repoId, sha256ByteString.hex())
|
||||
// TODO for later: implement a backend wrapper that handles retries for transient errors
|
||||
val size = backendManager.backend.save(handle).use { outputStream ->
|
||||
val outputBuffer = outputStream.sink().buffer()
|
||||
val length = outputBuffer.writeAll(buffer)
|
||||
// flushing is important here, otherwise data doesn't get fully written!
|
||||
outputBuffer.flush()
|
||||
length
|
||||
}
|
||||
buffer.clear()
|
||||
return blob {
|
||||
id = ByteString.copyFrom(sha256ByteString.asByteBuffer())
|
||||
length = size.toInt()
|
||||
uncompressedLength = chunk.length
|
||||
}
|
||||
}
|
||||
}
|
111
app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
Normal file
111
app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
Normal file
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.SequenceInputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.Enumeration
|
||||
|
||||
internal class Loader(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
/**
|
||||
* Downloads the given [fileHandle], decrypts and decompresses its content
|
||||
* and returns the content as a decrypted and decompressed stream.
|
||||
*
|
||||
* Attention: The responsibility with closing the returned stream lies with the caller.
|
||||
*
|
||||
* @param cacheFile if non-null, the ciphertext of the loaded file will be cached there
|
||||
* for later loading with [loadFile].
|
||||
*/
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
suspend fun loadFile(fileHandle: AppBackupFileType, cacheFile: File? = null): InputStream {
|
||||
val expectedHash = when (fileHandle) {
|
||||
is AppBackupFileType.Snapshot -> fileHandle.hash
|
||||
is AppBackupFileType.Blob -> fileHandle.name
|
||||
}
|
||||
return loadFromStream(backendManager.backend.load(fileHandle), expectedHash, cacheFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* The responsibility with closing the returned stream lies with the caller.
|
||||
*/
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
fun loadFile(file: File, expectedHash: String): InputStream {
|
||||
return loadFromStream(file.inputStream(), expectedHash)
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
suspend fun loadFiles(handles: List<AppBackupFileType>): InputStream {
|
||||
val enumeration: Enumeration<InputStream> = object : Enumeration<InputStream> {
|
||||
val iterator = handles.iterator()
|
||||
|
||||
override fun hasMoreElements(): Boolean {
|
||||
return iterator.hasNext()
|
||||
}
|
||||
|
||||
override fun nextElement(): InputStream {
|
||||
return runBlocking { loadFile(iterator.next()) }
|
||||
}
|
||||
}
|
||||
return SequenceInputStream(enumeration)
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
private fun loadFromStream(
|
||||
inputStream: InputStream,
|
||||
expectedHash: String,
|
||||
cacheFile: File? = null,
|
||||
): InputStream {
|
||||
// We load the entire ciphertext into memory,
|
||||
// so we can check the SHA-256 hash before decrypting and parsing the data.
|
||||
val cipherText = inputStream.use { it.readAllBytes() }
|
||||
// check SHA-256 hash first thing
|
||||
val sha256 = crypto.sha256(cipherText).toHexString()
|
||||
if (sha256 != expectedHash) {
|
||||
throw GeneralSecurityException("File had wrong SHA-256 hash: $expectedHash")
|
||||
}
|
||||
// check that we can handle the version of that snapshot
|
||||
val version = cipherText[0]
|
||||
if (version <= 1) throw GeneralSecurityException("Unexpected version: $version")
|
||||
if (version > VERSION) throw UnsupportedVersionException(version)
|
||||
// cache ciperText in cacheFile, if existing
|
||||
try {
|
||||
cacheFile?.outputStream()?.use { outputStream ->
|
||||
outputStream.write(cipherText)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error writing cache file $cacheFile: " }
|
||||
cacheFile?.delete()
|
||||
}
|
||||
// get associated data for version, used for authenticated decryption
|
||||
val ad = crypto.getAdForVersion(version)
|
||||
// skip first version byte when creating cipherText stream
|
||||
val byteStream = ByteArrayInputStream(cipherText, 1, cipherText.size - 1)
|
||||
// decrypt, de-pad and decompress cipherText stream
|
||||
val decryptingStream = crypto.newDecryptingStream(byteStream, ad)
|
||||
val paddedStream = PaddedInputStream(decryptingStream)
|
||||
return ZstdInputStream(paddedStream)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
internal class PaddedInputStream(inputStream: InputStream) : FilterInputStream(inputStream) {
|
||||
|
||||
private val size: Int
|
||||
private var bytesRead: Int = 0
|
||||
|
||||
init {
|
||||
val sizeBytes = ByteArray(4)
|
||||
val bytesRead = inputStream.read(sizeBytes)
|
||||
if (bytesRead != 4) {
|
||||
throw IOException("Could not read padding size: ${sizeBytes.toHexString()}")
|
||||
}
|
||||
size = ByteBuffer.wrap(sizeBytes).getInt()
|
||||
}
|
||||
|
||||
override fun read(): Int {
|
||||
if (bytesRead >= size) return -1
|
||||
return getReadBytes(super.read())
|
||||
}
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||
if (bytesRead >= size) return -1
|
||||
if (bytesRead + len >= size) {
|
||||
return getReadBytes(super.read(b, off, size - bytesRead))
|
||||
}
|
||||
return getReadBytes(super.read(b, off, len))
|
||||
}
|
||||
|
||||
override fun available(): Int {
|
||||
return size - bytesRead
|
||||
}
|
||||
|
||||
private fun getReadBytes(read: Int): Int {
|
||||
if (read == -1) return -1
|
||||
bytesRead += read
|
||||
if (bytesRead > size) return -1
|
||||
return read
|
||||
}
|
||||
}
|
28
app/src/main/java/com/stevesoltys/seedvault/repo/Padding.kt
Normal file
28
app/src/main/java/com/stevesoltys/seedvault/repo/Padding.kt
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
object Padding {
|
||||
|
||||
/**
|
||||
* Pads the given [size] using the [Padmé algorithm](https://lbarman.ch/blog/padme/).
|
||||
*
|
||||
* @param size unpadded object length
|
||||
* @return the padded object length
|
||||
*/
|
||||
fun getPadTo(size: Int): Int {
|
||||
val e = floor(log2(size.toFloat()))
|
||||
val s = floor(log2(e)) + 1
|
||||
val lastBits = e - s
|
||||
val bitMask = (2.toFloat().pow(lastBits) - 1).toInt()
|
||||
return (size + bitMask) and bitMask.inv()
|
||||
}
|
||||
|
||||
}
|
122
app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
Normal file
122
app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
Normal file
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||
import java.security.GeneralSecurityException
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoField
|
||||
import java.time.temporal.TemporalAdjuster
|
||||
|
||||
/**
|
||||
* Cleans up old backups data that we do not need to retain.
|
||||
*/
|
||||
internal class Pruner(
|
||||
private val crypto: Crypto,
|
||||
private val backendManager: BackendManager,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val folder get() = TopLevelFolder(crypto.repoId)
|
||||
|
||||
/**
|
||||
* Keeps the last 3 daily and 2 weekly snapshots (this and last week), removes all others.
|
||||
* Then removes all blobs from the backend
|
||||
* that are not referenced anymore by remaining snapshots.
|
||||
*/
|
||||
suspend fun removeOldSnapshotsAndPruneUnusedBlobs() {
|
||||
// get snapshots currently available on backend
|
||||
val snapshotHandles = mutableListOf<AppBackupFileType.Snapshot>()
|
||||
backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
|
||||
snapshotHandles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
|
||||
}
|
||||
// load and parse snapshots
|
||||
val snapshotMap = mutableMapOf<Long, AppBackupFileType.Snapshot>()
|
||||
val snapshots = mutableListOf<Snapshot>()
|
||||
snapshotHandles.forEach { handle ->
|
||||
try {
|
||||
val snapshot = snapshotManager.loadSnapshot(handle)
|
||||
snapshotMap[snapshot.token] = handle
|
||||
snapshots.add(snapshot)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
log.error(e) { "Error loading snapshot $handle, will remove: " }
|
||||
snapshotManager.removeSnapshot(handle)
|
||||
} // other exceptions (like IOException) are allowed to bubble up, so we try again
|
||||
}
|
||||
// find out which snapshots to keep
|
||||
val toKeep = getTokenToKeep(snapshotMap.keys)
|
||||
log.info { "Found ${snapshots.size} snapshots, keeping ${toKeep.size}." }
|
||||
// remove snapshots we aren't keeping
|
||||
snapshotMap.forEach { (token, handle) ->
|
||||
if (token !in toKeep) {
|
||||
log.info { "Removing snapshot $token ${handle.name}" }
|
||||
snapshotManager.removeSnapshot(handle)
|
||||
}
|
||||
}
|
||||
// prune unused blobs
|
||||
val keptSnapshots = snapshots.filter { it.token in toKeep }
|
||||
pruneUnusedBlobs(keptSnapshots)
|
||||
}
|
||||
|
||||
private suspend fun pruneUnusedBlobs(snapshots: List<Snapshot>) {
|
||||
val blobHandles = mutableListOf<AppBackupFileType.Blob>()
|
||||
backendManager.backend.list(folder, AppBackupFileType.Blob::class) { fileInfo ->
|
||||
blobHandles.add(fileInfo.fileHandle as AppBackupFileType.Blob)
|
||||
}
|
||||
val usedBlobIds = snapshots.flatMap { snapshot ->
|
||||
snapshot.blobsMap.values.map { blob ->
|
||||
blob.id.hexFromProto()
|
||||
}
|
||||
}.toSet()
|
||||
blobHandles.forEach { blobHandle ->
|
||||
if (blobHandle.name !in usedBlobIds) {
|
||||
log.info { "Removing blob ${blobHandle.name}" }
|
||||
backendManager.backend.remove(blobHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTokenToKeep(tokenSet: Set<Long>): Set<Long> {
|
||||
if (tokenSet.size <= 3) return tokenSet // keep at least 3 snapshots
|
||||
val tokenList = tokenSet.sortedDescending()
|
||||
val toKeep = mutableSetOf<Long>()
|
||||
toKeep += getToKeep(tokenList, 3) // 3 daily
|
||||
toKeep += getToKeep(tokenList, 2) { temporal -> // keep one from this and last week
|
||||
temporal.with(ChronoField.DAY_OF_WEEK, 1)
|
||||
}
|
||||
// ensure we keep at least three snapshots
|
||||
val tokenIterator = tokenList.iterator()
|
||||
while (toKeep.size < 3 && tokenIterator.hasNext()) toKeep.add(tokenIterator.next())
|
||||
return toKeep
|
||||
}
|
||||
|
||||
private fun getToKeep(
|
||||
tokenList: List<Long>,
|
||||
keep: Int,
|
||||
temporalAdjuster: TemporalAdjuster? = null,
|
||||
): List<Long> {
|
||||
val toKeep = mutableListOf<Long>()
|
||||
if (keep == 0) return toKeep
|
||||
var last: LocalDate? = null
|
||||
for (token in tokenList) {
|
||||
val date = LocalDate.ofEpochDay(token / 1000 / 60 / 60 / 24)
|
||||
val period = if (temporalAdjuster == null) date else date.with(temporalAdjuster)
|
||||
if (period != last) {
|
||||
toKeep.add(token)
|
||||
if (toKeep.size >= keep) break
|
||||
last = period
|
||||
}
|
||||
}
|
||||
return toKeep
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
|
||||
val repoModule = module {
|
||||
single { AppBackupManager(get(), get(), get(), get(), get(), get()) }
|
||||
single { BackupReceiver(get(), get(), get()) }
|
||||
single { BlobCache(androidContext()) }
|
||||
single { BlobCreator(get(), get()) }
|
||||
single { Loader(get(), get()) }
|
||||
single {
|
||||
val snapshotFolder = File(androidContext().filesDir, FOLDER_SNAPSHOTS)
|
||||
SnapshotManager(snapshotFolder, get(), get(), get())
|
||||
}
|
||||
factory { SnapshotCreatorFactory(androidContext(), get(), get(), get()) }
|
||||
factory { Pruner(get(), get(), get()) }
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.os.Build
|
||||
import android.os.UserManager
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.Secure.ANDROID_ID
|
||||
import com.google.protobuf.ByteString
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata.Companion.toBackupType
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Apk
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.App
|
||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Assembles snapshot information over the course of a single backup run
|
||||
* and creates a [Snapshot] object in the end by calling [finalizeSnapshot].
|
||||
*/
|
||||
internal class SnapshotCreator(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val metadataManager: MetadataManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
private val snapshotBuilder = Snapshot.newBuilder()
|
||||
private val appBuilderMap = ConcurrentHashMap<String, App.Builder>()
|
||||
private val blobsMap = ConcurrentHashMap<String, Blob>()
|
||||
|
||||
private val launchableSystemApps by lazy {
|
||||
// as we can't ask [PackageInfo] for this, we keep a set of packages around
|
||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after all blobs for the given [apk] have been saved to the backend.
|
||||
* The [apk] must contain the ordered list of chunk IDs
|
||||
* and the given [blobMap] must have one [Blob] per chunk ID.
|
||||
*/
|
||||
fun onApkBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
apk: Apk,
|
||||
blobMap: Map<String, Blob>,
|
||||
) {
|
||||
appBuilderMap.getOrPut(packageInfo.packageName) {
|
||||
App.newBuilder()
|
||||
}.apply {
|
||||
val label = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||
if (label != null) name = label.toString()
|
||||
setApk(apk)
|
||||
}
|
||||
blobsMap.putAll(blobMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after all blobs for the package identified by the given [packageInfo]
|
||||
* have been saved to the backend.
|
||||
* The given [backupData] must contain the full ordered list of [BackupData.chunkIds]
|
||||
* and the [BackupData.blobMap] must have one [Blob] per chunk ID.
|
||||
*
|
||||
* Failure to call this method results in the package effectively not getting backed up.
|
||||
*/
|
||||
fun onPackageBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
backupType: BackupType,
|
||||
backupData: BackupData,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
val chunkIds = backupData.chunkIds.forProto()
|
||||
appBuilderMap.getOrPut(packageName) {
|
||||
App.newBuilder()
|
||||
}.apply {
|
||||
time = clock.time()
|
||||
type = backupType.forSnapshot()
|
||||
val label = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||
if (label != null) name = label.toString()
|
||||
system = isSystemApp
|
||||
launchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName)
|
||||
addAllChunkIds(chunkIds)
|
||||
size = backupData.size
|
||||
}
|
||||
blobsMap.putAll(backupData.blobMap)
|
||||
metadataManager.onPackageBackedUp(packageInfo, backupType, backupData.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when the given [packageName] may not call our transport at all in this run,
|
||||
* but we need to include data for the package in the current snapshot.
|
||||
* This may happen for K/V apps like @pm@ that don't call us when their data didn't change.
|
||||
*
|
||||
* If we do *not* have data for the given [packageName],
|
||||
* we try to extract data from the given [snapshot] (ideally we latest we have) and
|
||||
* add it to the current snapshot under construction.
|
||||
*
|
||||
* @param warnNoData log a warning, if [snapshot] had no data for the given [packageName].
|
||||
*/
|
||||
fun onNoDataInCurrentRun(snapshot: Snapshot, packageName: String, isStopped: Boolean = false) {
|
||||
log.info { "onKvPackageNotChanged(${snapshot.token}, $packageName)" }
|
||||
|
||||
if (appBuilderMap.containsKey(packageName)) {
|
||||
// the system backs up K/V apps repeatedly, e.g. @pm@
|
||||
log.info { " Already have data for $packageName in current snapshot, not touching it" }
|
||||
return
|
||||
}
|
||||
val app = snapshot.appsMap[packageName]
|
||||
if (app == null) {
|
||||
if (!isStopped) log.error {
|
||||
" No changed data for $packageName, but we had no data for it"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// get chunkIds from last snapshot
|
||||
val chunkIds = app.chunkIdsList.hexFromProto() +
|
||||
app.apk.splitsList.flatMap { it.chunkIdsList }.hexFromProto()
|
||||
|
||||
// get blobs for chunkIds
|
||||
val blobMap = mutableMapOf<String, Blob>()
|
||||
chunkIds.forEach { chunkId ->
|
||||
val blob = snapshot.blobsMap[chunkId]
|
||||
if (blob == null) log.error { " No blob for $packageName chunk $chunkId" }
|
||||
else blobMap[chunkId] = blob
|
||||
}
|
||||
|
||||
// add info to current snapshot
|
||||
appBuilderMap[packageName] = app.toBuilder()
|
||||
blobsMap.putAll(blobMap)
|
||||
|
||||
// record local metadata if this is not a stopped app
|
||||
if (!isStopped) {
|
||||
val packageInfo = PackageInfo().apply { this.packageName = packageName }
|
||||
metadataManager.onPackageBackedUp(packageInfo, app.type.toBackupType(), app.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after all blobs for the app icons have been saved to the backend.
|
||||
*/
|
||||
fun onIconsBackedUp(backupData: BackupData) {
|
||||
snapshotBuilder.addAllIconChunkIds(backupData.chunkIds.forProto())
|
||||
blobsMap.putAll(backupData.blobMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Must get called after all backup data was saved to the backend.
|
||||
* Returns the assembled [Snapshot] which must be saved to the backend as well
|
||||
* to complete the current backup run.
|
||||
*
|
||||
* Internal state will be cleared to free up memory.
|
||||
* Still, it isn't safe to re-use an instance of this class, after it has been finalized.
|
||||
*/
|
||||
fun finalizeSnapshot(): Snapshot {
|
||||
log.info { "finalizeSnapshot()" }
|
||||
@SuppressLint("HardwareIds")
|
||||
val snapshot = snapshotBuilder.apply {
|
||||
version = VERSION.toInt()
|
||||
token = clock.time()
|
||||
name = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
user = getUserName() ?: ""
|
||||
androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) ?: ""
|
||||
sdkInt = Build.VERSION.SDK_INT
|
||||
androidIncremental = Build.VERSION.INCREMENTAL
|
||||
d2D = true
|
||||
putAllApps(appBuilderMap.mapValues { it.value.build() })
|
||||
putAllBlobs(this@SnapshotCreator.blobsMap)
|
||||
}.build()
|
||||
// may as well fail the backup, if @pm@ isn't in it
|
||||
check(MAGIC_PACKAGE_MANAGER in snapshot.appsMap) { "No metadata for @pm@" }
|
||||
appBuilderMap.clear()
|
||||
snapshotBuilder.clear()
|
||||
blobsMap.clear()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun getUserName(): String? {
|
||||
@Suppress("UNRESOLVED_REFERENCE") // hidden AOSP API
|
||||
val perm = Manifest.permission.QUERY_USERS
|
||||
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
||||
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
||||
userManager.userName
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun BackupType.forSnapshot(): Snapshot.BackupType = when (this) {
|
||||
BackupType.KV -> Snapshot.BackupType.KV
|
||||
BackupType.FULL -> Snapshot.BackupType.FULL
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
|
||||
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
|
||||
fun ByteString.hexFromProto() = toByteArray().toHexString()
|
||||
fun Snapshot.getBlobHandles(repoId: String, chunkIds: List<String>) = chunkIds.map { chunkId ->
|
||||
val blobId = blobsMap[chunkId]?.id?.hexFromProto()
|
||||
?: error("Blob for $chunkId missing from snapshot $token")
|
||||
AppBackupFileType.Blob(repoId, blobId)
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import android.content.Context
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
||||
/**
|
||||
* Creates a new [SnapshotCreator], because one is only valid for a single backup run.
|
||||
*/
|
||||
internal class SnapshotCreatorFactory(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val metadataManager: MetadataManager,
|
||||
) {
|
||||
fun createSnapshotCreator() =
|
||||
SnapshotCreator(context, clock, packageService, metadataManager)
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.repo
|
||||
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.GeneralSecurityException
|
||||
|
||||
internal const val FOLDER_SNAPSHOTS = "snapshots"
|
||||
|
||||
/**
|
||||
* Manages interactions with snapshots, such as loading, saving and removing them.
|
||||
* Also keeps a reference to the [latestSnapshot] that holds important re-usable data.
|
||||
*/
|
||||
internal class SnapshotManager(
|
||||
private val snapshotFolderRoot: File,
|
||||
private val crypto: Crypto,
|
||||
private val loader: Loader,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val snapshotFolder: File get() = File(snapshotFolderRoot, crypto.repoId)
|
||||
|
||||
/**
|
||||
* The latest [Snapshot]. May be stale if [onSnapshotsLoaded] has not returned
|
||||
* or wasn't called since new snapshots have been created.
|
||||
*/
|
||||
@Volatile
|
||||
var latestSnapshot: Snapshot? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Call this before starting a backup run with the [handles] of snapshots
|
||||
* currently available on the backend.
|
||||
*/
|
||||
suspend fun onSnapshotsLoaded(handles: List<AppBackupFileType.Snapshot>): List<Snapshot> {
|
||||
// first reset latest snapshot, otherwise we'd hang on to a stale one
|
||||
// e.g. when switching to new storage without any snapshots
|
||||
latestSnapshot = null
|
||||
return handles.mapNotNull { snapshotHandle ->
|
||||
val snapshot = try {
|
||||
loadSnapshot(snapshotHandle)
|
||||
} catch (e: Exception) {
|
||||
// This isn't ideal, but the show must go on and we take the snapshots we can get.
|
||||
// After the first load, a snapshot will get cached, so we are not hitting backend.
|
||||
// TODO use a re-trying backend for snapshot loading
|
||||
log.error(e) { "Error loading snapshot: $snapshotHandle" }
|
||||
return@mapNotNull null
|
||||
}
|
||||
// update latest snapshot if this one is more recent
|
||||
if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given [snapshot] to the backend and local cache.
|
||||
*
|
||||
* @throws IOException or others if saving fails.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun saveSnapshot(snapshot: Snapshot) {
|
||||
// compress payload and get size
|
||||
val payloadStream = ByteArrayOutputStream()
|
||||
ZstdOutputStream(payloadStream).use { zstdOutputStream ->
|
||||
snapshot.writeTo(zstdOutputStream)
|
||||
}
|
||||
val payloadSize = payloadStream.size()
|
||||
val payloadSizeBytes = ByteBuffer.allocate(4).putInt(payloadSize).array()
|
||||
|
||||
// encrypt compressed payload and assemble entire blob
|
||||
val byteStream = ByteArrayOutputStream()
|
||||
byteStream.write(VERSION.toInt())
|
||||
crypto.newEncryptingStream(byteStream, crypto.getAdForVersion()).use { cryptoStream ->
|
||||
cryptoStream.write(payloadSizeBytes)
|
||||
cryptoStream.write(payloadStream.toByteArray())
|
||||
// not adding any padding here, because it doesn't matter for snapshots
|
||||
}
|
||||
payloadStream.reset()
|
||||
val bytes = byteStream.toByteArray()
|
||||
byteStream.reset()
|
||||
|
||||
// compute hash and save blob
|
||||
val sha256 = crypto.sha256(bytes).toHexString()
|
||||
val snapshotHandle = AppBackupFileType.Snapshot(crypto.repoId, sha256)
|
||||
backendManager.backend.save(snapshotHandle).use { outputStream ->
|
||||
outputStream.write(bytes)
|
||||
}
|
||||
// save to local cache while at it
|
||||
try {
|
||||
if (!snapshotFolder.isDirectory) snapshotFolder.mkdirs()
|
||||
File(snapshotFolder, snapshotHandle.name).outputStream().use { outputStream ->
|
||||
outputStream.write(bytes)
|
||||
}
|
||||
} catch (e: Exception) { // we'll let this one pass
|
||||
log.error(e) { "Error saving snapshot ${snapshotHandle.hash} to cache: " }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the snapshot referenced by the given [snapshotHandle] from the backend
|
||||
* and local cache.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun removeSnapshot(snapshotHandle: AppBackupFileType.Snapshot) {
|
||||
backendManager.backend.remove(snapshotHandle)
|
||||
// remove from cache as well
|
||||
File(snapshotFolder, snapshotHandle.name).delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses the snapshot referenced by the given [snapshotHandle].
|
||||
* If a locally cached version exists, the backend will not be hit.
|
||||
*/
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
suspend fun loadSnapshot(snapshotHandle: AppBackupFileType.Snapshot): Snapshot {
|
||||
val file = File(snapshotFolder, snapshotHandle.name)
|
||||
snapshotFolder.mkdirs()
|
||||
val inputStream = if (file.isFile) {
|
||||
try {
|
||||
loader.loadFile(file, snapshotHandle.hash)
|
||||
} catch (e: Exception) {
|
||||
log.error(e) { "Error loading $snapshotHandle from local cache. Trying backend..." }
|
||||
loader.loadFile(snapshotHandle, file)
|
||||
}
|
||||
} else {
|
||||
loader.loadFile(snapshotHandle, file)
|
||||
}
|
||||
return inputStream.use { Snapshot.parseFrom(it) }
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
fun loadCachedSnapshots(): List<Snapshot> {
|
||||
if (!snapshotFolder.isDirectory) return emptyList()
|
||||
return snapshotFolder.listFiles()?.mapNotNull { file ->
|
||||
val match = appSnapshotRegex.matchEntire(file.name)
|
||||
if (match == null) {
|
||||
log.error { "Unexpected file found: $file" }
|
||||
null
|
||||
} else {
|
||||
loader.loadFile(file, match.groupValues[1]).use { Snapshot.parseFrom(it) }
|
||||
}
|
||||
} ?: throw IOException("Could not access snapshotFolder")
|
||||
}
|
||||
|
||||
}
|
|
@ -23,12 +23,12 @@ 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.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
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.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
|
@ -54,7 +54,6 @@ internal data class AppRestoreResult(
|
|||
internal class AppDataRestoreManager(
|
||||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
@ -84,12 +83,6 @@ internal class AppDataRestoreManager(
|
|||
|
||||
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()
|
||||
|
@ -217,7 +210,7 @@ internal class AppDataRestoreManager(
|
|||
context.stopService(foregroundServiceIntent)
|
||||
}
|
||||
|
||||
fun closeSession() {
|
||||
private fun closeSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
}
|
||||
|
@ -263,20 +256,20 @@ internal class AppDataRestoreManager(
|
|||
/**
|
||||
* 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.
|
||||
* We need to restore packages in chunks, otherwise [BackupTransport.startRestore] in the
|
||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder transaction,
|
||||
* causing the entire restoration to fail due to too many package names.
|
||||
*/
|
||||
private fun restoreNextPackages() {
|
||||
// Make sure metadata for selected backup is cached before starting each chunk.
|
||||
val backupMetadata = restorableBackup.backupMetadata
|
||||
restoreCoordinator.beforeStartRestore(backupMetadata)
|
||||
restoreCoordinator.beforeStartRestore(restorableBackup)
|
||||
|
||||
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
|
||||
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||
packageIndex += packageChunk.size
|
||||
Log.d(TAG, "restoreNextPackages() with packageIndex=$packageIndex")
|
||||
|
||||
val token = backupMetadata.token
|
||||
val token = restorableBackup.token
|
||||
val result = session.restorePackages(token, this, packageChunk, monitor)
|
||||
|
||||
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
||||
|
@ -317,6 +310,7 @@ internal class AppDataRestoreManager(
|
|||
*/
|
||||
override fun restoreFinished(result: Int) {
|
||||
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
||||
Log.d(TAG, "restoreFinished($result) with chunkIndex=$chunkIndex")
|
||||
chunkResults[chunkIndex] = result
|
||||
|
||||
// Restore next chunk if successful and there are more packages to restore.
|
||||
|
@ -325,6 +319,7 @@ internal class AppDataRestoreManager(
|
|||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "onRestoreComplete()")
|
||||
// Restore finished, time to get the result.
|
||||
onRestoreComplete(getRestoreResult(), restorableBackup)
|
||||
closeSession()
|
||||
|
|
|
@ -12,9 +12,10 @@ 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.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import com.stevesoltys.seedvault.worker.IconManager
|
||||
|
@ -68,6 +69,7 @@ internal class AppSelectionManager(
|
|||
val name = context.getString(data.nameRes)
|
||||
SelectableAppItem(packageName, metadata.copy(name = name), true)
|
||||
}
|
||||
if (restorableBackup.packageMetadataMap.isNotEmpty()) {
|
||||
val systemItem = SelectableAppItem(
|
||||
packageName = PACKAGE_NAME_SYSTEM,
|
||||
metadata = PackageMetadata(
|
||||
|
@ -83,16 +85,25 @@ internal class AppSelectionManager(
|
|||
selected = isSetupWizard,
|
||||
)
|
||||
items.add(0, systemItem)
|
||||
}
|
||||
items.addAll(0, systemDataItems)
|
||||
selectedApps.value =
|
||||
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
|
||||
// download icons
|
||||
coroutineScope.launch(workDispatcher) {
|
||||
val packagesWithIcons = try {
|
||||
if (restorableBackup.version == 1.toByte()) {
|
||||
val backend = backendManager.backend
|
||||
val token = restorableBackup.token
|
||||
val packagesWithIcons = try {
|
||||
backend.load(LegacyAppBackupFile.IconsFile(token)).use {
|
||||
iconManager.downloadIcons(restorableBackup.version, token, it)
|
||||
iconManager.downloadIconsV1(token, it)
|
||||
}
|
||||
} else if (restorableBackup.version >= 2) {
|
||||
val repoId = restorableBackup.repoId ?: error("No repoId in v2 backup")
|
||||
val snapshot = restorableBackup.snapshot ?: error("No snapshot in v2 backup")
|
||||
iconManager.downloadIcons(repoId, snapshot)
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading icons:", e)
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
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 com.stevesoltys.seedvault.R
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class RecycleBackupFragment : Fragment() {
|
||||
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
val v: View = inflater.inflate(R.layout.fragment_recycle_backup, container, false)
|
||||
|
||||
val backupName = viewModel.chosenRestorableBackup.value?.name
|
||||
v.requireViewById<TextView>(R.id.descriptionView).text =
|
||||
getString(R.string.restore_recycle_backup_text, backupName)
|
||||
|
||||
v.requireViewById<Button>(R.id.noButton).setOnClickListener {
|
||||
viewModel.onRecycleBackupFinished(false)
|
||||
}
|
||||
v.requireViewById<Button>(R.id.yesButton).setOnClickListener {
|
||||
viewModel.onRecycleBackupFinished(true)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
|
||||
data class RestorableBackup(val backupMetadata: BackupMetadata) {
|
||||
|
||||
val name: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
||||
val version: Byte
|
||||
get() = backupMetadata.version
|
||||
|
||||
val token: Long
|
||||
get() = backupMetadata.token
|
||||
|
||||
val salt: String
|
||||
get() = backupMetadata.salt
|
||||
|
||||
val time: Long
|
||||
get() = backupMetadata.time
|
||||
|
||||
val size: Long?
|
||||
get() = backupMetadata.size
|
||||
|
||||
val deviceName: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
||||
val d2dBackup: Boolean
|
||||
get() = backupMetadata.d2dBackup
|
||||
|
||||
val packageMetadataMap: PackageMetadataMap
|
||||
get() = backupMetadata.packageMetadataMap
|
||||
|
||||
}
|
|
@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore
|
|||
import android.os.Bundle
|
||||
import androidx.annotation.CallSuper
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
|
@ -35,6 +36,7 @@ class RestoreActivity : RequireProvisioningActivity() {
|
|||
SELECT_APPS -> showFragment(AppSelectionFragment())
|
||||
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
||||
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
||||
RECYCLE_BACKUP -> showFragment(RecycleBackupFragment())
|
||||
RESTORE_FILES -> showFragment(RestoreFilesFragment())
|
||||
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
|
||||
RESTORE_FILES_STARTED -> {
|
||||
|
|
|
@ -39,8 +39,8 @@ class RestoreService : Service() {
|
|||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "onDestroy")
|
||||
super.onDestroy()
|
||||
nm.cancelRestoreNotification()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
|
||||
import android.text.format.DateUtils.HOUR_IN_MILLIS
|
||||
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||
import android.text.format.Formatter
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
|
@ -19,6 +19,7 @@ import androidx.recyclerview.widget.RecyclerView.Adapter
|
|||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
|
||||
internal class RestoreSetAdapter(
|
||||
private val listener: RestorableBackupClickListener,
|
||||
|
@ -40,32 +41,40 @@ internal class RestoreSetAdapter(
|
|||
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
||||
|
||||
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
||||
private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
|
||||
private val sizeView = v.requireViewById<TextView>(R.id.sizeView)
|
||||
private val appView = v.requireViewById<TextView>(R.id.appView)
|
||||
private val apkView = v.requireViewById<TextView>(R.id.apkView)
|
||||
private val timeView = v.requireViewById<TextView>(R.id.timeView)
|
||||
|
||||
internal fun bind(item: RestorableBackup) {
|
||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||
titleView.text = item.name
|
||||
|
||||
val lastBackup = getRelativeTime(item.time)
|
||||
val setup = getRelativeTime(item.token)
|
||||
subtitleView.text =
|
||||
v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
|
||||
val size = item.size
|
||||
if (size == null) {
|
||||
sizeView.visibility = GONE
|
||||
} else {
|
||||
sizeView.text = v.context.getString(
|
||||
R.string.restore_restore_set_size,
|
||||
Formatter.formatShortFileSize(v.context, size),
|
||||
appView.text = if (item.sizeAppData > 0) {
|
||||
v.context.getString(
|
||||
R.string.restore_restore_set_apps,
|
||||
item.numAppData,
|
||||
formatShortFileSize(v.context, item.sizeAppData),
|
||||
)
|
||||
sizeView.visibility = VISIBLE
|
||||
} else {
|
||||
v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
|
||||
}
|
||||
appView.visibility = if (item.numAppData > 0) VISIBLE else GONE
|
||||
apkView.text = if (item.sizeApks > 0) {
|
||||
v.context.getString(
|
||||
R.string.restore_restore_set_apks,
|
||||
item.numApks,
|
||||
formatShortFileSize(v.context, item.sizeApks),
|
||||
)
|
||||
} else {
|
||||
v.context.getString(R.string.restore_restore_set_apks_no_size, item.numApks)
|
||||
}
|
||||
apkView.visibility = if (item.numApks > 0) VISIBLE else GONE
|
||||
timeView.text = getRelativeTime(item.time)
|
||||
}
|
||||
|
||||
private fun getRelativeTime(time: Long): CharSequence {
|
||||
val now = System.currentTimeMillis()
|
||||
return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
||||
return getRelativeTimeSpanString(time, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import android.widget.TextView
|
|||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class RestoreSetFragment : Fragment() {
|
||||
|
|
|
@ -19,6 +19,7 @@ val restoreUiModule = module {
|
|||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
appBackupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
|
|
|
@ -17,8 +17,10 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
|
@ -30,6 +32,9 @@ import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
|
|||
import com.stevesoltys.seedvault.restore.install.InstallResult
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.storage.StorageRestoreService
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.ErrorResult
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.SuccessResult
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
|
@ -62,6 +67,7 @@ internal class RestoreViewModel(
|
|||
keyManager: KeyManager,
|
||||
backupManager: IBackupManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val apkRestore: ApkRestore,
|
||||
private val iconManager: IconManager,
|
||||
storageBackup: StorageBackup,
|
||||
|
@ -77,7 +83,7 @@ internal class RestoreViewModel(
|
|||
private val appSelectionManager =
|
||||
AppSelectionManager(app, backendManager, iconManager, viewModelScope)
|
||||
private val appDataRestoreManager = AppDataRestoreManager(
|
||||
app, backupManager, settingsManager, restoreCoordinator, backendManager
|
||||
app, backupManager, restoreCoordinator, backendManager
|
||||
)
|
||||
|
||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||
|
@ -106,20 +112,11 @@ internal class RestoreViewModel(
|
|||
private var storedSnapshot: StoredSnapshot? = null
|
||||
|
||||
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
||||
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
||||
when (metadata.time) {
|
||||
0L -> {
|
||||
Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
|
||||
null
|
||||
}
|
||||
|
||||
else -> RestorableBackup(metadata)
|
||||
}
|
||||
}
|
||||
val result = when {
|
||||
backups == null -> RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
backups.isEmpty() -> RestoreSetResult(app.getString(R.string.restore_set_empty_result))
|
||||
else -> RestoreSetResult(backups)
|
||||
val result = when (val backups = restoreCoordinator.getAvailableBackups()) {
|
||||
is ErrorResult -> RestoreSetResult(
|
||||
app.getString(R.string.restore_set_error) + "\n\n${backups.e}"
|
||||
)
|
||||
is SuccessResult -> RestoreSetResult(backups.backups)
|
||||
}
|
||||
mRestoreSetResults.postValue(result)
|
||||
}
|
||||
|
@ -176,11 +173,28 @@ internal class RestoreViewModel(
|
|||
super.onCleared()
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
|
||||
appDataRestoreManager.closeSession()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
internal fun onFinishClickedAfterRestoringAppData() {
|
||||
val backup = chosenRestorableBackup.value
|
||||
if (appBackupManager.canRecycleBackupRepo(backup?.repoId, backup?.version)) {
|
||||
mDisplayFragment.setEvent(RECYCLE_BACKUP)
|
||||
} else {
|
||||
mDisplayFragment.setEvent(RESTORE_FILES)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
internal fun onRecycleBackupFinished(shouldRecycle: Boolean) {
|
||||
val repoId = chosenRestorableBackup.value?.repoId
|
||||
if (shouldRecycle && repoId != null) viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
appBackupManager.recycleBackupRepo(repoId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error transferring backup repo: ", e)
|
||||
}
|
||||
}
|
||||
mDisplayFragment.setEvent(RESTORE_FILES)
|
||||
}
|
||||
|
||||
|
@ -225,6 +239,7 @@ internal enum class DisplayFragment {
|
|||
SELECT_APPS,
|
||||
RESTORE_APPS,
|
||||
RESTORE_BACKUP,
|
||||
RECYCLE_BACKUP,
|
||||
RESTORE_FILES,
|
||||
RESTORE_SELECT_FILES,
|
||||
RESTORE_FILES_STARTED,
|
||||
|
|
|
@ -11,15 +11,19 @@ import android.content.Intent
|
|||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.GET_SIGNATURES
|
||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||
import android.content.pm.SigningInfo
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import com.stevesoltys.seedvault.repo.getBlobHandles
|
||||
import com.stevesoltys.seedvault.restore.RestoreService
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||
|
@ -27,8 +31,8 @@ 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.backup.isSystemApp
|
||||
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
||||
import com.stevesoltys.seedvault.worker.getSignatures
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.worker.hashSignature
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
@ -37,6 +41,10 @@ import org.calyxos.seedvault.core.backends.Backend
|
|||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
private val TAG = ApkRestore::class.java.simpleName
|
||||
|
@ -46,6 +54,7 @@ internal class ApkRestore(
|
|||
private val backupManager: IBackupManager,
|
||||
private val backupStateManager: BackupStateManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val loader: Loader,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||
private val crypto: Crypto,
|
||||
|
@ -130,6 +139,7 @@ internal class ApkRestore(
|
|||
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
} catch (e: Exception) {
|
||||
if (e::class.simpleName == "MockKException") throw e
|
||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
}
|
||||
|
@ -154,7 +164,12 @@ internal class ApkRestore(
|
|||
}
|
||||
|
||||
@Suppress("ThrowsCount")
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
@Throws(
|
||||
GeneralSecurityException::class,
|
||||
UnsupportedVersionException::class,
|
||||
IOException::class,
|
||||
SecurityException::class,
|
||||
)
|
||||
private suspend fun restore(
|
||||
backup: RestorableBackup,
|
||||
packageName: String,
|
||||
|
@ -168,10 +183,10 @@ internal class ApkRestore(
|
|||
}
|
||||
|
||||
// cache the APK and get its hash
|
||||
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
||||
val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.baseApkChunkIds)
|
||||
|
||||
// check APK's SHA-256 hash
|
||||
if (metadata.sha256 != sha256) throw SecurityException(
|
||||
// check APK's SHA-256 hash for backup versions before 2
|
||||
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
|
||||
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
||||
)
|
||||
|
||||
|
@ -262,10 +277,9 @@ internal class ApkRestore(
|
|||
}
|
||||
splits.forEach { apkSplit -> // cache and check all splits
|
||||
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
||||
val salt = backup.salt
|
||||
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
|
||||
// check APK split's SHA-256 hash
|
||||
if (apkSplit.sha256 != sha256) throw SecurityException(
|
||||
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
|
||||
// check APK split's SHA-256 hash for backup versions before 2
|
||||
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
|
||||
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
||||
" but '${apkSplit.sha256}' expected."
|
||||
)
|
||||
|
@ -280,22 +294,32 @@ internal class ApkRestore(
|
|||
*
|
||||
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||
private suspend fun cacheApk(
|
||||
version: Byte,
|
||||
token: Long,
|
||||
salt: String,
|
||||
backup: RestorableBackup,
|
||||
packageName: String,
|
||||
chunkIds: List<String>?,
|
||||
suffix: String = "",
|
||||
): Pair<File, String> {
|
||||
// create a cache file to write the APK into
|
||||
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
||||
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
||||
val inputStream = if (version == 0.toByte()) {
|
||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
||||
} else {
|
||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
||||
backend.load(LegacyAppBackupFile.Blob(token, name))
|
||||
val inputStream = when (backup.version) {
|
||||
0.toByte() -> {
|
||||
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
|
||||
}
|
||||
1.toByte() -> {
|
||||
val name = crypto.getNameForApk(backup.salt, packageName, suffix)
|
||||
backend.load(LegacyAppBackupFile.Blob(backup.token, name))
|
||||
}
|
||||
else -> {
|
||||
val repoId = backup.repoId ?: error("No repoId for v2 backup")
|
||||
val snapshot = backup.snapshot ?: error("No snapshot for v2 backup")
|
||||
val handles = chunkIds?.let {
|
||||
snapshot.getBlobHandles(repoId, it)
|
||||
} ?: error("No chunkIds for $packageName-$suffix")
|
||||
loader.loadFiles(handles)
|
||||
}
|
||||
}
|
||||
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
||||
return Pair(cachedApk, sha256)
|
||||
|
@ -343,3 +367,45 @@ internal class ApkRestore(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the APK from the given [InputStream] to the given [OutputStream]
|
||||
* and calculate the SHA-256 hash while at it.
|
||||
*
|
||||
* Both streams will be closed when the method returns.
|
||||
*
|
||||
* @return the APK's SHA-256 hash in Base64 format.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
outputStream.use { oStream ->
|
||||
inputStream.use { inputStream ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = inputStream.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
oStream.write(buffer, 0, bytes)
|
||||
messageDigest.update(buffer, 0, bytes)
|
||||
bytes = inputStream.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
return messageDigest.digest().encodeBase64()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of Base64 encoded SHA-256 signature hashes.
|
||||
*/
|
||||
fun SigningInfo?.getSignatures(): List<String> {
|
||||
return if (this == null) {
|
||||
emptyList()
|
||||
} else if (hasMultipleSigners()) {
|
||||
apkContentsSigners.map { signature ->
|
||||
hashSignature(signature).encodeBase64()
|
||||
}
|
||||
} else {
|
||||
signingCertificateHistory.map { signature ->
|
||||
hashSignature(signature).encodeBase64()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ val installModule = module {
|
|||
factory { DeviceInfo(androidContext()) }
|
||||
factory { ApkSplitCompatibilityChecker(get()) }
|
||||
factory {
|
||||
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) {
|
||||
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) {
|
||||
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
|
|||
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)
|
||||
appInfo.visibility = GONE
|
||||
when (item.state) {
|
||||
IN_PROGRESS -> {
|
||||
|
|
|
@ -9,7 +9,6 @@ import android.annotation.StringRes
|
|||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
@ -30,8 +29,6 @@ import com.stevesoltys.seedvault.ui.notification.getAppName
|
|||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import java.util.Locale
|
||||
|
||||
private const val TAG = "AppListRetriever"
|
||||
|
||||
sealed class AppListItem
|
||||
|
||||
data class AppStatus(
|
||||
|
@ -62,7 +59,6 @@ internal class AppListRetriever(
|
|||
val appListSections = linkedMapOf(
|
||||
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
|
||||
AppSectionTitle(R.string.backup_section_user) to getApps(),
|
||||
AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
|
||||
).filter { it.value.isNotEmpty() }
|
||||
|
||||
return appListSections.flatMap { (sectionTitle, appList) ->
|
||||
|
@ -81,8 +77,7 @@ internal class AppListRetriever(
|
|||
AppStatus(
|
||||
packageName = packageName,
|
||||
enabled = settingsManager.isBackupEnabled(packageName),
|
||||
icon = data.iconRes?.let { getDrawable(context, it) }
|
||||
?: getIconFromPackageManager(packageName),
|
||||
icon = getDrawable(context, data.iconRes) ?: getIconFromPackageManager(packageName),
|
||||
name = context.getString(data.nameRes),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
|
@ -99,14 +94,11 @@ internal class AppListRetriever(
|
|||
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
||||
val time = metadata?.time ?: 0
|
||||
val status = metadata?.state.toAppBackupState()
|
||||
if (status == NOT_YET_BACKED_UP) {
|
||||
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
||||
}
|
||||
AppStatus(
|
||||
packageName = it.packageName,
|
||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||
icon = getIconFromPackageManager(it.packageName),
|
||||
name = getAppName(context, it.packageName).toString(),
|
||||
name = metadata?.name?.toString() ?: getAppName(context, it.packageName).toString(),
|
||||
time = time,
|
||||
size = metadata?.size,
|
||||
status = status,
|
||||
|
@ -121,7 +113,8 @@ internal class AppListRetriever(
|
|||
packageName = packageName,
|
||||
enabled = settingsManager.isBackupEnabled(packageName),
|
||||
icon = getIconFromPackageManager(packageName),
|
||||
name = it.loadLabel(context.packageManager).toString(),
|
||||
name = metadata?.name?.toString()
|
||||
?: it.loadLabel(context.packageManager).toString(),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
status = metadata?.state.toAppBackupState(),
|
||||
|
@ -129,21 +122,6 @@ internal class AppListRetriever(
|
|||
}).sortedBy { it.name.lowercase(locale) }
|
||||
}
|
||||
|
||||
private fun getNotAllowedApps(): List<AppStatus> {
|
||||
val locale = Locale.getDefault()
|
||||
return packageService.userNotAllowedApps.map {
|
||||
AppStatus(
|
||||
packageName = it.packageName,
|
||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||
icon = getIconFromPackageManager(it.packageName),
|
||||
name = getAppName(context, it.packageName).toString(),
|
||||
time = 0,
|
||||
size = null,
|
||||
status = FAILED_NOT_ALLOWED,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase(locale) }
|
||||
}
|
||||
|
||||
private fun getIconFromPackageManager(packageName: String): Drawable = try {
|
||||
pm.getApplicationIcon(packageName)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||
|
@ -24,7 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||
import com.stevesoltys.seedvault.ui.toRelativeTime
|
||||
|
@ -114,13 +115,7 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
|||
startActivity(context, intent, null)
|
||||
true
|
||||
}
|
||||
if (item.status == FAILED_NOT_ALLOWED) {
|
||||
appStatus.visibility = INVISIBLE
|
||||
progressBar.visibility = INVISIBLE
|
||||
appInfo.visibility = GONE
|
||||
} else {
|
||||
setState(item.status, false)
|
||||
}
|
||||
if (item.status == SUCCEEDED) {
|
||||
appInfo.text = if (item.size == null) {
|
||||
item.time.toRelativeTime(context)
|
||||
|
@ -129,7 +124,17 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
|||
" (${formatShortFileSize(v.context, item.size)})"
|
||||
}
|
||||
appInfo.visibility = VISIBLE
|
||||
} else if (item.status == FAILED_WAS_STOPPED && item.time > 0) {
|
||||
@SuppressLint("SetTextI18n")
|
||||
appInfo.text = if (item.size == null) {
|
||||
item.time.toRelativeTime(context).toString()
|
||||
} else {
|
||||
item.time.toRelativeTime(context).toString() +
|
||||
" (${formatShortFileSize(v.context, item.size)})"
|
||||
} + "\n${item.status.getBackupText(context)}"
|
||||
appInfo.visibility = VISIBLE
|
||||
}
|
||||
// setState() above sets appInfo state for other cases already
|
||||
checkBox.visibility = INVISIBLE
|
||||
}
|
||||
// show disabled items differently
|
||||
|
|
|
@ -5,11 +5,14 @@
|
|||
|
||||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.app.backup.IBackupManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.Preference.OnPreferenceChangeListener
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import androidx.preference.TwoStatePreference
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.mms.ContentType.TEXT_PLAIN
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
|
@ -21,6 +24,9 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
|||
|
||||
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||
private val packageService: PackageService by inject()
|
||||
private val backupManager: IBackupManager by inject()
|
||||
|
||||
private lateinit var apkBackup: TwoStatePreference
|
||||
|
||||
private val createFileLauncher =
|
||||
registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri ->
|
||||
|
@ -32,6 +38,25 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
|||
setPreferencesFromResource(R.xml.settings_expert, rootKey)
|
||||
}
|
||||
|
||||
apkBackup = findPreference(PREF_KEY_BACKUP_APK)!!
|
||||
apkBackup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
val enable = newValue as Boolean
|
||||
if (enable) return@OnPreferenceChangeListener true
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setTitle(R.string.settings_backup_apk_dialog_title)
|
||||
.setMessage(R.string.settings_backup_apk_dialog_message)
|
||||
.setPositiveButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.settings_backup_apk_dialog_disable) { dialog, _ ->
|
||||
apkBackup.isChecked = false
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
return@OnPreferenceChangeListener false
|
||||
}
|
||||
|
||||
findPreference<Preference>("logcat")?.setOnPreferenceClickListener {
|
||||
val versionName = packageService.getVersionName(requireContext().packageName) ?: "ver"
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
@ -39,29 +64,11 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
|||
createFileLauncher.launch(name)
|
||||
true
|
||||
}
|
||||
|
||||
val quotaPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_UNLIMITED_QUOTA)
|
||||
|
||||
quotaPreference?.setOnPreferenceChangeListener { _, newValue ->
|
||||
quotaPreference.isChecked = newValue as Boolean
|
||||
true
|
||||
}
|
||||
|
||||
val d2dPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_D2D_BACKUPS)
|
||||
|
||||
d2dPreference?.setOnPreferenceChangeListener { _, newValue ->
|
||||
d2dPreference.isChecked = newValue as Boolean
|
||||
|
||||
// automatically enable unlimited quota when enabling D2D backups
|
||||
if (d2dPreference.isChecked) {
|
||||
quotaPreference?.isChecked = true
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activity?.setTitle(R.string.settings_expert_title)
|
||||
apkBackup.isEnabled = backupManager.isBackupEnabled
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,8 @@ import androidx.preference.PreferenceManager
|
|||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.settings.preference.M3ListPreference
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
|
|
@ -47,7 +47,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
|
||||
private lateinit var backup: TwoStatePreference
|
||||
private lateinit var autoRestore: TwoStatePreference
|
||||
private lateinit var apkBackup: TwoStatePreference
|
||||
private lateinit var backupLocation: Preference
|
||||
private lateinit var backupStatus: Preference
|
||||
private lateinit var backupScheduling: Preference
|
||||
|
@ -121,24 +120,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
apkBackup = findPreference(PREF_KEY_BACKUP_APK)!!
|
||||
apkBackup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
val enable = newValue as Boolean
|
||||
if (enable) return@OnPreferenceChangeListener true
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setIcon(R.drawable.ic_warning)
|
||||
.setTitle(R.string.settings_backup_apk_dialog_title)
|
||||
.setMessage(R.string.settings_backup_apk_dialog_message)
|
||||
.setPositiveButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.settings_backup_apk_dialog_disable) { dialog, _ ->
|
||||
apkBackup.isChecked = false
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
return@OnPreferenceChangeListener false
|
||||
}
|
||||
backupStatus = findPreference("backup_status")!!
|
||||
backupScheduling = findPreference("backup_scheduling")!!
|
||||
|
||||
|
|
|
@ -10,10 +10,11 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
|||
import android.hardware.usb.UsbDevice
|
||||
import android.net.Uri
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler.Companion.createWebDavProperties
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||
|
@ -54,25 +55,19 @@ private const val PREF_KEY_WEBDAV_PASS = "webDavPass"
|
|||
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
|
||||
|
||||
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
|
||||
internal const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
|
||||
internal const val PREF_KEY_D2D_BACKUPS = "d2d_backups"
|
||||
internal const val PREF_KEY_LAST_BACKUP = "lastBackup"
|
||||
|
||||
class SettingsManager(private val context: Context) {
|
||||
|
||||
private val prefs = permitDiskReads {
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
}
|
||||
private val mLastBackupTime = MutableLiveData(prefs.getLong(PREF_KEY_LAST_BACKUP, -1))
|
||||
|
||||
@Volatile
|
||||
private var token: Long? = null
|
||||
|
||||
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
/**
|
||||
* Returns a LiveData of the last backup time in unix epoch milli seconds.
|
||||
*/
|
||||
internal val lastBackupTime: LiveData<Long> = mLastBackupTime
|
||||
|
||||
/**
|
||||
* This gets accessed by non-UI threads when saving with [PreferenceManager]
|
||||
|
@ -83,17 +78,9 @@ class SettingsManager(private val context: Context) {
|
|||
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
|
||||
}
|
||||
|
||||
fun getToken(): Long? = token ?: run {
|
||||
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
|
||||
if (value == 0L) null else value
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new RestoreSet token.
|
||||
* Should only be called by the [BackupCoordinator]
|
||||
* to ensure that related work is performed after moving to a new token.
|
||||
*/
|
||||
fun setNewToken(newToken: Long?) {
|
||||
@Volatile
|
||||
var token: Long? = null
|
||||
private set(newToken) {
|
||||
if (newToken == null) {
|
||||
prefs.edit()
|
||||
.remove(PREF_KEY_TOKEN)
|
||||
|
@ -103,8 +90,13 @@ class SettingsManager(private val context: Context) {
|
|||
.putLong(PREF_KEY_TOKEN, newToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
token = newToken
|
||||
field = newToken
|
||||
}
|
||||
// we may be able to get this from latest snapshot,
|
||||
// but that is not always readily available
|
||||
get() = field ?: run {
|
||||
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
|
||||
if (value == 0L) null else value
|
||||
}
|
||||
|
||||
internal val storagePluginType: StoragePluginType?
|
||||
|
@ -128,6 +120,21 @@ class SettingsManager(private val context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun onSuccessfulBackupCompleted(token: Long) {
|
||||
this.token = token
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(PREF_KEY_LAST_BACKUP, now).apply()
|
||||
mLastBackupTime.postValue(now)
|
||||
}
|
||||
|
||||
fun setStorageBackend(plugin: Backend) {
|
||||
val value = when (plugin) {
|
||||
is SafBackend -> StoragePluginType.SAF
|
||||
|
@ -238,15 +245,7 @@ class SettingsManager(private val context: Context) {
|
|||
prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply()
|
||||
}
|
||||
|
||||
fun isQuotaUnlimited() = prefs.getBoolean(PREF_KEY_UNLIMITED_QUOTA, false)
|
||||
|
||||
fun d2dBackupsEnabled() = prefs.getBoolean(PREF_KEY_D2D_BACKUPS, false)
|
||||
|
||||
fun setD2dBackupsEnabled(enabled: Boolean) {
|
||||
prefs.edit()
|
||||
.putBoolean(PREF_KEY_D2D_BACKUPS, enabled)
|
||||
.apply()
|
||||
}
|
||||
val quota: Long = 1024 * 1024 * 1024 // 1 GiB for now
|
||||
|
||||
/**
|
||||
* This assumes that if there's no storage plugin set, it is the first start.
|
||||
|
|
|
@ -35,12 +35,10 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
|||
import androidx.work.WorkManager
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
|
@ -68,11 +66,9 @@ internal class SettingsViewModel(
|
|||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
backendManager: BackendManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val appListRetriever: AppListRetriever,
|
||||
private val storageBackup: StorageBackup,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
backupStateManager: BackupStateManager,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager) {
|
||||
|
||||
|
@ -88,7 +84,7 @@ internal class SettingsViewModel(
|
|||
private val mBackupPossible = MutableLiveData(false)
|
||||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||
|
||||
internal val lastBackupTime = metadataManager.lastBackupTime
|
||||
internal val lastBackupTime = settingsManager.lastBackupTime
|
||||
internal val appBackupWorkInfo =
|
||||
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
|
||||
it.getOrNull(0)
|
||||
|
@ -143,8 +139,6 @@ internal class SettingsViewModel(
|
|||
initialValue = false,
|
||||
)
|
||||
scope.launch {
|
||||
// ensures the lastBackupTime LiveData gets set
|
||||
metadataManager.getLastBackupTime()
|
||||
// update running state
|
||||
isBackupRunning.collect {
|
||||
onBackupRunningStateChanged()
|
||||
|
@ -258,21 +252,6 @@ internal class SettingsViewModel(
|
|||
|
||||
fun onBackupEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
if (metadataManager.requiresInit) {
|
||||
val onError: () -> Unit = {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val res = R.string.storage_check_fragment_backup_error
|
||||
Toast.makeText(app, res, LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
backupInitializer.initialize(onError) {
|
||||
mInitEvent.postEvent(false)
|
||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
||||
}
|
||||
mInitEvent.postEvent(true)
|
||||
}
|
||||
}
|
||||
// enable call log backups for existing installs (added end of 2020)
|
||||
enableCallLogBackup()
|
||||
} else {
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ import android.os.ParcelFileDescriptor
|
|||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -43,7 +42,6 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
|||
|
||||
private val backupCoordinator by inject<BackupCoordinator>()
|
||||
private val restoreCoordinator by inject<RestoreCoordinator>()
|
||||
private val settingsManager by inject<SettingsManager>()
|
||||
|
||||
override fun transportDirName(): String {
|
||||
return TRANSPORT_DIRECTORY_NAME
|
||||
|
@ -62,13 +60,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
|||
* which is accessible to the BackupAgent.
|
||||
* This allows the agent to decide what to do based on properties of the transport.
|
||||
*/
|
||||
override fun getTransportFlags(): Int {
|
||||
return if (settingsManager.d2dBackupsEnabled()) {
|
||||
D2D_TRANSPORT_FLAGS
|
||||
} else {
|
||||
DEFAULT_TRANSPORT_FLAGS
|
||||
}
|
||||
}
|
||||
override fun getTransportFlags(): Int = D2D_TRANSPORT_FLAGS
|
||||
|
||||
/**
|
||||
* Ask the transport for an [Intent] that can be used to launch
|
||||
|
@ -135,8 +127,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
|||
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
||||
}
|
||||
|
||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
|
||||
backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||
}
|
||||
|
||||
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
|
||||
|
|
|
@ -11,9 +11,12 @@ import android.content.Intent
|
|||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
|
@ -34,6 +37,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
|
||||
private val keyManager: KeyManager by inject()
|
||||
private val backupManager: IBackupManager by inject()
|
||||
private val appBackupManager: AppBackupManager by inject()
|
||||
private val notificationManager: BackupNotificationManager by inject()
|
||||
|
||||
override fun onCreate() {
|
||||
|
@ -45,7 +49,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
// refuse to work until we have the main key
|
||||
val noMainKey = keyManager.hasBackupKey() && !keyManager.hasMainKey()
|
||||
val noMainKey = permitDiskReads { keyManager.hasBackupKey() && !keyManager.hasMainKey() }
|
||||
if (noMainKey && backupManager.currentTransport == TRANSPORT_ID) {
|
||||
notificationManager.onNoMainKeyError()
|
||||
backupManager.isBackupEnabled = false
|
||||
|
@ -61,6 +65,11 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
notificationManager.onServiceDestroyed()
|
||||
transport = null
|
||||
mIsRunning.value = false
|
||||
runBlocking {
|
||||
// This is a hack for `adb shell bmgr backupnow`. Better would be a foreground service,
|
||||
// but since this isn't a typical use-case we don't bother for now.
|
||||
appBackupManager.finalizeBackupIfNeeded()
|
||||
}
|
||||
Log.d(TAG, "Service destroyed.")
|
||||
}
|
||||
|
||||
|
|
|
@ -15,25 +15,23 @@ import android.app.backup.BackupTransport.TRANSPORT_NOT_INITIALIZED
|
|||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
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.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.getMetadataOutputStream
|
||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import org.calyxos.seedvault.core.backends.isOutOfSpace
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit.DAYS
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
|
@ -63,42 +61,30 @@ private class CoordinatorState(
|
|||
internal class BackupCoordinator(
|
||||
private val context: Context,
|
||||
private val backendManager: BackendManager,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val kv: KVBackup,
|
||||
private val full: FullBackup,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val nm: BackupNotificationManager,
|
||||
) {
|
||||
|
||||
private val backend get() = backendManager.backend
|
||||
private val snapshotCreator
|
||||
get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||
private val state = CoordinatorState(
|
||||
calledInitialize = false,
|
||||
calledClearBackupData = false,
|
||||
cancelReason = UNKNOWN_ERROR
|
||||
)
|
||||
private val launchableSystemApps by lazy {
|
||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Transport initialization and quota
|
||||
//
|
||||
|
||||
/**
|
||||
* Starts a new [RestoreSet] with a new token (the current unix epoch in milliseconds).
|
||||
* Call this at least once before calling [initializeDevice]
|
||||
* which must be called after this method to properly initialize the backup transport.
|
||||
*
|
||||
* @return the token of the new [RestoreSet].
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private suspend fun startNewRestoreSet() {
|
||||
val token = clock.time()
|
||||
Log.i(TAG, "Starting new RestoreSet with token $token...")
|
||||
settingsManager.setNewToken(token)
|
||||
Log.d(TAG, "Resetting backup metadata...")
|
||||
metadataManager.onDeviceInitialization(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the storage for this device, erasing all stored data.
|
||||
* The transport may send the request immediately, or may buffer it.
|
||||
|
@ -107,6 +93,8 @@ internal class BackupCoordinator(
|
|||
*
|
||||
* If the transport returns anything other than [TRANSPORT_OK] from this method,
|
||||
* the OS will halt the current initialize operation and schedule a retry in the near future.
|
||||
* Attention: [finishBackup] will not be called in this case.
|
||||
*
|
||||
* Even if the transport is in a state
|
||||
* such that attempting to "initialize" the backend storage is meaningless -
|
||||
* for example, if there is no current live data-set at all,
|
||||
|
@ -117,20 +105,11 @@ internal class BackupCoordinator(
|
|||
* @return One of [TRANSPORT_OK] (OK so far) or
|
||||
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
||||
*/
|
||||
suspend fun initializeDevice(): Int = try {
|
||||
// we don't respect the intended system behavior here by always starting a new [RestoreSet]
|
||||
// instead of simply deleting the current one
|
||||
startNewRestoreSet()
|
||||
fun initializeDevice(): Int {
|
||||
Log.i(TAG, "Initialize Device!")
|
||||
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
|
||||
// so we remember that we initialized successfully
|
||||
// we don't respect the intended system behavior of erasing all stored data
|
||||
state.calledInitialize = true
|
||||
TRANSPORT_OK
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error initializing device", e)
|
||||
// Show error notification if we needed init or were ready for backups
|
||||
if (metadataManager.requiresInit || backendManager.canDoBackupNow()) nm.onBackupError()
|
||||
TRANSPORT_ERROR
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
||||
fun isAppEligibleForBackup(
|
||||
|
@ -151,10 +130,15 @@ internal class BackupCoordinator(
|
|||
* otherwise for key-value backup.
|
||||
* @return Current limit on backup size in bytes.
|
||||
*/
|
||||
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
// report back quota
|
||||
suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
|
||||
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
|
||||
|
||||
if (!isFullBackup) {
|
||||
// hack for `adb shell bmgr backupnow`
|
||||
// which starts with a K/V backup calling this method, so we hook in here
|
||||
appBackupManager.ensureBackupPrepared()
|
||||
}
|
||||
val quota = settingsManager.quota
|
||||
Log.i(TAG, "Reported quota of $quota bytes.")
|
||||
return quota
|
||||
}
|
||||
|
@ -214,24 +198,13 @@ internal class BackupCoordinator(
|
|||
* [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to
|
||||
* inactivity purge or some other reason and needs re-initializing)
|
||||
*/
|
||||
suspend fun performIncrementalBackup(
|
||||
fun performIncrementalBackup(
|
||||
packageInfo: PackageInfo,
|
||||
data: ParcelFileDescriptor,
|
||||
flags: Int,
|
||||
): Int {
|
||||
state.cancelReason = UNKNOWN_ERROR
|
||||
if (metadataManager.requiresInit) {
|
||||
Log.w(TAG, "Metadata requires re-init!")
|
||||
// Tell the system that we are not initialized, it will initialize us afterwards.
|
||||
// This will start a new restore set to upgrade from legacy format
|
||||
// by starting a clean backup with all files using the new version.
|
||||
//
|
||||
// This causes a backup error, but things should go back to normal afterwards.
|
||||
return TRANSPORT_NOT_INITIALIZED
|
||||
}
|
||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
||||
val salt = metadataManager.salt
|
||||
return kv.performBackup(packageInfo, data, flags, token, salt)
|
||||
return kv.performBackup(packageInfo, data, flags)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
@ -262,15 +235,13 @@ internal class BackupCoordinator(
|
|||
return result
|
||||
}
|
||||
|
||||
suspend fun performFullBackup(
|
||||
fun performFullBackup(
|
||||
targetPackage: PackageInfo,
|
||||
fileDescriptor: ParcelFileDescriptor,
|
||||
flags: Int,
|
||||
): Int {
|
||||
state.cancelReason = UNKNOWN_ERROR
|
||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
||||
val salt = metadataManager.salt
|
||||
return full.performFullBackup(targetPackage, fileDescriptor, flags, token, salt)
|
||||
return full.performFullBackup(targetPackage, fileDescriptor, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -299,18 +270,17 @@ internal class BackupCoordinator(
|
|||
* It needs to tear down any ongoing backup state here.
|
||||
*/
|
||||
suspend fun cancelFullBackup() {
|
||||
val packageInfo = full.getCurrentPackage()
|
||||
?: throw AssertionError("Cancelling full backup, but no current package")
|
||||
Log.i(
|
||||
TAG, "Cancel full backup of ${packageInfo.packageName}" +
|
||||
" because of ${state.cancelReason}"
|
||||
)
|
||||
// don't bother with system apps that have no data
|
||||
val ignoreApp = state.cancelReason == NO_DATA && packageInfo.isSystemApp()
|
||||
val packageInfo = full.currentPackageInfo
|
||||
?: error("Cancelling full backup, but no current package")
|
||||
val packageName = packageInfo.packageName
|
||||
Log.i(TAG, "Cancel full backup of $packageName because of ${state.cancelReason}")
|
||||
// don't bother with remembering state for boring system apps that have no data
|
||||
val ignoreApp = state.cancelReason == NO_DATA &&
|
||||
packageInfo.isSystemApp() &&
|
||||
packageName !in systemData.keys && // don't ignore our special system apps
|
||||
packageName !in launchableSystemApps // don't ignore launchable system apps
|
||||
if (!ignoreApp) onPackageBackupError(packageInfo, BackupType.FULL)
|
||||
val token = settingsManager.getToken() ?: error("no token in cancelFullBackup")
|
||||
val salt = metadataManager.salt
|
||||
full.cancelFullBackup(token, salt, ignoreApp)
|
||||
full.cancelFullBackup()
|
||||
}
|
||||
|
||||
// Clear and Finish
|
||||
|
@ -324,23 +294,9 @@ internal class BackupCoordinator(
|
|||
*
|
||||
* @return the same error codes as [performFullBackup].
|
||||
*/
|
||||
suspend fun clearBackupData(packageInfo: PackageInfo): Int {
|
||||
val packageName = packageInfo.packageName
|
||||
Log.i(TAG, "Clear Backup Data of $packageName.")
|
||||
val token = settingsManager.getToken() ?: error("no token in clearBackupData")
|
||||
val salt = metadataManager.salt
|
||||
try {
|
||||
kv.clearBackupData(packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
|
||||
return TRANSPORT_ERROR
|
||||
}
|
||||
try {
|
||||
full.clearBackupData(packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error clearing full backup data for $packageName", e)
|
||||
return TRANSPORT_ERROR
|
||||
}
|
||||
fun clearBackupData(packageInfo: PackageInfo): Int {
|
||||
Log.i(TAG, "Ignoring clear backup data of ${packageInfo.packageName}.")
|
||||
// we don't clear backup data anymore, we have snapshots and those old ones stay valid
|
||||
state.calledClearBackupData = true
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
@ -354,49 +310,43 @@ internal class BackupCoordinator(
|
|||
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
|
||||
*/
|
||||
suspend fun finishBackup(): Int = when {
|
||||
kv.hasState() -> {
|
||||
check(!full.hasState()) {
|
||||
kv.hasState -> {
|
||||
check(!full.hasState) {
|
||||
"K/V backup has state, but full backup has dangling state as well"
|
||||
}
|
||||
// getCurrentPackage() not-null because we have state, call before finishing
|
||||
val packageInfo = kv.getCurrentPackage()!!
|
||||
val packageInfo = kv.currentPackageInfo!!
|
||||
val packageName = packageInfo.packageName
|
||||
val size = kv.getCurrentSize()
|
||||
// tell K/V backup to finish
|
||||
var result = kv.finishBackup()
|
||||
if (result == TRANSPORT_OK) {
|
||||
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
||||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
||||
if (isNormalBackup || backendManager.canDoBackupNow()) {
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||
// tell K/V backup to finish
|
||||
val backupData = kv.finishBackup()
|
||||
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, backupData)
|
||||
TRANSPORT_OK
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
||||
Log.e(TAG, "Error finishing K/V backup for $packageName", e)
|
||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||
result = TRANSPORT_PACKAGE_REJECTED
|
||||
onPackageBackupError(packageInfo, BackupType.KV)
|
||||
TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
full.hasState() -> {
|
||||
check(!kv.hasState()) {
|
||||
full.hasState -> {
|
||||
check(!kv.hasState) {
|
||||
"Full backup has state, but K/V backup has dangling state as well"
|
||||
}
|
||||
// getCurrentPackage() not-null because we have state
|
||||
val packageInfo = full.getCurrentPackage()!!
|
||||
val packageInfo = full.currentPackageInfo!!
|
||||
val packageName = packageInfo.packageName
|
||||
val size = full.getCurrentSize()
|
||||
// tell full backup to finish
|
||||
var result = full.finishBackup()
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.FULL, size)
|
||||
val backupData = full.finishBackup()
|
||||
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.FULL, backupData)
|
||||
TRANSPORT_OK
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||
result = TRANSPORT_PACKAGE_REJECTED
|
||||
onPackageBackupError(packageInfo, BackupType.FULL)
|
||||
TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
result
|
||||
}
|
||||
state.expectFinish -> {
|
||||
state.onFinish()
|
||||
|
@ -405,20 +355,10 @@ internal class BackupCoordinator(
|
|||
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
||||
}
|
||||
|
||||
private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
|
||||
val token = settingsManager.getToken() ?: error("no token")
|
||||
backend.getMetadataOutputStream(token).use {
|
||||
metadataManager.onPackageBackedUp(packageInfo, type, size, it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
|
||||
private fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
|
||||
val packageName = packageInfo.packageName
|
||||
try {
|
||||
val token = settingsManager.getToken() ?: error("no token")
|
||||
backend.getMetadataOutputStream(token).use {
|
||||
metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it, type)
|
||||
}
|
||||
metadataManager.onPackageBackupError(packageInfo, state.cancelReason, type)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
||||
}
|
||||
|
|
|
@ -11,8 +11,6 @@ import android.app.backup.IBackupObserver
|
|||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
|
||||
class BackupInitializer(
|
||||
|
@ -25,17 +23,7 @@ class BackupInitializer(
|
|||
|
||||
fun initialize(onError: () -> Unit, onSuccess: () -> Unit) {
|
||||
val observer = BackupObserver("Initialization", onError) {
|
||||
// After successful initialization, we request a @pm@ backup right away,
|
||||
// because if this finds empty state, it asks us to do another initialization.
|
||||
// And then we end up with yet another restore set token.
|
||||
// Since we want the final token as soon as possible, we need to get it here.
|
||||
Log.d(TAG, "Requesting initial $MAGIC_PACKAGE_MANAGER backup...")
|
||||
backupManager.requestBackup(
|
||||
arrayOf(MAGIC_PACKAGE_MANAGER),
|
||||
BackupObserver("Initial backup of @pm@", onError, onSuccess),
|
||||
BackupMonitor(),
|
||||
0,
|
||||
)
|
||||
onSuccess()
|
||||
}
|
||||
backupManager.initializeTransportsForUser(
|
||||
UserHandle.myUserId(),
|
||||
|
|
|
@ -9,12 +9,12 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
|
||||
val backupModule = module {
|
||||
factory { BackupTransportMonitor(get(), get()) }
|
||||
single { BackupInitializer(get()) }
|
||||
single { InputFactory() }
|
||||
single {
|
||||
PackageService(
|
||||
context = androidContext(),
|
||||
backupManager = get(),
|
||||
settingsManager = get(),
|
||||
backendManager = get(),
|
||||
)
|
||||
|
@ -22,30 +22,26 @@ val backupModule = module {
|
|||
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
||||
single {
|
||||
KVBackup(
|
||||
backendManager = get(),
|
||||
settingsManager = get(),
|
||||
nm = get(),
|
||||
backupReceiver = get(),
|
||||
inputFactory = get(),
|
||||
crypto = get(),
|
||||
dbManager = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
FullBackup(
|
||||
backendManager = get(),
|
||||
settingsManager = get(),
|
||||
nm = get(),
|
||||
backupReceiver = get(),
|
||||
inputFactory = get(),
|
||||
crypto = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
BackupCoordinator(
|
||||
context = androidContext(),
|
||||
backendManager = get(),
|
||||
appBackupManager = get(),
|
||||
kv = get(),
|
||||
full = get(),
|
||||
clock = get(),
|
||||
packageService = get(),
|
||||
metadataManager = get(),
|
||||
settingsManager = get(),
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import android.app.backup.BackupManagerMonitor.LOG_EVENT_CATEGORY_BACKUP_MANAGER_POLICY
|
||||
import android.app.backup.BackupManagerMonitor.LOG_EVENT_ID_NO_DATA_TO_SEND
|
||||
import android.os.Bundle
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.SnapshotManager
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
|
||||
internal class BackupTransportMonitor(
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
) : BackupMonitor() {
|
||||
|
||||
private val log = KotlinLogging.logger { }
|
||||
|
||||
override fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
|
||||
super.onEvent(id, category, packageName, bundle)
|
||||
if (packageName != null && id == LOG_EVENT_ID_NO_DATA_TO_SEND &&
|
||||
category == LOG_EVENT_CATEGORY_BACKUP_MANAGER_POLICY
|
||||
) {
|
||||
sendNoDataChanged(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoDataChanged(packageName: String) {
|
||||
log.info { "sendNoDataChanged($packageName)" }
|
||||
|
||||
val snapshot = snapshotManager.latestSnapshot
|
||||
if (snapshot == null) {
|
||||
log.error { "No latest snapshot!" }
|
||||
} else {
|
||||
val snapshotCreator = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||
snapshotCreator.onNoDataInCurrentRun(snapshot, packageName)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,65 +13,46 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||
import com.stevesoltys.seedvault.repo.BackupData
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import org.calyxos.seedvault.core.backends.isOutOfSpace
|
||||
import java.io.Closeable
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
private class FullBackupState(
|
||||
val packageInfo: PackageInfo,
|
||||
val inputFileDescriptor: ParcelFileDescriptor,
|
||||
val inputStream: InputStream,
|
||||
var outputStreamInit: (suspend () -> OutputStream)?,
|
||||
) {
|
||||
/**
|
||||
* This is an encrypted stream that can be written to directly.
|
||||
*/
|
||||
var outputStream: OutputStream? = null
|
||||
val packageName: String = packageInfo.packageName
|
||||
var size: Long = 0
|
||||
}
|
||||
|
||||
const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
|
||||
|
||||
private val TAG = FullBackup::class.java.simpleName
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class FullBackup(
|
||||
private val backendManager: BackendManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val nm: BackupNotificationManager,
|
||||
private val backupReceiver: BackupReceiver,
|
||||
private val inputFactory: InputFactory,
|
||||
private val crypto: Crypto,
|
||||
) {
|
||||
|
||||
private val backend get() = backendManager.backend
|
||||
private var state: FullBackupState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
|
||||
fun getCurrentPackage() = state?.packageInfo
|
||||
|
||||
fun getCurrentSize() = state?.size
|
||||
|
||||
fun getQuota(): Long {
|
||||
return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else DEFAULT_QUOTA_FULL_BACKUP
|
||||
}
|
||||
val hasState: Boolean get() = state != null
|
||||
val currentPackageInfo get() = state?.packageInfo
|
||||
val quota get() = settingsManager.quota
|
||||
|
||||
fun checkFullBackupSize(size: Long): Int {
|
||||
Log.i(TAG, "Check full backup size of $size bytes.")
|
||||
return when {
|
||||
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
||||
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
|
||||
size > quota -> TRANSPORT_QUOTA_EXCEEDED
|
||||
else -> TRANSPORT_OK
|
||||
}
|
||||
}
|
||||
|
@ -111,71 +92,41 @@ internal class FullBackup(
|
|||
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
|
||||
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
|
||||
*/
|
||||
suspend fun performFullBackup(
|
||||
fun performFullBackup(
|
||||
targetPackage: PackageInfo,
|
||||
socket: ParcelFileDescriptor,
|
||||
@Suppress("UNUSED_PARAMETER") flags: Int = 0,
|
||||
token: Long,
|
||||
salt: String,
|
||||
): Int {
|
||||
if (state != null) throw AssertionError()
|
||||
if (state != null) error("state wasn't initialized for $targetPackage")
|
||||
val packageName = targetPackage.packageName
|
||||
Log.i(TAG, "Perform full backup for $packageName.")
|
||||
|
||||
// create new state
|
||||
val inputStream = inputFactory.getInputStream(socket)
|
||||
state = FullBackupState(targetPackage, socket, inputStream) {
|
||||
Log.d(TAG, "Initializing OutputStream for $packageName.")
|
||||
val name = crypto.getNameForPackage(salt, packageName)
|
||||
// get OutputStream to write backup data into
|
||||
val outputStream = try {
|
||||
backend.save(LegacyAppBackupFile.Blob(token, name))
|
||||
} catch (e: IOException) {
|
||||
"Error getting OutputStream for full backup of $packageName".let {
|
||||
Log.e(TAG, it, e)
|
||||
}
|
||||
throw(e)
|
||||
}
|
||||
// store version header
|
||||
val state = this.state ?: throw AssertionError()
|
||||
outputStream.write(ByteArray(1) { VERSION })
|
||||
crypto.newEncryptingStream(outputStream, getADForFull(VERSION, state.packageName))
|
||||
} // this lambda is only called before we actually write backup data the first time
|
||||
state = FullBackupState(targetPackage, socket, inputStream)
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
||||
suspend fun sendBackupData(numBytes: Int): Int {
|
||||
val state = this.state
|
||||
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
|
||||
val state = this.state ?: error("Attempted sendBackupData before performFullBackup")
|
||||
|
||||
// check if size fits quota
|
||||
state.size += numBytes
|
||||
val quota = getQuota()
|
||||
if (state.size > quota) {
|
||||
val newSize = state.size + numBytes
|
||||
if (newSize > quota) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Full backup of additional $numBytes exceeds quota of $quota with ${state.size}."
|
||||
"Full backup of additional $numBytes exceeds quota of $quota with $newSize."
|
||||
)
|
||||
return TRANSPORT_QUOTA_EXCEEDED
|
||||
}
|
||||
|
||||
return try {
|
||||
// get output stream or initialize it, if it does not yet exist
|
||||
check((state.outputStream != null) xor (state.outputStreamInit != null)) {
|
||||
"No OutputStream xor no StreamGetter"
|
||||
}
|
||||
val outputStream = state.outputStream ?: suspend {
|
||||
val stream = state.outputStreamInit!!() // not-null due to check above
|
||||
state.outputStream = stream
|
||||
stream
|
||||
}()
|
||||
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
|
||||
|
||||
// read backup data and write it to encrypted output stream
|
||||
val payload = ByteArray(numBytes)
|
||||
val read = state.inputStream.read(payload, 0, numBytes)
|
||||
if (read != numBytes) throw EOFException("Read $read bytes instead of $numBytes.")
|
||||
outputStream.write(payload)
|
||||
backupReceiver.addBytes(getOwner(state.packageName), payload)
|
||||
state.size += numBytes
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
|
||||
|
@ -184,44 +135,44 @@ internal class FullBackup(
|
|||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
|
||||
val name = crypto.getNameForPackage(salt, packageInfo.packageName)
|
||||
backend.remove(LegacyAppBackupFile.Blob(token, name))
|
||||
}
|
||||
|
||||
suspend fun cancelFullBackup(token: Long, salt: String, ignoreApp: Boolean) {
|
||||
Log.i(TAG, "Cancel full backup")
|
||||
val state = this.state ?: throw AssertionError("No state when canceling")
|
||||
suspend fun cancelFullBackup() {
|
||||
val state = this.state ?: error("No state when canceling")
|
||||
Log.i(TAG, "Cancel full backup for ${state.packageName}")
|
||||
// finalize the receiver
|
||||
try {
|
||||
if (!ignoreApp) clearBackupData(state.packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e)
|
||||
backupReceiver.finalize(getOwner(state.packageName))
|
||||
} catch (e: Exception) {
|
||||
// as the backup was cancelled anyway, we don't care if finalizing had an error
|
||||
Log.e(TAG, "Error finalizing backup in cancelFullBackup().", e)
|
||||
}
|
||||
// If the transport receives this callback, it will *not* receive a call to [finishBackup].
|
||||
// It needs to tear down any ongoing backup state here.
|
||||
clearState()
|
||||
// TODO roll back to the previous known-good archive
|
||||
}
|
||||
|
||||
fun finishBackup(): Int {
|
||||
Log.i(TAG, "Finish full backup of ${state!!.packageName}. Wrote ${state!!.size} bytes")
|
||||
return clearState()
|
||||
/**
|
||||
* Returns a pair of the [BackupData] after finalizing last chunks and the total backup size.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun finishBackup(): BackupData {
|
||||
val state = this.state ?: error("No state when finishing")
|
||||
Log.i(TAG, "Finish full backup of ${state.packageName}. Wrote ${state.size} bytes")
|
||||
val result = try {
|
||||
backupReceiver.finalize(getOwner(state.packageName))
|
||||
} finally {
|
||||
clearState()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun clearState(): Int {
|
||||
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
|
||||
return try {
|
||||
state.outputStream?.flush()
|
||||
closeLogging(state.outputStream)
|
||||
private fun clearState() {
|
||||
val state = this.state ?: error("Trying to clear empty state.")
|
||||
closeLogging(state.inputStream)
|
||||
closeLogging(state.inputFileDescriptor)
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error when clearing state", e)
|
||||
TRANSPORT_ERROR
|
||||
} finally {
|
||||
this.state = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOwner(packageName: String) = "FullBackup $packageName"
|
||||
|
||||
private fun closeLogging(closable: Closeable?) = try {
|
||||
closable?.close()
|
||||
|
|
|
@ -14,122 +14,81 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForKV
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||
import com.stevesoltys.seedvault.repo.BackupData
|
||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||
import java.io.IOException
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
class KVBackupState(
|
||||
internal val packageInfo: PackageInfo,
|
||||
val token: Long,
|
||||
val name: String,
|
||||
val db: KVDb,
|
||||
) {
|
||||
var needsUpload: Boolean = false
|
||||
}
|
||||
|
||||
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
|
||||
)
|
||||
|
||||
private val TAG = KVBackup::class.java.simpleName
|
||||
|
||||
internal class KVBackup(
|
||||
private val backendManager: BackendManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val nm: BackupNotificationManager,
|
||||
private val backupReceiver: BackupReceiver,
|
||||
private val inputFactory: InputFactory,
|
||||
private val crypto: Crypto,
|
||||
private val dbManager: KvDbManager,
|
||||
) {
|
||||
|
||||
private val backend get() = backendManager.backend
|
||||
private var state: KVBackupState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
val hasState get() = state != null
|
||||
val currentPackageInfo get() = state?.packageInfo
|
||||
|
||||
fun getCurrentPackage() = state?.packageInfo
|
||||
|
||||
fun getCurrentSize() = getCurrentPackage()?.let {
|
||||
dbManager.getDbSize(it.packageName)
|
||||
}
|
||||
|
||||
fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
||||
}
|
||||
|
||||
suspend fun performBackup(
|
||||
fun performBackup(
|
||||
packageInfo: PackageInfo,
|
||||
data: ParcelFileDescriptor,
|
||||
flags: Int,
|
||||
token: Long,
|
||||
salt: String,
|
||||
): Int {
|
||||
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
|
||||
val isIncremental = flags and FLAG_INCREMENTAL != 0
|
||||
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
|
||||
val packageName = packageInfo.packageName
|
||||
|
||||
when {
|
||||
dataNotChanged -> {
|
||||
Log.i(TAG, "No K/V backup data has changed for $packageName")
|
||||
}
|
||||
isIncremental -> {
|
||||
Log.i(TAG, "Performing incremental K/V backup for $packageName")
|
||||
}
|
||||
isNonIncremental -> {
|
||||
Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Performing K/V backup for $packageName")
|
||||
}
|
||||
dataNotChanged -> Log.i(TAG, "No K/V backup data has changed for $packageName")
|
||||
isIncremental -> Log.i(TAG, "Performing incremental K/V backup for $packageName")
|
||||
isNonIncremental -> Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
|
||||
else -> Log.i(TAG, "Performing K/V backup for $packageName")
|
||||
}
|
||||
check(state == null) { "Have unexpected state for ${state?.packageInfo?.packageName}" }
|
||||
// This fake package name just signals that we've seen all packages without new data
|
||||
if (packageName == NO_DATA_END_SENTINEL) return TRANSPORT_OK
|
||||
|
||||
// initialize state
|
||||
val state = this.state
|
||||
if (state != null) {
|
||||
throw AssertionError("Have state for ${state.packageInfo.packageName}")
|
||||
}
|
||||
val name = crypto.getNameForPackage(salt, packageName)
|
||||
val db = dbManager.getDb(packageName)
|
||||
this.state = KVBackupState(packageInfo, token, name, db)
|
||||
state = KVBackupState(packageInfo = packageInfo, db = dbManager.getDb(packageName))
|
||||
|
||||
// no need for backup when no data has changed
|
||||
// handle case where data hasn't changed since last backup
|
||||
val hasDataForPackage = dbManager.existsDb(packageName)
|
||||
if (dataNotChanged) {
|
||||
data.close()
|
||||
return TRANSPORT_OK
|
||||
return if (hasDataForPackage) {
|
||||
TRANSPORT_OK
|
||||
} else {
|
||||
Log.w(TAG, "No previous data for $packageName, requesting non-incremental backup!")
|
||||
backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
||||
}
|
||||
}
|
||||
|
||||
// check if we have existing data for the given package
|
||||
val hasDataForPackage = dbManager.existsDb(packageName)
|
||||
if (isIncremental && !hasDataForPackage) {
|
||||
Log.w(
|
||||
TAG, "Requested incremental, but transport currently stores no data" +
|
||||
" for $packageName, requesting non-incremental retry."
|
||||
)
|
||||
data.close()
|
||||
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
||||
}
|
||||
|
||||
// TODO check if package is over-quota and respect unlimited setting
|
||||
|
||||
// check if we have existing data, but the system wants clean slate
|
||||
if (isNonIncremental && hasDataForPackage) {
|
||||
Log.w(TAG, "Requested non-incremental, deleting existing data.")
|
||||
try {
|
||||
clearBackupData(packageInfo, token, salt)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
|
||||
Log.w(TAG, "Requested non-incremental, deleting existing data...")
|
||||
dbManager.deleteDb(packageInfo.packageName)
|
||||
// KvBackupInstrumentationTest tells us that the DB gets re-created automatically
|
||||
}
|
||||
}
|
||||
|
||||
// parse and store the K/V updates
|
||||
return storeRecords(data)
|
||||
return data.use {
|
||||
storeRecords(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeRecords(data: ParcelFileDescriptor): Int {
|
||||
|
@ -140,18 +99,6 @@ internal class KVBackup(
|
|||
Log.e(TAG, "Exception reading backup input", result.exception)
|
||||
return backupError(TRANSPORT_ERROR)
|
||||
}
|
||||
state.needsUpload = if (state.packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
|
||||
// Don't upload, if we currently can't do backups.
|
||||
// If we tried, we would fail @pm@ backup which causes the system to do a re-init.
|
||||
// See: https://github.com/seedvault-app/seedvault/issues/102
|
||||
// K/V backups (typically starting with package manager metadata - @pm@)
|
||||
// are scheduled with JobInfo.Builder#setOverrideDeadline()
|
||||
// and thus do not respect backoff.
|
||||
backendManager.canDoBackupNow()
|
||||
} else {
|
||||
// all other packages always need upload
|
||||
true
|
||||
}
|
||||
val op = (result as Result.Ok).result
|
||||
if (op.value == null) {
|
||||
Log.e(TAG, "Deleting record with key ${op.key}")
|
||||
|
@ -205,27 +152,21 @@ internal class KVBackup(
|
|||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
|
||||
Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}")
|
||||
val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName)
|
||||
backend.remove(LegacyAppBackupFile.Blob(token, name))
|
||||
if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException()
|
||||
}
|
||||
|
||||
suspend fun finishBackup(): Int {
|
||||
suspend fun finishBackup(): BackupData {
|
||||
val state = this.state ?: error("No state in finishBackup")
|
||||
val packageName = state.packageInfo.packageName
|
||||
Log.i(TAG, "Finish K/V Backup of $packageName - needs upload: ${state.needsUpload}")
|
||||
val owner = "KV $packageName"
|
||||
Log.i(TAG, "Finish K/V Backup of $packageName")
|
||||
|
||||
return try {
|
||||
if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db)
|
||||
else state.db.close()
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error uploading DB", e)
|
||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||
TRANSPORT_ERROR
|
||||
} finally {
|
||||
try {
|
||||
state.db.vacuum()
|
||||
state.db.close()
|
||||
val backupData = dbManager.getDbInputStream(packageName).use { inputStream ->
|
||||
backupReceiver.readFromStream(owner, inputStream)
|
||||
}
|
||||
Log.d(TAG, "Uploaded db file for $packageName.")
|
||||
return backupData
|
||||
} finally { // exceptions bubble up
|
||||
this.state = null
|
||||
}
|
||||
}
|
||||
|
@ -240,36 +181,10 @@ internal class KVBackup(
|
|||
Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
|
||||
|
||||
state.db.close()
|
||||
|
||||
this.state = null
|
||||
return result
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private suspend fun uploadDb(
|
||||
token: Long,
|
||||
name: String,
|
||||
packageName: String,
|
||||
db: KVDb,
|
||||
) {
|
||||
db.vacuum()
|
||||
db.close()
|
||||
|
||||
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||
backend.save(handle).use { outputStream ->
|
||||
outputStream.write(ByteArray(1) { VERSION })
|
||||
val ad = getADForKV(VERSION, packageName)
|
||||
crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
|
||||
GZIPOutputStream(encryptedStream).use { gZipStream ->
|
||||
dbManager.getDbInputStream(packageName).use { inputStream ->
|
||||
inputStream.copyTo(gZipStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Uploaded db file for $packageName.")
|
||||
}
|
||||
|
||||
private class KVOperation(
|
||||
val key: String,
|
||||
/**
|
||||
|
|
|
@ -5,12 +5,10 @@
|
|||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import android.app.backup.IBackupManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_MAIN
|
||||
import android.content.Intent.CATEGORY_LAUNCHER
|
||||
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
|
||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY
|
||||
|
@ -22,7 +20,6 @@ import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
|||
import android.content.pm.PackageManager.MATCH_SYSTEM_ONLY
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.os.RemoteException
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import android.util.Log.INFO
|
||||
import androidx.annotation.WorkerThread
|
||||
|
@ -41,13 +38,11 @@ private const val LOG_MAX_PACKAGES = 100
|
|||
*/
|
||||
internal class PackageService(
|
||||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val backendManager: BackendManager,
|
||||
) {
|
||||
|
||||
private val packageManager: PackageManager = context.packageManager
|
||||
private val myUserId = UserHandle.myUserId()
|
||||
private val backend: Backend get() = backendManager.backend
|
||||
|
||||
val eligiblePackages: List<String>
|
||||
|
@ -64,25 +59,17 @@ internal class PackageService(
|
|||
logPackages(packages)
|
||||
}
|
||||
|
||||
val eligibleApps = if (settingsManager.d2dBackupsEnabled()) {
|
||||
// if D2D is enabled, use the "new method" for filtering packages
|
||||
packages.filter(::shouldIncludeAppInBackup).toTypedArray()
|
||||
} else {
|
||||
// otherwise, use the BackupManager call.
|
||||
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
|
||||
}
|
||||
|
||||
val eligibleApps = packages.filter(::shouldIncludeAppInBackup).toMutableList()
|
||||
// log eligible packages
|
||||
if (Log.isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
|
||||
logPackages(eligibleApps.toList())
|
||||
logPackages(eligibleApps)
|
||||
}
|
||||
|
||||
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
|
||||
val packageArray = eligibleApps.toMutableList()
|
||||
packageArray.add(MAGIC_PACKAGE_MANAGER)
|
||||
eligibleApps.add(0, MAGIC_PACKAGE_MANAGER)
|
||||
|
||||
return packageArray
|
||||
return eligibleApps
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -137,21 +124,6 @@ internal class PackageService(
|
|||
packageInfo.allowsBackup()
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of apps that do not allow backup.
|
||||
*/
|
||||
val userNotAllowedApps: List<PackageInfo>
|
||||
@WorkerThread
|
||||
get() {
|
||||
// if D2D backups are enabled, all apps are allowed
|
||||
if (settingsManager.d2dBackupsEnabled()) return emptyList()
|
||||
|
||||
return packageManager.getInstalledPackages(0).filter { packageInfo ->
|
||||
!packageInfo.allowsBackup() &&
|
||||
!packageInfo.isSystemApp()
|
||||
}
|
||||
}
|
||||
|
||||
val launchableSystemApps: List<ResolveInfo>
|
||||
@WorkerThread
|
||||
get() {
|
||||
|
@ -196,10 +168,6 @@ internal class PackageService(
|
|||
}
|
||||
|
||||
private fun PackageInfo.allowsBackup(): Boolean {
|
||||
val appInfo = applicationInfo
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return false
|
||||
|
||||
return if (settingsManager.d2dBackupsEnabled()) {
|
||||
/**
|
||||
* TODO: Consider ways of replicating the system's logic so that the user can have
|
||||
* advance knowledge of apps that the system will exclude, particularly apps targeting
|
||||
|
@ -212,10 +180,8 @@ internal class PackageService(
|
|||
* See frameworks/base/services/backup/java/com/android/server/backup/utils/
|
||||
* BackupEligibilityRules.java lines 74-81 and 163-167 (tag: android-13.0.0_r8).
|
||||
*/
|
||||
true
|
||||
} else {
|
||||
appInfo.flags and FLAG_ALLOW_BACKUP != 0
|
||||
}
|
||||
val appInfo = applicationInfo
|
||||
return !(packageName == MAGIC_PACKAGE_MANAGER || appInfo == null)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,14 +12,16 @@ import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.HeaderReader
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
|
@ -29,9 +31,10 @@ import java.security.GeneralSecurityException
|
|||
|
||||
private class FullRestoreState(
|
||||
val version: Byte,
|
||||
val token: Long,
|
||||
val name: String,
|
||||
val packageInfo: PackageInfo,
|
||||
val blobHandles: List<Blob>? = null,
|
||||
val token: Long? = null,
|
||||
val name: String? = null,
|
||||
) {
|
||||
var inputStream: InputStream? = null
|
||||
}
|
||||
|
@ -40,6 +43,7 @@ private val TAG = FullRestore::class.java.simpleName
|
|||
|
||||
internal class FullRestore(
|
||||
private val backendManager: BackendManager,
|
||||
private val loader: Loader,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin: LegacyStoragePlugin,
|
||||
private val outputFactory: OutputFactory,
|
||||
|
@ -50,7 +54,7 @@ internal class FullRestore(
|
|||
private val backend get() = backendManager.backend
|
||||
private var state: FullRestoreState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
val hasState get() = state != null
|
||||
|
||||
/**
|
||||
* Return true if there is data stored for the given package.
|
||||
|
@ -69,8 +73,16 @@ internal class FullRestore(
|
|||
* It is possible that the system decides to not restore the package.
|
||||
* Then a new state will be initialized right away without calling other methods.
|
||||
*/
|
||||
fun initializeState(version: Byte, token: Long, name: String, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(version, token, name, packageInfo)
|
||||
fun initializeState(version: Byte, packageInfo: PackageInfo, blobHandles: List<Blob>) {
|
||||
state = FullRestoreState(version, packageInfo, blobHandles)
|
||||
}
|
||||
|
||||
fun initializeStateV1(token: Long, name: String, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(1, packageInfo, null, token, name)
|
||||
}
|
||||
|
||||
fun initializeStateV0(token: Long, packageInfo: PackageInfo) {
|
||||
state = FullRestoreState(0x00, packageInfo, null, token)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,19 +119,29 @@ internal class FullRestore(
|
|||
if (state.inputStream == null) {
|
||||
Log.i(TAG, "First Chunk, initializing package input stream.")
|
||||
try {
|
||||
if (state.version == 0.toByte()) {
|
||||
when (state.version) {
|
||||
0.toByte() -> {
|
||||
val token = state.token ?: error("no token for v0 backup")
|
||||
val inputStream =
|
||||
legacyPlugin.getInputStreamForPackage(state.token, state.packageInfo)
|
||||
legacyPlugin.getInputStreamForPackage(token, state.packageInfo)
|
||||
val version = headerReader.readVersion(inputStream, state.version)
|
||||
@Suppress("deprecation")
|
||||
crypto.decryptHeader(inputStream, version, packageName)
|
||||
state.inputStream = inputStream
|
||||
} else {
|
||||
val handle = LegacyAppBackupFile.Blob(state.token, state.name)
|
||||
}
|
||||
1.toByte() -> {
|
||||
val token = state.token ?: error("no token for v1 backup")
|
||||
val name = state.name ?: error("no name for v1 backup")
|
||||
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||
val inputStream = backend.load(handle)
|
||||
val version = headerReader.readVersion(inputStream, state.version)
|
||||
val ad = getADForFull(version, packageName)
|
||||
state.inputStream = crypto.newDecryptingStream(inputStream, ad)
|
||||
state.inputStream = crypto.newDecryptingStreamV1(inputStream, ad)
|
||||
}
|
||||
else -> {
|
||||
val handles = state.blobHandles ?: error("no blob handles for v2")
|
||||
state.inputStream = loader.loadFiles(handles)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error getting input stream for $packageName", e)
|
||||
|
|
|
@ -14,17 +14,18 @@ import android.util.Log
|
|||
import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
|
||||
import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.decodeBase64
|
||||
import com.stevesoltys.seedvault.header.HeaderReader
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForKV
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import com.stevesoltys.seedvault.transport.backup.KVDb
|
||||
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
|
@ -33,19 +34,21 @@ import javax.crypto.AEADBadTagException
|
|||
|
||||
private class KVRestoreState(
|
||||
val version: Byte,
|
||||
val token: Long,
|
||||
val name: String,
|
||||
val packageInfo: PackageInfo,
|
||||
val blobHandles: List<Blob>? = null,
|
||||
val token: Long? = null,
|
||||
val name: String? = null,
|
||||
/**
|
||||
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
|
||||
*/
|
||||
val autoRestorePackageInfo: PackageInfo?,
|
||||
val autoRestorePackageInfo: PackageInfo? = null,
|
||||
)
|
||||
|
||||
private val TAG = KVRestore::class.java.simpleName
|
||||
|
||||
internal class KVRestore(
|
||||
private val backendManager: BackendManager,
|
||||
private val loader: Loader,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin: LegacyStoragePlugin,
|
||||
private val outputFactory: OutputFactory,
|
||||
|
@ -78,12 +81,32 @@ internal class KVRestore(
|
|||
*/
|
||||
fun initializeState(
|
||||
version: Byte,
|
||||
packageInfo: PackageInfo,
|
||||
blobHandles: List<Blob>,
|
||||
autoRestorePackageInfo: PackageInfo? = null,
|
||||
) {
|
||||
state = KVRestoreState(
|
||||
version = version,
|
||||
packageInfo = packageInfo,
|
||||
blobHandles = blobHandles,
|
||||
autoRestorePackageInfo = autoRestorePackageInfo,
|
||||
)
|
||||
}
|
||||
|
||||
fun initializeStateV1(
|
||||
token: Long,
|
||||
name: String,
|
||||
packageInfo: PackageInfo,
|
||||
autoRestorePackageInfo: PackageInfo? = null,
|
||||
) {
|
||||
state = KVRestoreState(version, token, name, packageInfo, autoRestorePackageInfo)
|
||||
state = KVRestoreState(1, packageInfo, null, token, name, autoRestorePackageInfo)
|
||||
}
|
||||
|
||||
fun initializeStateV0(
|
||||
token: Long,
|
||||
packageInfo: PackageInfo,
|
||||
) {
|
||||
state = KVRestoreState(0x00, packageInfo, null, token)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -106,7 +129,8 @@ internal class KVRestore(
|
|||
val database = if (isAutoRestore) {
|
||||
getCachedRestoreDb(state)
|
||||
} else {
|
||||
downloadRestoreDb(state)
|
||||
if (state.version == 1.toByte()) downloadRestoreDbV1(state)
|
||||
else downloadRestoreDb(state)
|
||||
}
|
||||
database.use { db ->
|
||||
val out = outputFactory.getBackupDataOutput(data)
|
||||
|
@ -150,18 +174,38 @@ internal class KVRestore(
|
|||
return if (dbManager.existsDb(packageName)) {
|
||||
dbManager.getDb(packageName)
|
||||
} else {
|
||||
downloadRestoreDb(state)
|
||||
if (state.version == 1.toByte()) downloadRestoreDbV1(state)
|
||||
else downloadRestoreDb(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
|
||||
private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb {
|
||||
val packageName = state.packageInfo.packageName
|
||||
val handle = LegacyAppBackupFile.Blob(state.token, state.name)
|
||||
val handles = state.blobHandles ?: error("no blob handles for v2")
|
||||
loader.loadFiles(handles).use { inputStream ->
|
||||
dbManager.getDbOutputStream(packageName).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
return dbManager.getDb(packageName, true)
|
||||
}
|
||||
|
||||
//
|
||||
// v1 restore legacy code below
|
||||
//
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
|
||||
private suspend fun downloadRestoreDbV1(state: KVRestoreState): KVDb {
|
||||
val token = state.token ?: error("No token for v1 restore")
|
||||
val name = state.name ?: error("No name for v1 restore")
|
||||
val packageName = state.packageInfo.packageName
|
||||
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||
backend.load(handle).use { inputStream ->
|
||||
headerReader.readVersion(inputStream, state.version)
|
||||
val ad = getADForKV(VERSION, packageName)
|
||||
crypto.newDecryptingStream(inputStream, ad).use { decryptedStream ->
|
||||
val ad = getADForKV(state.version, packageName)
|
||||
crypto.newDecryptingStreamV1(inputStream, ad).use { decryptedStream ->
|
||||
GZIPInputStream(decryptedStream).use { gzipStream ->
|
||||
dbManager.getDbOutputStream(packageName).use { outputStream ->
|
||||
gzipStream.copyTo(outputStream)
|
||||
|
@ -182,7 +226,8 @@ internal class KVRestore(
|
|||
// We return the data in lexical order sorted by key,
|
||||
// so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
|
||||
// will see the date in the most obvious order.
|
||||
val sortedKeys = getSortedKeysV0(state.token, state.packageInfo)
|
||||
val token = state.token ?: error("No token for v0 restore")
|
||||
val sortedKeys = getSortedKeysV0(token, state.packageInfo)
|
||||
if (sortedKeys == null) {
|
||||
// nextRestorePackage() ensures the dir exists, so this is an error
|
||||
Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
|
||||
|
@ -245,7 +290,7 @@ internal class KVRestore(
|
|||
state: KVRestoreState,
|
||||
dKey: DecodedKey,
|
||||
out: BackupDataOutput,
|
||||
) = legacyPlugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
|
||||
) = legacyPlugin.getInputStreamForRecord(state.token!!, state.packageInfo, dKey.base64Key)
|
||||
.use { inputStream ->
|
||||
val version = headerReader.readVersion(inputStream, state.version)
|
||||
val packageName = state.packageInfo.packageName
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.restore
|
||||
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.proto.Snapshot
|
||||
|
||||
sealed class RestorableBackupResult {
|
||||
data class ErrorResult(val e: Exception?) : RestorableBackupResult()
|
||||
data class SuccessResult(val backups: List<RestorableBackup>) : RestorableBackupResult()
|
||||
}
|
||||
|
||||
data class RestorableBackup(
|
||||
val backupMetadata: BackupMetadata,
|
||||
val repoId: String? = null,
|
||||
val snapshot: Snapshot? = null,
|
||||
) {
|
||||
|
||||
constructor(repoId: String, snapshot: Snapshot) : this(
|
||||
backupMetadata = BackupMetadata.fromSnapshot(snapshot),
|
||||
repoId = repoId,
|
||||
snapshot = snapshot,
|
||||
)
|
||||
|
||||
val name: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
||||
val version: Byte
|
||||
get() = backupMetadata.version
|
||||
|
||||
val token: Long
|
||||
get() = backupMetadata.token
|
||||
|
||||
val salt: String
|
||||
get() = backupMetadata.salt
|
||||
|
||||
val time: Long
|
||||
get() = snapshot?.token ?: backupMetadata.time
|
||||
|
||||
val size: Long = snapshot?.blobsMap?.values?.sumOf { it.uncompressedLength.toLong() }
|
||||
?: backupMetadata.size
|
||||
|
||||
val deviceName: String
|
||||
get() = backupMetadata.deviceName
|
||||
|
||||
val user: String?
|
||||
get() = snapshot?.user?.takeIf { it.isNotBlank() }
|
||||
|
||||
val d2dBackup: Boolean
|
||||
get() = backupMetadata.d2dBackup
|
||||
|
||||
val numAppData: Int = snapshot?.appsMap?.values?.count { it.chunkIdsCount > 0 }
|
||||
?: packageMetadataMap.values.count { packageMetadata ->
|
||||
packageMetadata.backupType != null && packageMetadata.state == APK_AND_DATA
|
||||
}
|
||||
|
||||
val sizeAppData: Long = snapshot?.appsMap?.values?.sumOf { it.size }
|
||||
?: packageMetadataMap.values.sumOf { it.size ?: 0L }
|
||||
|
||||
val numApks: Int = snapshot?.appsMap?.values?.count { it.apk.splitsCount > 0 }
|
||||
?: packageMetadataMap.values.count { it.hasApk() }
|
||||
|
||||
val sizeApks: Long = size - sizeAppData
|
||||
|
||||
val packageMetadataMap: PackageMetadataMap
|
||||
get() = backupMetadata.packageMetadataMap
|
||||
|
||||
}
|
|
@ -18,21 +18,24 @@ import android.os.ParcelFileDescriptor
|
|||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
||||
import com.stevesoltys.seedvault.repo.SnapshotManager
|
||||
import com.stevesoltys.seedvault.repo.getBlobHandles
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
||||
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
|
||||
/**
|
||||
* Device name used in AOSP to indicate that a restore set is part of a device-to-device migration.
|
||||
|
@ -49,7 +52,7 @@ private data class RestoreCoordinatorState(
|
|||
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
|
||||
*/
|
||||
val autoRestorePackageInfo: PackageInfo?,
|
||||
val backupMetadata: BackupMetadata,
|
||||
val backup: RestorableBackup,
|
||||
) {
|
||||
var currentPackage: String? = null
|
||||
}
|
||||
|
@ -63,6 +66,7 @@ internal class RestoreCoordinator(
|
|||
private val metadataManager: MetadataManager,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val snapshotManager: SnapshotManager,
|
||||
private val kv: KVRestore,
|
||||
private val full: FullRestore,
|
||||
private val metadataReader: MetadataReader,
|
||||
|
@ -70,34 +74,58 @@ internal class RestoreCoordinator(
|
|||
|
||||
private val backend: Backend get() = backendManager.backend
|
||||
private var state: RestoreCoordinatorState? = null
|
||||
private var backupMetadata: BackupMetadata? = null
|
||||
private var restorableBackup: RestorableBackup? = null
|
||||
private val failedPackages = ArrayList<String>()
|
||||
|
||||
suspend fun getAvailableMetadata(): Map<Long, BackupMetadata>? {
|
||||
val availableBackups = backend.getAvailableBackups() ?: return null
|
||||
val metadataMap = HashMap<Long, BackupMetadata>()
|
||||
for (encryptedMetadata in availableBackups) {
|
||||
try {
|
||||
val metadata = encryptedMetadata.inputStreamRetriever().use { inputStream ->
|
||||
metadataReader.readMetadata(inputStream, encryptedMetadata.token)
|
||||
suspend fun getAvailableBackups(): RestorableBackupResult {
|
||||
Log.i(TAG, "getAvailableBackups")
|
||||
val fileHandles = try {
|
||||
backend.getAvailableBackupFileHandles()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting available backups.", e)
|
||||
return RestorableBackupResult.ErrorResult(e)
|
||||
}
|
||||
metadataMap[encryptedMetadata.token] = metadata
|
||||
val backups = ArrayList<RestorableBackup>()
|
||||
var lastException: Exception? = null
|
||||
for (handle in fileHandles) {
|
||||
try {
|
||||
val backup = when (handle) {
|
||||
is AppBackupFileType.Snapshot -> RestorableBackup(
|
||||
repoId = handle.repoId,
|
||||
snapshot = snapshotManager.loadSnapshot(handle),
|
||||
)
|
||||
is LegacyAppBackupFile.Metadata -> {
|
||||
val metadata = backend.load(handle).use { inputStream ->
|
||||
metadataReader.readMetadata(inputStream, handle.token)
|
||||
}
|
||||
RestorableBackup(backupMetadata = metadata)
|
||||
}
|
||||
else -> error("Unexpected file handle: $handle")
|
||||
}
|
||||
backups.add(backup)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error while getting restore set ${encryptedMetadata.token}", e)
|
||||
Log.e(TAG, "Error while getting restore set $handle", e)
|
||||
lastException = e
|
||||
continue
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Error while getting restore set ${encryptedMetadata.token}", e)
|
||||
return null
|
||||
Log.e(TAG, "Error while getting restore set $handle", e)
|
||||
return RestorableBackupResult.ErrorResult(e)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
Log.e(TAG, "General security error while decrypting restore set $handle", e)
|
||||
lastException = e
|
||||
continue
|
||||
} catch (e: DecryptionFailedException) {
|
||||
Log.e(TAG, "Error while decrypting restore set ${encryptedMetadata.token}", e)
|
||||
Log.e(TAG, "Error while decrypting restore set $handle", e)
|
||||
lastException = e
|
||||
continue
|
||||
} catch (e: UnsupportedVersionException) {
|
||||
Log.w(TAG, "Backup with unsupported version read", e)
|
||||
lastException = e
|
||||
continue
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Got available metadata for tokens: ${metadataMap.keys}")
|
||||
return metadataMap
|
||||
if (backups.isEmpty()) return RestorableBackupResult.ErrorResult(lastException)
|
||||
return RestorableBackupResult.SuccessResult(backups)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,22 +135,22 @@ internal class RestoreCoordinator(
|
|||
* or null if an error occurred (the attempt should be rescheduled).
|
||||
**/
|
||||
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
||||
return getAvailableMetadata()?.map { (_, metadata) ->
|
||||
|
||||
val transportFlags = if (metadata.d2dBackup) {
|
||||
Log.d(TAG, "getAvailableRestoreSets")
|
||||
val result = getAvailableBackups() as? RestorableBackupResult.SuccessResult ?: return null
|
||||
val backups = result.backups
|
||||
return backups.map { backup ->
|
||||
val transportFlags = if (backup.d2dBackup) {
|
||||
D2D_TRANSPORT_FLAGS
|
||||
} else {
|
||||
DEFAULT_TRANSPORT_FLAGS
|
||||
}
|
||||
|
||||
val deviceName = if (metadata.d2dBackup) {
|
||||
val deviceName = if (backup.d2dBackup) {
|
||||
D2D_DEVICE_NAME
|
||||
} else {
|
||||
metadata.deviceName
|
||||
backup.deviceName
|
||||
}
|
||||
|
||||
RestoreSet(metadata.deviceName, deviceName, metadata.token, transportFlags)
|
||||
}?.toTypedArray()
|
||||
RestoreSet(backup.deviceName, deviceName, backup.token, transportFlags)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -133,20 +161,16 @@ internal class RestoreCoordinator(
|
|||
* or 0 if there is no backup set available corresponding to the current device state.
|
||||
*/
|
||||
fun getCurrentRestoreSet(): Long {
|
||||
return (settingsManager.getToken() ?: 0L).apply {
|
||||
Log.i(TAG, "Got current restore set token: $this")
|
||||
}
|
||||
val token = settingsManager.token ?: 0L
|
||||
Log.d(TAG, "getCurrentRestoreSet() = $token")
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this before starting the restore as an optimization to prevent re-fetching metadata.
|
||||
*/
|
||||
fun beforeStartRestore(backupMetadata: BackupMetadata) {
|
||||
this.backupMetadata = backupMetadata
|
||||
|
||||
if (backupMetadata.d2dBackup) {
|
||||
settingsManager.setD2dBackupsEnabled(true)
|
||||
}
|
||||
fun beforeStartRestore(restorableBackup: RestorableBackup) {
|
||||
this.restorableBackup = restorableBackup
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -164,10 +188,10 @@ internal class RestoreCoordinator(
|
|||
*/
|
||||
suspend fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
|
||||
check(state == null) { "Started new restore with existing state: $state" }
|
||||
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
|
||||
Log.i(TAG, "Start restore $token with ${packages.map { info -> info.packageName }}")
|
||||
|
||||
// If there's only one package to restore (Auto Restore feature), add it to the state
|
||||
val pmPackageInfo =
|
||||
val autoRestorePackageInfo =
|
||||
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
|
||||
val pmPackageName = packages[1].packageName
|
||||
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
|
||||
|
@ -188,13 +212,38 @@ internal class RestoreCoordinator(
|
|||
packages[1]
|
||||
} else null
|
||||
|
||||
val metadata = if (backupMetadata?.token == token) {
|
||||
backupMetadata!! // if token matches, backupMetadata is non-null
|
||||
val backup = if (restorableBackup?.token == token) {
|
||||
restorableBackup!! // if token matches, backupMetadata is non-null
|
||||
} else {
|
||||
getAvailableMetadata()?.get(token) ?: return TRANSPORT_ERROR
|
||||
if (autoRestorePackageInfo == null) { // no auto-restore
|
||||
Log.e(TAG, "No cached backups, loading all and look for $token")
|
||||
val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult
|
||||
?: return TRANSPORT_ERROR
|
||||
backup.backups.find { it.token == token } ?: return TRANSPORT_ERROR
|
||||
} else {
|
||||
// this is auto-restore, so we use cache and try hard to find a working restore set
|
||||
Log.i(TAG, "No cached backups, loading all and look for $token")
|
||||
val backups = try {
|
||||
snapshotManager.loadCachedSnapshots().map { snapshot ->
|
||||
RestorableBackup(crypto.repoId, snapshot)
|
||||
}
|
||||
state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo, metadata)
|
||||
backupMetadata = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading cached snapshots: ", e)
|
||||
(getAvailableBackups() as? RestorableBackupResult.SuccessResult)?.backups
|
||||
?: return TRANSPORT_ERROR
|
||||
}
|
||||
Log.i(TAG, "Found ${backups.size} snapshots.")
|
||||
val autoRestorePackageName = autoRestorePackageInfo.packageName
|
||||
val sortedBackups = backups.sortedByDescending { it.token } // latest first
|
||||
sortedBackups.find { it.token == token } ?: sortedBackups.find {
|
||||
val chunkIds = it.packageMetadataMap[autoRestorePackageName]?.chunkIds
|
||||
// try a backup where our auto restore package has data
|
||||
!chunkIds.isNullOrEmpty()
|
||||
} ?: return TRANSPORT_ERROR
|
||||
}
|
||||
}
|
||||
state = RestoreCoordinatorState(token, packages.iterator(), autoRestorePackageInfo, backup)
|
||||
restorableBackup = null
|
||||
failedPackages.clear()
|
||||
return TRANSPORT_OK
|
||||
}
|
||||
|
@ -226,39 +275,93 @@ internal class RestoreCoordinator(
|
|||
* or null to indicate a transport-level error.
|
||||
*/
|
||||
suspend fun nextRestorePackage(): RestoreDescription? {
|
||||
Log.i(TAG, "Next restore package!")
|
||||
val state = this.state ?: throw IllegalStateException("no state")
|
||||
|
||||
if (!state.packages.hasNext()) return NO_MORE_PACKAGES
|
||||
val packageInfo = state.packages.next()
|
||||
val version = state.backupMetadata.version
|
||||
Log.i(TAG, "nextRestorePackage() => ${packageInfo.packageName}")
|
||||
val version = state.backup.version
|
||||
if (version == 0.toByte()) return nextRestorePackageV0(state, packageInfo)
|
||||
if (version == 1.toByte()) return nextRestorePackageV1(state, packageInfo)
|
||||
|
||||
val packageName = packageInfo.packageName
|
||||
val type = when (state.backupMetadata.packageMetadataMap[packageName]?.backupType) {
|
||||
val repoId = state.backup.repoId ?: error("No repoId in v2 backup")
|
||||
val snapshot = state.backup.snapshot ?: error("No snapshot in v2 backup")
|
||||
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
|
||||
BackupType.KV -> {
|
||||
val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName)
|
||||
val blobHandles = try {
|
||||
val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds
|
||||
?: error("no metadata or chunkIds")
|
||||
snapshot.getBlobHandles(repoId, chunkIds)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting blob handles: ", e)
|
||||
failedPackages.add(packageName)
|
||||
// abort here as this is close to an assertion error
|
||||
return null
|
||||
}
|
||||
kv.initializeState(
|
||||
version = version,
|
||||
token = state.token,
|
||||
name = name,
|
||||
packageInfo = packageInfo,
|
||||
autoRestorePackageInfo = state.autoRestorePackageInfo
|
||||
blobHandles = blobHandles,
|
||||
autoRestorePackageInfo = state.autoRestorePackageInfo,
|
||||
)
|
||||
state.currentPackage = packageName
|
||||
TYPE_KEY_VALUE
|
||||
}
|
||||
|
||||
BackupType.FULL -> {
|
||||
val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName)
|
||||
full.initializeState(version, state.token, name, packageInfo)
|
||||
val blobHandles = try {
|
||||
val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds
|
||||
?: error("no metadata or chunkIds")
|
||||
snapshot.getBlobHandles(repoId, chunkIds)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting blob handles: ", e)
|
||||
failedPackages.add(packageName)
|
||||
// abort here as this is close to an assertion error
|
||||
return null
|
||||
}
|
||||
full.initializeState(version, packageInfo, blobHandles)
|
||||
state.currentPackage = packageName
|
||||
TYPE_FULL_STREAM
|
||||
}
|
||||
|
||||
null -> {
|
||||
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
||||
state.backupMetadata.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||
state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||
Log.w(TAG, "State was ${s.name}")
|
||||
}
|
||||
failedPackages.add(packageName)
|
||||
// don't return null and cause abort here, but try next package
|
||||
return nextRestorePackage()
|
||||
}
|
||||
}
|
||||
return RestoreDescription(packageName, type)
|
||||
}
|
||||
|
||||
@Suppress("deprecation")
|
||||
private suspend fun nextRestorePackageV1(
|
||||
state: RestoreCoordinatorState,
|
||||
packageInfo: PackageInfo,
|
||||
): RestoreDescription? {
|
||||
val packageName = packageInfo.packageName
|
||||
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
|
||||
BackupType.KV -> {
|
||||
kv.initializeStateV1(
|
||||
token = state.token,
|
||||
name = crypto.getNameForPackage(state.backup.salt, packageName),
|
||||
packageInfo = packageInfo,
|
||||
autoRestorePackageInfo = state.autoRestorePackageInfo,
|
||||
)
|
||||
state.currentPackage = packageName
|
||||
TYPE_KEY_VALUE
|
||||
}
|
||||
BackupType.FULL -> {
|
||||
val name = crypto.getNameForPackage(state.backup.salt, packageName)
|
||||
full.initializeStateV1(state.token, name, packageInfo)
|
||||
state.currentPackage = packageName
|
||||
TYPE_FULL_STREAM
|
||||
}
|
||||
null -> {
|
||||
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
||||
state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||
Log.w(TAG, "State was ${s.name}")
|
||||
}
|
||||
failedPackages.add(packageName)
|
||||
|
@ -280,18 +383,16 @@ internal class RestoreCoordinator(
|
|||
// check key/value data first and if available, don't even check for full data
|
||||
kv.hasDataForPackage(state.token, packageInfo) -> {
|
||||
Log.i(TAG, "Found K/V data for $packageName.")
|
||||
kv.initializeState(0x00, state.token, "", packageInfo, null)
|
||||
kv.initializeStateV0(state.token, packageInfo)
|
||||
state.currentPackage = packageName
|
||||
TYPE_KEY_VALUE
|
||||
}
|
||||
|
||||
full.hasDataForPackage(state.token, packageInfo) -> {
|
||||
Log.i(TAG, "Found full backup data for $packageName.")
|
||||
full.initializeState(0x00, state.token, "", packageInfo)
|
||||
full.initializeStateV0(state.token, packageInfo)
|
||||
state.currentPackage = packageName
|
||||
TYPE_FULL_STREAM
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.i(TAG, "No data found for $packageName. Skipping.")
|
||||
return nextRestorePackage()
|
||||
|
@ -315,6 +416,7 @@ internal class RestoreCoordinator(
|
|||
* @return the same error codes as [startRestore].
|
||||
*/
|
||||
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
|
||||
Log.d(TAG, "getRestoreData()")
|
||||
return kv.getRestoreData(data).apply {
|
||||
if (this != TRANSPORT_OK) {
|
||||
// add current package to failed ones
|
||||
|
@ -352,7 +454,7 @@ internal class RestoreCoordinator(
|
|||
*/
|
||||
fun finishRestore() {
|
||||
Log.d(TAG, "finishRestore")
|
||||
if (full.hasState()) full.finishRestore()
|
||||
if (full.hasState) full.finishRestore()
|
||||
state = null
|
||||
}
|
||||
|
||||
|
|
|
@ -10,9 +10,20 @@ import org.koin.dsl.module
|
|||
|
||||
val restoreModule = module {
|
||||
single { OutputFactory() }
|
||||
single { KVRestore(get(), get(), get(), get(), get(), get()) }
|
||||
single { FullRestore(get(), get(), get(), get(), get()) }
|
||||
single { KVRestore(get(), get(), get(), get(), get(), get(), get()) }
|
||||
single { FullRestore(get(), get(), get(), get(), get(), get()) }
|
||||
single {
|
||||
RestoreCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get())
|
||||
RestoreCoordinator(
|
||||
context = androidContext(),
|
||||
crypto = get(),
|
||||
settingsManager = get(),
|
||||
metadataManager = get(),
|
||||
notificationManager = get(),
|
||||
backendManager = get(),
|
||||
snapshotManager = get(),
|
||||
kv = get(),
|
||||
full = get(),
|
||||
metadataReader = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ enum class AppBackupState {
|
|||
FAILED -> notShownString
|
||||
FAILED_NO_DATA -> context.getString(R.string.backup_app_no_data)
|
||||
FAILED_WAS_STOPPED -> context.getString(R.string.backup_app_was_stopped)
|
||||
FAILED_NOT_ALLOWED -> notShownString
|
||||
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
|
||||
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
|
||||
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.backup_app_quota_exceeded)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import androidx.annotation.CallSuper
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.extensions.setupEdgeToEdge
|
||||
|
||||
abstract class BackupActivity : AppCompatActivity() {
|
||||
|
||||
|
|
|
@ -7,8 +7,9 @@ package com.stevesoltys.seedvault.ui
|
|||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
||||
abstract class RequireProvisioningViewModel(
|
||||
|
@ -26,7 +27,7 @@ abstract class RequireProvisioningViewModel(
|
|||
|
||||
internal fun validLocationIsSet() = backendManager.isValidAppPluginSet()
|
||||
|
||||
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
|
||||
internal fun recoveryCodeIsSet() = permitDiskReads { keyManager.hasBackupKey() }
|
||||
|
||||
open fun onStorageLocationChanged() {
|
||||
// noop can be overwritten by sub-classes
|
||||
|
|
|
@ -8,10 +8,27 @@ package com.stevesoltys.seedvault.ui
|
|||
import android.content.Context
|
||||
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener
|
||||
import androidx.core.view.WindowCompat.setDecorFitsSystemWindows
|
||||
import androidx.core.view.WindowInsetsCompat.CONSUMED
|
||||
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
|
||||
import androidx.core.view.WindowInsetsCompat.Type.ime
|
||||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
||||
fun AppCompatActivity.setupEdgeToEdge() {
|
||||
val rootView = window.decorView.rootView
|
||||
setDecorFitsSystemWindows(window, false)
|
||||
setOnApplyWindowInsetsListener(rootView) { v, windowInsets ->
|
||||
val insets = windowInsets.getInsets(systemBars() or ime() or displayCutout())
|
||||
v.setPadding(insets.left, insets.top, insets.right, insets.bottom)
|
||||
CONSUMED
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.toRelativeTime(context: Context): CharSequence {
|
||||
return if (this == 0L) {
|
||||
return if (this == 0L || this == -1L) {
|
||||
context.getString(R.string.settings_backup_last_backup_never)
|
||||
} else {
|
||||
val now = System.currentTimeMillis()
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.stevesoltys.seedvault.R
|
|||
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
|
||||
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
||||
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
||||
import com.stevesoltys.seedvault.restore.RestoreActivity
|
||||
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||
import kotlin.math.min
|
||||
|
@ -40,13 +41,14 @@ private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess"
|
|||
private const val CHANNEL_ID_ERROR = "NotificationError"
|
||||
private const val CHANNEL_ID_RESTORE = "NotificationRestore"
|
||||
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
||||
private const val CHANNEL_ID_PRUNING = "NotificationPruning"
|
||||
internal const val NOTIFICATION_ID_OBSERVER = 1
|
||||
private const val NOTIFICATION_ID_SUCCESS = 2
|
||||
private const val NOTIFICATION_ID_ERROR = 3
|
||||
private const val NOTIFICATION_ID_SPACE_ERROR = 4
|
||||
internal const val NOTIFICATION_ID_RESTORE = 5
|
||||
private const val NOTIFICATION_ID_RESTORE_ERROR = 6
|
||||
private const val NOTIFICATION_ID_BACKGROUND = 7
|
||||
internal const val NOTIFICATION_ID_PRUNING = 7
|
||||
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 8
|
||||
|
||||
private val TAG = BackupNotificationManager::class.java.simpleName
|
||||
|
@ -59,6 +61,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
createNotificationChannel(getErrorChannel())
|
||||
createNotificationChannel(getRestoreChannel())
|
||||
createNotificationChannel(getRestoreErrorChannel())
|
||||
createNotificationChannel(getPruningChannel())
|
||||
}
|
||||
|
||||
private fun getObserverChannel(): NotificationChannel {
|
||||
|
@ -90,6 +93,11 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
|
||||
}
|
||||
|
||||
private fun getPruningChannel(): NotificationChannel {
|
||||
val title = context.getString(R.string.notification_pruning_channel_title)
|
||||
return NotificationChannel(CHANNEL_ID_PRUNING, title, IMPORTANCE_LOW)
|
||||
}
|
||||
|
||||
/**
|
||||
* This should get called for each APK we are backing up.
|
||||
*/
|
||||
|
@ -158,7 +166,6 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}
|
||||
|
||||
fun onServiceDestroyed() {
|
||||
nm.cancel(NOTIFICATION_ID_BACKGROUND)
|
||||
// Cancel left-over notifications that are still ongoing.
|
||||
//
|
||||
// We have seen a race condition where the service was taken down at the same time
|
||||
|
@ -179,21 +186,17 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
// }
|
||||
}
|
||||
|
||||
fun onBackupFinished(success: Boolean, numBackedUp: Int?, total: Int, size: Long) {
|
||||
val titleRes =
|
||||
if (success) R.string.notification_success_title else R.string.notification_failed_title
|
||||
val contentText = if (numBackedUp == null) null else {
|
||||
fun onBackupSuccess(numBackedUp: Int, total: Int, size: Long) {
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
val contentText =
|
||||
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
|
||||
}
|
||||
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
|
||||
val intent = Intent(context, SettingsActivity::class.java).apply {
|
||||
if (success) action = ACTION_APP_STATUS_LIST
|
||||
action = ACTION_APP_STATUS_LIST
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
|
||||
setSmallIcon(iconRes)
|
||||
setContentTitle(context.getString(titleRes))
|
||||
setSmallIcon(R.drawable.ic_cloud_done)
|
||||
setContentTitle(context.getString(R.string.notification_success_title))
|
||||
setContentText(contentText)
|
||||
setOngoing(false)
|
||||
setShowWhen(true)
|
||||
|
@ -207,8 +210,27 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
nm.notify(NOTIFICATION_ID_SUCCESS, notification)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun onBackupError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_error)
|
||||
setContentTitle(context.getString(R.string.notification_failed_title))
|
||||
setContentText(context.getString(R.string.notification_failed_text))
|
||||
setOngoing(false)
|
||||
setShowWhen(true)
|
||||
setAutoCancel(true)
|
||||
setContentIntent(pendingIntent)
|
||||
setWhen(System.currentTimeMillis())
|
||||
setProgress(0, 0, false)
|
||||
priority = PRIORITY_LOW
|
||||
}.build()
|
||||
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
||||
nm.notify(NOTIFICATION_ID_ERROR, notification)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun onFixableBackupError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
val actionText = context.getString(R.string.notification_error_action)
|
||||
|
@ -244,6 +266,9 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}
|
||||
|
||||
fun getRestoreNotification() = Notification.Builder(context, CHANNEL_ID_RESTORE).apply {
|
||||
val intent = Intent(context, RestoreActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||
setContentIntent(pendingIntent)
|
||||
setSmallIcon(R.drawable.ic_cloud_restore)
|
||||
setContentTitle(context.getString(R.string.notification_restore_title))
|
||||
setOngoing(true)
|
||||
|
@ -288,6 +313,17 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
nm.cancel(NOTIFICATION_ID_RESTORE_ERROR)
|
||||
}
|
||||
|
||||
fun getPruningNotification(): Notification {
|
||||
return Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
setSmallIcon(R.drawable.ic_auto_delete)
|
||||
setContentTitle(context.getString(R.string.notification_pruning_title))
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
priority = PRIORITY_LOW
|
||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||
}.build()
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun onNoMainKeyError() {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
|
|
|
@ -11,16 +11,23 @@ import android.app.backup.IBackupObserver
|
|||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Log.INFO
|
||||
import android.util.Log.isLoggable
|
||||
import com.stevesoltys.seedvault.ERROR_BACKUP_CANCELLED
|
||||
import com.stevesoltys.seedvault.ERROR_BACKUP_NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.repo.hexFromProto
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
|
||||
import com.stevesoltys.seedvault.worker.BackupRequester
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
|
@ -33,15 +40,19 @@ internal class NotificationBackupObserver(
|
|||
) : IBackupObserver.Stub(), KoinComponent {
|
||||
|
||||
private val nm: BackupNotificationManager by inject()
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val packageService: PackageService by inject()
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val appBackupManager: AppBackupManager by inject()
|
||||
private var currentPackage: String? = null
|
||||
private var numPackages: Int = 0
|
||||
private var numPackagesToReport: Int = 0
|
||||
private var pmCounted: Boolean = false
|
||||
|
||||
private var errorPackageName: String? = null
|
||||
private val launchableSystemApps by lazy {
|
||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||
}
|
||||
|
||||
init {
|
||||
// Inform the notification manager that a backup has started
|
||||
|
@ -73,7 +84,7 @@ internal class NotificationBackupObserver(
|
|||
* that was initialized
|
||||
* @param status Zero on success; a nonzero error code if the backup operation failed.
|
||||
*/
|
||||
override fun onResult(target: String?, status: Int) {
|
||||
override fun onResult(target: String, status: Int) {
|
||||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Completed. Target: $target, status: $status")
|
||||
}
|
||||
|
@ -87,7 +98,7 @@ internal class NotificationBackupObserver(
|
|||
numPackages += 1
|
||||
}
|
||||
// count package if success and not a system app
|
||||
if (status == 0 && target != null && target != MAGIC_PACKAGE_MANAGER) try {
|
||||
if (status == 0 && target != MAGIC_PACKAGE_MANAGER) try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(target, 0)
|
||||
// exclude system apps from final count for now
|
||||
if (appInfo.flags and FLAG_SYSTEM == 0) {
|
||||
|
@ -97,6 +108,14 @@ internal class NotificationBackupObserver(
|
|||
// should only happen for MAGIC_PACKAGE_MANAGER, but better save than sorry
|
||||
Log.e(TAG, "Error getting ApplicationInfo: ", e)
|
||||
}
|
||||
// record status for not-allowed apps visible in UI
|
||||
if (status == ERROR_BACKUP_NOT_ALLOWED) {
|
||||
// this should only ever happen for system apps, as we use only d2d now
|
||||
if (target in launchableSystemApps) {
|
||||
val packageInfo = context.packageManager.getPackageInfo(target, 0)
|
||||
metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED)
|
||||
}
|
||||
}
|
||||
|
||||
// Apps that get killed while interacting with their [BackupAgent] cancel the entire backup.
|
||||
// In order to prevent them from DoSing us, we remember them here to auto-disable them.
|
||||
|
@ -133,15 +152,33 @@ internal class NotificationBackupObserver(
|
|||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
|
||||
}
|
||||
val success = status == 0
|
||||
val size = if (success) metadataManager.getPackagesBackupSize() else 0L
|
||||
var success = status == 0
|
||||
val total = try {
|
||||
packageService.allUserPackages.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting number of all user packages: ", e)
|
||||
requestedPackages
|
||||
}
|
||||
nm.onBackupFinished(success, numPackagesToReport, total, size)
|
||||
val snapshot = runBlocking {
|
||||
check(!Looper.getMainLooper().isCurrentThread)
|
||||
Log.d(TAG, "Finalizing backup...")
|
||||
val snapshot = appBackupManager.afterBackupFinished(success)
|
||||
success = snapshot != null
|
||||
snapshot
|
||||
}
|
||||
val size = if (snapshot != null) { // TODO for later: count size of APKs separately
|
||||
val chunkIds = snapshot.appsMap.values.flatMap { it.chunkIdsList }
|
||||
chunkIds.sumOf {
|
||||
snapshot.blobsMap[it.hexFromProto()]?.uncompressedLength?.toLong() ?: 0L
|
||||
}
|
||||
} else 0L
|
||||
if (success) {
|
||||
nm.onBackupSuccess(numPackagesToReport, total, size)
|
||||
// prune old backups
|
||||
AppBackupPruneWorker.scheduleNow(context)
|
||||
} else {
|
||||
nm.onBackupError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,15 +26,15 @@ class RecoveryCodeActivity : BackupActivity() {
|
|||
setContentView(R.layout.activity_recovery_code)
|
||||
|
||||
viewModel.isRestore = isRestore()
|
||||
viewModel.confirmButtonClicked.observeEvent(this, { clicked ->
|
||||
viewModel.confirmButtonClicked.observeEvent(this) { clicked ->
|
||||
if (clicked) showInput(true)
|
||||
})
|
||||
viewModel.recoveryCodeSaved.observeEvent(this, { saved ->
|
||||
}
|
||||
viewModel.recoveryCodeSaved.observeEvent(this) { saved ->
|
||||
if (saved) {
|
||||
setResult(RESULT_OK)
|
||||
finishAfterTransition()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
if (viewModel.isRestore) showInput(false)
|
||||
|
|
|
@ -16,15 +16,18 @@ import cash.z.ecc.android.bip39.toSeed
|
|||
import com.stevesoltys.seedvault.App
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import java.io.IOException
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
internal const val WORD_NUM = 12
|
||||
|
||||
|
@ -35,6 +38,7 @@ internal class RecoveryCodeViewModel(
|
|||
private val crypto: Crypto,
|
||||
private val keyManager: KeyManager,
|
||||
private val backupManager: IBackupManager,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val storageBackup: StorageBackup,
|
||||
|
@ -104,20 +108,33 @@ internal class RecoveryCodeViewModel(
|
|||
* The reason is that old backups won't be readable anymore with the new key.
|
||||
* We can't delete other backups safely, because we can't be sure
|
||||
* that they don't belong to a different device or user.
|
||||
*
|
||||
* Our process will be terminated at the end to ensure the old key isn't used anymore.
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun reinitializeBackupLocation() {
|
||||
Log.d(TAG, "Re-initializing backup location...")
|
||||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// remove old backup repository and clear local blob cache
|
||||
try {
|
||||
appBackupManager.removeBackupRepo()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error removing backup repo: ", e)
|
||||
}
|
||||
// remove old storage snapshots and clear cache
|
||||
storageBackup.init()
|
||||
// we'll need to kill our process to not have references to the old key around
|
||||
// trying to re-set all those references is complicated, so exiting the app is easier.
|
||||
val exitApp = {
|
||||
Log.w(TAG, "Shutting down app...")
|
||||
exitProcess(0)
|
||||
}
|
||||
try {
|
||||
// initialize the new location
|
||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
||||
// no-op
|
||||
}
|
||||
if (backupManager.isBackupEnabled) backupInitializer.initialize(exitApp, exitApp)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||
exitApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
|
|||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
|
@ -28,6 +27,7 @@ import org.calyxos.backup.storage.api.StorageBackup
|
|||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ package com.stevesoltys.seedvault.ui.storage
|
|||
import android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.stevesoltys.seedvault.extensions.setupEdgeToEdge
|
||||
import com.stevesoltys.seedvault.ui.setupEdgeToEdge
|
||||
|
||||
class PermissionGrantActivity : AppCompatActivity() {
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ import org.calyxos.seedvault.core.backends.Backend
|
|||
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
|
||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = RestoreStorageViewModel::class.java.simpleName
|
||||
|
||||
|
@ -37,9 +36,11 @@ internal class RestoreStorageViewModel(
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val hasBackup = try {
|
||||
safHandler.hasAppBackup(safProperties)
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading URI: ${safProperties.uri}", e)
|
||||
false
|
||||
val errorMsg = app.getString(R.string.restore_set_error) + "\n\n$e"
|
||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||
return@launch
|
||||
}
|
||||
if (hasBackup) {
|
||||
safHandler.save(safProperties)
|
||||
|
@ -60,9 +61,11 @@ internal class RestoreStorageViewModel(
|
|||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val hasBackup = try {
|
||||
webdavHandler.hasAppBackup(backend)
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading: ${properties.config.url}", e)
|
||||
false
|
||||
val errorMsg = app.getString(R.string.restore_set_error) + "\n\n$e"
|
||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||
return@launch
|
||||
}
|
||||
if (hasBackup) {
|
||||
webdavHandler.save(properties)
|
||||
|
|
|
@ -24,8 +24,6 @@ import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
|||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
|
||||
import org.koin.androidx.viewmodel.ext.android.getViewModel
|
||||
|
||||
private val TAG = StorageActivity::class.java.name
|
||||
|
||||
class StorageActivity : BackupActivity() {
|
||||
|
||||
private lateinit var viewModel: StorageViewModel
|
||||
|
|
|
@ -20,7 +20,6 @@ import android.view.View
|
|||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
||||
|
@ -47,8 +46,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
|||
|
||||
private lateinit var viewModel: StorageViewModel
|
||||
private lateinit var titleView: TextView
|
||||
private lateinit var warningIcon: ImageView
|
||||
private lateinit var warningText: TextView
|
||||
private lateinit var listView: RecyclerView
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var skipView: TextView
|
||||
|
@ -63,8 +60,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
|||
val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false)
|
||||
|
||||
titleView = v.requireViewById(R.id.titleView)
|
||||
warningIcon = v.requireViewById(R.id.warningIcon)
|
||||
warningText = v.requireViewById(R.id.warningText)
|
||||
listView = v.requireViewById(R.id.listView)
|
||||
progressBar = v.requireViewById(R.id.progressBar)
|
||||
skipView = v.requireViewById(R.id.skipView)
|
||||
|
@ -90,12 +85,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
|||
requireActivity().setResult(RESULT_FIRST_USER)
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
} else {
|
||||
warningIcon.visibility = VISIBLE
|
||||
if (viewModel.hasStorageSet) {
|
||||
warningText.setText(R.string.storage_fragment_warning_delete)
|
||||
}
|
||||
warningText.visibility = VISIBLE
|
||||
}
|
||||
|
||||
listView.adapter = adapter
|
||||
|
|
|
@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
|
|||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
|
@ -26,6 +25,7 @@ import kotlinx.coroutines.launch
|
|||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
|
||||
internal abstract class StorageViewModel(
|
||||
private val app: Application,
|
||||
|
@ -48,8 +48,6 @@ internal abstract class StorageViewModel(
|
|||
private var safOption: SafOption? = null
|
||||
|
||||
internal var isSetupWizard: Boolean = false
|
||||
internal val hasStorageSet: Boolean
|
||||
get() = backendManager.backendProperties != null
|
||||
abstract val isRestoreOperation: Boolean
|
||||
|
||||
internal fun loadStorageRoots() {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue