Merge pull request #750 from grote/app-backup-v2

App backup format v2 with compression and deduplication
This commit is contained in:
Torsten Grote 2024-10-10 17:29:58 -03:00 committed by GitHub
commit 5365ef3a5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
213 changed files with 10054 additions and 4090 deletions

View file

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

View file

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

View file

@ -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">

View file

@ -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,

View file

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

View file

@ -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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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()

View file

@ -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(),

View file

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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" />

View 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>

View file

@ -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"

View file

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

View file

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

View 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)"
}
}

View file

@ -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 {

View file

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

View file

@ -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
}
/**

View file

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

View file

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

View file

@ -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 -> {

View file

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

View file

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

View file

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

View file

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

View file

@ -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 =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"
}
}
}
}
}

View file

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

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

View file

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

View 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()
}
}

View 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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 -> {

View file

@ -39,8 +39,8 @@ class RestoreService : Service() {
override fun onDestroy() {
Log.i(TAG, "onDestroy")
super.onDestroy()
nm.cancelRestoreNotification()
super.onDestroy()
}
}

View file

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

View file

@ -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() {

View file

@ -19,6 +19,7 @@ val restoreUiModule = module {
settingsManager = get(),
keyManager = get(),
backupManager = get(),
appBackupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),

View file

@ -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,

View file

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

View file

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

View file

@ -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 -> {

View file

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

View file

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

View file

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

View file

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

View file

@ -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")!!

View file

@ -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.

View file

@ -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 {

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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(),

View file

@ -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(),

View file

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

View file

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

View file

@ -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,
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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