Compare commits
81 commits
Author | SHA1 | Date | |
---|---|---|---|
|
87663a5235 | ||
|
6855fa2c24 | ||
|
fed09d1193 | ||
|
447f1e5fb4 | ||
|
63c2d2be11 | ||
|
7aa96b53ab | ||
|
2131f6bf20 | ||
|
6355a99c71 | ||
|
56256d3eeb | ||
|
5dcca5d62d | ||
|
1245985b50 | ||
|
f300a7a979 | ||
|
6820ada3b3 | ||
|
d42b350af3 | ||
|
0c576e724a | ||
|
14b9feddf6 | ||
|
f2d375adec | ||
|
3f69269c5f | ||
|
4169340f82 | ||
|
ca27c6dcf7 | ||
|
80cb57d253 | ||
|
87f9c8f96e | ||
|
98e34a1eb3 | ||
|
47790d7556 | ||
|
9d688ef9a4 | ||
|
c8cdb84877 | ||
|
16ab41c4b6 | ||
|
716fc31e1b | ||
|
798b0beb1b | ||
|
0246e2aad2 | ||
|
5738b41a8e | ||
|
bc999bb141 | ||
|
37a16ed42e | ||
|
1ce2d199fa | ||
|
15de2da9d8 | ||
|
932e414dff | ||
|
24cffd5a6e | ||
|
3bbd1b0fc5 | ||
|
915977551e | ||
|
a3b6c9ac36 | ||
|
10fa0e8039 | ||
|
4379b01235 | ||
|
adcdc70761 | ||
|
78d7966d56 | ||
|
b974c31515 | ||
|
4b72cf87ec | ||
|
06c9642a22 | ||
|
db0721cd8d | ||
|
9f85a66235 | ||
|
965431149e | ||
|
397f27b460 | ||
|
1e3263ec54 | ||
|
5f771ff4ec | ||
|
fa617fbaae | ||
|
15969e0d88 | ||
|
02438c91d3 | ||
|
fac1eada12 | ||
|
e9fd97c41e | ||
|
0ce613b64d | ||
|
b12adcd4c0 | ||
|
30e70527fb | ||
|
b9ffe2c03e | ||
|
72871d3d66 | ||
|
425459fe79 | ||
|
f6ea5c1db5 | ||
|
a425ae706e | ||
|
d2c426db93 | ||
|
740fe53a52 | ||
|
897fd8473e | ||
|
77ce3f6fe8 | ||
|
0b6742df44 | ||
|
a63a893a61 | ||
|
5515e5c88f | ||
|
30e66f368e | ||
|
2958c8fac8 | ||
|
18d83767b3 | ||
|
131c5b6b29 | ||
|
22aaaeb1fd | ||
|
2f62e9515c | ||
|
b563893304 | ||
|
f5f341b7b7 |
194 changed files with 5341 additions and 1162 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -7,7 +7,8 @@ hs_err_pid*
|
||||||
## Intellij
|
## Intellij
|
||||||
out/
|
out/
|
||||||
lib/
|
lib/
|
||||||
.idea/
|
.idea/*
|
||||||
|
!.idea/runConfigurations*
|
||||||
*.ipr
|
*.ipr
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
|
|
12
.idea/runConfigurations.xml
Normal file
12
.idea/runConfigurations.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
48
.idea/runConfigurations/Instrumentation_Tests.xml
Normal file
48
.idea/runConfigurations/Instrumentation_Tests.xml
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
|
||||||
|
<module name="app" />
|
||||||
|
<option name="TESTING_TYPE" value="0" />
|
||||||
|
<option name="METHOD_NAME" value="" />
|
||||||
|
<option name="CLASS_NAME" value="" />
|
||||||
|
<option name="PACKAGE_NAME" value="" />
|
||||||
|
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
|
||||||
|
<option name="EXTRA_OPTIONS" value="-e notAnnotation androidx.test.filters.LargeTest" />
|
||||||
|
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
|
||||||
|
<option name="CLEAR_LOGCAT" value="false" />
|
||||||
|
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
|
||||||
|
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
|
||||||
|
<option name="FORCE_STOP_RUNNING_APP" value="true" />
|
||||||
|
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
|
||||||
|
<option name="DEBUGGER_TYPE" value="Auto" />
|
||||||
|
<Auto>
|
||||||
|
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||||
|
<option name="SHOW_STATIC_VARS" value="true" />
|
||||||
|
<option name="WORKING_DIR" value="" />
|
||||||
|
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||||
|
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||||
|
</Auto>
|
||||||
|
<Hybrid>
|
||||||
|
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||||
|
<option name="SHOW_STATIC_VARS" value="true" />
|
||||||
|
<option name="WORKING_DIR" value="" />
|
||||||
|
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||||
|
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||||
|
</Hybrid>
|
||||||
|
<Java />
|
||||||
|
<Native>
|
||||||
|
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
|
||||||
|
<option name="SHOW_STATIC_VARS" value="true" />
|
||||||
|
<option name="WORKING_DIR" value="" />
|
||||||
|
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
|
||||||
|
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
|
||||||
|
</Native>
|
||||||
|
<Profilers>
|
||||||
|
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
|
||||||
|
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
|
||||||
|
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
|
||||||
|
</Profilers>
|
||||||
|
<method v="2">
|
||||||
|
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
17
.idea/runConfigurations/Unit_Tests.xml
Normal file
17
.idea/runConfigurations/Unit_Tests.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
|
||||||
|
<module name="app" />
|
||||||
|
<useClassPathOnly />
|
||||||
|
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||||
|
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />
|
||||||
|
<option name="MAIN_CLASS_NAME" value="" />
|
||||||
|
<option name="METHOD_NAME" value="" />
|
||||||
|
<option name="TEST_OBJECT" value="directory" />
|
||||||
|
<option name="PARAMETERS" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
|
||||||
|
<dir value="$PROJECT_DIR$/app/src/test/java/com/stevesoltys/seedvault" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
|
@ -31,12 +31,3 @@ cache:
|
||||||
- $HOME/.gradle/caches/
|
- $HOME/.gradle/caches/
|
||||||
- $HOME/.gradle/wrapper/
|
- $HOME/.gradle/wrapper/
|
||||||
- $HOME/.android/build-cache
|
- $HOME/.android/build-cache
|
||||||
|
|
||||||
deploy:
|
|
||||||
provider: script
|
|
||||||
script: ./deploy-prebuilt.sh
|
|
||||||
skip_cleanup: true
|
|
||||||
on:
|
|
||||||
repo: stevesoltys/seedvault
|
|
||||||
all_branches: true
|
|
||||||
condition: $TRAVIS_BRANCH =~ ^(master|develop)$
|
|
||||||
|
|
69
Android.bp
Normal file
69
Android.bp
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// Copyright (C) 2018 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
//
|
||||||
|
|
||||||
|
android_app {
|
||||||
|
name: "Seedvault",
|
||||||
|
srcs: [
|
||||||
|
"app/src/main/java/**/*.kt",
|
||||||
|
"app/src/main/java/**/*.java",
|
||||||
|
],
|
||||||
|
resource_dirs: [
|
||||||
|
"app/src/main/res",
|
||||||
|
],
|
||||||
|
static_libs: [
|
||||||
|
"com.google.android.material_material",
|
||||||
|
"androidx.core_core",
|
||||||
|
"androidx.preference_preference",
|
||||||
|
"androidx.lifecycle_lifecycle-extensions",
|
||||||
|
"androidx-constraintlayout_constraintlayout",
|
||||||
|
"seedvault-lib-androidx-core-ktx",
|
||||||
|
"seedvault-lib-androidx-lifecycle-livedata-core-ktx",
|
||||||
|
"seedvault-lib-androidx-lifecycle-livedata-ktx",
|
||||||
|
"seedvault-lib-androidx-lifecycle-viewmodel-ktx",
|
||||||
|
"seedvault-lib-koin-android",
|
||||||
|
"seedvault-lib-koin-androidx-viewmodel",
|
||||||
|
"seedvault-lib-commons-io",
|
||||||
|
"seedvault-lib-koin-core",
|
||||||
|
"seedvault-lib-kotlinx-coroutines-android",
|
||||||
|
"seedvault-lib-kotlinx-coroutines-core",
|
||||||
|
"seedvault-lib-novacrypto-bip39",
|
||||||
|
"seedvault-lib-novacrypto-sha256",
|
||||||
|
"seedvault-lib-novacrypto-toruntime"
|
||||||
|
],
|
||||||
|
manifest: "app/src/main/AndroidManifest.xml",
|
||||||
|
|
||||||
|
platform_apis: true,
|
||||||
|
certificate: "platform",
|
||||||
|
privileged: true,
|
||||||
|
required: [
|
||||||
|
"privapp_whitelist_com.stevesoltys.backup",
|
||||||
|
"com.stevesoltys.backup_whitelist"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
prebuilt_etc {
|
||||||
|
name: "privapp_whitelist_com.stevesoltys.backup",
|
||||||
|
sub_dir: "permissions",
|
||||||
|
src: "permissions_com.stevesoltys.seedvault.xml",
|
||||||
|
filename_from_src: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
prebuilt_etc {
|
||||||
|
name: "com.stevesoltys.backup_whitelist",
|
||||||
|
sub_dir: "sysconfig",
|
||||||
|
src: "whitelist_com.stevesoltys.seedvault.xml",
|
||||||
|
filename_from_src: true,
|
||||||
|
}
|
27
Android.mk
27
Android.mk
|
@ -1,27 +0,0 @@
|
||||||
LOCAL_PATH := $(call my-dir)
|
|
||||||
|
|
||||||
include $(CLEAR_VARS)
|
|
||||||
LOCAL_MODULE := permissions_com.stevesoltys.seedvault.xml
|
|
||||||
LOCAL_MODULE_CLASS := ETC
|
|
||||||
LOCAL_MODULE_TAGS := optional
|
|
||||||
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/permissions
|
|
||||||
LOCAL_SRC_FILES := $(LOCAL_MODULE)
|
|
||||||
include $(BUILD_PREBUILT)
|
|
||||||
|
|
||||||
include $(CLEAR_VARS)
|
|
||||||
LOCAL_MODULE := whitelist_com.stevesoltys.seedvault.xml
|
|
||||||
LOCAL_MODULE_CLASS := ETC
|
|
||||||
LOCAL_MODULE_TAGS := optional
|
|
||||||
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/sysconfig
|
|
||||||
LOCAL_SRC_FILES := $(LOCAL_MODULE)
|
|
||||||
include $(BUILD_PREBUILT)
|
|
||||||
|
|
||||||
include $(CLEAR_VARS)
|
|
||||||
LOCAL_MODULE := Seedvault
|
|
||||||
LOCAL_SRC_FILES := Seedvault.apk
|
|
||||||
LOCAL_CERTIFICATE := platform
|
|
||||||
LOCAL_MODULE_CLASS := APPS
|
|
||||||
LOCAL_PRIVILEGED_MODULE := true
|
|
||||||
LOCAL_DEX_PREOPT := false
|
|
||||||
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.seedvault.xml whitelist_com.stevesoltys.seedvault.xml
|
|
||||||
include $(BUILD_PREBUILT)
|
|
|
@ -24,7 +24,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
|
||||||
* `android.permission.BACKUP` to back up application data.
|
* `android.permission.BACKUP` to back up application data.
|
||||||
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
|
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
|
||||||
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
|
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
|
||||||
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings.
|
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
|
||||||
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
|
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
|
@ -2,16 +2,25 @@ import groovy.xml.XmlUtil
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'kotlin-android-extensions'
|
|
||||||
|
def gitDescribe = { ->
|
||||||
|
def stdout = new ByteArrayOutputStream()
|
||||||
|
exec {
|
||||||
|
commandLine 'git', 'describe', '--always', '--tags', '--dirty=-dirty'
|
||||||
|
standardOutput = stdout
|
||||||
|
}
|
||||||
|
return stdout.toString().trim()
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
||||||
compileSdkVersion 29
|
compileSdkVersion 29
|
||||||
buildToolsVersion '29.0.2'
|
buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 29
|
minSdkVersion 29
|
||||||
targetSdkVersion 29
|
targetSdkVersion 29
|
||||||
|
versionNameSuffix "-$gitDescribe"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
||||||
}
|
}
|
||||||
|
@ -109,6 +118,9 @@ def aospDeps = fileTree(include: [
|
||||||
'libcore.jar'
|
'libcore.jar'
|
||||||
], dir: 'libs')
|
], dir: 'libs')
|
||||||
|
|
||||||
|
// If the dependencies below are updated please make sure to update the
|
||||||
|
// prebuilt libraries and Android.bp in the top `libs` folder to reflect that.
|
||||||
|
// You can copy these libraries from ~/.gradle/caches/modules-2
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly aospDeps
|
compileOnly aospDeps
|
||||||
|
|
||||||
|
@ -123,20 +135,26 @@ dependencies {
|
||||||
implementation 'com.google.android.material:material:1.0.0'
|
implementation 'com.google.android.material:material:1.0.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-rc03'
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha05'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||||
|
|
||||||
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
|
lintChecks 'com.github.thirdegg:lint-rules:0.0.5-alpha'
|
||||||
|
|
||||||
def junit_version = "5.5.2"
|
def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
|
||||||
testImplementation aospDeps
|
def mockk_version = "1.10.0"
|
||||||
|
testImplementation aospDeps // anything less fails tests run with gradlew
|
||||||
testImplementation 'androidx.test.ext:junit:1.1.1'
|
testImplementation 'androidx.test.ext:junit:1.1.1'
|
||||||
testImplementation 'org.robolectric:robolectric:4.3.1'
|
testImplementation('org.robolectric:robolectric:4.3.1') {
|
||||||
|
// https://github.com/robolectric/robolectric/issues/5245
|
||||||
|
exclude group: "com.google.auto.service", module: "auto-service"
|
||||||
|
}
|
||||||
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
|
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
|
||||||
testImplementation 'io.mockk:mockk:1.9.3'
|
testImplementation "io.mockk:mockk:$mockk_version"
|
||||||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
|
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
|
||||||
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
|
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
androidTestImplementation 'androidx.test:runner:1.2.0'
|
||||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
androidTestImplementation 'androidx.test:rules:1.2.0'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||||
|
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
|
||||||
}
|
}
|
||||||
|
|
4
app/lint.xml
Normal file
4
app/lint.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<lint>
|
||||||
|
<issue id="MissingTranslation" severity="ignore" />
|
||||||
|
</lint>
|
|
@ -1,8 +1,8 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.LargeTest
|
import androidx.test.filters.LargeTest
|
||||||
import androidx.test.runner.AndroidJUnit4
|
|
||||||
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
|
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
package com.stevesoltys.seedvault
|
|
||||||
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.runner.AndroidJUnit4
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
|
||||||
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.core.KoinComponent
|
|
||||||
import org.koin.core.inject
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
private const val filename = "test-file"
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class DocumentsStorageTest : KoinComponent {
|
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
private val metadataManager by inject<MetadataManager>()
|
|
||||||
private val settingsManager by inject<SettingsManager>()
|
|
||||||
private val storage = DocumentsStorage(context, metadataManager, settingsManager)
|
|
||||||
|
|
||||||
private lateinit var file: DocumentFile
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
|
|
||||||
file = storage.rootBackupDir?.createOrGetFile(filename)
|
|
||||||
?: throw RuntimeException("Could not create test file")
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testWritingAndReadingFile() {
|
|
||||||
// write to output stream
|
|
||||||
val outputStream = storage.getOutputStream(file)
|
|
||||||
val content = ByteArray(1337).apply { Random.nextBytes(this) }
|
|
||||||
outputStream.write(content)
|
|
||||||
outputStream.flush()
|
|
||||||
outputStream.close()
|
|
||||||
|
|
||||||
// read written data from input stream
|
|
||||||
val inputStream = storage.getInputStream(file)
|
|
||||||
val readContent = inputStream.readBytes()
|
|
||||||
inputStream.close()
|
|
||||||
assertArrayEquals(content, readContent)
|
|
||||||
|
|
||||||
// write smaller content to same file
|
|
||||||
val outputStream2 = storage.getOutputStream(file)
|
|
||||||
val content2 = ByteArray(42).apply { Random.nextBytes(this) }
|
|
||||||
outputStream2.write(content2)
|
|
||||||
outputStream2.flush()
|
|
||||||
outputStream2.close()
|
|
||||||
|
|
||||||
// read written data from input stream
|
|
||||||
val inputStream2 = storage.getInputStream(file)
|
|
||||||
val readContent2 = inputStream2.readBytes()
|
|
||||||
inputStream2.close()
|
|
||||||
assertArrayEquals(content2, readContent2)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
349
app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
Normal file
349
app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
Normal file
|
@ -0,0 +1,349 @@
|
||||||
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullBackup
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVBackup
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH_NEXTCLOUD
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.deleteContents
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
|
import org.koin.core.inject
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
class PluginTest : KoinComponent {
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val mockedSettingsManager: SettingsManager = mockk()
|
||||||
|
private val storage = DocumentsStorage(context, mockedSettingsManager)
|
||||||
|
|
||||||
|
private val kvBackupPlugin: KVBackupPlugin = DocumentsProviderKVBackup(context, storage)
|
||||||
|
private val fullBackupPlugin: FullBackupPlugin = DocumentsProviderFullBackup(context, storage)
|
||||||
|
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(
|
||||||
|
context,
|
||||||
|
storage,
|
||||||
|
kvBackupPlugin,
|
||||||
|
fullBackupPlugin
|
||||||
|
)
|
||||||
|
|
||||||
|
private val kvRestorePlugin: KVRestorePlugin =
|
||||||
|
DocumentsProviderKVRestorePlugin(context, storage)
|
||||||
|
private val fullRestorePlugin: FullRestorePlugin =
|
||||||
|
DocumentsProviderFullRestorePlugin(context, storage)
|
||||||
|
private val restorePlugin: RestorePlugin =
|
||||||
|
DocumentsProviderRestorePlugin(context, storage, kvRestorePlugin, fullRestorePlugin)
|
||||||
|
|
||||||
|
private val token = Random.nextLong()
|
||||||
|
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
||||||
|
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() = runBlocking {
|
||||||
|
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
|
||||||
|
storage.rootBackupDir?.deleteContents(context)
|
||||||
|
?: error("Select a storage location in the app first!")
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = runBlocking {
|
||||||
|
storage.rootBackupDir?.deleteContents(context)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testProviderPackageName() {
|
||||||
|
assertNotNull(backupPlugin.providerPackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test initializes the storage three times while creating two new restore sets.
|
||||||
|
*
|
||||||
|
* If this is run against a Nextcloud storage backend,
|
||||||
|
* it has a high chance of getting a loading cursor in the underlying queries
|
||||||
|
* that needs to get re-queried to get real results.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||||
|
// no backups available initially
|
||||||
|
assertEquals(0, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
|
||||||
|
assertFalse(restorePlugin.hasBackup(uri))
|
||||||
|
|
||||||
|
// prepare returned tokens requested when initializing device
|
||||||
|
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||||
|
|
||||||
|
// start new restore set and initialize device afterwards
|
||||||
|
backupPlugin.startNewRestoreSet(token)
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
|
||||||
|
// write metadata (needed for backup to be recognized)
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||||
|
|
||||||
|
// one backup available now
|
||||||
|
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
assertTrue(restorePlugin.hasBackup(uri))
|
||||||
|
|
||||||
|
// initializing again (with another restore set) does add a restore set
|
||||||
|
backupPlugin.startNewRestoreSet(token + 1)
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||||
|
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
assertTrue(restorePlugin.hasBackup(uri))
|
||||||
|
|
||||||
|
// initializing again (without new restore set) doesn't change number of restore sets
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||||
|
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
|
// ensure that the new backup dirs exist
|
||||||
|
assertTrue(storage.currentKvBackupDir!!.exists())
|
||||||
|
assertTrue(storage.currentFullBackupDir!!.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||||
|
every { mockedSettingsManager.getToken() } returns token
|
||||||
|
|
||||||
|
backupPlugin.startNewRestoreSet(token)
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
|
||||||
|
// write metadata
|
||||||
|
val metadata = getRandomByteArray()
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
|
||||||
|
|
||||||
|
// get available backups, expect only one with our token and no error
|
||||||
|
var availableBackups = restorePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
|
assertEquals(1, availableBackups.size)
|
||||||
|
assertEquals(token, availableBackups[0].token)
|
||||||
|
assertFalse(availableBackups[0].error)
|
||||||
|
|
||||||
|
// read metadata matches what was written earlier
|
||||||
|
assertReadEquals(metadata, availableBackups[0].inputStream)
|
||||||
|
|
||||||
|
// initializing again (without changing storage) keeps restore set with same token
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
|
||||||
|
availableBackups = restorePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
|
assertEquals(1, availableBackups.size)
|
||||||
|
assertEquals(token, availableBackups[0].token)
|
||||||
|
assertFalse(availableBackups[0].error)
|
||||||
|
|
||||||
|
// metadata hasn't changed
|
||||||
|
assertReadEquals(metadata, availableBackups[0].inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testApkWriteRead() = runBlocking {
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// write random bytes as APK
|
||||||
|
val apk1 = getRandomByteArray(1337 * 1024)
|
||||||
|
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk1)
|
||||||
|
|
||||||
|
// assert that read APK bytes match what was written
|
||||||
|
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName))
|
||||||
|
|
||||||
|
// write random bytes as another APK
|
||||||
|
val apk2 = getRandomByteArray(23 * 1024 * 1024)
|
||||||
|
backupPlugin.getApkOutputStream(packageInfo2).writeAndClose(apk2)
|
||||||
|
|
||||||
|
// assert that read APK bytes match what was written
|
||||||
|
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testKvBackupRestore() = runBlocking {
|
||||||
|
// define shortcuts
|
||||||
|
val kvBackup = backupPlugin.kvBackupPlugin
|
||||||
|
val kvRestore = restorePlugin.kvRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// no data available for given package
|
||||||
|
assertFalse(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// define key/value pair records
|
||||||
|
val record1 = Pair(getRandomBase64(23), getRandomByteArray(1337))
|
||||||
|
val record2 = Pair(getRandomBase64(42), getRandomByteArray(42 * 1024))
|
||||||
|
val record3 = Pair(getRandomBase64(128), getRandomByteArray(5 * 1024 * 1024))
|
||||||
|
|
||||||
|
// write first record
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record1.first).writeAndClose(record1.second)
|
||||||
|
|
||||||
|
// data is now available for current token and given package, but not for different token
|
||||||
|
assertTrue(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token + 1, packageInfo))
|
||||||
|
|
||||||
|
// record for package is found and returned properly
|
||||||
|
var records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(1, records.size)
|
||||||
|
assertEquals(record1.first, records[0])
|
||||||
|
assertReadEquals(
|
||||||
|
record1.second,
|
||||||
|
kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)
|
||||||
|
)
|
||||||
|
|
||||||
|
// write second and third record
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record2.first).writeAndClose(record2.second)
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, record3.first).writeAndClose(record3.second)
|
||||||
|
|
||||||
|
// all records for package are found and returned properly
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(record1.first, record2.first, record3.first).sorted(), records.sorted())
|
||||||
|
assertReadEquals(
|
||||||
|
record1.second,
|
||||||
|
kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)
|
||||||
|
)
|
||||||
|
assertReadEquals(
|
||||||
|
record2.second,
|
||||||
|
kvRestore.getInputStreamForRecord(token, packageInfo, record2.first)
|
||||||
|
)
|
||||||
|
assertReadEquals(
|
||||||
|
record3.second,
|
||||||
|
kvRestore.getInputStreamForRecord(token, packageInfo, record3.first)
|
||||||
|
)
|
||||||
|
|
||||||
|
// delete record3 and ensure that the other two are still found
|
||||||
|
kvBackup.deleteRecord(packageInfo, record3.first)
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(record1.first, record2.first).sorted(), records.sorted())
|
||||||
|
|
||||||
|
// remove all data of package and ensure that it is gone
|
||||||
|
kvBackup.removeDataOfPackage(packageInfo)
|
||||||
|
assertFalse(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMaxKvKeyLength() = runBlocking {
|
||||||
|
// define shortcuts
|
||||||
|
val kvBackup = backupPlugin.kvBackupPlugin
|
||||||
|
val kvRestore = restorePlugin.kvRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
assertFalse(kvBackup.hasDataForPackage(packageInfo))
|
||||||
|
|
||||||
|
// FIXME get Nextcloud to have the same limit
|
||||||
|
// Since Nextcloud is using WebDAV and that seems to have undefined lower file name limits
|
||||||
|
// we might have to lower our maximum to accommodate for that.
|
||||||
|
val max = if (isNextcloud()) MAX_KEY_LENGTH_NEXTCLOUD else MAX_KEY_LENGTH
|
||||||
|
val maxOver = if (isNextcloud()) max + 10 else max + 1
|
||||||
|
|
||||||
|
// define record with maximum key length and one above the maximum
|
||||||
|
val recordMax = Pair(getRandomBase64(max), getRandomByteArray(1024))
|
||||||
|
val recordOver = Pair(getRandomBase64(maxOver), getRandomByteArray(1024))
|
||||||
|
|
||||||
|
// write max record
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, recordMax.first)
|
||||||
|
.writeAndClose(recordMax.second)
|
||||||
|
|
||||||
|
// max record is found correctly
|
||||||
|
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
val records = kvRestore.listRecords(token, packageInfo)
|
||||||
|
assertEquals(listOf(recordMax.first), records)
|
||||||
|
|
||||||
|
// write exceeding key length record
|
||||||
|
if (isNextcloud()) {
|
||||||
|
// Nextcloud simply refuses to write long filenames
|
||||||
|
coAssertThrows(IOException::class.java) {
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first)
|
||||||
|
.writeAndClose(recordOver.second)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
coAssertThrows(IllegalStateException::class.java) {
|
||||||
|
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first)
|
||||||
|
.writeAndClose(recordOver.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFullBackupRestore() = runBlocking {
|
||||||
|
// define shortcuts
|
||||||
|
val fullBackup = backupPlugin.fullBackupPlugin
|
||||||
|
val fullRestore = restorePlugin.fullRestorePlugin
|
||||||
|
|
||||||
|
// initialize storage with given token
|
||||||
|
initStorage(token)
|
||||||
|
|
||||||
|
// no data available initially
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// write full backup data
|
||||||
|
val data = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
fullBackup.getOutputStream(packageInfo).writeAndClose(data)
|
||||||
|
|
||||||
|
// data is available now, but only this token
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo))
|
||||||
|
|
||||||
|
// restore data matches backed up data
|
||||||
|
assertReadEquals(data, fullRestore.getInputStreamForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// write and check data for second package
|
||||||
|
val data2 = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
fullBackup.getOutputStream(packageInfo2).writeAndClose(data2)
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
assertReadEquals(data2, fullRestore.getInputStreamForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// remove data of first package again and ensure that no more data is found
|
||||||
|
fullBackup.removeDataOfPackage(packageInfo)
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
|
||||||
|
|
||||||
|
// second package is still there
|
||||||
|
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
|
||||||
|
// ensure that it gets deleted as well
|
||||||
|
fullBackup.removeDataOfPackage(packageInfo2)
|
||||||
|
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initStorage(token: Long) = runBlocking {
|
||||||
|
every { mockedSettingsManager.getToken() } returns token
|
||||||
|
backupPlugin.initializeDevice()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNextcloud(): Boolean {
|
||||||
|
return backupPlugin.providerPackageName?.startsWith("com.nextcloud") ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract.EXTRA_LOADING
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.stevesoltys.seedvault.assertReadEquals
|
||||||
|
import com.stevesoltys.seedvault.coAssertThrows
|
||||||
|
import com.stevesoltys.seedvault.getRandomBase64
|
||||||
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.writeAndClose
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
|
import org.koin.core.inject
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
class DocumentsStorageTest : KoinComponent {
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val metadataManager by inject<MetadataManager>()
|
||||||
|
private val settingsManager by inject<SettingsManager>()
|
||||||
|
private val storage = DocumentsStorage(context, settingsManager)
|
||||||
|
|
||||||
|
private val filename = getRandomBase64()
|
||||||
|
private lateinit var file: DocumentFile
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() = runBlocking {
|
||||||
|
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
|
||||||
|
file = storage.rootBackupDir?.createOrGetFile(context, filename)
|
||||||
|
?: error("Could not create test file")
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWritingAndReadingFile() {
|
||||||
|
// write to output stream
|
||||||
|
val outputStream = storage.getOutputStream(file)
|
||||||
|
val content = ByteArray(1337).apply { Random.nextBytes(this) }
|
||||||
|
outputStream.write(content)
|
||||||
|
outputStream.flush()
|
||||||
|
outputStream.close()
|
||||||
|
|
||||||
|
// read written data from input stream
|
||||||
|
val inputStream = storage.getInputStream(file)
|
||||||
|
val readContent = inputStream.readBytes()
|
||||||
|
inputStream.close()
|
||||||
|
assertArrayEquals(content, readContent)
|
||||||
|
|
||||||
|
// write smaller content to same file
|
||||||
|
val outputStream2 = storage.getOutputStream(file)
|
||||||
|
val content2 = ByteArray(42).apply { Random.nextBytes(this) }
|
||||||
|
outputStream2.write(content2)
|
||||||
|
outputStream2.flush()
|
||||||
|
outputStream2.close()
|
||||||
|
|
||||||
|
// read written data from input stream
|
||||||
|
val inputStream2 = storage.getInputStream(file)
|
||||||
|
val readContent2 = inputStream2.readBytes()
|
||||||
|
inputStream2.close()
|
||||||
|
assertArrayEquals(content2, readContent2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFindFile() = runBlocking(Dispatchers.IO) {
|
||||||
|
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
|
||||||
|
assertNotNull(foundFile)
|
||||||
|
assertEquals(filename, foundFile!!.name)
|
||||||
|
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateFile() {
|
||||||
|
// create test file
|
||||||
|
val dir = storage.rootBackupDir!!
|
||||||
|
val createdFile = dir.createFile("text", getRandomBase64())
|
||||||
|
assertNotNull(createdFile)
|
||||||
|
assertNotNull(createdFile!!.name)
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data = getRandomByteArray()
|
||||||
|
context.contentResolver.openOutputStream(createdFile.uri)!!.writeAndClose(data)
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data, context.contentResolver.openInputStream(createdFile.uri))
|
||||||
|
|
||||||
|
// delete again
|
||||||
|
createdFile.delete()
|
||||||
|
assertFalse(createdFile.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTwoFiles() = runBlocking {
|
||||||
|
val mimeType = "application/octet-stream"
|
||||||
|
val dir = storage.rootBackupDir!!
|
||||||
|
|
||||||
|
// create test file
|
||||||
|
val name1 = getRandomBase64(Random.nextInt(1, 10))
|
||||||
|
val file1 = requireNotNull(dir.createFile(mimeType, name1))
|
||||||
|
assertTrue(file1.exists())
|
||||||
|
assertEquals(name1, file1.name)
|
||||||
|
assertEquals(0L, file1.length())
|
||||||
|
|
||||||
|
assertReadEquals(getRandomByteArray(0), context.contentResolver.openInputStream(file1.uri))
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data1 = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
context.contentResolver.openOutputStream(file1.uri)!!.writeAndClose(data1)
|
||||||
|
assertEquals(data1.size.toLong(), file1.length())
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data1, context.contentResolver.openInputStream(file1.uri))
|
||||||
|
|
||||||
|
// create test file
|
||||||
|
val name2 = getRandomBase64(Random.nextInt(1, 10))
|
||||||
|
val file2 = requireNotNull(dir.createFile(mimeType, name2))
|
||||||
|
assertTrue(file2.exists())
|
||||||
|
assertEquals(name2, file2.name)
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data2 = getRandomByteArray(12 * 1024 * 1024)
|
||||||
|
context.contentResolver.openOutputStream(file2.uri)!!.writeAndClose(data2)
|
||||||
|
assertEquals(data2.size.toLong(), file2.length())
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data2, context.contentResolver.openInputStream(file2.uri))
|
||||||
|
|
||||||
|
// delete files again
|
||||||
|
file1.delete()
|
||||||
|
file2.delete()
|
||||||
|
assertFalse(file1.exists())
|
||||||
|
assertFalse(file2.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetLoadedCursor() = runBlocking {
|
||||||
|
// empty cursor extras are like not loading, returns same cursor right away
|
||||||
|
val cursor1: Cursor = mockk()
|
||||||
|
every { cursor1.extras } returns Bundle()
|
||||||
|
assertEquals(cursor1, getLoadedCursor { cursor1 })
|
||||||
|
|
||||||
|
// explicitly not loading, returns same cursor right away
|
||||||
|
val cursor2: Cursor = mockk()
|
||||||
|
every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) }
|
||||||
|
assertEquals(cursor2, getLoadedCursor { cursor2 })
|
||||||
|
|
||||||
|
// loading cursor registers content observer, times out and closes cursor
|
||||||
|
val cursor3: Cursor = mockk()
|
||||||
|
every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor3.registerContentObserver(any()) } just Runs
|
||||||
|
every { cursor3.close() } just Runs
|
||||||
|
coAssertThrows(TimeoutCancellationException::class.java) {
|
||||||
|
getLoadedCursor(1000) { cursor3 }
|
||||||
|
}
|
||||||
|
verify { cursor3.registerContentObserver(any()) }
|
||||||
|
verify { cursor3.close() } // ensure that cursor gets closed
|
||||||
|
|
||||||
|
// loading cursor registers content observer, but re-query fails
|
||||||
|
val cursor4: Cursor = mockk()
|
||||||
|
val observer4 = slot<ContentObserver>()
|
||||||
|
val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 }
|
||||||
|
every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor4.registerContentObserver(capture(observer4)) } answers {
|
||||||
|
observer4.captured.onChange(false, Uri.parse("foo://bar"))
|
||||||
|
}
|
||||||
|
every { cursor4.close() } just Runs
|
||||||
|
coAssertThrows(IOException::class.java) {
|
||||||
|
getLoadedCursor(10_000, query)
|
||||||
|
}
|
||||||
|
assertTrue(observer4.isCaptured)
|
||||||
|
verify { cursor4.close() } // ensure that cursor gets closed
|
||||||
|
|
||||||
|
// loading cursor registers content observer, re-queries and returns new result
|
||||||
|
val cursor5: Cursor = mockk()
|
||||||
|
val result5: Cursor = mockk()
|
||||||
|
val observer5 = slot<ContentObserver>()
|
||||||
|
val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 }
|
||||||
|
every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor5.registerContentObserver(capture(observer5)) } answers {
|
||||||
|
observer5.captured.onChange(false, null)
|
||||||
|
}
|
||||||
|
every { cursor5.close() } just Runs
|
||||||
|
assertEquals(result5, getLoadedCursor(10_000, query5))
|
||||||
|
assertTrue(observer5.isCaptured)
|
||||||
|
verify { cursor5.close() } // ensure that initial cursor got closed
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
|
import org.koin.core.inject
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PackageServiceTest : KoinComponent {
|
||||||
|
|
||||||
|
private val packageService: PackageService by inject()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNotAllowedPackages() {
|
||||||
|
val packages = packageService.notAllowedPackages
|
||||||
|
assertTrue(packages.isNotEmpty())
|
||||||
|
Log.e("TEST", "Packages: $packages")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -2,8 +2,12 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.stevesoltys.seedvault"
|
package="com.stevesoltys.seedvault"
|
||||||
android:versionCode="7"
|
android:versionCode="29000001"
|
||||||
android:versionName="1.0.0">
|
android:versionName="10-1.0.0">
|
||||||
|
<!--
|
||||||
|
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
|
||||||
|
The version name is the targeted Android version followed by - and our own version name.
|
||||||
|
-->
|
||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BACKUP"
|
android:name="android.permission.BACKUP"
|
||||||
|
@ -18,7 +22,7 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.MANAGE_USB"
|
android:name="android.permission.MANAGE_USB"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
g
|
|
||||||
<!-- This is needed to change system backup settings -->
|
<!-- This is needed to change system backup settings -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||||
|
|
|
@ -8,6 +8,7 @@ import android.os.Build
|
||||||
import android.os.ServiceManager.getService
|
import android.os.ServiceManager.getService
|
||||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||||
import com.stevesoltys.seedvault.header.headerModule
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||||
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
||||||
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
||||||
|
@ -15,9 +16,11 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsViewModel
|
import com.stevesoltys.seedvault.settings.SettingsViewModel
|
||||||
import com.stevesoltys.seedvault.transport.backup.backupModule
|
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||||
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
|
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
|
||||||
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
|
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
|
||||||
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
|
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
@ -36,9 +39,9 @@ class App : Application() {
|
||||||
single { Clock() }
|
single { Clock() }
|
||||||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||||
|
|
||||||
viewModel { SettingsViewModel(this@App, get(), get(), get()) }
|
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||||
viewModel { RecoveryCodeViewModel(this@App, get()) }
|
viewModel { RecoveryCodeViewModel(this@App, get()) }
|
||||||
viewModel { BackupStorageViewModel(this@App, get(), get()) }
|
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
|
||||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
||||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
|
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
@ -48,7 +51,8 @@ class App : Application() {
|
||||||
startKoin {
|
startKoin {
|
||||||
androidLogger()
|
androidLogger()
|
||||||
androidContext(this@App)
|
androidContext(this@App)
|
||||||
modules(listOf(
|
modules(
|
||||||
|
listOf(
|
||||||
cryptoModule,
|
cryptoModule,
|
||||||
headerModule,
|
headerModule,
|
||||||
metadataModule,
|
metadataModule,
|
||||||
|
@ -56,7 +60,25 @@ class App : Application() {
|
||||||
backupModule,
|
backupModule,
|
||||||
restoreModule,
|
restoreModule,
|
||||||
appModule
|
appModule
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
migrateTokenFromMetadataToSettingsManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val metadataManager: MetadataManager 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() {
|
||||||
|
val token = metadataManager.getBackupToken()
|
||||||
|
if (token != 0L && settingsManager.getToken() == null) {
|
||||||
|
settingsManager.setNewToken(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.metadata
|
package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
|
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
|
@ -39,6 +40,10 @@ enum class PackageState {
|
||||||
* Package data could not get backed up, because the app reported no data to back up.
|
* Package data could not get backed up, because the app reported no data to back up.
|
||||||
*/
|
*/
|
||||||
NO_DATA,
|
NO_DATA,
|
||||||
|
/**
|
||||||
|
* Package data could not get backed up, because the app has [FLAG_STOPPED].
|
||||||
|
*/
|
||||||
|
WAS_STOPPED,
|
||||||
/**
|
/**
|
||||||
* Package data could not get backed up, because it was not allowed.
|
* Package data could not get backed up, because it was not allowed.
|
||||||
* Most often, this is a manifest opt-out, but it could also be a disabled or system-user app.
|
* Most often, this is a manifest opt-out, but it could also be a disabled or system-user app.
|
||||||
|
|
|
@ -2,8 +2,6 @@ package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
|
||||||
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
@ -12,23 +10,27 @@ import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.distinctUntilChanged
|
import androidx.lifecycle.distinctUntilChanged
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
private val TAG = MetadataManager::class.java.simpleName
|
private val TAG = MetadataManager::class.java.simpleName
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
internal const val METADATA_CACHE_FILE = "metadata.cache"
|
internal const val METADATA_CACHE_FILE = "metadata.cache"
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
class MetadataManager(
|
class MetadataManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
private val metadataWriter: MetadataWriter,
|
private val metadataWriter: MetadataWriter,
|
||||||
private val metadataReader: MetadataReader) {
|
private val metadataReader: MetadataReader
|
||||||
|
) {
|
||||||
|
|
||||||
private val uninitializedMetadata = BackupMetadata(token = 0L)
|
private val uninitializedMetadata = BackupMetadata(token = 0L)
|
||||||
private var metadata: BackupMetadata = uninitializedMetadata
|
private var metadata: BackupMetadata = uninitializedMetadata
|
||||||
|
@ -67,7 +69,11 @@ class MetadataManager(
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun onApkBackedUp(packageInfo: PackageInfo, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) {
|
fun onApkBackedUp(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
packageMetadata: PackageMetadata,
|
||||||
|
metadataOutputStream: OutputStream
|
||||||
|
) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
metadata.packageMetadataMap[packageName]?.let {
|
metadata.packageMetadataMap[packageName]?.let {
|
||||||
check(packageMetadata.version != null) {
|
check(packageMetadata.version != null) {
|
||||||
|
@ -78,20 +84,24 @@ class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
||||||
?: PackageMetadata()
|
?: PackageMetadata()
|
||||||
// only allow state change if backup of this package is not allowed
|
// only allow state change if backup of this package is not allowed,
|
||||||
val newState = if (packageMetadata.state == NOT_ALLOWED)
|
// because we need to change from the default of UNKNOWN_ERROR here,
|
||||||
packageMetadata.state
|
// but otherwise don't want to modify the state since set elsewhere.
|
||||||
else
|
val newState =
|
||||||
oldPackageMetadata.state
|
if (packageMetadata.state == NOT_ALLOWED || packageMetadata.state == WAS_STOPPED) {
|
||||||
|
packageMetadata.state
|
||||||
|
} else {
|
||||||
|
oldPackageMetadata.state
|
||||||
|
}
|
||||||
modifyMetadata(metadataOutputStream) {
|
modifyMetadata(metadataOutputStream) {
|
||||||
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
||||||
state = newState,
|
state = newState,
|
||||||
system = packageInfo.isSystemApp(),
|
system = packageInfo.isSystemApp(),
|
||||||
version = packageMetadata.version,
|
version = packageMetadata.version,
|
||||||
installer = packageMetadata.installer,
|
installer = packageMetadata.installer,
|
||||||
sha256 = packageMetadata.sha256,
|
sha256 = packageMetadata.sha256,
|
||||||
signatures = packageMetadata.signatures
|
signatures = packageMetadata.signatures
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -114,9 +124,9 @@ class MetadataManager(
|
||||||
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
|
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
|
||||||
} else {
|
} else {
|
||||||
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
||||||
time = now,
|
time = now,
|
||||||
state = APK_AND_DATA,
|
state = APK_AND_DATA,
|
||||||
system = packageInfo.isSystemApp()
|
system = packageInfo.isSystemApp()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,7 +140,11 @@ class MetadataManager(
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
internal fun onPackageBackupError(packageInfo: PackageInfo, packageState: PackageState, metadataOutputStream: OutputStream) {
|
internal fun onPackageBackupError(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
packageState: PackageState,
|
||||||
|
metadataOutputStream: OutputStream
|
||||||
|
) {
|
||||||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
modifyMetadata(metadataOutputStream) {
|
modifyMetadata(metadataOutputStream) {
|
||||||
|
@ -138,9 +152,9 @@ class MetadataManager(
|
||||||
metadata.packageMetadataMap[packageName]!!.state = packageState
|
metadata.packageMetadataMap[packageName]!!.state = packageState
|
||||||
} else {
|
} else {
|
||||||
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
||||||
time = 0L,
|
time = 0L,
|
||||||
state = packageState,
|
state = packageState,
|
||||||
system = packageInfo.isSystemApp()
|
system = packageInfo.isSystemApp()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -168,6 +182,7 @@ class MetadataManager(
|
||||||
* If the token is 0L, it is not yet initialized and must not be used for anything.
|
* If the token is 0L, it is not yet initialized and must not be used for anything.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
|
||||||
fun getBackupToken(): Long = metadata.token
|
fun getBackupToken(): Long = metadata.token
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -187,9 +202,14 @@ class MetadataManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun getPackagesNumNotBackedUp(): Int {
|
fun getPackagesNumBackedUp(): Int {
|
||||||
|
// FIXME we are under-reporting packages here,
|
||||||
|
// because we have no way to also include upgraded system apps
|
||||||
return metadata.packageMetadataMap.filter { (_, packageMetadata) ->
|
return metadata.packageMetadataMap.filter { (_, packageMetadata) ->
|
||||||
!packageMetadata.system && packageMetadata.state != APK_AND_DATA
|
!packageMetadata.system && ( // ignore system apps
|
||||||
|
packageMetadata.state == APK_AND_DATA || // either full success
|
||||||
|
packageMetadata.state == NO_DATA // or apps that simply had no data
|
||||||
|
)
|
||||||
}.count()
|
}.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -219,13 +239,3 @@ class MetadataManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PackageInfo.isSystemApp(): Boolean {
|
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
|
|
||||||
return applicationInfo.flags and FLAG_SYSTEM != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PackageInfo.isUpdatedSystemApp(): Boolean {
|
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
|
|
||||||
return applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -64,11 +65,12 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
for (packageName in json.keys()) {
|
for (packageName in json.keys()) {
|
||||||
if (packageName == JSON_METADATA) continue
|
if (packageName == JSON_METADATA) continue
|
||||||
val p = json.getJSONObject(packageName)
|
val p = json.getJSONObject(packageName)
|
||||||
val pState = when(p.optString(JSON_PACKAGE_STATE)) {
|
val pState = when (p.optString(JSON_PACKAGE_STATE)) {
|
||||||
"" -> APK_AND_DATA
|
"" -> APK_AND_DATA
|
||||||
QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED
|
QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED
|
||||||
NO_DATA.name -> NO_DATA
|
NO_DATA.name -> NO_DATA
|
||||||
NOT_ALLOWED.name -> NOT_ALLOWED
|
NOT_ALLOWED.name -> NOT_ALLOWED
|
||||||
|
WAS_STOPPED.name -> WAS_STOPPED
|
||||||
else -> UNKNOWN_ERROR
|
else -> UNKNOWN_ERROR
|
||||||
}
|
}
|
||||||
val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
|
val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
||||||
|
@ -10,52 +11,50 @@ import java.io.OutputStream
|
||||||
|
|
||||||
private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
|
private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class DocumentsProviderBackupPlugin(
|
internal class DocumentsProviderBackupPlugin(
|
||||||
private val storage: DocumentsStorage,
|
private val context: Context,
|
||||||
packageManager: PackageManager) : BackupPlugin {
|
private val storage: DocumentsStorage,
|
||||||
|
override val kvBackupPlugin: KVBackupPlugin,
|
||||||
|
override val fullBackupPlugin: FullBackupPlugin
|
||||||
|
) : BackupPlugin {
|
||||||
|
|
||||||
override val kvBackupPlugin: KVBackupPlugin by lazy {
|
private val packageManager: PackageManager = context.packageManager
|
||||||
DocumentsProviderKVBackup(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val fullBackupPlugin: FullBackupPlugin by lazy {
|
|
||||||
DocumentsProviderFullBackup(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun initializeDevice(newToken: Long): Boolean {
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
// check if storage is already initialized
|
|
||||||
if (storage.isInitialized()) return false
|
|
||||||
|
|
||||||
// reset current storage
|
// reset current storage
|
||||||
storage.reset(newToken)
|
storage.reset(token)
|
||||||
|
|
||||||
// get or create root backup dir
|
// get or create root backup dir
|
||||||
storage.rootBackupDir ?: throw IOException()
|
storage.rootBackupDir ?: throw IOException()
|
||||||
|
|
||||||
// create backup folders
|
|
||||||
val kvDir = storage.currentKvBackupDir
|
|
||||||
val fullDir = storage.currentFullBackupDir
|
|
||||||
|
|
||||||
// wipe existing data
|
|
||||||
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
|
|
||||||
kvDir?.deleteContents()
|
|
||||||
fullDir?.deleteContents()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getMetadataOutputStream(): OutputStream {
|
override suspend fun initializeDevice() {
|
||||||
|
// wipe existing data
|
||||||
|
storage.getSetDir()?.deleteContents(context)
|
||||||
|
|
||||||
|
// reset storage without new token, so folders get recreated
|
||||||
|
// otherwise stale DocumentFiles will hang around
|
||||||
|
storage.reset(null)
|
||||||
|
|
||||||
|
// create backup folders
|
||||||
|
storage.currentKvBackupDir ?: throw IOException()
|
||||||
|
storage.currentFullBackupDir ?: throw IOException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getMetadataOutputStream(): OutputStream {
|
||||||
val setDir = storage.getSetDir() ?: throw IOException()
|
val setDir = storage.getSetDir() ?: throw IOException()
|
||||||
val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA)
|
val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA)
|
||||||
return storage.getOutputStream(metadataFile)
|
return storage.getOutputStream(metadataFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
|
override suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
|
||||||
val setDir = storage.getSetDir() ?: throw IOException()
|
val setDir = storage.getSetDir() ?: throw IOException()
|
||||||
val file = setDir.createOrGetFile("${packageInfo.packageName}.apk", MIME_TYPE_APK)
|
val file = setDir.createOrGetFile(context, "${packageInfo.packageName}.apk", MIME_TYPE_APK)
|
||||||
return storage.getOutputStream(file)
|
return storage.getOutputStream(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
|
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
|
||||||
|
@ -9,23 +10,27 @@ import java.io.OutputStream
|
||||||
|
|
||||||
private val TAG = DocumentsProviderFullBackup::class.java.simpleName
|
private val TAG = DocumentsProviderFullBackup::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class DocumentsProviderFullBackup(
|
internal class DocumentsProviderFullBackup(
|
||||||
private val storage: DocumentsStorage) : FullBackupPlugin {
|
private val context: Context,
|
||||||
|
private val storage: DocumentsStorage
|
||||||
|
) : FullBackupPlugin {
|
||||||
|
|
||||||
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
|
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
|
override suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream {
|
||||||
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName)
|
val file = storage.currentFullBackupDir?.createOrGetFile(context, targetPackage.packageName)
|
||||||
?: throw IOException()
|
?: throw IOException()
|
||||||
return storage.getOutputStream(file)
|
return storage.getOutputStream(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun removeDataOfPackage(packageInfo: PackageInfo) {
|
override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
Log.i(TAG, "Deleting $packageName...")
|
Log.i(TAG, "Deleting $packageName...")
|
||||||
val file = storage.currentFullBackupDir?.findFile(packageName) ?: return
|
val file = storage.currentFullBackupDir?.findFileBlocking(context, packageName)
|
||||||
|
?: return
|
||||||
if (!file.delete()) throw IOException("Failed to delete $packageName")
|
if (!file.delete()) throw IOException("Failed to delete $packageName")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,31 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
|
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class DocumentsProviderFullRestorePlugin(
|
internal class DocumentsProviderFullRestorePlugin(
|
||||||
private val documentsStorage: DocumentsStorage) : FullRestorePlugin {
|
private val context: Context,
|
||||||
|
private val documentsStorage: DocumentsStorage
|
||||||
|
) : FullRestorePlugin {
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
|
val backupDir = documentsStorage.getFullBackupDir(token) ?: return false
|
||||||
return backupDir.findFile(packageInfo.packageName) != null
|
return backupDir.findFileBlocking(context, packageInfo.packageName) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream {
|
override suspend fun getInputStreamForPackage(
|
||||||
|
token: Long,
|
||||||
|
packageInfo: PackageInfo
|
||||||
|
): InputStream {
|
||||||
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
|
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
|
||||||
val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException()
|
val packageFile =
|
||||||
|
backupDir.findFileBlocking(context, packageInfo.packageName) ?: throw IOException()
|
||||||
return documentsStorage.getInputStream(packageFile)
|
return documentsStorage.getInputStream(packageFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,53 +1,105 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
||||||
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
|
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin {
|
const val MAX_KEY_LENGTH = 255
|
||||||
|
const val MAX_KEY_LENGTH_NEXTCLOUD = 225
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
internal class DocumentsProviderKVBackup(
|
||||||
|
private val context: Context,
|
||||||
|
private val storage: DocumentsStorage
|
||||||
|
) : KVBackupPlugin {
|
||||||
|
|
||||||
private var packageFile: DocumentFile? = null
|
private var packageFile: DocumentFile? = null
|
||||||
|
private var packageChildren: List<DocumentFile>? = null
|
||||||
|
|
||||||
override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
|
override suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
|
||||||
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName)
|
// get the folder for the package (or create it) and all files in it
|
||||||
?: return false
|
val dir =
|
||||||
return packageFile.listFiles().isNotEmpty()
|
storage.getOrCreateKVBackupDir().createOrGetDirectory(context, packageInfo.packageName)
|
||||||
|
val children = dir.listFilesBlocking(context)
|
||||||
|
// cache package file for subsequent operations
|
||||||
|
packageFile = dir
|
||||||
|
// also cache children as doing this for every record is super slow
|
||||||
|
packageChildren = children
|
||||||
|
return children.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
|
override suspend fun getOutputStreamForRecord(
|
||||||
// remember package file for subsequent operations
|
packageInfo: PackageInfo,
|
||||||
packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName)
|
key: String
|
||||||
}
|
): OutputStream {
|
||||||
|
// check maximum key lengths
|
||||||
@Throws(IOException::class)
|
check(key.length <= MAX_KEY_LENGTH) {
|
||||||
override fun removeDataOfPackage(packageInfo: PackageInfo) {
|
"Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
|
||||||
// we cannot use the cached this.packageFile here,
|
}
|
||||||
// because this can be called before [ensureRecordStorageForPackage]
|
if (key.length > MAX_KEY_LENGTH_NEXTCLOUD) {
|
||||||
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
|
Log.e(
|
||||||
packageFile.delete()
|
DocumentsProviderKVBackup::class.java.simpleName,
|
||||||
}
|
"Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
|
||||||
|
)
|
||||||
@Throws(IOException::class)
|
}
|
||||||
override fun deleteRecord(packageInfo: PackageInfo, key: String) {
|
// get dir and children from cache
|
||||||
val packageFile = this.packageFile ?: throw AssertionError()
|
val packageFile = this.packageFile
|
||||||
|
?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
|
||||||
packageFile.assertRightFile(packageInfo)
|
packageFile.assertRightFile(packageInfo)
|
||||||
val keyFile = packageFile.findFile(key) ?: return
|
val children = packageChildren
|
||||||
keyFile.delete()
|
?: throw AssertionError("No cached children for ${packageInfo.packageName}")
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
// get file for key from cache,
|
||||||
override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream {
|
val keyFile = children.find { it.name == key } // try cache first
|
||||||
val packageFile = this.packageFile ?: throw AssertionError()
|
?: packageFile.createFile(MIME_TYPE, key) // assume it doesn't exist, create it
|
||||||
packageFile.assertRightFile(packageInfo)
|
?: packageFile.createOrGetFile(context, key) // cache was stale, so try to find it
|
||||||
val keyFile = packageFile.createOrGetFile(key)
|
check(keyFile.name == key) { "Key file named ${keyFile.name}, but should be $key" }
|
||||||
return storage.getOutputStream(keyFile)
|
return storage.getOutputStream(keyFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun deleteRecord(packageInfo: PackageInfo, key: String) {
|
||||||
|
val packageFile = this.packageFile
|
||||||
|
?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
|
||||||
|
packageFile.assertRightFile(packageInfo)
|
||||||
|
|
||||||
|
val children = packageChildren
|
||||||
|
?: throw AssertionError("No cached children for ${packageInfo.packageName}")
|
||||||
|
|
||||||
|
// try to find file for given key and delete it if found
|
||||||
|
val keyFile = children.find { it.name == key } // try to find in cache
|
||||||
|
?: packageFile.findFileBlocking(context, key) // fall-back to provider
|
||||||
|
?: return // not found, nothing left to do
|
||||||
|
keyFile.delete()
|
||||||
|
|
||||||
|
// we don't update the children cache as deleted records
|
||||||
|
// are not expected to get re-added in the same backup pass
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
|
||||||
|
val packageFile = this.packageFile
|
||||||
|
?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
|
||||||
|
packageFile.assertRightFile(packageInfo)
|
||||||
|
// We are not using the cached children here in case they are stale.
|
||||||
|
// This operation isn't frequent, so we don't need to heavily optimize it.
|
||||||
|
packageFile.deleteContents(context)
|
||||||
|
// clear children cache
|
||||||
|
packageChildren = ArrayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun packageFinished(packageInfo: PackageInfo) {
|
||||||
|
packageFile = null
|
||||||
|
packageChildren = null
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,60 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
|
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
internal class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin {
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
internal class DocumentsProviderKVRestorePlugin(
|
||||||
|
private val context: Context,
|
||||||
|
private val storage: DocumentsStorage
|
||||||
|
) : KVRestorePlugin {
|
||||||
|
|
||||||
private var packageDir: DocumentFile? = null
|
private var packageDir: DocumentFile? = null
|
||||||
|
private var packageChildren: List<DocumentFile>? = null
|
||||||
|
|
||||||
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val backupDir = storage.getKVBackupDir(token) ?: return false
|
val backupDir = storage.getKVBackupDir(token) ?: return false
|
||||||
|
val dir = backupDir.findFileBlocking(context, packageInfo.packageName) ?: return false
|
||||||
|
val children = dir.listFilesBlocking(context)
|
||||||
// remember package file for subsequent operations
|
// remember package file for subsequent operations
|
||||||
packageDir = backupDir.findFile(packageInfo.packageName)
|
packageDir = dir
|
||||||
packageDir != null
|
// remember package children for subsequent operations
|
||||||
|
packageChildren = children
|
||||||
|
// we have data if we have a non-empty list of children
|
||||||
|
children.isNotEmpty()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
|
@Throws(IOException::class)
|
||||||
val packageDir = this.packageDir ?: throw AssertionError()
|
override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
|
||||||
|
val packageDir = this.packageDir
|
||||||
|
?: throw AssertionError("No cached packageDir for ${packageInfo.packageName}")
|
||||||
packageDir.assertRightFile(packageInfo)
|
packageDir.assertRightFile(packageInfo)
|
||||||
return packageDir.listFiles()
|
return packageChildren
|
||||||
.filter { file -> file.name != null }
|
?.filter { file -> file.name != null }
|
||||||
.map { file -> file.name!! }
|
?.map { file -> file.name!! }
|
||||||
|
?: throw AssertionError("No cached children for ${packageInfo.packageName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream {
|
override suspend fun getInputStreamForRecord(
|
||||||
val packageDir = this.packageDir ?: throw AssertionError()
|
token: Long,
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
key: String
|
||||||
|
): InputStream {
|
||||||
|
val packageDir = this.packageDir
|
||||||
|
?: throw AssertionError("No cached packageDir for ${packageInfo.packageName}")
|
||||||
packageDir.assertRightFile(packageInfo)
|
packageDir.assertRightFile(packageInfo)
|
||||||
val keyFile = packageDir.findFile(key) ?: throw IOException()
|
val keyFile = packageChildren?.find { it.name == key }
|
||||||
|
?: packageDir.findFileBlocking(context, key)
|
||||||
|
?: throw IOException()
|
||||||
return storage.getInputStream(keyFile)
|
return storage.getInputStream(keyFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val documentsProviderModule = module {
|
val documentsProviderModule = module {
|
||||||
single { DocumentsStorage(androidContext(), get(), get()) }
|
single { DocumentsStorage(androidContext(), get()) }
|
||||||
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
|
|
||||||
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
|
single<KVBackupPlugin> { DocumentsProviderKVBackup(androidContext(), get()) }
|
||||||
|
single<FullBackupPlugin> { DocumentsProviderFullBackup(androidContext(), get()) }
|
||||||
|
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) }
|
||||||
|
|
||||||
|
single<KVRestorePlugin> { DocumentsProviderKVRestorePlugin(androidContext(), get()) }
|
||||||
|
single<FullRestorePlugin> { DocumentsProviderFullRestorePlugin(androidContext(), get()) }
|
||||||
|
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,32 +15,29 @@ import java.io.InputStream
|
||||||
|
|
||||||
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
|
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
|
||||||
internal class DocumentsProviderRestorePlugin(
|
internal class DocumentsProviderRestorePlugin(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val storage: DocumentsStorage) : RestorePlugin {
|
private val storage: DocumentsStorage,
|
||||||
|
override val kvRestorePlugin: KVRestorePlugin,
|
||||||
|
override val fullRestorePlugin: FullRestorePlugin
|
||||||
|
) : RestorePlugin {
|
||||||
|
|
||||||
override val kvRestorePlugin: KVRestorePlugin by lazy {
|
@Throws(IOException::class)
|
||||||
DocumentsProviderKVRestorePlugin(storage)
|
override suspend fun hasBackup(uri: Uri): Boolean {
|
||||||
}
|
|
||||||
|
|
||||||
override val fullRestorePlugin: FullRestorePlugin by lazy {
|
|
||||||
DocumentsProviderFullRestorePlugin(storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
override fun hasBackup(uri: Uri): Boolean {
|
|
||||||
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
|
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
|
||||||
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
|
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
|
||||||
val backupSets = getBackups(context, rootDir)
|
val backupSets = getBackups(context, rootDir)
|
||||||
return backupSets.isNotEmpty()
|
return backupSets.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
|
override suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
|
||||||
val rootDir = storage.rootBackupDir ?: return null
|
val rootDir = storage.rootBackupDir ?: return null
|
||||||
val backupSets = getBackups(context, rootDir)
|
val backupSets = getBackups(context, rootDir)
|
||||||
val iterator = backupSets.iterator()
|
val iterator = backupSets.iterator()
|
||||||
return generateSequence {
|
return generateSequence {
|
||||||
if (!iterator.hasNext()) return@generateSequence null // end sequence
|
if (!iterator.hasNext()) return@generateSequence null // end sequence
|
||||||
val backupSet = iterator.next()
|
val backupSet = iterator.next()
|
||||||
try {
|
try {
|
||||||
val stream = storage.getInputStream(backupSet.metadataFile)
|
val stream = storage.getInputStream(backupSet.metadataFile)
|
||||||
|
@ -52,8 +49,7 @@ internal class DocumentsProviderRestorePlugin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
||||||
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
|
||||||
val backupSets = ArrayList<BackupSet>()
|
val backupSets = ArrayList<BackupSet>()
|
||||||
val files = try {
|
val files = try {
|
||||||
// block until the DocumentsProvider has results
|
// block until the DocumentsProvider has results
|
||||||
|
@ -63,20 +59,16 @@ internal class DocumentsProviderRestorePlugin(
|
||||||
return backupSets
|
return backupSets
|
||||||
}
|
}
|
||||||
for (set in files) {
|
for (set in files) {
|
||||||
if (!set.isDirectory || set.name == null) {
|
// get current token from set or continue to next file/set
|
||||||
if (set.name != FILE_NO_MEDIA) {
|
val token = set.getTokenOrNull() ?: continue
|
||||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val token = try {
|
|
||||||
set.name!!.toLong()
|
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// block until children of set are available
|
// block until children of set are available
|
||||||
val metadata = set.findFileBlocking(context, FILE_BACKUP_METADATA)
|
val metadata = try {
|
||||||
|
set.findFileBlocking(context, FILE_BACKUP_METADATA)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error reading metadata file in backup set folder: ${set.name}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
if (metadata == null) {
|
if (metadata == null) {
|
||||||
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
|
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
|
||||||
} else {
|
} else {
|
||||||
|
@ -86,10 +78,26 @@ internal class DocumentsProviderRestorePlugin(
|
||||||
return backupSets
|
return backupSets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun DocumentFile.getTokenOrNull(): Long? {
|
||||||
|
if (!isDirectory || name == null) {
|
||||||
|
if (name != FILE_NO_MEDIA) {
|
||||||
|
Log.w(TAG, "Found invalid backup set folder: $name")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
name!!.toLong()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
Log.w(TAG, "Found invalid backup set folder: $name")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun getApkInputStream(token: Long, packageName: String): InputStream {
|
override suspend fun getApkInputStream(token: Long, packageName: String): InputStream {
|
||||||
val setDir = storage.getSetDir(token) ?: throw IOException()
|
val setDir = storage.getSetDir(token) ?: throw IOException()
|
||||||
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
|
val file =
|
||||||
|
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
|
||||||
return storage.getInputStream(file)
|
return storage.getInputStream(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,42 +1,47 @@
|
||||||
|
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.FileUtils.closeQuietly
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
|
|
||||||
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
|
|
||||||
import android.provider.DocumentsContract.EXTRA_LOADING
|
import android.provider.DocumentsContract.EXTRA_LOADING
|
||||||
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
|
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
|
||||||
import android.provider.DocumentsContract.buildDocumentUriUsingTree
|
import android.provider.DocumentsContract.buildDocumentUriUsingTree
|
||||||
import android.provider.DocumentsContract.buildTreeDocumentUri
|
|
||||||
import android.provider.DocumentsContract.getDocumentId
|
import android.provider.DocumentsContract.getDocumentId
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.settings.Storage
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.TimeUnit.MINUTES
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
||||||
const val DIRECTORY_FULL_BACKUP = "full"
|
const val DIRECTORY_FULL_BACKUP = "full"
|
||||||
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
|
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
|
||||||
const val FILE_BACKUP_METADATA = ".backup.metadata"
|
const val FILE_BACKUP_METADATA = ".backup.metadata"
|
||||||
const val FILE_NO_MEDIA = ".nomedia"
|
const val FILE_NO_MEDIA = ".nomedia"
|
||||||
private const val MIME_TYPE = "application/octet-stream"
|
const val MIME_TYPE = "application/octet-stream"
|
||||||
|
|
||||||
private val TAG = DocumentsStorage::class.java.simpleName
|
private val TAG = DocumentsStorage::class.java.simpleName
|
||||||
|
|
||||||
internal class DocumentsStorage(
|
internal class DocumentsStorage(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val metadataManager: MetadataManager,
|
private val settingsManager: SettingsManager
|
||||||
private val settingsManager: SettingsManager) {
|
) {
|
||||||
|
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
internal var storage: Storage? = null
|
internal var storage: Storage? = null
|
||||||
get() {
|
get() {
|
||||||
|
@ -45,76 +50,74 @@ internal class DocumentsStorage(
|
||||||
}
|
}
|
||||||
|
|
||||||
internal var rootBackupDir: DocumentFile? = null
|
internal var rootBackupDir: DocumentFile? = null
|
||||||
get() {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
val parent = storage?.getDocumentFile(context) ?: return null
|
val parent = storage?.getDocumentFile(context)
|
||||||
|
?: return@runBlocking null
|
||||||
field = try {
|
field = try {
|
||||||
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
|
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
||||||
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
|
// create .nomedia file to prevent Android's MediaScanner
|
||||||
rootDir.createOrGetFile(FILE_NO_MEDIA)
|
// from trying to index the backup
|
||||||
rootDir
|
createOrGetFile(context, FILE_NO_MEDIA)
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating root backup dir.", e)
|
Log.e(TAG, "Error creating root backup dir.", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return field
|
field
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentToken: Long = 0L
|
private var currentToken: Long? = null
|
||||||
get() {
|
get() {
|
||||||
if (field == 0L) field = metadataManager.getBackupToken()
|
if (field == null) field = settingsManager.getToken()
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentSetDir: DocumentFile? = null
|
private var currentSetDir: DocumentFile? = null
|
||||||
get() {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
if (currentToken == 0L) return null
|
if (currentToken == 0L) return@runBlocking null
|
||||||
field = try {
|
field = try {
|
||||||
rootBackupDir?.createOrGetDirectory(currentToken.toString())
|
rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating current restore set dir.", e)
|
Log.e(TAG, "Error creating current restore set dir.", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return field
|
field
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentFullBackupDir: DocumentFile? = null
|
var currentFullBackupDir: DocumentFile? = null
|
||||||
get() {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
field = try {
|
field = try {
|
||||||
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
|
currentSetDir?.createOrGetDirectory(context, DIRECTORY_FULL_BACKUP)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating full backup dir.", e)
|
Log.e(TAG, "Error creating full backup dir.", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return field
|
field
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentKvBackupDir: DocumentFile? = null
|
var currentKvBackupDir: DocumentFile? = null
|
||||||
get() {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
field = try {
|
field = try {
|
||||||
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
|
currentSetDir?.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating K/V backup dir.", e)
|
Log.e(TAG, "Error creating K/V backup dir.", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return field
|
field
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isInitialized(): Boolean {
|
/**
|
||||||
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
|
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
|
||||||
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
|
*/
|
||||||
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
|
fun reset(newToken: Long?) {
|
||||||
return kvEmpty && fullEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset(newToken: Long) {
|
|
||||||
storage = null
|
storage = null
|
||||||
currentToken = newToken
|
currentToken = newToken
|
||||||
rootBackupDir = null
|
rootBackupDir = null
|
||||||
|
@ -125,57 +128,80 @@ internal class DocumentsStorage(
|
||||||
|
|
||||||
fun getAuthority(): String? = storage?.uri?.authority
|
fun getAuthority(): String? = storage?.uri?.authority
|
||||||
|
|
||||||
fun getSetDir(token: Long = currentToken): DocumentFile? {
|
@Throws(IOException::class)
|
||||||
|
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||||
if (token == currentToken) return currentSetDir
|
if (token == currentToken) return currentSetDir
|
||||||
return rootBackupDir?.findFile(token.toString())
|
return rootBackupDir?.findFileBlocking(context, token.toString())
|
||||||
}
|
|
||||||
|
|
||||||
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
|
|
||||||
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
|
|
||||||
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile {
|
suspend fun getKVBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||||
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
|
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
|
||||||
val setDir = getSetDir(token) ?: throw IOException()
|
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
|
||||||
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getFullBackupDir(token: Long = currentToken): DocumentFile? {
|
@Throws(IOException::class)
|
||||||
|
suspend fun getOrCreateKVBackupDir(
|
||||||
|
token: Long = currentToken ?: error("no token")
|
||||||
|
): DocumentFile {
|
||||||
|
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
|
||||||
|
val setDir = getSetDir(token) ?: throw IOException()
|
||||||
|
return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun getFullBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||||
if (token == currentToken) return currentFullBackupDir ?: throw IOException()
|
if (token == currentToken) return currentFullBackupDir ?: throw IOException()
|
||||||
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
|
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getInputStream(file: DocumentFile): InputStream {
|
fun getInputStream(file: DocumentFile): InputStream {
|
||||||
return context.contentResolver.openInputStream(file.uri) ?: throw IOException()
|
return contentResolver.openInputStream(file.uri) ?: throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getOutputStream(file: DocumentFile): OutputStream {
|
fun getOutputStream(file: DocumentFile): OutputStream {
|
||||||
return context.contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
|
return contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file exists and if not, creates it.
|
||||||
|
*
|
||||||
|
* If we were trying to create it right away, some providers create "filename (1)".
|
||||||
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile {
|
internal suspend fun DocumentFile.createOrGetFile(
|
||||||
return findFile(name) ?: createFile(mimeType, name) ?: throw IOException()
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
mimeType: String = MIME_TYPE
|
||||||
|
): DocumentFile {
|
||||||
|
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
|
||||||
|
check(this.name == name) { "File named ${this.name}, but should be $name" }
|
||||||
|
} ?: throw IOException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a directory already exists and if not, creates it.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
|
||||||
|
return findFileBlocking(context, name) ?: createDirectory(name)?.apply {
|
||||||
|
check(this.name == name) { "Directory named ${this.name}, but should be $name" }
|
||||||
|
} ?: throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun DocumentFile.createOrGetDirectory(name: String): DocumentFile {
|
suspend fun DocumentFile.deleteContents(context: Context) {
|
||||||
return findFile(name) ?: createDirectory(name) ?: throw IOException()
|
for (file in listFilesBlocking(context)) file.delete()
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun DocumentFile.deleteContents() {
|
|
||||||
for (file in listFiles()) file.delete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
||||||
if (name != packageInfo.packageName) throw AssertionError()
|
if (name != packageInfo.packageName) {
|
||||||
|
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -183,56 +209,56 @@ fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
||||||
* This prevents getting an empty list even though there are children to be listed.
|
* This prevents getting an empty list even though there are children to be listed.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
|
suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
|
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
|
||||||
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE)
|
val projection = arrayOf(COLUMN_DOCUMENT_ID)
|
||||||
val result = ArrayList<DocumentFile>()
|
val result = ArrayList<DocumentFile>()
|
||||||
|
|
||||||
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null
|
try {
|
||||||
var cursor = resolver.query(childrenUri, projection, null, null, null)
|
getLoadedCursor {
|
||||||
?: throw IOException()
|
resolver.query(childrenUri, projection, null, null, null)
|
||||||
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
|
|
||||||
if (loading) {
|
|
||||||
Log.d(TAG, "Wait for children to get loaded...")
|
|
||||||
var loaded = false
|
|
||||||
cursor.registerContentObserver(object : ContentObserver(null) {
|
|
||||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
|
||||||
Log.d(TAG, "Children loaded. Continue...")
|
|
||||||
loaded = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val timeout = MINUTES.toMillis(2)
|
|
||||||
var time = 0
|
|
||||||
while (!loaded && time < timeout) {
|
|
||||||
Thread.sleep(50)
|
|
||||||
time += 50
|
|
||||||
}
|
}
|
||||||
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
|
} catch (e: TimeoutCancellationException) {
|
||||||
closeQuietly(cursor)
|
throw IOException(e)
|
||||||
// do a new query after content was loaded
|
}.use { cursor ->
|
||||||
@SuppressLint("Recycle") // gets closed after with block
|
while (cursor.moveToNext()) {
|
||||||
cursor = resolver.query(childrenUri, projection, null, null, null)
|
val documentId = cursor.getString(0)
|
||||||
?: throw IOException()
|
val documentUri = buildDocumentUriUsingTree(uri, documentId)
|
||||||
}
|
result.add(getTreeDocumentFile(this, context, documentUri))
|
||||||
with(cursor) {
|
|
||||||
while (moveToNext()) {
|
|
||||||
val documentId = getString(0)
|
|
||||||
val isDirectory = getString(1) == MIME_TYPE_DIR
|
|
||||||
val file = if (isDirectory) {
|
|
||||||
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
|
|
||||||
DocumentFile.fromTreeUri(context, treeUri)!!
|
|
||||||
} else {
|
|
||||||
val documentUri = buildDocumentUriUsingTree(uri, documentId)
|
|
||||||
DocumentFile.fromSingleUri(context, documentUri)!!
|
|
||||||
}
|
|
||||||
result.add(file)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
|
/**
|
||||||
|
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
|
||||||
|
*
|
||||||
|
* All other public ways to get a TreeDocumentFile only work from [Uri]s
|
||||||
|
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
|
||||||
|
*
|
||||||
|
* We have a test for this method to ensure CI will alert us when this reflection breaks.
|
||||||
|
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
|
||||||
|
@SuppressWarnings("MagicNumber")
|
||||||
|
val constructor = parent.javaClass.declaredConstructors.find {
|
||||||
|
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
|
||||||
|
}
|
||||||
|
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
|
||||||
|
constructor.isAccessible = true
|
||||||
|
return constructor.newInstance(parent, context, uri) as DocumentFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
|
||||||
|
*
|
||||||
|
* Most documents providers including Nextcloud are listing the full directory content
|
||||||
|
* when querying for a specific file in a directory,
|
||||||
|
* so there is no point in trying to optimize the query by not listing all children.
|
||||||
|
*/
|
||||||
|
suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
|
||||||
val files = try {
|
val files = try {
|
||||||
listFilesBlocking(context)
|
listFilesBlocking(context)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
@ -244,3 +270,46 @@ fun DocumentFile.findFileBlocking(context: Context, displayName: String): Docume
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cursor for the given query while ensuring that the cursor was loaded.
|
||||||
|
*
|
||||||
|
* When the SAF backend is a cloud storage provider (e.g. Nextcloud),
|
||||||
|
* it can happen that the query returns an outdated (e.g. empty) cursor
|
||||||
|
* which will only be updated in response to this query.
|
||||||
|
*
|
||||||
|
* See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
|
||||||
|
*
|
||||||
|
* This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver]
|
||||||
|
* registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]).
|
||||||
|
* If the cursor is not loading, it will be returned right away.
|
||||||
|
*
|
||||||
|
* @param timeout an optional time-out in milliseconds
|
||||||
|
* @throws TimeoutCancellationException if there was no result before the time-out
|
||||||
|
* @throws IOException if the query returns null
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
@Throws(IOException::class, TimeoutCancellationException::class)
|
||||||
|
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
|
||||||
|
withTimeout(timeout) {
|
||||||
|
suspendCancellableCoroutine<Cursor> { cont ->
|
||||||
|
val cursor = query() ?: throw IOException()
|
||||||
|
cont.invokeOnCancellation { closeQuietly(cursor) }
|
||||||
|
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
|
||||||
|
if (loading) {
|
||||||
|
Log.d(TAG, "Wait for children to get loaded...")
|
||||||
|
cursor.registerContentObserver(object : ContentObserver(null) {
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
Log.d(TAG, "Children loaded. Continue...")
|
||||||
|
closeQuietly(cursor)
|
||||||
|
val newCursor = query()
|
||||||
|
if (newCursor == null) cont.cancel(IOException("query returned no results"))
|
||||||
|
else cont.resume(newCursor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// not loading, return cursor right away
|
||||||
|
cont.resume(cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4,16 +4,19 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
|
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
|
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
|
||||||
import com.stevesoltys.seedvault.transport.restore.InstallResult
|
import com.stevesoltys.seedvault.transport.restore.InstallResult
|
||||||
import com.stevesoltys.seedvault.transport.restore.getInProgress
|
import com.stevesoltys.seedvault.transport.restore.getInProgress
|
||||||
import kotlinx.android.synthetic.main.fragment_restore_progress.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class InstallProgressFragment : Fragment() {
|
class InstallProgressFragment : Fragment() {
|
||||||
|
@ -23,9 +26,23 @@ class InstallProgressFragment : Fragment() {
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = InstallProgressAdapter()
|
private val adapter = InstallProgressAdapter()
|
||||||
|
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var titleView: TextView
|
||||||
|
private lateinit var backupNameView: TextView
|
||||||
|
private lateinit var appList: RecyclerView
|
||||||
|
private lateinit var button: Button
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
|
||||||
|
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
titleView = v.findViewById(R.id.titleView)
|
||||||
|
backupNameView = v.findViewById(R.id.backupNameView)
|
||||||
|
appList = v.findViewById(R.id.appList)
|
||||||
|
button = v.findViewById(R.id.button)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import org.koin.core.context.GlobalContext.get
|
import org.koin.core.context.GlobalContext.get
|
||||||
|
|
||||||
internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL"
|
internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL"
|
||||||
|
|
|
@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
|
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
|
||||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||||
import java.util.*
|
import java.util.LinkedList
|
||||||
|
|
||||||
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class PackageViewHolder(v: View) : AppViewHolder(v) {
|
class PackageViewHolder(v: View) : AppViewHolder(v) {
|
||||||
fun bind(item: AppRestoreResult) {
|
fun bind(item: AppRestoreResult) {
|
||||||
appName.text = item.name
|
appName.text = item.name
|
||||||
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
|
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
|
@ -71,8 +71,10 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
|
||||||
enum class AppRestoreStatus {
|
enum class AppRestoreStatus {
|
||||||
IN_PROGRESS,
|
IN_PROGRESS,
|
||||||
SUCCEEDED,
|
SUCCEEDED,
|
||||||
|
NOT_YET_BACKED_UP,
|
||||||
FAILED,
|
FAILED,
|
||||||
FAILED_NO_DATA,
|
FAILED_NO_DATA,
|
||||||
|
FAILED_WAS_STOPPED,
|
||||||
FAILED_NOT_ALLOWED,
|
FAILED_NOT_ALLOWED,
|
||||||
FAILED_QUOTA_EXCEEDED,
|
FAILED_QUOTA_EXCEEDED,
|
||||||
FAILED_NOT_INSTALLED,
|
FAILED_NOT_INSTALLED,
|
||||||
|
|
|
@ -6,14 +6,17 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat.getColor
|
import androidx.core.content.ContextCompat.getColor
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
|
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_restore_progress.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class RestoreProgressFragment : Fragment() {
|
class RestoreProgressFragment : Fragment() {
|
||||||
|
@ -23,9 +26,23 @@ class RestoreProgressFragment : Fragment() {
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = RestoreProgressAdapter()
|
private val adapter = RestoreProgressAdapter()
|
||||||
|
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var titleView: TextView
|
||||||
|
private lateinit var backupNameView: TextView
|
||||||
|
private lateinit var appList: RecyclerView
|
||||||
|
private lateinit var button: Button
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_restore_progress, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
|
||||||
|
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
titleView = v.findViewById(R.id.titleView)
|
||||||
|
backupNameView = v.findViewById(R.id.backupNameView)
|
||||||
|
appList = v.findViewById(R.id.appList)
|
||||||
|
button = v.findViewById(R.id.button)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -7,19 +7,33 @@ import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_restore_set.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class RestoreSetFragment : Fragment() {
|
class RestoreSetFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
|
private lateinit var listView: RecyclerView
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var errorView: TextView
|
||||||
|
private lateinit var backView: TextView
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_restore_set, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_restore_set, container, false)
|
||||||
|
|
||||||
|
listView = v.findViewById(R.id.listView)
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
errorView = v.findViewById(R.id.errorView)
|
||||||
|
backView = v.findViewById(R.id.backView)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -19,18 +19,20 @@ import com.stevesoltys.seedvault.BackupMonitor
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.getAppName
|
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
|
||||||
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||||
|
@ -166,6 +168,12 @@ internal class RestoreViewModel(
|
||||||
private suspend fun startRestore(token: Long) {
|
private suspend fun startRestore(token: Long) {
|
||||||
Log.d(TAG, "Starting new restore session to restore backup $token")
|
Log.d(TAG, "Starting new restore session to restore backup $token")
|
||||||
|
|
||||||
|
// if we had no token before (i.e. restore from setup wizard),
|
||||||
|
// use the token of the current restore set from now on
|
||||||
|
if (settingsManager.getToken() == null) {
|
||||||
|
settingsManager.setNewToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
// we need to start a new session and retrieve the restore sets before starting the restore
|
// we need to start a new session and retrieve the restore sets before starting the restore
|
||||||
val restoreSetResult = getAvailableRestoreSets()
|
val restoreSetResult = getAvailableRestoreSets()
|
||||||
if (restoreSetResult.hasError()) {
|
if (restoreSetResult.hasError()) {
|
||||||
|
@ -212,6 +220,7 @@ internal class RestoreViewModel(
|
||||||
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
|
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
|
||||||
return when (metadata.state) {
|
return when (metadata.state) {
|
||||||
NO_DATA -> FAILED_NO_DATA
|
NO_DATA -> FAILED_NO_DATA
|
||||||
|
WAS_STOPPED -> NOT_YET_BACKED_UP
|
||||||
NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
||||||
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
||||||
UNKNOWN_ERROR -> FAILED
|
UNKNOWN_ERROR -> FAILED
|
||||||
|
@ -296,7 +305,8 @@ internal class RestoreViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RestoreSetResult(restorableBackups)
|
if (restorableBackups.isEmpty()) RestoreSetResult(app.getString(R.string.restore_set_empty_result))
|
||||||
|
else RestoreSetResult(restorableBackups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continuation.resume(result)
|
continuation.resume(result)
|
||||||
|
|
|
@ -5,18 +5,40 @@ import android.text.method.LinkMovementMethod
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_about.*
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
class AboutDialogFragment : DialogFragment() {
|
class AboutDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
|
private val packageService: PackageService by inject()
|
||||||
|
|
||||||
|
private lateinit var versionView: TextView
|
||||||
|
private lateinit var licenseView: TextView
|
||||||
|
private lateinit var authorView: TextView
|
||||||
|
private lateinit var designView: TextView
|
||||||
|
private lateinit var sponsorView: TextView
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val TAG = AboutDialogFragment::class.java.simpleName
|
internal val TAG = AboutDialogFragment::class.java.simpleName
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
return inflater.inflate(R.layout.fragment_about, container, false)
|
savedInstanceState: Bundle?): View? {
|
||||||
|
val v: View = inflater.inflate(R.layout.fragment_about, container, false)
|
||||||
|
|
||||||
|
versionView = v.findViewById(R.id.versionView)
|
||||||
|
licenseView = v.findViewById(R.id.licenseView)
|
||||||
|
authorView = v.findViewById(R.id.authorView)
|
||||||
|
designView = v.findViewById(R.id.designView)
|
||||||
|
sponsorView = v.findViewById(R.id.sponsorView)
|
||||||
|
|
||||||
|
val versionName = packageService.getVersionName(requireContext().packageName) ?: "???"
|
||||||
|
versionView.text = getString(R.string.about_version, versionName)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package com.stevesoltys.seedvault.settings
|
package com.stevesoltys.seedvault.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.GONE
|
import android.view.View.GONE
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.DiffUtil.DiffResult
|
import androidx.recyclerview.widget.DiffUtil.DiffResult
|
||||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||||
|
@ -54,8 +58,8 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
||||||
fun bind(item: AppStatus) {
|
fun bind(item: AppStatus) {
|
||||||
appName.text = item.name
|
appName.text = item.name
|
||||||
appIcon.setImageDrawable(item.icon)
|
appIcon.setImageDrawable(item.icon)
|
||||||
|
v.background = clickableBackground
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
v.background = clickableBackground
|
|
||||||
v.setOnClickListener {
|
v.setOnClickListener {
|
||||||
switchView.toggle()
|
switchView.toggle()
|
||||||
item.enabled = switchView.isChecked
|
item.enabled = switchView.isChecked
|
||||||
|
@ -67,8 +71,14 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
||||||
switchView.visibility = VISIBLE
|
switchView.visibility = VISIBLE
|
||||||
switchView.isChecked = item.enabled
|
switchView.isChecked = item.enabled
|
||||||
} else {
|
} else {
|
||||||
v.background = null
|
|
||||||
v.setOnClickListener(null)
|
v.setOnClickListener(null)
|
||||||
|
v.setOnLongClickListener {
|
||||||
|
val intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", item.packageName, null)
|
||||||
|
}
|
||||||
|
startActivity(context, intent, null)
|
||||||
|
true
|
||||||
|
}
|
||||||
setStatus(item.status)
|
setStatus(item.status)
|
||||||
if (item.status == SUCCEEDED) {
|
if (item.status == SUCCEEDED) {
|
||||||
appInfo.text = item.time.toRelativeTime(context)
|
appInfo.text = item.time.toRelativeTime(context)
|
||||||
|
|
|
@ -9,11 +9,12 @@ import android.view.View
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ProgressBar
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_app_status.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
internal interface AppStatusToggleListener {
|
internal interface AppStatusToggleListener {
|
||||||
|
@ -26,12 +27,20 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = AppStatusAdapter(this)
|
private val adapter = AppStatusAdapter(this)
|
||||||
|
|
||||||
private lateinit var appEditMenuItem: MenuItem
|
private lateinit var appEditMenuItem: MenuItem
|
||||||
|
private lateinit var list: RecyclerView
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
return inflater.inflate(R.layout.fragment_app_status, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)
|
||||||
|
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
list = v.findViewById(R.id.list)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import androidx.annotation.CallSuper
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
|
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||||
|
|
|
@ -70,6 +70,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
val enabled = newValue as Boolean
|
val enabled = newValue as Boolean
|
||||||
try {
|
try {
|
||||||
backupManager.isBackupEnabled = enabled
|
backupManager.isBackupEnabled = enabled
|
||||||
|
if (enabled) viewModel.enableCallLogBackup()
|
||||||
return@OnPreferenceChangeListener true
|
return@OnPreferenceChangeListener true
|
||||||
} catch (e: RemoteException) {
|
} catch (e: RemoteException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
@ -171,6 +172,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
try {
|
try {
|
||||||
backup.isChecked = backupManager.isBackupEnabled
|
backup.isChecked = backupManager.isBackupEnabled
|
||||||
backup.isEnabled = true
|
backup.isEnabled = true
|
||||||
|
// enable call log backups for existing installs (added end of 2020)
|
||||||
|
if (backup.isChecked) viewModel.enableCallLogBackup()
|
||||||
} catch (e: RemoteException) {
|
} catch (e: RemoteException) {
|
||||||
Log.e(TAG, "Error communicating with BackupManager", e)
|
Log.e(TAG, "Error communicating with BackupManager", e)
|
||||||
backup.isEnabled = false
|
backup.isEnabled = false
|
||||||
|
|
|
@ -6,8 +6,10 @@ import android.net.Uri
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
|
import java.util.concurrent.ConcurrentSkipListSet
|
||||||
|
|
||||||
|
internal const val PREF_KEY_TOKEN = "token"
|
||||||
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
|
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
|
||||||
|
|
||||||
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
||||||
|
@ -25,49 +27,66 @@ class SettingsManager(context: Context) {
|
||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false)
|
@Volatile
|
||||||
|
private var token: Long? = null
|
||||||
|
|
||||||
private val blacklistedApps: HashSet<String> by lazy {
|
/**
|
||||||
prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()).toHashSet()
|
* This gets accessed by non-UI threads when saving with [PreferenceManager]
|
||||||
|
* and when [isBackupEnabled] is called during a backup run.
|
||||||
|
* Therefore, it is implemented with a thread-safe [ConcurrentSkipListSet].
|
||||||
|
*/
|
||||||
|
private val blacklistedApps: MutableSet<String> by lazy {
|
||||||
|
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): Long? = token ?: {
|
||||||
|
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) {
|
||||||
|
prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
|
||||||
|
token = newToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME Storage is currently plugin specific and not generic
|
// FIXME Storage is currently plugin specific and not generic
|
||||||
fun setStorage(storage: Storage) {
|
fun setStorage(storage: Storage) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
||||||
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
||||||
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
|
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
|
||||||
.apply()
|
.apply()
|
||||||
isStorageChanging.set(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStorage(): Storage? {
|
fun getStorage(): Storage? {
|
||||||
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
|
||||||
val uri = Uri.parse(uriStr)
|
val uri = Uri.parse(uriStr)
|
||||||
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException("no storage name")
|
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
|
||||||
|
?: throw IllegalStateException("no storage name")
|
||||||
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
|
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
|
||||||
return Storage(uri, name, isUsb)
|
return Storage(uri, name, isUsb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAndResetIsStorageChanging(): Boolean {
|
|
||||||
return isStorageChanging.getAndSet(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setFlashDrive(usb: FlashDrive?) {
|
fun setFlashDrive(usb: FlashDrive?) {
|
||||||
if (usb == null) {
|
if (usb == null) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove(PREF_KEY_FLASH_DRIVE_NAME)
|
.remove(PREF_KEY_FLASH_DRIVE_NAME)
|
||||||
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
|
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
|
||||||
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
|
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
|
||||||
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
|
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
|
||||||
.apply()
|
.apply()
|
||||||
} else {
|
} else {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
|
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
|
||||||
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
|
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
|
||||||
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
|
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
|
||||||
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
|
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,24 +114,26 @@ class SettingsManager(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Storage(
|
data class Storage(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val name: String,
|
val name: String,
|
||||||
val isUsb: Boolean) {
|
val isUsb: Boolean
|
||||||
|
) {
|
||||||
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
|
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
|
||||||
?: throw AssertionError("Should only happen on API < 21.")
|
?: throw AssertionError("Should only happen on API < 21.")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FlashDrive(
|
data class FlashDrive(
|
||||||
val name: String,
|
val name: String,
|
||||||
val serialNumber: String?,
|
val serialNumber: String?,
|
||||||
val vendorId: Int,
|
val vendorId: Int,
|
||||||
val productId: Int) {
|
val productId: Int
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(device: UsbDevice) = FlashDrive(
|
fun from(device: UsbDevice) = FlashDrive(
|
||||||
name = "${device.manufacturerName} ${device.productName}",
|
name = "${device.manufacturerName} ${device.productName}",
|
||||||
serialNumber = device.serialNumber,
|
serialNumber = device.serialNumber,
|
||||||
vendorId = device.vendorId,
|
vendorId = device.vendorId,
|
||||||
productId = device.productId
|
productId = device.productId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,10 @@ package com.stevesoltys.seedvault.settings
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.pm.PackageManager.NameNotFoundException
|
import android.content.pm.PackageManager.NameNotFoundException
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.widget.Toast.LENGTH_LONG
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import androidx.core.content.ContextCompat.getDrawable
|
import androidx.core.content.ContextCompat.getDrawable
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
|
@ -14,39 +17,50 @@ import androidx.recyclerview.widget.DiffUtil.calculateDiff
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.getAppName
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.metadata.isSystemApp
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
||||||
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_WAS_STOPPED
|
||||||
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
import com.stevesoltys.seedvault.transport.requestBackup
|
import com.stevesoltys.seedvault.transport.requestBackup
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"
|
||||||
|
|
||||||
private val TAG = SettingsViewModel::class.java.simpleName
|
private val TAG = SettingsViewModel::class.java.simpleName
|
||||||
|
|
||||||
class SettingsViewModel(
|
internal class SettingsViewModel(
|
||||||
app: Application,
|
app: Application,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
keyManager: KeyManager,
|
keyManager: KeyManager,
|
||||||
private val metadataManager: MetadataManager
|
private val notificationManager: BackupNotificationManager,
|
||||||
|
private val metadataManager: MetadataManager,
|
||||||
|
private val packageService: PackageService
|
||||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
||||||
|
|
||||||
override val isRestoreOperation = false
|
override val isRestoreOperation = false
|
||||||
|
|
||||||
internal val lastBackupTime = metadataManager.lastBackupTime
|
internal val lastBackupTime = metadataManager.lastBackupTime
|
||||||
|
|
||||||
private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() }
|
private val mAppStatusList = switchMap(lastBackupTime) {
|
||||||
|
// updates app list when lastBackupTime changes
|
||||||
|
getAppStatusResult()
|
||||||
|
}
|
||||||
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
|
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
|
||||||
|
|
||||||
private val mAppEditMode = MutableLiveData<Boolean>()
|
private val mAppEditMode = MutableLiveData<Boolean>()
|
||||||
|
@ -60,49 +74,52 @@ class SettingsViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun backupNow() {
|
internal fun backupNow() {
|
||||||
Thread { requestBackup(app) }.start()
|
if (notificationManager.hasActiveBackupNotifications()) {
|
||||||
|
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
|
||||||
|
} else {
|
||||||
|
Thread { requestBackup(app) }.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData(Dispatchers.Main) {
|
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData {
|
||||||
val pm = app.packageManager
|
val pm = app.packageManager
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val list = pm.getInstalledPackages(0)
|
val list = packageService.userApps.map {
|
||||||
.filter { !it.isSystemApp() }
|
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
.map {
|
getDrawable(app, R.drawable.ic_launcher_default)!!
|
||||||
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
|
} else {
|
||||||
getDrawable(app, R.drawable.ic_launcher_default)!!
|
try {
|
||||||
} else {
|
pm.getApplicationIcon(it.packageName)
|
||||||
try {
|
} catch (e: NameNotFoundException) {
|
||||||
pm.getApplicationIcon(it.packageName)
|
getDrawable(app, R.drawable.ic_launcher_default)!!
|
||||||
} catch (e: NameNotFoundException) {
|
}
|
||||||
getDrawable(app, R.drawable.ic_launcher_default)!!
|
}
|
||||||
}
|
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
||||||
}
|
val time = metadata?.time ?: 0
|
||||||
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
val status = when (metadata?.state) {
|
||||||
val time = metadata?.time ?: 0
|
null -> {
|
||||||
val status = when (metadata?.state) {
|
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
||||||
null -> {
|
NOT_YET_BACKED_UP
|
||||||
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
}
|
||||||
FAILED
|
NO_DATA -> FAILED_NO_DATA
|
||||||
}
|
WAS_STOPPED -> FAILED_WAS_STOPPED
|
||||||
NO_DATA -> FAILED_NO_DATA
|
NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
||||||
NOT_ALLOWED -> FAILED_NOT_ALLOWED
|
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
||||||
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
|
UNKNOWN_ERROR -> FAILED
|
||||||
UNKNOWN_ERROR -> FAILED
|
APK_AND_DATA -> SUCCEEDED
|
||||||
APK_AND_DATA -> SUCCEEDED
|
}
|
||||||
}
|
if (metadata?.hasApk() == false) {
|
||||||
if (metadata?.hasApk() == false) {
|
Log.w(TAG, "No APK stored for: ${it.packageName}")
|
||||||
Log.w(TAG, "No APK stored for: ${it.packageName}")
|
}
|
||||||
}
|
AppStatus(
|
||||||
AppStatus(
|
packageName = it.packageName,
|
||||||
packageName = it.packageName,
|
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
icon = icon,
|
||||||
icon = icon,
|
name = getAppName(app, it.packageName).toString(),
|
||||||
name = getAppName(app, it.packageName).toString(),
|
time = time,
|
||||||
time = time,
|
status = status
|
||||||
status = status
|
)
|
||||||
)
|
}.sortedBy { it.name.toLowerCase(locale) }
|
||||||
}.sortedBy { it.name.toLowerCase(locale) }
|
|
||||||
val oldList = mAppStatusList.value?.appStatusList ?: emptyList()
|
val oldList = mAppStatusList.value?.appStatusList ?: emptyList()
|
||||||
val diff = calculateDiff(AppStatusDiff(oldList, list))
|
val diff = calculateDiff(AppStatusDiff(oldList, list))
|
||||||
emit(AppStatusResult(list, diff))
|
emit(AppStatusResult(list, diff))
|
||||||
|
@ -118,4 +135,18 @@ class SettingsViewModel(
|
||||||
settingsManager.onAppBackupStatusChanged(status)
|
settingsManager.onAppBackupStatusChanged(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the call log will be included in backups.
|
||||||
|
*
|
||||||
|
* An AOSP code search found that call log backups get disabled if [USER_FULL_DATA_BACKUP_AWARE]
|
||||||
|
* is not set. This method sets this flag, if it is not already set.
|
||||||
|
* No other apps were found to check for this, so this should affect only call log.
|
||||||
|
*/
|
||||||
|
fun enableCallLogBackup() {
|
||||||
|
// first check if the flag is already set
|
||||||
|
if (Settings.Secure.getInt(app.contentResolver, USER_FULL_DATA_BACKUP_AWARE, 0) == 0) {
|
||||||
|
Settings.Secure.putInt(app.contentResolver, USER_FULL_DATA_BACKUP_AWARE, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,22 +9,26 @@ import android.content.Intent
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.KoinComponent
|
import org.koin.core.KoinComponent
|
||||||
import org.koin.core.inject
|
import org.koin.core.inject
|
||||||
|
|
||||||
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
|
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
|
||||||
|
|
||||||
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.seedvault.transport.ConfigurableBackupTransport"
|
private const val TRANSPORT_DIRECTORY_NAME =
|
||||||
|
"com.stevesoltys.seedvault.transport.ConfigurableBackupTransport"
|
||||||
private val TAG = ConfigurableBackupTransport::class.java.simpleName
|
private val TAG = ConfigurableBackupTransport::class.java.simpleName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport(), KoinComponent {
|
class ConfigurableBackupTransport internal constructor(private val context: Context) :
|
||||||
|
BackupTransport(), KoinComponent {
|
||||||
|
|
||||||
private val backupCoordinator by inject<BackupCoordinator>()
|
private val backupCoordinator by inject<BackupCoordinator>()
|
||||||
private val restoreCoordinator by inject<RestoreCoordinator>()
|
private val restoreCoordinator by inject<RestoreCoordinator>()
|
||||||
|
@ -46,35 +50,38 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dataManagementLabel(): String {
|
override fun dataManagementLabel(): String {
|
||||||
return "Please file a bug if you see this! 1"
|
return context.getString(R.string.data_management_label)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun currentDestinationString(): String {
|
override fun currentDestinationString(): String {
|
||||||
return "Please file a bug if you see this! 2"
|
return context.getString(R.string.current_destination_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
// General backup methods
|
// General backup methods
|
||||||
//
|
//
|
||||||
|
|
||||||
override fun initializeDevice(): Int {
|
override fun initializeDevice(): Int = runBlocking {
|
||||||
return backupCoordinator.initializeDevice()
|
backupCoordinator.initializeDevice()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
|
override fun isAppEligibleForBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
isFullBackup: Boolean
|
||||||
|
): Boolean {
|
||||||
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
|
||||||
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearBackupData(packageInfo: PackageInfo): Int {
|
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
|
||||||
return backupCoordinator.clearBackupData(packageInfo)
|
backupCoordinator.clearBackupData(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun finishBackup(): Int {
|
override fun finishBackup(): Int = runBlocking {
|
||||||
return backupCoordinator.finishBackup()
|
backupCoordinator.finishBackup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
@ -85,11 +92,18 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
return backupCoordinator.requestBackupTime()
|
return backupCoordinator.requestBackupTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int {
|
override fun performBackup(
|
||||||
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
|
packageInfo: PackageInfo,
|
||||||
|
inFd: ParcelFileDescriptor,
|
||||||
|
flags: Int
|
||||||
|
): Int = runBlocking {
|
||||||
|
backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
|
override fun performBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
fileDescriptor: ParcelFileDescriptor
|
||||||
|
): Int {
|
||||||
Log.w(TAG, "Warning: Legacy performBackup() method called.")
|
Log.w(TAG, "Warning: Legacy performBackup() method called.")
|
||||||
return performBackup(targetPackage, fileDescriptor, 0)
|
return performBackup(targetPackage, fileDescriptor, 0)
|
||||||
}
|
}
|
||||||
|
@ -106,20 +120,27 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
return backupCoordinator.checkFullBackupSize(size)
|
return backupCoordinator.checkFullBackupSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int {
|
override fun performFullBackup(
|
||||||
return backupCoordinator.performFullBackup(targetPackage, socket, flags)
|
targetPackage: PackageInfo,
|
||||||
|
socket: ParcelFileDescriptor,
|
||||||
|
flags: Int
|
||||||
|
): Int = runBlocking {
|
||||||
|
backupCoordinator.performFullBackup(targetPackage, socket, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
|
override fun performFullBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
fileDescriptor: ParcelFileDescriptor
|
||||||
|
): Int = runBlocking {
|
||||||
Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
|
Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
|
||||||
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
|
backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendBackupData(numBytes: Int): Int {
|
override fun sendBackupData(numBytes: Int): Int = runBlocking {
|
||||||
return backupCoordinator.sendBackupData(numBytes)
|
backupCoordinator.sendBackupData(numBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancelFullBackup() {
|
override fun cancelFullBackup() = runBlocking {
|
||||||
backupCoordinator.cancelFullBackup()
|
backupCoordinator.cancelFullBackup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,8 +148,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
// Restore
|
// Restore
|
||||||
//
|
//
|
||||||
|
|
||||||
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
override fun getAvailableRestoreSets(): Array<RestoreSet>? = runBlocking {
|
||||||
return restoreCoordinator.getAvailableRestoreSets()
|
restoreCoordinator.getAvailableRestoreSets()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentRestoreSet(): Long {
|
override fun getCurrentRestoreSet(): Long {
|
||||||
|
@ -139,16 +160,16 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
return restoreCoordinator.startRestore(token, packages)
|
return restoreCoordinator.startRestore(token, packages)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
|
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int = runBlocking {
|
||||||
return restoreCoordinator.getNextFullRestoreDataChunk(socket)
|
restoreCoordinator.getNextFullRestoreDataChunk(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun nextRestorePackage(): RestoreDescription? {
|
override fun nextRestorePackage(): RestoreDescription? = runBlocking {
|
||||||
return restoreCoordinator.nextRestorePackage()
|
restoreCoordinator.nextRestorePackage()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {
|
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int = runBlocking {
|
||||||
return restoreCoordinator.getRestoreData(outputFileDescriptor)
|
restoreCoordinator.getRestoreData(outputFileDescriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun abortFullRestore(): Int {
|
override fun abortFullRestore(): Int {
|
||||||
|
|
|
@ -13,10 +13,12 @@ import android.os.RemoteException
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.BackupMonitor
|
import com.stevesoltys.seedvault.BackupMonitor
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
|
||||||
import com.stevesoltys.seedvault.NotificationBackupObserver
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
|
||||||
|
import org.koin.core.KoinComponent
|
||||||
import org.koin.core.context.GlobalContext.get
|
import org.koin.core.context.GlobalContext.get
|
||||||
|
import org.koin.core.inject
|
||||||
|
|
||||||
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
||||||
|
|
||||||
|
@ -24,10 +26,12 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
class ConfigurableBackupTransportService : Service() {
|
class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
|
|
||||||
private var transport: ConfigurableBackupTransport? = null
|
private var transport: ConfigurableBackupTransport? = null
|
||||||
|
|
||||||
|
private val notificationManager: BackupNotificationManager by inject()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
transport = ConfigurableBackupTransport(applicationContext)
|
transport = ConfigurableBackupTransport(applicationContext)
|
||||||
|
@ -43,6 +47,7 @@ class ConfigurableBackupTransportService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
notificationManager.onBackupBackgroundFinished()
|
||||||
transport = null
|
transport = null
|
||||||
Log.d(TAG, "Service destroyed.")
|
Log.d(TAG, "Service destroyed.")
|
||||||
}
|
}
|
||||||
|
@ -53,11 +58,12 @@ class ConfigurableBackupTransportService : Service() {
|
||||||
fun requestBackup(context: Context) {
|
fun requestBackup(context: Context) {
|
||||||
val packageService: PackageService = get().koin.get()
|
val packageService: PackageService = get().koin.get()
|
||||||
val packages = packageService.eligiblePackages
|
val packages = packageService.eligiblePackages
|
||||||
|
val appTotals = packageService.expectedAppTotals
|
||||||
|
|
||||||
val observer = NotificationBackupObserver(context, packages.size, true)
|
val observer = NotificationBackupObserver(context, packages.size, appTotals)
|
||||||
val result = try {
|
val result = try {
|
||||||
val backupManager: IBackupManager = get().koin.get()
|
val backupManager: IBackupManager = get().koin.get()
|
||||||
backupManager.requestBackup(packages, observer, BackupMonitor(), FLAG_USER_INITIATED)
|
backupManager.requestBackup(packages, observer, BackupMonitor(), 0)
|
||||||
} catch (e: RemoteException) {
|
} catch (e: RemoteException) {
|
||||||
Log.e(TAG, "Error during backup: ", e)
|
Log.e(TAG, "Error during backup: ", e)
|
||||||
val nm: BackupNotificationManager = get().koin.get()
|
val nm: BackupNotificationManager = get().koin.get()
|
||||||
|
|
|
@ -11,8 +11,6 @@ import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState
|
import com.stevesoltys.seedvault.metadata.PackageState
|
||||||
import com.stevesoltys.seedvault.metadata.isSystemApp
|
|
||||||
import com.stevesoltys.seedvault.metadata.isUpdatedSystemApp
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
@ -23,9 +21,10 @@ import java.security.MessageDigest
|
||||||
private val TAG = ApkBackup::class.java.simpleName
|
private val TAG = ApkBackup::class.java.simpleName
|
||||||
|
|
||||||
class ApkBackup(
|
class ApkBackup(
|
||||||
private val pm: PackageManager,
|
private val pm: PackageManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager) {
|
private val metadataManager: MetadataManager
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a new APK needs to get backed up,
|
* Checks if a new APK needs to get backed up,
|
||||||
|
@ -36,7 +35,11 @@ class ApkBackup(
|
||||||
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
|
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun backupApkIfNecessary(packageInfo: PackageInfo, packageState: PackageState, streamGetter: () -> OutputStream): PackageMetadata? {
|
suspend fun backupApkIfNecessary(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
packageState: PackageState,
|
||||||
|
streamGetter: suspend () -> OutputStream
|
||||||
|
): PackageMetadata? {
|
||||||
// do not back up @pm@
|
// do not back up @pm@
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER) return null
|
if (packageName == MAGIC_PACKAGE_MANAGER) return null
|
||||||
|
@ -45,7 +48,7 @@ class ApkBackup(
|
||||||
if (!settingsManager.backupApks()) return null
|
if (!settingsManager.backupApks()) return null
|
||||||
|
|
||||||
// do not back up system apps that haven't been updated
|
// do not back up system apps that haven't been updated
|
||||||
if (packageInfo.isSystemApp() && !packageInfo.isUpdatedSystemApp()) {
|
if (packageInfo.isNotUpdatedSystemApp()) {
|
||||||
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
|
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -65,15 +68,19 @@ class ApkBackup(
|
||||||
|
|
||||||
// get cached metadata about package
|
// get cached metadata about package
|
||||||
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
||||||
?: PackageMetadata()
|
?: PackageMetadata()
|
||||||
|
|
||||||
// get version codes
|
// get version codes
|
||||||
val version = packageInfo.longVersionCode
|
val version = packageInfo.longVersionCode
|
||||||
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
|
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
|
||||||
|
|
||||||
// do not backup if we have the version already and signatures did not change
|
// do not backup if we have the version already and signatures did not change
|
||||||
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
|
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
|
||||||
Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Package $packageName with version $version already has a backup ($backedUpVersion)" +
|
||||||
|
" with the same signature. Not backing it up."
|
||||||
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,7 +98,7 @@ class ApkBackup(
|
||||||
|
|
||||||
// copy the APK to the storage's output and calculate SHA-256 hash while at it
|
// copy the APK to the storage's output and calculate SHA-256 hash while at it
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||||
streamGetter.invoke().use { outputStream ->
|
streamGetter().use { outputStream ->
|
||||||
inputStream.use { inputStream ->
|
inputStream.use { inputStream ->
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
var bytes = inputStream.read(buffer)
|
var bytes = inputStream.read(buffer)
|
||||||
|
@ -107,15 +114,18 @@ class ApkBackup(
|
||||||
|
|
||||||
// return updated metadata
|
// return updated metadata
|
||||||
return PackageMetadata(
|
return PackageMetadata(
|
||||||
state = packageState,
|
state = packageState,
|
||||||
version = version,
|
version = version,
|
||||||
installer = pm.getInstallerPackageName(packageName),
|
installer = pm.getInstallerPackageName(packageName),
|
||||||
sha256 = sha256,
|
sha256 = sha256,
|
||||||
signatures = signatures
|
signatures = signatures
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {
|
private fun signaturesChanged(
|
||||||
|
packageMetadata: PackageMetadata,
|
||||||
|
signatures: List<String>
|
||||||
|
): Boolean {
|
||||||
// no signatures in package metadata counts as them not having changed
|
// no signatures in package metadata counts as them not having changed
|
||||||
if (packageMetadata.signatures == null) return false
|
if (packageMetadata.signatures == null) return false
|
||||||
// TODO to support multiple signers check if lists differ
|
// TODO to support multiple signers check if lists differ
|
||||||
|
|
|
@ -4,12 +4,15 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||||
|
import android.app.backup.RestoreSet
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.annotation.VisibleForTesting.PRIVATE
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
@ -18,8 +21,9 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.metadata.isSystemApp
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit.DAYS
|
import java.util.concurrent.TimeUnit.DAYS
|
||||||
|
|
||||||
|
@ -29,17 +33,20 @@ private val TAG = BackupCoordinator::class.java.simpleName
|
||||||
* @author Steve Soltys
|
* @author Steve Soltys
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
|
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class BackupCoordinator(
|
internal class BackupCoordinator(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val plugin: BackupPlugin,
|
private val plugin: BackupPlugin,
|
||||||
private val kv: KVBackup,
|
private val kv: KVBackup,
|
||||||
private val full: FullBackup,
|
private val full: FullBackup,
|
||||||
private val apkBackup: ApkBackup,
|
private val apkBackup: ApkBackup,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
private val packageService: PackageService,
|
private val packageService: PackageService,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val nm: BackupNotificationManager) {
|
private val nm: BackupNotificationManager
|
||||||
|
) {
|
||||||
|
|
||||||
private var calledInitialize = false
|
private var calledInitialize = false
|
||||||
private var calledClearBackupData = false
|
private var calledClearBackupData = false
|
||||||
|
@ -49,6 +56,19 @@ internal class BackupCoordinator(
|
||||||
// Transport initialization and quota
|
// 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.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun startNewRestoreSet() {
|
||||||
|
val token = clock.time()
|
||||||
|
Log.i(TAG, "Starting new RestoreSet with token $token...")
|
||||||
|
settingsManager.setNewToken(token)
|
||||||
|
plugin.startNewRestoreSet(token)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the storage for this device, erasing all stored data.
|
* Initialize the storage for this device, erasing all stored data.
|
||||||
* The transport may send the request immediately, or may buffer it.
|
* The transport may send the request immediately, or may buffer it.
|
||||||
|
@ -67,29 +87,33 @@ internal class BackupCoordinator(
|
||||||
* @return One of [TRANSPORT_OK] (OK so far) or
|
* @return One of [TRANSPORT_OK] (OK so far) or
|
||||||
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
||||||
*/
|
*/
|
||||||
fun initializeDevice(): Int {
|
suspend fun initializeDevice(): Int = try {
|
||||||
Log.i(TAG, "Initialize Device!")
|
val token = settingsManager.getToken()
|
||||||
return try {
|
if (token == null) {
|
||||||
val token = clock.time()
|
Log.i(TAG, "No RestoreSet started, initialization is no-op.")
|
||||||
if (plugin.initializeDevice(token)) {
|
} else {
|
||||||
Log.d(TAG, "Resetting backup metadata...")
|
Log.i(TAG, "Initialize Device!")
|
||||||
metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream())
|
plugin.initializeDevice()
|
||||||
} else {
|
Log.d(TAG, "Resetting backup metadata for token $token...")
|
||||||
Log.d(TAG, "Storage was already initialized, doing no-op")
|
plugin.getMetadataOutputStream().use {
|
||||||
|
metadataManager.onDeviceInitialization(token, it)
|
||||||
}
|
}
|
||||||
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
|
|
||||||
// so we remember that we initialized successfully
|
|
||||||
calledInitialize = true
|
|
||||||
TRANSPORT_OK
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.e(TAG, "Error initializing device", e)
|
|
||||||
// Show error notification if we were ready for backups
|
|
||||||
if (getBackupBackoff() == 0L) nm.onBackupError()
|
|
||||||
TRANSPORT_ERROR
|
|
||||||
}
|
}
|
||||||
|
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
|
||||||
|
// so we remember that we initialized successfully
|
||||||
|
calledInitialize = true
|
||||||
|
TRANSPORT_OK
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error initializing device", e)
|
||||||
|
// Show error notification if we were ready for backups
|
||||||
|
if (getBackupBackoff() == 0L) nm.onBackupError()
|
||||||
|
TRANSPORT_ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean {
|
fun isAppEligibleForBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
@Suppress("UNUSED_PARAMETER") isFullBackup: Boolean
|
||||||
|
): Boolean {
|
||||||
val packageName = targetPackage.packageName
|
val packageName = targetPackage.packageName
|
||||||
// Check that the app is not blacklisted by the user
|
// Check that the app is not blacklisted by the user
|
||||||
val enabled = settingsManager.isBackupEnabled(packageName)
|
val enabled = settingsManager.isBackupEnabled(packageName)
|
||||||
|
@ -107,7 +131,7 @@ internal class BackupCoordinator(
|
||||||
* otherwise for key-value backup.
|
* otherwise for key-value backup.
|
||||||
* @return Current limit on backup size in bytes.
|
* @return Current limit on backup size in bytes.
|
||||||
*/
|
*/
|
||||||
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||||
if (packageName != MAGIC_PACKAGE_MANAGER) {
|
if (packageName != MAGIC_PACKAGE_MANAGER) {
|
||||||
// try to back up APK here as later methods are sometimes not called called
|
// try to back up APK here as later methods are sometimes not called called
|
||||||
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
|
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
|
||||||
|
@ -139,7 +163,11 @@ internal class BackupCoordinator(
|
||||||
Log.i(TAG, "Request incremental backup time. Returned $this")
|
Log.i(TAG, "Request incremental backup time. Returned $this")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
|
suspend fun performIncrementalBackup(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
flags: Int
|
||||||
|
): Int {
|
||||||
cancelReason = UNKNOWN_ERROR
|
cancelReason = UNKNOWN_ERROR
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER) {
|
if (packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
|
@ -148,10 +176,13 @@ internal class BackupCoordinator(
|
||||||
if (getBackupBackoff() != 0L) {
|
if (getBackupBackoff() != 0L) {
|
||||||
return TRANSPORT_PACKAGE_REJECTED
|
return TRANSPORT_PACKAGE_REJECTED
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val result = kv.performBackup(packageInfo, data, flags)
|
||||||
|
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
// hook in here to back up APKs of apps that are otherwise not allowed for backup
|
// hook in here to back up APKs of apps that are otherwise not allowed for backup
|
||||||
backUpNotAllowedPackages()
|
backUpNotAllowedPackages()
|
||||||
}
|
}
|
||||||
return kv.performBackup(packageInfo, data, flags)
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
@ -182,12 +213,16 @@ internal class BackupCoordinator(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
|
suspend fun performFullBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
fileDescriptor: ParcelFileDescriptor,
|
||||||
|
flags: Int
|
||||||
|
): Int {
|
||||||
cancelReason = UNKNOWN_ERROR
|
cancelReason = UNKNOWN_ERROR
|
||||||
return full.performFullBackup(targetPackage, fileDescriptor, flags)
|
return full.performFullBackup(targetPackage, fileDescriptor, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
|
suspend fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the transport to cancel the currently-ongoing full backup operation.
|
* Tells the transport to cancel the currently-ongoing full backup operation.
|
||||||
|
@ -202,9 +237,9 @@ internal class BackupCoordinator(
|
||||||
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
|
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
|
||||||
* It needs to tear down any ongoing backup state here.
|
* It needs to tear down any ongoing backup state here.
|
||||||
*/
|
*/
|
||||||
fun cancelFullBackup() {
|
suspend fun cancelFullBackup() {
|
||||||
val packageInfo = full.getCurrentPackage()
|
val packageInfo = full.getCurrentPackage()
|
||||||
?: throw AssertionError("Cancelling full backup, but no current package")
|
?: throw AssertionError("Cancelling full backup, but no current package")
|
||||||
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
|
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
|
||||||
onPackageBackupError(packageInfo)
|
onPackageBackupError(packageInfo)
|
||||||
full.cancelFullBackup()
|
full.cancelFullBackup()
|
||||||
|
@ -221,7 +256,7 @@ internal class BackupCoordinator(
|
||||||
*
|
*
|
||||||
* @return the same error codes as [performFullBackup].
|
* @return the same error codes as [performFullBackup].
|
||||||
*/
|
*/
|
||||||
fun clearBackupData(packageInfo: PackageInfo): Int {
|
suspend fun clearBackupData(packageInfo: PackageInfo): Int {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
Log.i(TAG, "Clear Backup Data of $packageName.")
|
Log.i(TAG, "Clear Backup Data of $packageName.")
|
||||||
try {
|
try {
|
||||||
|
@ -248,15 +283,15 @@ internal class BackupCoordinator(
|
||||||
*
|
*
|
||||||
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
|
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
|
||||||
*/
|
*/
|
||||||
fun finishBackup(): Int = when {
|
suspend fun finishBackup(): Int = when {
|
||||||
kv.hasState() -> {
|
kv.hasState() -> {
|
||||||
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
|
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
|
||||||
onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state
|
onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state
|
||||||
kv.finishBackup()
|
kv.finishBackup()
|
||||||
}
|
}
|
||||||
full.hasState() -> {
|
full.hasState() -> {
|
||||||
check(!kv.hasState()) { "Full backup has state, but K/V backup has dangling state as well" }
|
check(!kv.hasState()) { "Full backup has state, but K/V backup has dangling state as well" }
|
||||||
onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
|
onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
|
||||||
full.finishBackup()
|
full.finishBackup()
|
||||||
}
|
}
|
||||||
calledInitialize || calledClearBackupData -> {
|
calledInitialize || calledClearBackupData -> {
|
||||||
|
@ -267,48 +302,76 @@ internal class BackupCoordinator(
|
||||||
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backUpNotAllowedPackages() {
|
@VisibleForTesting(otherwise = PRIVATE)
|
||||||
|
internal suspend fun backUpNotAllowedPackages() {
|
||||||
Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
|
Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
|
||||||
packageService.notAllowedPackages.forEach { optOutPackageInfo ->
|
val notAllowedPackages = packageService.notAllowedPackages
|
||||||
|
notAllowedPackages.forEachIndexed { i, packageInfo ->
|
||||||
|
val packageName = packageInfo.packageName
|
||||||
try {
|
try {
|
||||||
backUpApk(optOutPackageInfo, NOT_ALLOWED)
|
nm.onOptOutAppBackup(packageName, i + 1, notAllowedPackages.size)
|
||||||
|
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
|
||||||
|
val wasBackedUp = backUpApk(packageInfo, packageState)
|
||||||
|
if (!wasBackedUp) {
|
||||||
|
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
||||||
|
val oldPackageState = packageMetadata?.state
|
||||||
|
if (oldPackageState != null && oldPackageState != packageState) {
|
||||||
|
Log.e(TAG, "Package $packageName was in $oldPackageState, update to $packageState")
|
||||||
|
plugin.getMetadataOutputStream().use {
|
||||||
|
metadataManager.onPackageBackupError(packageInfo, packageState, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e)
|
Log.e(TAG, "Error backing up opt-out APK of $packageName", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backUpApk(packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR) {
|
/**
|
||||||
|
* Backs up an APK for the given [PackageInfo].
|
||||||
|
*
|
||||||
|
* @return true if a backup was performed and false if no backup was needed or it failed.
|
||||||
|
*/
|
||||||
|
private suspend fun backUpApk(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
packageState: PackageState = UNKNOWN_ERROR
|
||||||
|
): Boolean {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
try {
|
return try {
|
||||||
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
|
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
|
||||||
plugin.getApkOutputStream(packageInfo)
|
plugin.getApkOutputStream(packageInfo)
|
||||||
}?.let { packageMetadata ->
|
}?.let { packageMetadata ->
|
||||||
val outputStream = plugin.getMetadataOutputStream()
|
plugin.getMetadataOutputStream().use {
|
||||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream)
|
metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)
|
||||||
}
|
}
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
|
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPackageBackedUp(packageInfo: PackageInfo) {
|
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
try {
|
try {
|
||||||
val outputStream = plugin.getMetadataOutputStream()
|
plugin.getMetadataOutputStream().use {
|
||||||
metadataManager.onPackageBackedUp(packageInfo, outputStream)
|
metadataManager.onPackageBackedUp(packageInfo, it)
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPackageBackupError(packageInfo: PackageInfo) {
|
private suspend fun onPackageBackupError(packageInfo: PackageInfo) {
|
||||||
// don't bother with system apps that have no data
|
// don't bother with system apps that have no data
|
||||||
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
|
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
try {
|
try {
|
||||||
val outputStream = plugin.getMetadataOutputStream()
|
plugin.getMetadataOutputStream().use {
|
||||||
metadataManager.onPackageBackupError(packageInfo, cancelReason, outputStream)
|
metadataManager.onPackageBackupError(packageInfo, cancelReason, it)
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,9 @@ import org.koin.dsl.module
|
||||||
|
|
||||||
val backupModule = module {
|
val backupModule = module {
|
||||||
single { InputFactory() }
|
single { InputFactory() }
|
||||||
single { PackageService(androidContext().packageManager, get()) }
|
single { PackageService(androidContext(), get()) }
|
||||||
single { ApkBackup(androidContext().packageManager, get(), get()) }
|
single { ApkBackup(androidContext().packageManager, get(), get()) }
|
||||||
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
|
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get(), get()) }
|
||||||
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
|
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
|
||||||
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
|
import android.app.backup.RestoreSet
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
@ -11,25 +12,30 @@ interface BackupPlugin {
|
||||||
val fullBackupPlugin: FullBackupPlugin
|
val fullBackupPlugin: FullBackupPlugin
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the storage for this device, erasing all stored data.
|
* Start a new [RestoreSet] with the given token.
|
||||||
*
|
*
|
||||||
* @return true if the device needs initialization or
|
* This is typically followed by a call to [initializeDevice].
|
||||||
* false if the device was initialized already and initialization should be a no-op.
|
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun initializeDevice(newToken: Long): Boolean
|
suspend fun startNewRestoreSet(token: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the storage for this device, erasing all stored data in the current [RestoreSet].
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun initializeDevice()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an [OutputStream] for writing backup metadata.
|
* Returns an [OutputStream] for writing backup metadata.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getMetadataOutputStream(): OutputStream
|
suspend fun getMetadataOutputStream(): OutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an [OutputStream] for writing an APK to be backed up.
|
* Returns an [OutputStream] for writing an APK to be backed up.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getApkOutputStream(packageInfo: PackageInfo): OutputStream
|
suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the package name of the app that provides the backend storage
|
* Returns the package name of the app that provides the backend storage
|
||||||
|
|
|
@ -18,10 +18,11 @@ import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
private class FullBackupState(
|
private class FullBackupState(
|
||||||
internal val packageInfo: PackageInfo,
|
internal val packageInfo: PackageInfo,
|
||||||
internal val inputFileDescriptor: ParcelFileDescriptor,
|
internal val inputFileDescriptor: ParcelFileDescriptor,
|
||||||
internal val inputStream: InputStream,
|
internal val inputStream: InputStream,
|
||||||
internal var outputStreamInit: (() -> OutputStream)?) {
|
internal var outputStreamInit: (suspend () -> OutputStream)?
|
||||||
|
) {
|
||||||
internal var outputStream: OutputStream? = null
|
internal var outputStream: OutputStream? = null
|
||||||
internal val packageName: String = packageInfo.packageName
|
internal val packageName: String = packageInfo.packageName
|
||||||
internal var size: Long = 0
|
internal var size: Long = 0
|
||||||
|
@ -31,11 +32,13 @@ const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
|
||||||
|
|
||||||
private val TAG = FullBackup::class.java.simpleName
|
private val TAG = FullBackup::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class FullBackup(
|
internal class FullBackup(
|
||||||
private val plugin: FullBackupPlugin,
|
private val plugin: FullBackupPlugin,
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val headerWriter: HeaderWriter,
|
private val headerWriter: HeaderWriter,
|
||||||
private val crypto: Crypto) {
|
private val crypto: Crypto
|
||||||
|
) {
|
||||||
|
|
||||||
private var state: FullBackupState? = null
|
private var state: FullBackupState? = null
|
||||||
|
|
||||||
|
@ -89,7 +92,11 @@ internal class FullBackup(
|
||||||
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
|
* [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.
|
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
|
||||||
*/
|
*/
|
||||||
fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int {
|
suspend fun performFullBackup(
|
||||||
|
targetPackage: PackageInfo,
|
||||||
|
socket: ParcelFileDescriptor,
|
||||||
|
@Suppress("UNUSED_PARAMETER") flags: Int = 0
|
||||||
|
): Int {
|
||||||
if (state != null) throw AssertionError()
|
if (state != null) throw AssertionError()
|
||||||
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
|
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
|
||||||
|
|
||||||
|
@ -101,7 +108,9 @@ internal class FullBackup(
|
||||||
val outputStream = try {
|
val outputStream = try {
|
||||||
plugin.getOutputStream(targetPackage)
|
plugin.getOutputStream(targetPackage)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e)
|
"Error getting OutputStream for full backup of ${targetPackage.packageName}".let {
|
||||||
|
Log.e(TAG, it, e)
|
||||||
|
}
|
||||||
throw(e)
|
throw(e)
|
||||||
}
|
}
|
||||||
// store version header
|
// store version header
|
||||||
|
@ -115,31 +124,36 @@ internal class FullBackup(
|
||||||
throw(e)
|
throw(e)
|
||||||
}
|
}
|
||||||
outputStream
|
outputStream
|
||||||
} // this lambda is only called before we actually write backup data the first time
|
} // this lambda is only called before we actually write backup data the first time
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendBackupData(numBytes: Int): Int {
|
suspend fun sendBackupData(numBytes: Int): Int {
|
||||||
val state = this.state
|
val state = this.state
|
||||||
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
|
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
|
||||||
|
|
||||||
// check if size fits quota
|
// check if size fits quota
|
||||||
state.size += numBytes
|
state.size += numBytes
|
||||||
val quota = plugin.getQuota()
|
val quota = plugin.getQuota()
|
||||||
if (state.size > quota) {
|
if (state.size > quota) {
|
||||||
Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Full backup of additional $numBytes exceeds quota of $quota with ${state.size}."
|
||||||
|
)
|
||||||
return TRANSPORT_QUOTA_EXCEEDED
|
return TRANSPORT_QUOTA_EXCEEDED
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
// get output stream or initialize it, if it does not yet exist
|
// 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" }
|
check((state.outputStream != null) xor (state.outputStreamInit != null)) {
|
||||||
val outputStream = state.outputStream ?: {
|
"No OutputStream xor no StreamGetter"
|
||||||
val stream = state.outputStreamInit!!.invoke() // not-null due to check above
|
}
|
||||||
|
val outputStream = state.outputStream ?: suspend {
|
||||||
|
val stream = state.outputStreamInit!!() // not-null due to check above
|
||||||
state.outputStream = stream
|
state.outputStream = stream
|
||||||
stream
|
stream
|
||||||
}.invoke()
|
}()
|
||||||
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
|
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
|
||||||
|
|
||||||
// read backup data, encrypt it and write it to output stream
|
// read backup data, encrypt it and write it to output stream
|
||||||
val payload = IOUtils.readFully(state.inputStream, numBytes)
|
val payload = IOUtils.readFully(state.inputStream, numBytes)
|
||||||
|
@ -152,11 +166,11 @@ internal class FullBackup(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun clearBackupData(packageInfo: PackageInfo) {
|
suspend fun clearBackupData(packageInfo: PackageInfo) {
|
||||||
plugin.removeDataOfPackage(packageInfo)
|
plugin.removeDataOfPackage(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelFullBackup() {
|
suspend fun cancelFullBackup() {
|
||||||
Log.i(TAG, "Cancel full backup")
|
Log.i(TAG, "Cancel full backup")
|
||||||
val state = this.state ?: throw AssertionError("No state when canceling")
|
val state = this.state ?: throw AssertionError("No state when canceling")
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -10,12 +10,12 @@ interface FullBackupPlugin {
|
||||||
|
|
||||||
// TODO consider using a salted hash for the package name to not leak it to the storage server
|
// TODO consider using a salted hash for the package name to not leak it to the storage server
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getOutputStream(targetPackage: PackageInfo): OutputStream
|
suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all data associated with the given package.
|
* Remove all data associated with the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun removeDataOfPackage(packageInfo: PackageInfo)
|
suspend fun removeDataOfPackage(packageInfo: PackageInfo)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.header.HeaderWriter
|
import com.stevesoltys.seedvault.header.HeaderWriter
|
||||||
|
@ -21,11 +23,14 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
|
||||||
|
|
||||||
private val TAG = KVBackup::class.java.simpleName
|
private val TAG = KVBackup::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class KVBackup(
|
internal class KVBackup(
|
||||||
private val plugin: KVBackupPlugin,
|
private val plugin: KVBackupPlugin,
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val headerWriter: HeaderWriter,
|
private val headerWriter: HeaderWriter,
|
||||||
private val crypto: Crypto) {
|
private val crypto: Crypto,
|
||||||
|
private val nm: BackupNotificationManager
|
||||||
|
) {
|
||||||
|
|
||||||
private var state: KVBackupState? = null
|
private var state: KVBackupState? = null
|
||||||
|
|
||||||
|
@ -35,7 +40,11 @@ internal class KVBackup(
|
||||||
|
|
||||||
fun getQuota(): Long = plugin.getQuota()
|
fun getQuota(): Long = plugin.getQuota()
|
||||||
|
|
||||||
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
|
suspend fun performBackup(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
flags: Int
|
||||||
|
): Int {
|
||||||
val isIncremental = flags and FLAG_INCREMENTAL != 0
|
val isIncremental = flags and FLAG_INCREMENTAL != 0
|
||||||
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
|
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
|
@ -64,7 +73,10 @@ internal class KVBackup(
|
||||||
return backupError(TRANSPORT_ERROR)
|
return backupError(TRANSPORT_ERROR)
|
||||||
}
|
}
|
||||||
if (isIncremental && !hasDataForPackage) {
|
if (isIncremental && !hasDataForPackage) {
|
||||||
Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.")
|
Log.w(
|
||||||
|
TAG, "Requested incremental, but transport currently stores no data" +
|
||||||
|
" for $packageName, requesting non-incremental retry."
|
||||||
|
)
|
||||||
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,47 +91,79 @@ internal class KVBackup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure there's a place to store K/V for the given package
|
|
||||||
try {
|
|
||||||
plugin.ensureRecordStorageForPackage(packageInfo)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.e(TAG, "Error ensuring storage for ${packageInfo.packageName}.", e)
|
|
||||||
return backupError(TRANSPORT_ERROR)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse and store the K/V updates
|
// parse and store the K/V updates
|
||||||
return storeRecords(packageInfo, data)
|
return storeRecords(packageInfo, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
|
private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
|
||||||
|
val backupSequence: Iterable<Result<KVOperation>>
|
||||||
|
val pmRecordNumber: Int?
|
||||||
|
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
|
// Since the package manager has many small keys to store,
|
||||||
|
// and this can be slow, especially on cloud-based storage,
|
||||||
|
// we get the entire data set first, so we can show progress notifications.
|
||||||
|
val list = parseBackupStream(data).toList()
|
||||||
|
backupSequence = list
|
||||||
|
pmRecordNumber = list.size
|
||||||
|
} else {
|
||||||
|
backupSequence = parseBackupStream(data).asIterable()
|
||||||
|
pmRecordNumber = null
|
||||||
|
}
|
||||||
// apply the delta operations
|
// apply the delta operations
|
||||||
for (result in parseBackupStream(data)) {
|
var i = 1
|
||||||
|
for (result in backupSequence) {
|
||||||
if (result is Result.Error) {
|
if (result is Result.Error) {
|
||||||
Log.e(TAG, "Exception reading backup input", result.exception)
|
Log.e(TAG, "Exception reading backup input", result.exception)
|
||||||
return backupError(TRANSPORT_ERROR)
|
return backupError(TRANSPORT_ERROR)
|
||||||
}
|
}
|
||||||
val op = (result as Result.Ok).result
|
val op = (result as Result.Ok).result
|
||||||
try {
|
try {
|
||||||
if (op.value == null) {
|
storeRecord(packageInfo, op, i++, pmRecordNumber)
|
||||||
Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
|
|
||||||
plugin.deleteRecord(packageInfo, op.base64Key)
|
|
||||||
} else {
|
|
||||||
val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key)
|
|
||||||
val header = VersionHeader(packageName = packageInfo.packageName, key = op.key)
|
|
||||||
headerWriter.writeVersion(outputStream, header)
|
|
||||||
crypto.encryptHeader(outputStream, header)
|
|
||||||
crypto.encryptMultipleSegments(outputStream, op.value)
|
|
||||||
outputStream.flush()
|
|
||||||
closeQuietly(outputStream)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
|
Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
|
||||||
|
// Returning something more forgiving such as TRANSPORT_PACKAGE_REJECTED
|
||||||
|
// will still make the entire backup fail.
|
||||||
|
// TODO However, TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED might buy us a retry,
|
||||||
|
// we would just need to be careful not to create an infinite loop
|
||||||
|
// for permanent errors.
|
||||||
return backupError(TRANSPORT_ERROR)
|
return backupError(TRANSPORT_ERROR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private suspend fun storeRecord(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
op: KVOperation,
|
||||||
|
currentNum: Int,
|
||||||
|
pmRecordNumber: Int?
|
||||||
|
) {
|
||||||
|
// update notification for package manager backup
|
||||||
|
if (pmRecordNumber != null) {
|
||||||
|
nm.onPmKvBackup(op.key, currentNum, pmRecordNumber)
|
||||||
|
}
|
||||||
|
// check if record should get deleted
|
||||||
|
if (op.value == null) {
|
||||||
|
Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
|
||||||
|
plugin.deleteRecord(packageInfo, op.base64Key)
|
||||||
|
} else {
|
||||||
|
val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key)
|
||||||
|
try {
|
||||||
|
val header = VersionHeader(
|
||||||
|
packageName = packageInfo.packageName,
|
||||||
|
key = op.key
|
||||||
|
)
|
||||||
|
headerWriter.writeVersion(outputStream, header)
|
||||||
|
crypto.encryptHeader(outputStream, header)
|
||||||
|
crypto.encryptMultipleSegments(outputStream, op.value)
|
||||||
|
outputStream.flush()
|
||||||
|
} finally {
|
||||||
|
closeQuietly(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a backup stream into individual key/value operations
|
* Parses a backup stream into individual key/value operations
|
||||||
*/
|
*/
|
||||||
|
@ -132,7 +176,7 @@ internal class KVBackup(
|
||||||
return generateSequence {
|
return generateSequence {
|
||||||
// read the next header or end the sequence in case of error or no more headers
|
// read the next header or end the sequence in case of error or no more headers
|
||||||
try {
|
try {
|
||||||
if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence
|
if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error reading next header", e)
|
Log.e(TAG, "Error reading next header", e)
|
||||||
return@generateSequence Result.Error(e)
|
return@generateSequence Result.Error(e)
|
||||||
|
@ -163,12 +207,13 @@ internal class KVBackup(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun clearBackupData(packageInfo: PackageInfo) {
|
suspend fun clearBackupData(packageInfo: PackageInfo) {
|
||||||
plugin.removeDataOfPackage(packageInfo)
|
plugin.removeDataOfPackage(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finishBackup(): Int {
|
fun finishBackup(): Int {
|
||||||
Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}")
|
Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}")
|
||||||
|
plugin.packageFinished(state!!.packageInfo)
|
||||||
state = null
|
state = null
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
@ -178,18 +223,21 @@ internal class KVBackup(
|
||||||
* because [finishBackup] is not called when we don't return [TRANSPORT_OK].
|
* because [finishBackup] is not called when we don't return [TRANSPORT_OK].
|
||||||
*/
|
*/
|
||||||
private fun backupError(result: Int): Int {
|
private fun backupError(result: Int): Int {
|
||||||
Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}")
|
"Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}".let {
|
||||||
|
Log.i(TAG, it)
|
||||||
|
}
|
||||||
|
plugin.packageFinished(state!!.packageInfo)
|
||||||
state = null
|
state = null
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private class KVOperation(
|
private class KVOperation(
|
||||||
internal val key: String,
|
val key: String,
|
||||||
internal val base64Key: String,
|
val base64Key: String,
|
||||||
/**
|
/**
|
||||||
* value is null when this is a deletion operation
|
* value is null when this is a deletion operation
|
||||||
*/
|
*/
|
||||||
internal val value: ByteArray?
|
val value: ByteArray?
|
||||||
)
|
)
|
||||||
|
|
||||||
private sealed class Result<out T> {
|
private sealed class Result<out T> {
|
||||||
|
|
|
@ -14,36 +14,38 @@ interface KVBackupPlugin {
|
||||||
// TODO consider using a salted hash for the package name (and key) to not leak it to the storage server
|
// TODO consider using a salted hash for the package name (and key) to not leak it to the storage server
|
||||||
/**
|
/**
|
||||||
* Return true if there are records stored for the given package.
|
* Return true if there are records stored for the given package.
|
||||||
*/
|
* This is always called first per [PackageInfo], before subsequent methods.
|
||||||
@Throws(IOException::class)
|
|
||||||
fun hasDataForPackage(packageInfo: PackageInfo): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This marks the beginning of a backup operation.
|
|
||||||
*
|
*
|
||||||
* Make sure that there is a place to store K/V pairs for the given package.
|
* Independent of the return value, the storage should now be prepared to store K/V pairs.
|
||||||
* E.g. file-based plugins should a create a directory for the package, if none exists.
|
* E.g. file-based plugins should a create a directory for the package, if none exists.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
|
suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return an [OutputStream] for the given package and key
|
* Return an [OutputStream] for the given package and key
|
||||||
* which will receive the record's encrypted value.
|
* which will receive the record's encrypted value.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
|
suspend fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete the record for the given package identified by the given key.
|
* Delete the record for the given package identified by the given key.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun deleteRecord(packageInfo: PackageInfo, key: String)
|
suspend fun deleteRecord(packageInfo: PackageInfo, key: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all data associated with the given package.
|
* Remove all data associated with the given package,
|
||||||
|
* but be prepared to receive new records afterwards with [getOutputStreamForRecord].
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun removeDataOfPackage(packageInfo: PackageInfo)
|
suspend fun removeDataOfPackage(packageInfo: PackageInfo)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The package finished backup.
|
||||||
|
* This can be an opportunity to clear existing caches or to do other clean-up work.
|
||||||
|
*/
|
||||||
|
fun packageFinished(packageInfo: PackageInfo)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
import android.app.backup.IBackupManager
|
import android.app.backup.IBackupManager
|
||||||
|
import android.content.Context
|
||||||
|
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_UPDATED_SYSTEM_APP
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.PackageManager.GET_INSTRUMENTATION
|
||||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
|
@ -20,9 +26,11 @@ private const val LOG_MAX_PACKAGES = 100
|
||||||
* @author Torsten Grote
|
* @author Torsten Grote
|
||||||
*/
|
*/
|
||||||
internal class PackageService(
|
internal class PackageService(
|
||||||
private val packageManager: PackageManager,
|
private val context: Context,
|
||||||
private val backupManager: IBackupManager) {
|
private val backupManager: IBackupManager
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val packageManager: PackageManager = context.packageManager
|
||||||
private val myUserId = UserHandle.myUserId()
|
private val myUserId = UserHandle.myUserId()
|
||||||
|
|
||||||
val eligiblePackages: Array<String>
|
val eligiblePackages: Array<String>
|
||||||
|
@ -30,8 +38,8 @@ internal class PackageService(
|
||||||
@Throws(RemoteException::class)
|
@Throws(RemoteException::class)
|
||||||
get() {
|
get() {
|
||||||
val packages = packageManager.getInstalledPackages(0)
|
val packages = packageManager.getInstalledPackages(0)
|
||||||
.map { packageInfo -> packageInfo.packageName }
|
.map { packageInfo -> packageInfo.packageName }
|
||||||
.sorted()
|
.sorted()
|
||||||
|
|
||||||
// log packages
|
// log packages
|
||||||
if (Log.isLoggable(TAG, INFO)) {
|
if (Log.isLoggable(TAG, INFO)) {
|
||||||
|
@ -41,14 +49,13 @@ internal class PackageService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val eligibleApps = backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
|
val eligibleApps =
|
||||||
|
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
|
||||||
|
|
||||||
// log eligible packages
|
// log eligible packages
|
||||||
if (Log.isLoggable(TAG, INFO)) {
|
if (Log.isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
|
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
|
||||||
eligibleApps.toList().chunked(LOG_MAX_PACKAGES).forEach {
|
logPackages(eligibleApps.toList())
|
||||||
Log.i(TAG, it.toString())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
|
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
|
||||||
|
@ -61,16 +68,103 @@ internal class PackageService(
|
||||||
val notAllowedPackages: List<PackageInfo>
|
val notAllowedPackages: List<PackageInfo>
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
get() {
|
get() {
|
||||||
val installed = packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
|
// We need the GET_SIGNING_CERTIFICATES flag here,
|
||||||
val installedArray = installed.map { packageInfo ->
|
// because the package info is used by [ApkBackup] which needs signing info.
|
||||||
packageInfo.packageName
|
return packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
|
||||||
}.toTypedArray()
|
.filter { packageInfo ->
|
||||||
|
packageInfo.doesNotGetBackedUp() && // only apps that do not allow backup
|
||||||
val eligible = backupManager.filterAppsEligibleForBackupForUser(myUserId, installedArray)
|
!packageInfo.isNotUpdatedSystemApp() && // and are not vanilla system apps
|
||||||
|
packageInfo.packageName != context.packageName // not this app
|
||||||
return installed.filter { packageInfo ->
|
}.sortedBy { packageInfo ->
|
||||||
packageInfo.packageName !in eligible
|
packageInfo.packageName
|
||||||
}.sortedBy { it.packageName }
|
}.also { notAllowed ->
|
||||||
|
// log eligible packages
|
||||||
|
if (Log.isLoggable(TAG, INFO)) {
|
||||||
|
Log.i(TAG, "${notAllowed.size} apps do not allow backup:")
|
||||||
|
logPackages(notAllowed.map { it.packageName })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of non-system apps (without instrumentation test apps).
|
||||||
|
*/
|
||||||
|
val userApps: List<PackageInfo>
|
||||||
|
@WorkerThread
|
||||||
|
get() {
|
||||||
|
return packageManager.getInstalledPackages(GET_INSTRUMENTATION)
|
||||||
|
.filter { it.isUserVisible(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedAppTotals: ExpectedAppTotals
|
||||||
|
@WorkerThread
|
||||||
|
get() {
|
||||||
|
var appsTotal = 0
|
||||||
|
var appsOptOut = 0
|
||||||
|
packageManager.getInstalledPackages(GET_INSTRUMENTATION).forEach { packageInfo ->
|
||||||
|
if (packageInfo.isUserVisible(context)) {
|
||||||
|
appsTotal++
|
||||||
|
if (packageInfo.doesNotGetBackedUp()) {
|
||||||
|
appsOptOut++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ExpectedAppTotals(appsTotal, appsOptOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getVersionName(packageName: String): String? = try {
|
||||||
|
packageManager.getPackageInfo(packageName, 0).versionName
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logPackages(packages: List<String>) {
|
||||||
|
packages.chunked(LOG_MAX_PACKAGES).forEach {
|
||||||
|
Log.i(TAG, it.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ExpectedAppTotals(
|
||||||
|
/**
|
||||||
|
* The total number of non-system apps eligible for backup.
|
||||||
|
*/
|
||||||
|
val appsTotal: Int,
|
||||||
|
/**
|
||||||
|
* The number of non-system apps that has opted-out of backup.
|
||||||
|
*/
|
||||||
|
val appsOptOut: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun PackageInfo.isUserVisible(context: Context): Boolean {
|
||||||
|
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
|
||||||
|
return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PackageInfo.isSystemApp(): Boolean {
|
||||||
|
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
|
||||||
|
return applicationInfo.flags and FLAG_SYSTEM != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if this is a system app that hasn't been updated.
|
||||||
|
* We don't back up those APKs.
|
||||||
|
*/
|
||||||
|
internal fun PackageInfo.isNotUpdatedSystemApp(): Boolean {
|
||||||
|
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
|
||||||
|
val isSystemApp = applicationInfo.flags and FLAG_SYSTEM != 0
|
||||||
|
val isUpdatedSystemApp = applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
|
||||||
|
return isSystemApp && !isUpdatedSystemApp
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PackageInfo.doesNotGetBackedUp(): Boolean {
|
||||||
|
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
|
||||||
|
return applicationInfo.flags and FLAG_ALLOW_BACKUP == 0 || // does not allow backup
|
||||||
|
applicationInfo.flags and FLAG_STOPPED != 0 // is stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PackageInfo.isStopped(): Boolean {
|
||||||
|
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
|
||||||
|
return applicationInfo.flags and FLAG_STOPPED != 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import android.util.Log
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.metadata.isSystemApp
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.getSignatures
|
import com.stevesoltys.seedvault.transport.backup.getSignatures
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
|
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
|
||||||
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
|
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
|
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
|
||||||
|
@ -137,7 +137,7 @@ internal class ApkRestore(
|
||||||
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
|
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
|
||||||
} catch (e: NameNotFoundException) {
|
} catch (e: NameNotFoundException) {
|
||||||
Log.w(TAG, "Not installing $packageName because older or not a system app here.")
|
Log.w(TAG, "Not installing $packageName because older or not a system app here.")
|
||||||
fail(installResult, packageName)
|
emit(fail(installResult, packageName))
|
||||||
return@flow
|
return@flow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ private class FullRestoreState(
|
||||||
|
|
||||||
private val TAG = FullRestore::class.java.simpleName
|
private val TAG = FullRestore::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class FullRestore(
|
internal class FullRestore(
|
||||||
private val plugin: FullRestorePlugin,
|
private val plugin: FullRestorePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -37,7 +38,7 @@ internal class FullRestore(
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
return plugin.hasDataForPackage(token, packageInfo)
|
return plugin.hasDataForPackage(token, packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +79,7 @@ internal class FullRestore(
|
||||||
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
|
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
|
||||||
* that aborts all further restore operations on the current dataset.
|
* that aborts all further restore operations on the current dataset.
|
||||||
*/
|
*/
|
||||||
fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
|
suspend fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
|
||||||
val state = this.state ?: throw IllegalStateException("no state")
|
val state = this.state ?: throw IllegalStateException("no state")
|
||||||
val packageName = state.packageInfo.packageName
|
val packageName = state.packageInfo.packageName
|
||||||
|
|
||||||
|
@ -113,6 +114,7 @@ internal class FullRestore(
|
||||||
try {
|
try {
|
||||||
// read segment from input stream and decrypt it
|
// read segment from input stream and decrypt it
|
||||||
val decrypted = try {
|
val decrypted = try {
|
||||||
|
// TODO handle IOException
|
||||||
crypto.decryptSegment(inputStream)
|
crypto.decryptSegment(inputStream)
|
||||||
} catch (e: EOFException) {
|
} catch (e: EOFException) {
|
||||||
Log.i(TAG, " EOF")
|
Log.i(TAG, " EOF")
|
||||||
|
|
|
@ -10,9 +10,9 @@ interface FullRestorePlugin {
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
|
suspend fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,24 +15,27 @@ import com.stevesoltys.seedvault.header.HeaderReader
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.ArrayList
|
||||||
import javax.crypto.AEADBadTagException
|
import javax.crypto.AEADBadTagException
|
||||||
|
|
||||||
private class KVRestoreState(
|
private class KVRestoreState(
|
||||||
internal val token: Long,
|
internal val token: Long,
|
||||||
internal val packageInfo: PackageInfo,
|
internal val packageInfo: PackageInfo,
|
||||||
/**
|
/**
|
||||||
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
|
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
|
||||||
*/
|
*/
|
||||||
internal val pmPackageInfo: PackageInfo?)
|
internal val pmPackageInfo: PackageInfo?
|
||||||
|
)
|
||||||
|
|
||||||
private val TAG = KVRestore::class.java.simpleName
|
private val TAG = KVRestore::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class KVRestore(
|
internal class KVRestore(
|
||||||
private val plugin: KVRestorePlugin,
|
private val plugin: KVRestorePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
private val headerReader: HeaderReader,
|
private val headerReader: HeaderReader,
|
||||||
private val crypto: Crypto) {
|
private val crypto: Crypto
|
||||||
|
) {
|
||||||
|
|
||||||
private var state: KVRestoreState? = null
|
private var state: KVRestoreState? = null
|
||||||
|
|
||||||
|
@ -40,7 +43,7 @@ internal class KVRestore(
|
||||||
* Return true if there are records stored for the given package.
|
* Return true if there are records stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
return plugin.hasDataForPackage(token, packageInfo)
|
return plugin.hasDataForPackage(token, packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,7 +66,7 @@ internal class KVRestore(
|
||||||
* @return One of [TRANSPORT_OK]
|
* @return One of [TRANSPORT_OK]
|
||||||
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
|
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
|
||||||
*/
|
*/
|
||||||
fun getRestoreData(data: ParcelFileDescriptor): Int {
|
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
|
||||||
val state = this.state ?: throw IllegalStateException("no state")
|
val state = this.state ?: throw IllegalStateException("no state")
|
||||||
|
|
||||||
// The restore set is the concatenation of the individual record blobs,
|
// The restore set is the concatenation of the individual record blobs,
|
||||||
|
@ -109,7 +112,7 @@ internal class KVRestore(
|
||||||
* Return a list of the records (represented by key files) in the given directory,
|
* Return a list of the records (represented by key files) in the given directory,
|
||||||
* sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
|
* sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
|
||||||
*/
|
*/
|
||||||
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
|
private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
|
||||||
val records: List<String> = try {
|
val records: List<String> = try {
|
||||||
plugin.listRecords(token, packageInfo)
|
plugin.listRecords(token, packageInfo)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
@ -122,11 +125,12 @@ internal class KVRestore(
|
||||||
for (recordKey in records) contents.add(DecodedKey(recordKey))
|
for (recordKey in records) contents.add(DecodedKey(recordKey))
|
||||||
// remove keys that are not needed for single package @pm@ restore
|
// remove keys that are not needed for single package @pm@ restore
|
||||||
val pmPackageName = state?.pmPackageInfo?.packageName
|
val pmPackageName = state?.pmPackageInfo?.packageName
|
||||||
val sortedKeys = if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
|
val sortedKeys =
|
||||||
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
|
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
|
||||||
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
|
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
|
||||||
contents.filterTo(ArrayList()) { it.key in keys }
|
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
|
||||||
} else contents
|
contents.filterTo(ArrayList()) { it.key in keys }
|
||||||
|
} else contents
|
||||||
sortedKeys.sort()
|
sortedKeys.sort()
|
||||||
return sortedKeys
|
return sortedKeys
|
||||||
}
|
}
|
||||||
|
@ -135,9 +139,12 @@ internal class KVRestore(
|
||||||
* Read the encrypted value for the given key and write it to the given [BackupDataOutput].
|
* Read the encrypted value for the given key and write it to the given [BackupDataOutput].
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
|
@Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
|
||||||
private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) {
|
private suspend fun readAndWriteValue(
|
||||||
val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
|
state: KVRestoreState,
|
||||||
try {
|
dKey: DecodedKey,
|
||||||
|
out: BackupDataOutput
|
||||||
|
) = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
|
||||||
|
.use { inputStream ->
|
||||||
val version = headerReader.readVersion(inputStream)
|
val version = headerReader.readVersion(inputStream)
|
||||||
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
|
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
|
||||||
val value = crypto.decryptMultipleSegments(inputStream)
|
val value = crypto.decryptMultipleSegments(inputStream)
|
||||||
|
@ -146,10 +153,8 @@ internal class KVRestore(
|
||||||
|
|
||||||
out.writeEntityHeader(dKey.key, size)
|
out.writeEntityHeader(dKey.key, size)
|
||||||
out.writeEntityData(value, size)
|
out.writeEntityData(value, size)
|
||||||
} finally {
|
Unit
|
||||||
closeQuietly(inputStream)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
|
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
|
||||||
internal val key = base64Key.decodeBase64()
|
internal val key = base64Key.decodeBase64()
|
||||||
|
|
|
@ -10,21 +10,26 @@ interface KVRestorePlugin {
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all record keys for the given token and package.
|
* Return all record keys for the given token and package.
|
||||||
*
|
*
|
||||||
|
* Note: Implementations usually expect that you call [hasDataForPackage]
|
||||||
|
* with the same parameters before.
|
||||||
|
*
|
||||||
* For file-based plugins, this is usually a list of file names in the package directory.
|
* For file-based plugins, this is usually a list of file names in the package directory.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
|
suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return an [InputStream] for the given token, package and key
|
* Return an [InputStream] for the given token, package and key
|
||||||
* which will provide the record's encrypted value.
|
* which will provide the record's encrypted value.
|
||||||
|
*
|
||||||
|
* Note: Implementations might expect that you call [hasDataForPackage] before.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
|
suspend fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.collection.LongSparseArray
|
import androidx.collection.LongSparseArray
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
|
@ -22,30 +21,34 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
private class RestoreCoordinatorState(
|
private data class RestoreCoordinatorState(
|
||||||
internal val token: Long,
|
val token: Long,
|
||||||
internal val packages: Iterator<PackageInfo>,
|
val packages: Iterator<PackageInfo>,
|
||||||
/**
|
/**
|
||||||
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
|
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
|
||||||
*/
|
*/
|
||||||
internal val pmPackageInfo: PackageInfo?) {
|
val pmPackageInfo: PackageInfo?
|
||||||
internal var currentPackage: String? = null
|
) {
|
||||||
|
var currentPackage: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private val TAG = RestoreCoordinator::class.java.simpleName
|
private val TAG = RestoreCoordinator::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class RestoreCoordinator(
|
internal class RestoreCoordinator(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val notificationManager: BackupNotificationManager,
|
private val notificationManager: BackupNotificationManager,
|
||||||
private val plugin: RestorePlugin,
|
private val plugin: RestorePlugin,
|
||||||
private val kv: KVRestore,
|
private val kv: KVRestore,
|
||||||
private val full: FullRestore,
|
private val full: FullRestore,
|
||||||
private val metadataReader: MetadataReader) {
|
private val metadataReader: MetadataReader
|
||||||
|
) {
|
||||||
|
|
||||||
private var state: RestoreCoordinatorState? = null
|
private var state: RestoreCoordinatorState? = null
|
||||||
private var backupMetadata: LongSparseArray<BackupMetadata>? = null
|
private var backupMetadata: LongSparseArray<BackupMetadata>? = null
|
||||||
|
@ -57,7 +60,7 @@ internal class RestoreCoordinator(
|
||||||
* @return Descriptions of the set of restore images available for this device,
|
* @return Descriptions of the set of restore images available for this device,
|
||||||
* or null if an error occurred (the attempt should be rescheduled).
|
* or null if an error occurred (the attempt should be rescheduled).
|
||||||
**/
|
**/
|
||||||
fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
||||||
val availableBackups = plugin.getAvailableBackups() ?: return null
|
val availableBackups = plugin.getAvailableBackups() ?: return null
|
||||||
val restoreSets = ArrayList<RestoreSet>()
|
val restoreSets = ArrayList<RestoreSet>()
|
||||||
val metadataMap = LongSparseArray<BackupMetadata>()
|
val metadataMap = LongSparseArray<BackupMetadata>()
|
||||||
|
@ -67,7 +70,10 @@ internal class RestoreCoordinator(
|
||||||
"No error when getting encrypted metadata, but stream is still missing."
|
"No error when getting encrypted metadata, but stream is still missing."
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
|
val metadata = metadataReader.readMetadata(
|
||||||
|
encryptedMetadata.inputStream,
|
||||||
|
encryptedMetadata.token
|
||||||
|
)
|
||||||
metadataMap.put(encryptedMetadata.token, metadata)
|
metadataMap.put(encryptedMetadata.token, metadata)
|
||||||
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
|
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
|
||||||
restoreSets.add(set)
|
restoreSets.add(set)
|
||||||
|
@ -100,8 +106,9 @@ internal class RestoreCoordinator(
|
||||||
* or 0 if there is no backup set available corresponding to the current device state.
|
* or 0 if there is no backup set available corresponding to the current device state.
|
||||||
*/
|
*/
|
||||||
fun getCurrentRestoreSet(): Long {
|
fun getCurrentRestoreSet(): Long {
|
||||||
return metadataManager.getBackupToken()
|
return (settingsManager.getToken() ?: 0L).apply {
|
||||||
.apply { Log.i(TAG, "Got current restore set token: $this") }
|
Log.i(TAG, "Got current restore set token: $this")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -117,26 +124,30 @@ internal class RestoreCoordinator(
|
||||||
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
|
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
|
||||||
*/
|
*/
|
||||||
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
|
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
|
||||||
check(state == null) { "Started new restore with existing state" }
|
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 with ${packages.map { info -> info.packageName }}")
|
||||||
|
|
||||||
// If there's only one package to restore (Auto Restore feature), add it to the state
|
// If there's only one package to restore (Auto Restore feature), add it to the state
|
||||||
val pmPackageInfo = if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
|
val pmPackageInfo =
|
||||||
val pmPackageName = packages[1].packageName
|
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
|
val pmPackageName = packages[1].packageName
|
||||||
// check if the backup is on removable storage that is not plugged in
|
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
|
||||||
if (isStorageRemovableAndNotAvailable()) {
|
// check if the backup is on removable storage that is not plugged in
|
||||||
// check if we even have a backup of that app
|
if (isStorageRemovableAndNotAvailable()) {
|
||||||
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
|
// check if we even have a backup of that app
|
||||||
// remind user to plug in storage device
|
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
|
||||||
val storageName = settingsManager.getStorage()?.name
|
// remind user to plug in storage device
|
||||||
|
val storageName = settingsManager.getStorage()?.name
|
||||||
?: context.getString(R.string.settings_backup_location_none)
|
?: context.getString(R.string.settings_backup_location_none)
|
||||||
notificationManager.onRemovableStorageNotAvailableForRestore(pmPackageName, storageName)
|
notificationManager.onRemovableStorageNotAvailableForRestore(
|
||||||
|
pmPackageName,
|
||||||
|
storageName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return TRANSPORT_ERROR
|
||||||
}
|
}
|
||||||
return TRANSPORT_ERROR
|
packages[1]
|
||||||
}
|
} else null
|
||||||
packages[1]
|
|
||||||
} else null
|
|
||||||
|
|
||||||
state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo)
|
state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo)
|
||||||
failedPackages.clear()
|
failedPackages.clear()
|
||||||
|
@ -169,7 +180,7 @@ internal class RestoreCoordinator(
|
||||||
* or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
|
* or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
|
||||||
* or null to indicate a transport-level error.
|
* or null to indicate a transport-level error.
|
||||||
*/
|
*/
|
||||||
fun nextRestorePackage(): RestoreDescription? {
|
suspend fun nextRestorePackage(): RestoreDescription? {
|
||||||
Log.i(TAG, "Next restore package!")
|
Log.i(TAG, "Next restore package!")
|
||||||
val state = this.state ?: throw IllegalStateException("no state")
|
val state = this.state ?: throw IllegalStateException("no state")
|
||||||
|
|
||||||
|
@ -213,7 +224,7 @@ internal class RestoreCoordinator(
|
||||||
* @param data An open, writable file into which the key/value backup data should be stored.
|
* @param data An open, writable file into which the key/value backup data should be stored.
|
||||||
* @return the same error codes as [startRestore].
|
* @return the same error codes as [startRestore].
|
||||||
*/
|
*/
|
||||||
fun getRestoreData(data: ParcelFileDescriptor): Int {
|
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
|
||||||
return kv.getRestoreData(data).apply {
|
return kv.getRestoreData(data).apply {
|
||||||
if (this != TRANSPORT_OK) {
|
if (this != TRANSPORT_OK) {
|
||||||
// add current package to failed ones
|
// add current package to failed ones
|
||||||
|
@ -228,7 +239,7 @@ internal class RestoreCoordinator(
|
||||||
* After this method returns zero, the system will then call [nextRestorePackage]
|
* After this method returns zero, the system will then call [nextRestorePackage]
|
||||||
* to begin the restore process for the next application, and the sequence begins again.
|
* to begin the restore process for the next application, and the sequence begins again.
|
||||||
*/
|
*/
|
||||||
fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
|
suspend fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
|
||||||
return full.getNextFullRestoreDataChunk(outputFileDescriptor)
|
return full.getNextFullRestoreDataChunk(outputFileDescriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,6 +251,7 @@ internal class RestoreCoordinator(
|
||||||
* or will call [finishRestore] to shut down the restore operation.
|
* or will call [finishRestore] to shut down the restore operation.
|
||||||
*/
|
*/
|
||||||
fun abortFullRestore(): Int {
|
fun abortFullRestore(): Int {
|
||||||
|
Log.d(TAG, "abortFullRestore")
|
||||||
state?.currentPackage?.let { failedPackages.add(it) }
|
state?.currentPackage?.let { failedPackages.add(it) }
|
||||||
return full.abortFullRestore()
|
return full.abortFullRestore()
|
||||||
}
|
}
|
||||||
|
@ -249,7 +261,9 @@ internal class RestoreCoordinator(
|
||||||
* freeing any resources and connections used during the restore process.
|
* freeing any resources and connections used during the restore process.
|
||||||
*/
|
*/
|
||||||
fun finishRestore() {
|
fun finishRestore() {
|
||||||
|
Log.d(TAG, "finishRestore")
|
||||||
if (full.hasState()) full.finishRestore()
|
if (full.hasState()) full.finishRestore()
|
||||||
|
state = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -18,7 +18,7 @@ interface RestorePlugin {
|
||||||
* @return metadata for the set of restore images available,
|
* @return metadata for the set of restore images available,
|
||||||
* or null if an error occurred (the attempt should be rescheduled).
|
* or null if an error occurred (the attempt should be rescheduled).
|
||||||
**/
|
**/
|
||||||
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
|
suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches if there's really a backup available in the given location.
|
* Searches if there's really a backup available in the given location.
|
||||||
|
@ -27,12 +27,13 @@ interface RestorePlugin {
|
||||||
* FIXME: Passing a Uri is maybe too plugin-specific?
|
* FIXME: Passing a Uri is maybe too plugin-specific?
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun hasBackup(uri: Uri): Boolean
|
@Throws(IOException::class)
|
||||||
|
suspend fun hasBackup(uri: Uri): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
|
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getApkInputStream(token: Long, packageName: String): InputStream
|
suspend fun getApkInputStream(token: Long, packageName: String): InputStream
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,11 +18,12 @@ import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
|
||||||
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
|
||||||
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
|
||||||
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
|
||||||
|
|
||||||
|
internal abstract class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) {
|
||||||
internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) {
|
|
||||||
|
|
||||||
protected val context: Context = v.context
|
protected val context: Context = v.context
|
||||||
protected val pm: PackageManager = context.packageManager
|
protected val pm: PackageManager = context.packageManager
|
||||||
|
@ -41,7 +42,6 @@ internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHold
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun setStatus(status: AppRestoreStatus) {
|
protected fun setStatus(status: AppRestoreStatus) {
|
||||||
v.background = null
|
|
||||||
if (status == IN_PROGRESS) {
|
if (status == IN_PROGRESS) {
|
||||||
appInfo.visibility = GONE
|
appInfo.visibility = GONE
|
||||||
appStatus.visibility = INVISIBLE
|
appStatus.visibility = INVISIBLE
|
||||||
|
@ -63,7 +63,9 @@ internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHold
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppRestoreStatus.getInfo(): String = when (this) {
|
private fun AppRestoreStatus.getInfo(): String = when (this) {
|
||||||
|
NOT_YET_BACKED_UP -> context.getString(R.string.restore_app_not_yet_backed_up)
|
||||||
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
|
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
|
||||||
|
FAILED_WAS_STOPPED -> context.getString(R.string.restore_app_was_stopped)
|
||||||
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
|
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
|
||||||
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
|
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
|
||||||
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)
|
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault.ui.notification
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
@ -10,16 +10,20 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager.NameNotFoundException
|
import android.content.pm.PackageManager.NameNotFoundException
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat.Action
|
import androidx.core.app.NotificationCompat.Action
|
||||||
import androidx.core.app.NotificationCompat.Builder
|
import androidx.core.app.NotificationCompat.Builder
|
||||||
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
|
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
|
||||||
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
|
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
|
||||||
import androidx.core.app.NotificationCompat.PRIORITY_LOW
|
import androidx.core.app.NotificationCompat.PRIORITY_LOW
|
||||||
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
|
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
|
||||||
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
||||||
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
||||||
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
||||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
|
||||||
|
|
||||||
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
|
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
|
||||||
private const val CHANNEL_ID_ERROR = "NotificationError"
|
private const val CHANNEL_ID_ERROR = "NotificationError"
|
||||||
|
@ -27,14 +31,21 @@ private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
||||||
private const val NOTIFICATION_ID_OBSERVER = 1
|
private const val NOTIFICATION_ID_OBSERVER = 1
|
||||||
private const val NOTIFICATION_ID_ERROR = 2
|
private const val NOTIFICATION_ID_ERROR = 2
|
||||||
private const val NOTIFICATION_ID_RESTORE_ERROR = 3
|
private const val NOTIFICATION_ID_RESTORE_ERROR = 3
|
||||||
|
private const val NOTIFICATION_ID_BACKGROUND = 4
|
||||||
|
|
||||||
class BackupNotificationManager(private val context: Context) {
|
private val TAG = BackupNotificationManager::class.java.simpleName
|
||||||
|
|
||||||
|
internal class BackupNotificationManager(private val context: Context) {
|
||||||
|
|
||||||
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
|
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
|
||||||
createNotificationChannel(getObserverChannel())
|
createNotificationChannel(getObserverChannel())
|
||||||
createNotificationChannel(getErrorChannel())
|
createNotificationChannel(getErrorChannel())
|
||||||
createNotificationChannel(getRestoreErrorChannel())
|
createNotificationChannel(getRestoreErrorChannel())
|
||||||
}
|
}
|
||||||
|
private var expectedApps: Int? = null
|
||||||
|
private var expectedOptOutApps: Int? = null
|
||||||
|
private var expectedPmRecords: Int? = null
|
||||||
|
private var expectedAppTotals: ExpectedAppTotals? = null
|
||||||
|
|
||||||
private fun getObserverChannel(): NotificationChannel {
|
private fun getObserverChannel(): NotificationChannel {
|
||||||
val title = context.getString(R.string.notification_channel_title)
|
val title = context.getString(R.string.notification_channel_title)
|
||||||
|
@ -53,32 +64,124 @@ class BackupNotificationManager(private val context: Context) {
|
||||||
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
|
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) {
|
/**
|
||||||
|
* Call this right after starting a backup.
|
||||||
|
*
|
||||||
|
* We can not know [expectedPmRecords] here, because this number varies between backup runs
|
||||||
|
* and is only known when the system tells us to update [MAGIC_PACKAGE_MANAGER].
|
||||||
|
*/
|
||||||
|
fun onBackupStarted(
|
||||||
|
expectedPackages: Int,
|
||||||
|
appTotals: ExpectedAppTotals
|
||||||
|
) {
|
||||||
|
updateBackupNotification(
|
||||||
|
infoText = "", // This passes quickly, no need to show something here
|
||||||
|
transferred = 0,
|
||||||
|
expected = expectedPackages
|
||||||
|
)
|
||||||
|
expectedApps = expectedPackages
|
||||||
|
expectedOptOutApps = appTotals.appsOptOut
|
||||||
|
expectedAppTotals = appTotals
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is expected to get called before [onOptOutAppBackup] and [onBackupUpdate].
|
||||||
|
*/
|
||||||
|
fun onPmKvBackup(packageName: String, transferred: Int, expected: Int) {
|
||||||
|
val text = "@pm@ record for $packageName"
|
||||||
|
if (expectedApps == null) {
|
||||||
|
updateBackgroundBackupNotification(text)
|
||||||
|
} else {
|
||||||
|
val addend = (expectedOptOutApps ?: 0) + (expectedApps ?: 0)
|
||||||
|
updateBackupNotification(
|
||||||
|
infoText = text,
|
||||||
|
transferred = transferred,
|
||||||
|
expected = expected + addend
|
||||||
|
)
|
||||||
|
expectedPmRecords = expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This should get called after [onPmKvBackup], but before [onBackupUpdate].
|
||||||
|
*/
|
||||||
|
fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) {
|
||||||
|
val text = "Opt-out APK for $packageName"
|
||||||
|
if (expectedApps == null) {
|
||||||
|
updateBackgroundBackupNotification(text)
|
||||||
|
} else {
|
||||||
|
updateBackupNotification(
|
||||||
|
infoText = text,
|
||||||
|
transferred = transferred + (expectedPmRecords ?: 0),
|
||||||
|
expected = expected + (expectedApps ?: 0) + (expectedPmRecords ?: 0)
|
||||||
|
)
|
||||||
|
expectedOptOutApps = expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the series of notification updates,
|
||||||
|
* this type is is expected to get called after [onOptOutAppBackup] and [onPmKvBackup].
|
||||||
|
*/
|
||||||
|
fun onBackupUpdate(app: CharSequence, transferred: Int) {
|
||||||
|
val expected = expectedApps ?: error("expectedApps is null")
|
||||||
|
val addend = (expectedOptOutApps ?: 0) + (expectedPmRecords ?: 0)
|
||||||
|
updateBackupNotification(
|
||||||
|
infoText = app,
|
||||||
|
transferred = transferred + addend,
|
||||||
|
expected = expected + addend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBackupNotification(
|
||||||
|
infoText: CharSequence,
|
||||||
|
transferred: Int,
|
||||||
|
expected: Int
|
||||||
|
) {
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
val percentage = (transferred.toFloat() / expected) * 100
|
||||||
|
val percentageStr = "%.0f%%".format(percentage)
|
||||||
|
Log.i(TAG, "$transferred/$expected - $percentageStr - $infoText")
|
||||||
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||||
setSmallIcon(R.drawable.ic_cloud_upload)
|
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||||
setContentTitle(context.getString(R.string.notification_title))
|
setContentTitle(context.getString(R.string.notification_title))
|
||||||
setContentText(app)
|
setContentText(percentageStr)
|
||||||
setOngoing(true)
|
setOngoing(true)
|
||||||
setShowWhen(false)
|
setShowWhen(false)
|
||||||
setWhen(System.currentTimeMillis())
|
setWhen(System.currentTimeMillis())
|
||||||
setProgress(expected, transferred, false)
|
setProgress(expected, transferred, false)
|
||||||
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
|
priority = PRIORITY_DEFAULT
|
||||||
}.build()
|
}.build()
|
||||||
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) {
|
private fun updateBackgroundBackupNotification(infoText: CharSequence) {
|
||||||
if (!userInitiated) {
|
Log.i(TAG, "$infoText")
|
||||||
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||||
return
|
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||||
}
|
setContentTitle(context.getString(R.string.notification_title))
|
||||||
val titleRes = if (success) R.string.notification_success_title else R.string.notification_failed_title
|
setShowWhen(false)
|
||||||
val contentText = if (notBackedUp == null) null else {
|
setWhen(System.currentTimeMillis())
|
||||||
context.getString(R.string.notification_success_num_not_backed_up, notBackedUp)
|
setProgress(0, 0, true)
|
||||||
|
priority = PRIORITY_LOW
|
||||||
|
}.build()
|
||||||
|
nm.notify(NOTIFICATION_ID_BACKGROUND, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackupBackgroundFinished() {
|
||||||
|
nm.cancel(NOTIFICATION_ID_BACKGROUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBackupFinished(success: Boolean, numBackedUp: Int?) {
|
||||||
|
val titleRes =
|
||||||
|
if (success) R.string.notification_success_title else R.string.notification_failed_title
|
||||||
|
val total = expectedAppTotals?.appsTotal
|
||||||
|
val contentText = if (numBackedUp == null || total == null) null else {
|
||||||
|
context.getString(R.string.notification_success_text, numBackedUp, total)
|
||||||
}
|
}
|
||||||
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
|
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
|
||||||
val intent = Intent(context, SettingsActivity::class.java).apply {
|
val intent = Intent(context, SettingsActivity::class.java).apply {
|
||||||
action = ACTION_APP_STATUS_LIST
|
if (success) action = ACTION_APP_STATUS_LIST
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
|
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
|
||||||
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||||
|
@ -94,6 +197,20 @@ class BackupNotificationManager(private val context: Context) {
|
||||||
priority = PRIORITY_LOW
|
priority = PRIORITY_LOW
|
||||||
}.build()
|
}.build()
|
||||||
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
||||||
|
// reset number of expected apps
|
||||||
|
expectedOptOutApps = null
|
||||||
|
expectedPmRecords = null
|
||||||
|
expectedApps = null
|
||||||
|
expectedAppTotals = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasActiveBackupNotifications(): Boolean {
|
||||||
|
nm.activeNotifications.forEach {
|
||||||
|
if (it.packageName == context.packageName &&
|
||||||
|
(it.id == NOTIFICATION_ID_OBSERVER || it.id == NOTIFICATION_ID_BACKGROUND)
|
||||||
|
) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onBackupError() {
|
fun onBackupError() {
|
||||||
|
@ -128,7 +245,8 @@ class BackupNotificationManager(private val context: Context) {
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
putExtra(EXTRA_PACKAGE_NAME, packageName)
|
putExtra(EXTRA_PACKAGE_NAME, packageName)
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getBroadcast(context, REQUEST_CODE_UNINSTALL, intent, FLAG_UPDATE_CURRENT)
|
val pendingIntent =
|
||||||
|
PendingIntent.getBroadcast(context, REQUEST_CODE_UNINSTALL, intent, FLAG_UPDATE_CURRENT)
|
||||||
val actionText = context.getString(R.string.notification_restore_error_action)
|
val actionText = context.getString(R.string.notification_restore_error_action)
|
||||||
val action = Action(R.drawable.ic_warning, actionText, pendingIntent)
|
val action = Action(R.drawable.ic_warning, actionText, pendingIntent)
|
||||||
val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {
|
val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {
|
|
@ -1,4 +1,4 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault.ui.notification
|
||||||
|
|
||||||
import android.app.backup.BackupProgress
|
import android.app.backup.BackupProgress
|
||||||
import android.app.backup.IBackupObserver
|
import android.app.backup.IBackupObserver
|
||||||
|
@ -7,16 +7,20 @@ import android.content.pm.PackageManager.NameNotFoundException
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Log.INFO
|
import android.util.Log.INFO
|
||||||
import android.util.Log.isLoggable
|
import android.util.Log.isLoggable
|
||||||
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
|
||||||
import org.koin.core.KoinComponent
|
import org.koin.core.KoinComponent
|
||||||
import org.koin.core.inject
|
import org.koin.core.inject
|
||||||
|
|
||||||
private val TAG = NotificationBackupObserver::class.java.simpleName
|
private val TAG = NotificationBackupObserver::class.java.simpleName
|
||||||
|
|
||||||
class NotificationBackupObserver(
|
internal class NotificationBackupObserver(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val expectedPackages: Int,
|
private val expectedPackages: Int,
|
||||||
private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent {
|
appTotals: ExpectedAppTotals
|
||||||
|
) : IBackupObserver.Stub(), KoinComponent {
|
||||||
|
|
||||||
private val nm: BackupNotificationManager by inject()
|
private val nm: BackupNotificationManager by inject()
|
||||||
private val metadataManager: MetadataManager by inject()
|
private val metadataManager: MetadataManager by inject()
|
||||||
|
@ -24,14 +28,18 @@ class NotificationBackupObserver(
|
||||||
private var numPackages: Int = 0
|
private var numPackages: Int = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// we need to show this manually as [onUpdate] isn't called for first @pm@ package
|
// Inform the notification manager that a backup has started
|
||||||
nm.onBackupUpdate(getAppName(MAGIC_PACKAGE_MANAGER), 0, expectedPackages, userInitiated)
|
// and inform about the expected numbers, so it can compute a total.
|
||||||
|
nm.onBackupStarted(expectedPackages, appTotals)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method could be called several times for packages with full data backup.
|
* This method could be called several times for packages with full data backup.
|
||||||
* It will tell how much of backup data is already saved and how much is expected.
|
* It will tell how much of backup data is already saved and how much is expected.
|
||||||
*
|
*
|
||||||
|
* Note that this will not be called for [MAGIC_PACKAGE_MANAGER]
|
||||||
|
* which is usually the first package to get backed up.
|
||||||
|
*
|
||||||
* @param currentBackupPackage The name of the package that now being backed up.
|
* @param currentBackupPackage The name of the package that now being backed up.
|
||||||
* @param backupProgress Current progress of backup for the package.
|
* @param backupProgress Current progress of backup for the package.
|
||||||
*/
|
*/
|
||||||
|
@ -69,20 +77,22 @@ class NotificationBackupObserver(
|
||||||
Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
|
Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
|
||||||
}
|
}
|
||||||
val success = status == 0
|
val success = status == 0
|
||||||
val notBackedUp = if (success) metadataManager.getPackagesNumNotBackedUp() else null
|
val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
|
||||||
nm.onBackupFinished(success, notBackedUp, userInitiated)
|
nm.onBackupFinished(success, numBackedUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showProgressNotification(packageName: String) {
|
private fun showProgressNotification(packageName: String) {
|
||||||
if (currentPackage == packageName) return
|
if (currentPackage == packageName) return
|
||||||
|
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Showing progress notification for $currentPackage $numPackages/$expectedPackages")
|
"Showing progress notification for $currentPackage $numPackages/$expectedPackages".let {
|
||||||
|
Log.i(TAG, it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
currentPackage = packageName
|
currentPackage = packageName
|
||||||
val app = getAppName(packageName)
|
val app = getAppName(packageName)
|
||||||
numPackages += 1
|
numPackages += 1
|
||||||
nm.onBackupUpdate(app, numPackages, expectedPackages, userInitiated)
|
nm.onBackupUpdate(app, numPackages)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
|
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
|
||||||
|
@ -90,7 +100,9 @@ class NotificationBackupObserver(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAppName(context: Context, packageId: String): CharSequence {
|
fun getAppName(context: Context, packageId: String): CharSequence {
|
||||||
if (packageId == MAGIC_PACKAGE_MANAGER) return context.getString(R.string.restore_magic_package)
|
if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) {
|
||||||
|
return context.getString(R.string.restore_magic_package)
|
||||||
|
}
|
||||||
return try {
|
return try {
|
||||||
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
|
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
|
||||||
context.packageManager.getApplicationLabel(appInfo) ?: packageId
|
context.packageManager.getApplicationLabel(appInfo) ?: packageId
|
|
@ -8,25 +8,63 @@ import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.AutoCompleteTextView
|
import android.widget.AutoCompleteTextView
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.widget.Toast.LENGTH_LONG
|
import android.widget.Toast.LENGTH_LONG
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.isDebugBuild
|
import com.stevesoltys.seedvault.isDebugBuild
|
||||||
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
|
||||||
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
import io.github.novacrypto.bip39.Validation.WordNotFoundException
|
||||||
import io.github.novacrypto.bip39.wordlists.English
|
import io.github.novacrypto.bip39.wordlists.English
|
||||||
import kotlinx.android.synthetic.main.fragment_recovery_code_input.*
|
|
||||||
import kotlinx.android.synthetic.main.recovery_code_input.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class RecoveryCodeInputFragment : Fragment() {
|
class RecoveryCodeInputFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
||||||
|
|
||||||
|
private lateinit var introText: TextView
|
||||||
|
private lateinit var doneButton: Button
|
||||||
|
private lateinit var backView: TextView
|
||||||
|
private lateinit var wordLayout1: TextInputLayout
|
||||||
|
private lateinit var wordLayout2: TextInputLayout
|
||||||
|
private lateinit var wordLayout3: TextInputLayout
|
||||||
|
private lateinit var wordLayout4: TextInputLayout
|
||||||
|
private lateinit var wordLayout5: TextInputLayout
|
||||||
|
private lateinit var wordLayout6: TextInputLayout
|
||||||
|
private lateinit var wordLayout7: TextInputLayout
|
||||||
|
private lateinit var wordLayout8: TextInputLayout
|
||||||
|
private lateinit var wordLayout9: TextInputLayout
|
||||||
|
private lateinit var wordLayout10: TextInputLayout
|
||||||
|
private lateinit var wordLayout11: TextInputLayout
|
||||||
|
private lateinit var wordLayout12: TextInputLayout
|
||||||
|
private lateinit var wordList: ConstraintLayout
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
|
||||||
|
|
||||||
|
introText = v.findViewById(R.id.introText)
|
||||||
|
doneButton = v.findViewById(R.id.doneButton)
|
||||||
|
backView = v.findViewById(R.id.backView)
|
||||||
|
wordLayout1 = v.findViewById(R.id.wordLayout1)
|
||||||
|
wordLayout2 = v.findViewById(R.id.wordLayout2)
|
||||||
|
wordLayout3 = v.findViewById(R.id.wordLayout3)
|
||||||
|
wordLayout4 = v.findViewById(R.id.wordLayout4)
|
||||||
|
wordLayout5 = v.findViewById(R.id.wordLayout5)
|
||||||
|
wordLayout6 = v.findViewById(R.id.wordLayout6)
|
||||||
|
wordLayout7 = v.findViewById(R.id.wordLayout7)
|
||||||
|
wordLayout8 = v.findViewById(R.id.wordLayout8)
|
||||||
|
wordLayout9 = v.findViewById(R.id.wordLayout9)
|
||||||
|
wordLayout10 = v.findViewById(R.id.wordLayout10)
|
||||||
|
wordLayout11 = v.findViewById(R.id.wordLayout11)
|
||||||
|
wordLayout12 = v.findViewById(R.id.wordLayout12)
|
||||||
|
wordList = v.findViewById(R.id.wordList)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -5,20 +5,28 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_recovery_code_output.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class RecoveryCodeOutputFragment : Fragment() {
|
class RecoveryCodeOutputFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
|
||||||
|
|
||||||
|
private lateinit var wordList: RecyclerView
|
||||||
|
private lateinit var confirmCodeButton: Button
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
|
||||||
|
|
||||||
|
wordList = v.findViewById(R.id.wordList)
|
||||||
|
confirmCodeButton = v.findViewById(R.id.confirmCodeButton)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -8,35 +8,51 @@ import android.net.Uri
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
import com.stevesoltys.seedvault.transport.requestBackup
|
import com.stevesoltys.seedvault.transport.requestBackup
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
private val TAG = BackupStorageViewModel::class.java.simpleName
|
private val TAG = BackupStorageViewModel::class.java.simpleName
|
||||||
|
|
||||||
internal class BackupStorageViewModel(
|
internal class BackupStorageViewModel(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
settingsManager: SettingsManager) : StorageViewModel(app, settingsManager) {
|
private val backupCoordinator: BackupCoordinator,
|
||||||
|
settingsManager: SettingsManager
|
||||||
|
) : StorageViewModel(app, settingsManager) {
|
||||||
|
|
||||||
override val isRestoreOperation = false
|
override val isRestoreOperation = false
|
||||||
|
|
||||||
override fun onLocationSet(uri: Uri) {
|
override fun onLocationSet(uri: Uri) {
|
||||||
val isUsb = saveStorage(uri)
|
val isUsb = saveStorage(uri)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// will also generate a new backup token for the new restore set
|
||||||
|
backupCoordinator.startNewRestoreSet()
|
||||||
|
|
||||||
// initialize the new location, will also generate a new backup token
|
// initialize the new location
|
||||||
val observer = InitializationObserver()
|
backupManager.initializeTransportsForUser(
|
||||||
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)
|
UserHandle.myUserId(),
|
||||||
|
arrayOf(TRANSPORT_ID),
|
||||||
// if storage is on USB and this is not SetupWizard, do a backup right away
|
// if storage is on USB and this is not SetupWizard, do a backup right away
|
||||||
if (isUsb && !isSetupWizard) Thread {
|
InitializationObserver(isUsb && !isSetupWizard)
|
||||||
requestBackup(app)
|
)
|
||||||
}.start()
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||||
|
onInitializationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private inner class InitializationObserver : IBackupObserver.Stub() {
|
private inner class InitializationObserver(val requestBackup: Boolean) :
|
||||||
|
IBackupObserver.Stub() {
|
||||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
@ -52,12 +68,19 @@ internal class BackupStorageViewModel(
|
||||||
if (status == 0) {
|
if (status == 0) {
|
||||||
// notify the UI that the location has been set
|
// notify the UI that the location has been set
|
||||||
mLocationChecked.postEvent(LocationResult())
|
mLocationChecked.postEvent(LocationResult())
|
||||||
|
if (requestBackup) {
|
||||||
|
requestBackup(app)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// notify the UI that the location was invalid
|
// notify the UI that the location was invalid
|
||||||
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
|
onInitializationError()
|
||||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onInitializationError() {
|
||||||
|
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
|
||||||
|
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,14 @@ package com.stevesoltys.seedvault.ui.storage
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
|
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
private val TAG = RestoreStorageViewModel::class.java.simpleName
|
private val TAG = RestoreStorageViewModel::class.java.simpleName
|
||||||
|
|
||||||
|
@ -17,18 +21,26 @@ internal class RestoreStorageViewModel(
|
||||||
|
|
||||||
override val isRestoreOperation = true
|
override val isRestoreOperation = true
|
||||||
|
|
||||||
override fun onLocationSet(uri: Uri) = Thread {
|
override fun onLocationSet(uri: Uri) {
|
||||||
if (restorePlugin.hasBackup(uri)) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
saveStorage(uri)
|
val hasBackup = try {
|
||||||
|
restorePlugin.hasBackup(uri)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error reading URI: $uri", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (hasBackup) {
|
||||||
|
saveStorage(uri)
|
||||||
|
|
||||||
mLocationChecked.postEvent(LocationResult())
|
mLocationChecked.postEvent(LocationResult())
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Location was rejected: $uri")
|
Log.w(TAG, "Location was rejected: $uri")
|
||||||
|
|
||||||
// notify the UI that the location was invalid
|
// notify the UI that the location was invalid
|
||||||
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
|
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
|
||||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.start()
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,22 @@ import android.view.View
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import kotlinx.android.synthetic.main.fragment_storage_check.*
|
|
||||||
|
|
||||||
private const val TITLE = "title"
|
private const val TITLE = "title"
|
||||||
private const val ERROR_MSG = "errorMsg"
|
private const val ERROR_MSG = "errorMsg"
|
||||||
|
|
||||||
class StorageCheckFragment : Fragment() {
|
class StorageCheckFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var titleView: TextView
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var errorView: TextView
|
||||||
|
private lateinit var backButton: Button
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
|
fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
|
||||||
val f = StorageCheckFragment()
|
val f = StorageCheckFragment()
|
||||||
|
@ -28,7 +35,14 @@ class StorageCheckFragment : Fragment() {
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_storage_check, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_storage_check, container, false)
|
||||||
|
|
||||||
|
titleView = v.findViewById(R.id.titleView)
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
errorView = v.findViewById(R.id.errorView)
|
||||||
|
backButton = v.findViewById(R.id.backButton)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -173,9 +173,17 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
||||||
roots.add(root)
|
roots.add(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This adds a fake Nextcloud entry if no real one was found.
|
||||||
|
*
|
||||||
|
* If Nextcloud is *not* installed,
|
||||||
|
* the user will always have the option to install it by clicking the entry.
|
||||||
|
*
|
||||||
|
* If it *is* installed and this is restore, the user can set up a new account by clicking.
|
||||||
|
* If this isn't restore, the entry will be disabled,
|
||||||
|
* because we don't know if there's no account or an activated passcode.
|
||||||
|
*/
|
||||||
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageRoot>) {
|
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageRoot>) {
|
||||||
if (!isRestore) return
|
|
||||||
|
|
||||||
for (root in roots) {
|
for (root in roots) {
|
||||||
// return if we already have a NextCloud storage root
|
// return if we already have a NextCloud storage root
|
||||||
if (root.authority == AUTHORITY_NEXTCLOUD) return
|
if (root.authority == AUTHORITY_NEXTCLOUD) return
|
||||||
|
@ -188,16 +196,20 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
||||||
putExtra("onlyAdd", true)
|
putExtra("onlyAdd", true)
|
||||||
}
|
}
|
||||||
val isInstalled = packageManager.resolveActivity(intent, 0) != null
|
val isInstalled = packageManager.resolveActivity(intent, 0) != null
|
||||||
|
val summaryRes = if (isInstalled) {
|
||||||
|
if (isRestore) R.string.storage_fake_nextcloud_summary_installed
|
||||||
|
else R.string.storage_fake_nextcloud_summary_unavailable
|
||||||
|
} else R.string.storage_fake_nextcloud_summary
|
||||||
val root = StorageRoot(
|
val root = StorageRoot(
|
||||||
authority = AUTHORITY_NEXTCLOUD,
|
authority = AUTHORITY_NEXTCLOUD,
|
||||||
rootId = "fake",
|
rootId = "fake",
|
||||||
documentId = "fake",
|
documentId = "fake",
|
||||||
icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0),
|
icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0),
|
||||||
title = context.getString(R.string.storage_fake_nextcloud_title),
|
title = context.getString(R.string.storage_fake_nextcloud_title),
|
||||||
summary = context.getString(if (isInstalled) R.string.storage_fake_nextcloud_summary_installed else R.string.storage_fake_nextcloud_summary),
|
summary = context.getString(summaryRes),
|
||||||
availableBytes = null,
|
availableBytes = null,
|
||||||
isUsb = false,
|
isUsb = false,
|
||||||
enabled = true,
|
enabled = !isInstalled || isRestore,
|
||||||
overrideClickListener = {
|
overrideClickListener = {
|
||||||
if (isInstalled) context.startActivity(intent)
|
if (isInstalled) context.startActivity(intent)
|
||||||
else {
|
else {
|
||||||
|
|
|
@ -10,13 +10,16 @@ import android.view.View
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
||||||
import com.stevesoltys.seedvault.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE
|
import com.stevesoltys.seedvault.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE
|
||||||
import kotlinx.android.synthetic.main.fragment_storage_root.*
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
|
||||||
|
|
||||||
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
|
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
|
||||||
|
@ -32,12 +35,29 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var viewModel: StorageViewModel
|
private lateinit var viewModel: StorageViewModel
|
||||||
|
private lateinit var titleView: TextView
|
||||||
|
private lateinit var warningIcon: ImageView
|
||||||
|
private lateinit var warningText: TextView
|
||||||
|
private lateinit var divider: View
|
||||||
|
private lateinit var listView: RecyclerView
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var backView: TextView
|
||||||
|
|
||||||
private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
|
private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?): View? {
|
savedInstanceState: Bundle?): View? {
|
||||||
return inflater.inflate(R.layout.fragment_storage_root, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false)
|
||||||
|
|
||||||
|
titleView = v.findViewById(R.id.titleView)
|
||||||
|
warningIcon = v.findViewById(R.id.warningIcon)
|
||||||
|
warningText = v.findViewById(R.id.warningText)
|
||||||
|
divider = v.findViewById(R.id.divider)
|
||||||
|
listView = v.findViewById(R.id.listView)
|
||||||
|
progressBar = v.findViewById(R.id.progressBar)
|
||||||
|
backView = v.findViewById(R.id.backView)
|
||||||
|
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:tint="?android:attr/textColorSecondary"
|
android:tint="?attr/colorControlNormal"
|
||||||
android:viewportWidth="24.0"
|
android:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0">
|
android:viewportHeight="24.0">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="#FFFFFFFF"
|
||||||
android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
|
android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:tint="?android:attr/textColorSecondary"
|
android:tint="?attr/colorControlNormal"
|
||||||
android:viewportWidth="24.0"
|
android:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0">
|
android:viewportHeight="24.0">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FF000000"
|
android:fillColor="#FFFFFFFF"
|
||||||
android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" />
|
android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
@ -62,6 +62,19 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/appNameView" />
|
app:layout_constraintTop_toBottomOf="@+id/appNameView" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/versionView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:text="@string/about_version"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/summaryView" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/licenseView"
|
android:id="@+id/licenseView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
@ -73,7 +86,7 @@
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/summaryView" />
|
app:layout_constraintTop_toBottomOf="@+id/versionView" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/authorView"
|
android:id="@+id/authorView"
|
||||||
|
|
|
@ -19,11 +19,13 @@
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/titleView"
|
android:id="@+id/titleView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:text="@string/storage_fragment_backup_title"
|
android:text="@string/storage_fragment_backup_title"
|
||||||
android:textSize="24sp"
|
android:textSize="24sp"
|
||||||
|
android:gravity="center"
|
||||||
|
tools:text="Choose where to store backup (is a short title, but it can be longer)"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:tint="?android:attr/textColorSecondary"
|
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
@ -26,7 +25,9 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
app:layout_constraintBottom_toTopOf="@+id/summaryView"
|
app:layout_constraintBottom_toTopOf="@+id/summaryView"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
@ -40,9 +41,11 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:textColor="?android:attr/textColorTertiary"
|
android:textColor="?android:attr/textColorTertiary"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
|
app:layout_constrainedWidth="true"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
|
|
2
app/src/main/res/values-af/strings.xml
Normal file
2
app/src/main/res/values-af/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-am/strings.xml
Normal file
2
app/src/main/res/values-am/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
11
app/src/main/res/values-ar/strings.xml
Normal file
11
app/src/main/res/values-ar/strings.xml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="settings_auto_restore_title">استعادة تلقائية</string>
|
||||||
|
<string name="settings_backup_location_title">موقع النسخ الاحتياطي</string>
|
||||||
|
<string name="settings_backup_location_picker">اختر موقع النسخ الاحتياطي</string>
|
||||||
|
<string name="settings_backup_location">موقع النسخ الاحتياطي</string>
|
||||||
|
<string name="settings_backup">احتفظ باحتياطي من معلوماتي</string>
|
||||||
|
<string name="restore_backup_button">استرجاع النسخة الاحتياطية</string>
|
||||||
|
<string name="current_destination_string">حالة النسخ الاحتياطي والإعدادات</string>
|
||||||
|
<string name="backup">نسخ احتياطية</string>
|
||||||
|
</resources>
|
2
app/src/main/res/values-as/strings.xml
Normal file
2
app/src/main/res/values-as/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-az/strings.xml
Normal file
2
app/src/main/res/values-az/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
12
app/src/main/res/values-b+sr+Latn/strings.xml
Normal file
12
app/src/main/res/values-b+sr+Latn/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="settings_backup_location_invalid">Odabrana lokacija se ne može da koristiti.</string>
|
||||||
|
<string name="settings_backup_location_title">Lokacija rezervne kopije</string>
|
||||||
|
<string name="settings_backup_location_picker">Odaberite lokaciju rezervne kopije</string>
|
||||||
|
<string name="settings_backup_location">Lokacija rezervne kopije</string>
|
||||||
|
<string name="settings_backup">Napravi rezervnu kopiju mojih podataka</string>
|
||||||
|
<string name="restore_backup_button">Vrati rezervnu kopiju</string>
|
||||||
|
<string name="current_destination_string">Status i podešavanja rezervne kopije</string>
|
||||||
|
<string name="data_management_label">Rezervna kopija Seedvault</string>
|
||||||
|
<string name="backup">Rezervna kopija</string>
|
||||||
|
</resources>
|
2
app/src/main/res/values-b+zh+Hant+HK/strings.xml
Normal file
2
app/src/main/res/values-b+zh+Hant+HK/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-be/strings.xml
Normal file
2
app/src/main/res/values-be/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-bg/strings.xml
Normal file
2
app/src/main/res/values-bg/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-bn/strings.xml
Normal file
2
app/src/main/res/values-bn/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-bs/strings.xml
Normal file
2
app/src/main/res/values-bs/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-ca/strings.xml
Normal file
2
app/src/main/res/values-ca/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-cs/strings.xml
Normal file
2
app/src/main/res/values-cs/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-da/strings.xml
Normal file
2
app/src/main/res/values-da/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
121
app/src/main/res/values-de/strings.xml
Normal file
121
app/src/main/res/values-de/strings.xml
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="settings_backup_last_backup_never">Nie</string>
|
||||||
|
<string name="settings_backup_location_internal">Interner Speicher</string>
|
||||||
|
<string name="settings_backup_location_none">Keiner</string>
|
||||||
|
<string name="settings_backup_location">Sicherungsort</string>
|
||||||
|
<string name="settings_backup">Meine Daten sichern</string>
|
||||||
|
<string name="restore_backup_button">Sicherung wiederherstellen</string>
|
||||||
|
<string name="backup">Sicherung</string>
|
||||||
|
<string name="notification_restore_error_title">Daten von %1$s konnten nicht wiederhergestellt werden</string>
|
||||||
|
<string name="notification_restore_error_channel_title">Fehler bei automatischer Wiederherstellung vom Laufwerk</string>
|
||||||
|
<string name="notification_error_action">Beheben</string>
|
||||||
|
<string name="notification_error_text">Eine Gerätesicherung konnte nicht durchgeführt werden.</string>
|
||||||
|
<string name="notification_error_title">Sicherungsfehler</string>
|
||||||
|
<string name="notification_error_channel_title">Fehlermeldung</string>
|
||||||
|
<string name="notification_failed_title">Sicherung fehlgeschlagen</string>
|
||||||
|
<string name="notification_success_title">Sicherung fertiggestellt</string>
|
||||||
|
<string name="notification_title">Sicherung läuft</string>
|
||||||
|
<string name="notification_channel_title">Sicherungsbenachrichtigung</string>
|
||||||
|
<string name="recovery_code_error_checksum_word">Ihr Wiederherstellungsschlüssel ist ungültig. Bitte prüfen Sie alle eingegebenen Wörter und versuchen Sie es erneut!</string>
|
||||||
|
<string name="recovery_code_error_invalid_word">Falsches Wort. Meinten Sie %1$s oder %2$s\?</string>
|
||||||
|
<string name="recovery_code_error_empty_word">Sie vergaßen, dieses Wort einzugeben.</string>
|
||||||
|
<string name="recovery_code_input_hint_12">Wort 12</string>
|
||||||
|
<string name="recovery_code_input_hint_11">Wort 11</string>
|
||||||
|
<string name="recovery_code_input_hint_10">Wort 10</string>
|
||||||
|
<string name="recovery_code_input_hint_9">Wort 9</string>
|
||||||
|
<string name="recovery_code_input_hint_8">Wort 8</string>
|
||||||
|
<string name="recovery_code_input_hint_7">Wort 7</string>
|
||||||
|
<string name="recovery_code_input_hint_6">Wort 6</string>
|
||||||
|
<string name="recovery_code_input_hint_5">Wort 5</string>
|
||||||
|
<string name="recovery_code_input_hint_4">Wort 4</string>
|
||||||
|
<string name="recovery_code_input_hint_3">Wort 3</string>
|
||||||
|
<string name="recovery_code_input_hint_2">Wort 2</string>
|
||||||
|
<string name="recovery_code_input_hint_1">Wort 1</string>
|
||||||
|
<string name="recovery_code_done_button">Fertig</string>
|
||||||
|
<string name="recovery_code_input_intro">Geben Sie ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel ein, den sie beim Konfigurieren der Sicherungen aufgeschrieben haben.</string>
|
||||||
|
<string name="recovery_code_confirm_intro">Geben sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel ein, um sicher zu gehen, dass er funktionieren wird, wenn sie ihn brauchen.</string>
|
||||||
|
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> frei</string>
|
||||||
|
<string name="settings_backup_status_title">App-Sicherungsstatus</string>
|
||||||
|
<string name="settings_backup_apk_dialog_message">Beim Deaktivieren der App-Sicherungen werden immer noch die App-Daten gesichert. Allerdings werden diese nicht automatisch wiederhergestellt.
|
||||||
|
\n
|
||||||
|
\nSie werden Ihre Apps manuell installieren müssen während \"Automatische Sicherungen\" aktiviert sind.</string>
|
||||||
|
<string name="settings_backup_apk_summary">Die App\'s an sich sichern. Ohne würden nur die App-Daten gesichert werden.</string>
|
||||||
|
<string name="storage_check_fragment_restore_title">Suche nach Sicherungen…</string>
|
||||||
|
<string name="storage_check_fragment_backup_title">Bereite Speicherort vor…</string>
|
||||||
|
<string name="storage_fragment_backup_title">Wählen Sie aus, wo die Sicherungen gespeichert werden sollen</string>
|
||||||
|
<string name="recovery_code_confirm_button">Wiederherstellungsschlüssel bestätigen</string>
|
||||||
|
<string name="recovery_code_write_it_down">Schreiben Sie ihn jetzt auf!</string>
|
||||||
|
<string name="recovery_code_12_word_intro">Sie benötigen Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel zum Wiederherstellen der gesicherten Daten.</string>
|
||||||
|
<string name="recovery_code_title">Wiederherstellungsschlüssel</string>
|
||||||
|
<string name="storage_check_fragment_error_button">Zurück</string>
|
||||||
|
<string name="storage_check_fragment_permission_error">Es fehlen Schreib-Berechtigungen am Speicherort.</string>
|
||||||
|
<string name="storage_check_fragment_backup_error">Ein Fehler trat beim Zugriff auf den Speicherort auf.</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_installed">Klicken Sie hier, um ein Konto einzurichten</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary">Jetzt Installieren</string>
|
||||||
|
<string name="storage_fake_drive_summary">muss angeschlossen sein</string>
|
||||||
|
<string name="storage_fake_drive_title">USB-Speichergerät</string>
|
||||||
|
<string name="storage_fragment_warning">Personen mit Zugriff auf Ihren Speicherort können herausfinden, welche Apps sie nutzen, allerdings nicht auf Ihre APP-Daten zugreifen.</string>
|
||||||
|
<string name="storage_fragment_restore_title">Wo befinden sich Ihre Sicherungen\?</string>
|
||||||
|
<string name="settings_backup_now">Jetzt sichern</string>
|
||||||
|
<string name="settings_backup_exclude_apps">Apps ausschließen</string>
|
||||||
|
<string name="settings_backup_status_summary">Letzte Sicherung: %1$s</string>
|
||||||
|
<string name="settings_backup_apk_dialog_disable">App-Sicherungen deaktivieren</string>
|
||||||
|
<string name="settings_backup_apk_dialog_cancel">Abbrechen</string>
|
||||||
|
<string name="settings_backup_apk_dialog_title">Wollen Sie die App-Sicherungen wirklich deaktivieren\?</string>
|
||||||
|
<string name="settings_info">Alle Backups auf ihrem Gerät sind verschlüsselt. Zum Wiederherstellen benötigen Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel.</string>
|
||||||
|
<string name="settings_backup_apk_title">App-Sicherung</string>
|
||||||
|
<string name="settings_category_app_data_backup">App-Daten-Sicherung</string>
|
||||||
|
<string name="settings_auto_restore_summary_usb">Achtung: Ihr %1$s muss für diese Aktion verbunden sein.</string>
|
||||||
|
<string name="settings_auto_restore_summary">Bei der erneuten Installation einer APP die gesicherten Daten und Einstellungen wiederherstellen.</string>
|
||||||
|
<string name="settings_auto_restore_title">Automatische Wiederherstellung</string>
|
||||||
|
<string name="about_source_code">Quellcode: https://github.com/stevesoltys/seedvault</string>
|
||||||
|
<string name="about_sponsor">Gesponsert von: <a href="https://www.calyxinstitute.org">Calyx Institute</a> zur Verwendung in <a href="https://calyxos.org">CalyxOS</a></string>
|
||||||
|
<string name="about_design">Design von: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
|
||||||
|
<string name="about_author">Geschrieben von: <a href="https://github.com/stevesoltys">Steve Soltys</a> und <a href="https://blog.grobox.de">Torsten Grote</a></string>
|
||||||
|
<string name="about_license">Lizenz: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
|
||||||
|
<string name="about_summary">Eine Sicherungsanwendung, die die interne Sicherungs-API von Android verwendet.</string>
|
||||||
|
<string name="about_title">Über</string>
|
||||||
|
<string name="storage_internal_warning_use_anyway">Trotzdem verwenden</string>
|
||||||
|
<string name="storage_internal_warning_choose_other">Anderen wählen</string>
|
||||||
|
<string name="storage_internal_warning_message">Sie haben den internen Speicher für Ihre Sicherung ausgewählt. Dieser ist nicht verfügbar, wenn Ihr Telefon verloren geht oder kaputt ist.</string>
|
||||||
|
<string name="storage_internal_warning_title">Warnung</string>
|
||||||
|
<string name="restore_finished_button">Beenden</string>
|
||||||
|
<string name="restore_finished_error">Beim Wiederherstellen der Sicherung ist ein Fehler aufgetreten.</string>
|
||||||
|
<string name="restore_finished_success">Wiederherstellung abgeschlossen</string>
|
||||||
|
<string name="restore_magic_package">System-Paketmanager</string>
|
||||||
|
<string name="notification_restore_error_text">Schließen Sie Ihr %1$s an, bevor Sie die App installieren, um die Daten aus der Sicherung wiederherzustellen.</string>
|
||||||
|
<string name="restore_app_quota_exceeded">Sicherungskontingent überschritten</string>
|
||||||
|
<string name="restore_app_not_installed">App ist nicht installiert</string>
|
||||||
|
<string name="restore_app_not_allowed">App erlaubt keine Sicherung</string>
|
||||||
|
<string name="restore_app_no_data">App hat keine Daten für die Sicherung gemeldet</string>
|
||||||
|
<string name="restore_app_was_stopped">Nicht gesichert, da es in letzter Zeit nicht verwendet wurde</string>
|
||||||
|
<string name="restore_app_not_yet_backed_up">Noch nicht gesichert</string>
|
||||||
|
<string name="restore_restoring">Sicherung wird wiederhergestellt</string>
|
||||||
|
<string name="restore_installing_packages">Apps neu installieren</string>
|
||||||
|
<string name="restore_set_empty_result">Am angegebenen Speicherort wurden keine geeigneten Sicherungen gefunden.
|
||||||
|
\n
|
||||||
|
\nDies ist höchstwahrscheinlich auf einen falschen Wiederherstellungscode oder einen Speicherfehler zurückzuführen.</string>
|
||||||
|
<string name="restore_next">Weiter</string>
|
||||||
|
<string name="restore_title">Wiederherstellung von einer Sicherung</string>
|
||||||
|
<string name="restore_set_error">Beim Laden der Sicherungen ist ein Fehler aufgetreten.</string>
|
||||||
|
<string name="restore_invalid_location_message">Wir konnten keine Sicherungen an diesem Speicherort finden.
|
||||||
|
\n
|
||||||
|
\nWählen Sie einen anderen Speicherort aus, der einen %s Ordner enthält.</string>
|
||||||
|
<string name="restore_invalid_location_title">Keine Sicherungen gefunden</string>
|
||||||
|
<string name="restore_back">Nicht wiederherstellen</string>
|
||||||
|
<string name="restore_restore_set_times">Letzte Sicherung %1$s · Erste %2$s.</string>
|
||||||
|
<string name="restore_choose_restore_set">Wählen Sie eine Sicherung aus, um sie wiederherzustellen</string>
|
||||||
|
<string name="notification_restore_error_action">App deinstallieren</string>
|
||||||
|
<string name="notification_success_text">%1$d von %2$d Apps gesichert. Tippen Sie, um mehr zu erfahren.</string>
|
||||||
|
<string name="notification_backup_already_running">Sicherung wird bereits durchgeführt</string>
|
||||||
|
<string name="notification_backup_result_error">Sicherung fehlgeschlagen</string>
|
||||||
|
<string name="notification_backup_result_rejected">Nicht gesichert</string>
|
||||||
|
<string name="notification_backup_result_complete">Sicherung abgeschlossen</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_unavailable">Konto nicht verfügbar. Richten Sie ein Konto ein (oder deaktivieren Sie das Passcode).</string>
|
||||||
|
<string name="settings_backup_location_invalid">Der gewählte Speicherort kann nicht verwendet werden.</string>
|
||||||
|
<string name="settings_backup_location_picker">Sicherungsort wählen</string>
|
||||||
|
<string name="settings_backup_location_title">Sicherungsort</string>
|
||||||
|
<string name="current_destination_string">Sicherungsstatus und Einstellungen</string>
|
||||||
|
<string name="data_management_label">Seedvault Sicherung</string>
|
||||||
|
</resources>
|
121
app/src/main/res/values-el/strings.xml
Normal file
121
app/src/main/res/values-el/strings.xml
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="storage_check_fragment_error_button">Πίσω</string>
|
||||||
|
<string name="storage_check_fragment_permission_error">Δεν είναι δυνατή η λήψη δικαιωμάτων εγγραφής στην τοποθεσία αντιγράφων ασφαλείας.</string>
|
||||||
|
<string name="storage_check_fragment_backup_error">Παρουσιάστηκε σφάλμα κατά την πρόσβαση στην τοποθεσία αντιγράφων ασφαλείας.</string>
|
||||||
|
<string name="storage_check_fragment_restore_title">Αναζήτηση αντιγράφων ασφαλείας…</string>
|
||||||
|
<string name="storage_check_fragment_backup_title">Προετοιμασία τοποθεσίας αντιγράφων ασφαλείας…</string>
|
||||||
|
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> ελεύθερα</string>
|
||||||
|
<string name="storage_fake_drive_summary">Πρέπει να είναι συνδεδεμένο</string>
|
||||||
|
<string name="storage_fragment_warning">Άτομα με πρόσβαση στον αποθηκευτικό χώρο σας μπορούν να μάθουν ποιες εφαρμογές χρησιμοποιείτε, αλλά δεν έχουν πρόσβαση στα δεδομένα των εφαρμογών.</string>
|
||||||
|
<string name="storage_fragment_restore_title">Πού βρίσκονται τα αντίγραφα ασφαλείας σας;</string>
|
||||||
|
<string name="storage_fragment_backup_title">Επιλέξτε πού θα αποθηκεύονται τα αντίγραφα ασφαλείας</string>
|
||||||
|
<string name="settings_backup_now">Δημιουργία αντιγράφων ασφαλείας τώρα</string>
|
||||||
|
<string name="settings_backup_exclude_apps">Εξαίρεση εφαρμογών</string>
|
||||||
|
<string name="settings_backup_status_title">Κατάσταση αντιγράφων ασφαλείας εφαρμογών</string>
|
||||||
|
<string name="settings_backup_apk_dialog_disable">Απενεργοποίηση αντιγράφων ασφαλείας εφαρμογών</string>
|
||||||
|
<string name="settings_backup_apk_dialog_cancel">Άκυρο</string>
|
||||||
|
<string name="settings_backup_apk_dialog_message">Η απενεργοποιημένη δημιουργία αντιγράφων ασφαλείας θα εξακολουθεί να δημιουργεί αντίγραφα ασφαλείας των δεδομένων εφαρμογών. Ωστόσο, δεν θα επαναφέρονται αυτόματα.
|
||||||
|
\n
|
||||||
|
\nΘα πρέπει να εγκαταστήσετε όλες τις εφαρμογές σας χειροκίνητα ενώ έχετε ενεργοποιήσει την \"Αυτόματη επαναφορά\".</string>
|
||||||
|
<string name="settings_backup_apk_dialog_title">Θέλετε πραγματικά να απενεργοποιήσετε τα αντίγραφα ασφαλείας εφαρμογών;</string>
|
||||||
|
<string name="settings_backup_apk_summary">Δημιουργία αντιγράφων των ίδιων των εφαρμογών. Διαφορετικά, θα δημιουργούνται αντίγραφα ασφαλείας μόνο δεδομένων εφαρμογών.</string>
|
||||||
|
<string name="settings_backup_apk_title">Αντίγραφα ασφαλείας εφαρμογών</string>
|
||||||
|
<string name="settings_category_app_data_backup">Αντίγραφα ασφαλείας δεδομένων εφαρμογών</string>
|
||||||
|
<string name="settings_auto_restore_summary_usb">Σημείωση: Το %1$s πρέπει να συνδεθεί για να λειτουργήσει η αυτόματη επαναφορά.</string>
|
||||||
|
<string name="settings_auto_restore_summary">Κατά την επανεγκατάσταση μιας εφαρμογής, επαναφέρετε τα αντίγραφα ασφαλείας των ρυθμίσεων και των δεδομένων.</string>
|
||||||
|
<string name="settings_auto_restore_title">Αυτόματη επαναφορά</string>
|
||||||
|
<string name="settings_backup_last_backup_never">Ποτέ</string>
|
||||||
|
<string name="settings_backup_location_none">Καμία</string>
|
||||||
|
<string name="settings_backup_location">Τοποθεσία αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="settings_backup">Δημιουργία αντιγράφου ασφαλείας των δεδομένων μου</string>
|
||||||
|
<string name="restore_backup_button">Επαναφορά αντιγράφου ασφαλείας</string>
|
||||||
|
<string name="backup">Αντίγραφο ασφαλείας</string>
|
||||||
|
<string name="settings_info">Όλα τα αντίγραφα ασφαλείας είναι κρυπτογραφημένα στο τηλέφωνό σας. Για επαναφορά από το αντίγραφο ασφαλείας θα χρειαστείτε τον κωδικό αποκατάστασης 12 λέξεων.</string>
|
||||||
|
<string name="about_source_code">Πηγαίος κώδικας: https://github.com/stevesoltys/seedvault</string>
|
||||||
|
<string name="about_sponsor">Χορηγός: <a href="https://www.calyxinstitute.org">Calyx Institute</a> για χρήση στο <a href="https://calyxos.org">CalyxOS</a></string>
|
||||||
|
<string name="about_design">Σχεδιασμός από: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
|
||||||
|
<string name="about_author">Αναπτύχθηκε από: <a href="https://github.com/stevesoltys">Steve Soltys</a> και <a href="https://blog.grobox.de">Torsten Grote</a></string>
|
||||||
|
<string name="about_license">Άδεια: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
|
||||||
|
<string name="about_summary">Μια εφαρμογή δημιουργίας αντιγράφων ασφαλείας που χρησιμοποιεί το εσωτερικό API αντιγράφων ασφαλείας του Android.</string>
|
||||||
|
<string name="about_title">Σχετικά με</string>
|
||||||
|
<string name="storage_internal_warning_use_anyway">Χρησιμοποιήστε ούτως ή άλλως</string>
|
||||||
|
<string name="storage_internal_warning_choose_other">Επιλέξτε άλλο</string>
|
||||||
|
<string name="storage_internal_warning_message">Έχετε επιλέξει εσωτερικό αποθηκευτικό χώρο για το αντίγραφο ασφαλείας σας. Αυτό δεν θα είναι διαθέσιμο όταν το τηλέφωνό σας χαθεί ή σπάσει.</string>
|
||||||
|
<string name="storage_internal_warning_title">Προσοχή</string>
|
||||||
|
<string name="restore_finished_button">Τέλος</string>
|
||||||
|
<string name="restore_finished_error">Παρουσιάστηκε σφάλμα κατά την επαναφορά του αντιγράφου ασφαλείας.</string>
|
||||||
|
<string name="restore_finished_success">Η επαναφορά ολοκληρώθηκε</string>
|
||||||
|
<string name="restore_app_quota_exceeded">Έγινε υπέρβαση του ορίου αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="restore_app_not_installed">Η εφαρμογή δεν έχει εγκατασταθεί</string>
|
||||||
|
<string name="restore_app_not_allowed">Η εφαρμογή δεν επιτρέπει τη δημιουργία αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="restore_app_no_data">Η εφαρμογή δεν ανέφερε δεδομένα για δημιουργία αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="restore_magic_package">Διαχειριστής πακέτων συστήματος</string>
|
||||||
|
<string name="restore_restoring">Επαναφορά αντιγράφου ασφαλείας</string>
|
||||||
|
<string name="restore_next">Επόμενο</string>
|
||||||
|
<string name="restore_installing_packages">Επανεγκατάσταση εφαρμογών</string>
|
||||||
|
<string name="restore_set_empty_result">Δεν βρέθηκαν κατάλληλα αντίγραφα ασφαλείας στη δεδομένη τοποθεσία.
|
||||||
|
\n
|
||||||
|
\nΑυτό πιθανότατα οφείλεται σε λάθος κωδικό ανάκτησης ή σε σφάλμα αποθηκευτικού χώρου.</string>
|
||||||
|
<string name="restore_set_error">Παρουσιάστηκε σφάλμα κατά τη φόρτωση των αντιγράφων ασφαλείας.</string>
|
||||||
|
<string name="restore_invalid_location_message">Δεν ήταν δυνατή η εύρεση αντιγράφων ασφαλείας σε αυτήν την τοποθεσία.
|
||||||
|
\n
|
||||||
|
\nΕπιλέξτε άλλη τοποθεσία που περιέχει ένα φάκελο %s.</string>
|
||||||
|
<string name="restore_invalid_location_title">Δεν βρέθηκαν αντίγραφα ασφαλείας</string>
|
||||||
|
<string name="restore_back">Να μην γίνει επαναφορά</string>
|
||||||
|
<string name="restore_restore_set_times">Τελευταίο αντίγραφο ασφαλείας %1$s · Πρώτο %2$s.</string>
|
||||||
|
<string name="restore_choose_restore_set">Επιλέξτε ένα αντίγραφο ασφαλείας για επαναφορά</string>
|
||||||
|
<string name="restore_title">Επαναφορά από αντίγραφο ασφαλείας</string>
|
||||||
|
<string name="notification_restore_error_action">Απεγκατάσταση εφαρμογής</string>
|
||||||
|
<string name="notification_restore_error_text">Συνδέστε το %1$s πριν εγκαταστήσετε την εφαρμογή για να επαναφέρετε τα δεδομένα της από το αντίγραφο ασφαλείας.</string>
|
||||||
|
<string name="notification_restore_error_title">Δεν ήταν δυνατή η επαναφορά δεδομένων για %1$s</string>
|
||||||
|
<string name="notification_restore_error_channel_title">Σφάλμα αυτόματης επαναφοράς μονάδας flash</string>
|
||||||
|
<string name="notification_error_action">Επιδιόρθωση</string>
|
||||||
|
<string name="notification_error_text">Απέτυχε η εκτέλεση ενός αντιγράφου ασφαλείας συσκευής.</string>
|
||||||
|
<string name="notification_error_title">Σφάλμα δημιουργίας αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="notification_error_channel_title">Ειδοποίηση σφάλματος</string>
|
||||||
|
<string name="notification_failed_title">Η δημιουργία αντιγράφων ασφαλείας απέτυχε</string>
|
||||||
|
<string name="notification_success_text">Δημιουργήθηκαν αντίγραφα ασφαλείας για %1$d από %2$d εφαρμογές. Πατήστε για να μάθετε περισσότερα.</string>
|
||||||
|
<string name="notification_success_title">Η δημιουργία αντιγράφων ασφαλείας ολοκληρώθηκε</string>
|
||||||
|
<string name="notification_backup_already_running">Η δημιουργία αντιγράφων ασφαλείας βρίσκεται ήδη σε εξέλιξη</string>
|
||||||
|
<string name="notification_title">Εκτελείται δημιουργία αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="notification_channel_title">Ειδοποίηση δημιουργίας αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="recovery_code_error_checksum_word">Ο κωδικός σας δεν είναι έγκυρος. Ελέγξτε όλες τις λέξεις και δοκιμάστε ξανά!</string>
|
||||||
|
<string name="recovery_code_error_invalid_word">Λάθος λέξη. Εννοείτε %1$s ή %2$s;</string>
|
||||||
|
<string name="recovery_code_error_empty_word">Ξεχάσατε να εισαγάγετε αυτήν τη λέξη.</string>
|
||||||
|
<string name="recovery_code_input_hint_12">Λέξη 12</string>
|
||||||
|
<string name="recovery_code_input_hint_11">Λέξη 11</string>
|
||||||
|
<string name="recovery_code_input_hint_10">Λέξη 10</string>
|
||||||
|
<string name="recovery_code_input_hint_9">Λέξη 9</string>
|
||||||
|
<string name="recovery_code_input_hint_8">Λέξη 8</string>
|
||||||
|
<string name="recovery_code_input_hint_7">Λέξη 7</string>
|
||||||
|
<string name="recovery_code_input_hint_6">Λέξη 6</string>
|
||||||
|
<string name="recovery_code_input_hint_5">Λέξη 5</string>
|
||||||
|
<string name="recovery_code_input_hint_4">Λέξη 4</string>
|
||||||
|
<string name="recovery_code_input_hint_3">Λέξη 3</string>
|
||||||
|
<string name="recovery_code_input_hint_2">Λέξη 2</string>
|
||||||
|
<string name="recovery_code_input_hint_1">Λέξη 1</string>
|
||||||
|
<string name="recovery_code_done_button">Ολοκληρώθηκε</string>
|
||||||
|
<string name="recovery_code_input_intro">Εισαγάγετε τον κωδικό ανάκτησης 12 λέξεων που γράψατε κατά τη δημιουργία αντιγράφων ασφαλείας.</string>
|
||||||
|
<string name="recovery_code_confirm_intro">Εισαγάγετε τον κωδικό ανάκτησης 12 λέξεων για να βεβαιωθείτε ότι θα λειτουργήσει όταν το χρειάζεστε.</string>
|
||||||
|
<string name="recovery_code_confirm_button">Επιβεβαίωση κωδικού</string>
|
||||||
|
<string name="recovery_code_write_it_down">Γράψτε το σε χαρτί τώρα!</string>
|
||||||
|
<string name="recovery_code_12_word_intro">Χρειάζεστε τον κωδικό ανάκτησης 12 λέξεων για να επαναφέρετε τα αντίγραφα ασφαλείας των δεδομένων.</string>
|
||||||
|
<string name="recovery_code_title">Κωδικός ανάκτησης</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_installed">Κάντε κλικ για να δημιουργήσετε λογαριασμό</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary">Κάντε κλικ για εγκατάσταση</string>
|
||||||
|
<string name="storage_fake_drive_title">Μονάδα USB flash</string>
|
||||||
|
<string name="settings_backup_status_summary">Τελευταίο αντίγραφο ασφαλείας: %1$s</string>
|
||||||
|
<string name="settings_backup_location_internal">Εσωτερικός αποθηκευτικός χώρος</string>
|
||||||
|
<string name="notification_backup_result_error">Η δημιουργία αντιγράφων ασφαλείας απέτυχε</string>
|
||||||
|
<string name="notification_backup_result_rejected">Δεν δημιουργήθηκε αντίγραφο ασφαλείας</string>
|
||||||
|
<string name="notification_backup_result_complete">Η δημιουργία αντιγράφων ασφαλείας ολοκληρώθηκε</string>
|
||||||
|
<string name="settings_backup_location_invalid">Η επιλεγμένη τοποθεσία δεν μπορεί να χρησιμοποιηθεί.</string>
|
||||||
|
<string name="settings_backup_location_title">Τοποθεσία αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="settings_backup_location_picker">Επιλέξτε την τοποθεσία των αντιγράφων ασφαλείας</string>
|
||||||
|
<string name="restore_app_was_stopped">Δεν δημιουργήθηκε αντίγραφο ασφαλείας καθώς δεν χρησιμοποιήθηκε πρόσφατα</string>
|
||||||
|
<string name="restore_app_not_yet_backed_up">Δεν έχει δημιουργηθεί ακόμη αντίγραφο ασφαλείας</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_unavailable">Ο λογαριασμός δεν είναι διαθέσιμος. Ρυθμίστε έναν (ή απενεργοποιήστε τον κωδικό πρόσβασης).</string>
|
||||||
|
<string name="current_destination_string">Κατάσταση αντιγράφων ασφαλείας και ρυθμίσεις</string>
|
||||||
|
<string name="data_management_label">Αντίγραφα ασφαλείας Seedvault</string>
|
||||||
|
</resources>
|
2
app/src/main/res/values-en-rAU/strings.xml
Normal file
2
app/src/main/res/values-en-rAU/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-en-rCA/strings.xml
Normal file
2
app/src/main/res/values-en-rCA/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-en-rGB/strings.xml
Normal file
2
app/src/main/res/values-en-rGB/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-en-rIN/strings.xml
Normal file
2
app/src/main/res/values-en-rIN/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
106
app/src/main/res/values-es-rUS/strings.xml
Normal file
106
app/src/main/res/values-es-rUS/strings.xml
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="about_source_code">Código fuente: https://github.com/stevesoltys/seedvault</string>
|
||||||
|
<string name="about_sponsor">Patrocinado por: <a href="https://www.calyxinstitute.org">Calyx Institute</a> for use in <a href="https://calyxos.org">CalyxOS</a></string>
|
||||||
|
<string name="about_design">Diseño por: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
|
||||||
|
<string name="about_author">Escrito por: <a href="https://github.com/stevesoltys">Steve Soltys</a> and <a href="https://blog.grobox.de">Torsten Grote</a></string>
|
||||||
|
<string name="about_license">Licencia: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
|
||||||
|
<string name="about_summary">Una aplicación de respaldo que usa la API interna de respaldo de Android.</string>
|
||||||
|
<string name="about_title">Acerca de</string>
|
||||||
|
<string name="storage_internal_warning_use_anyway">Usar de todos modos</string>
|
||||||
|
<string name="storage_internal_warning_choose_other">Elegir Otro</string>
|
||||||
|
<string name="storage_internal_warning_message">Has elegido el almacenamiento interno para su respaldo. Esto no estará disponible cuando su teléfono se pierda o se rompa.</string>
|
||||||
|
<string name="storage_internal_warning_title">Advertencia</string>
|
||||||
|
<string name="restore_finished_button">Terminar</string>
|
||||||
|
<string name="restore_finished_error">Se produjo un error al restaurar el respaldo.</string>
|
||||||
|
<string name="restore_finished_success">Restauración completa</string>
|
||||||
|
<string name="restore_app_quota_exceeded">Cuota de respaldo excedida</string>
|
||||||
|
<string name="restore_app_not_installed">Aplicación no instalada</string>
|
||||||
|
<string name="restore_app_not_allowed">La aplicación no permite respaldo</string>
|
||||||
|
<string name="restore_app_no_data">La aplicación no reportó datos para respaldo</string>
|
||||||
|
<string name="restore_magic_package">Administrador de Paquetes del Sistema</string>
|
||||||
|
<string name="restore_restoring">Restaurando Respaldo</string>
|
||||||
|
<string name="restore_next">Próximo</string>
|
||||||
|
<string name="restore_installing_packages">Reinstalar Aplicaciones</string>
|
||||||
|
<string name="restore_set_empty_result">No se encontraron respaldos en la ubicación dada.
|
||||||
|
\n
|
||||||
|
\nEsto probablemente se deba a un código de recuperación incorrecto o un error de almacenamiento.</string>
|
||||||
|
<string name="restore_set_error">Se produjo un error al cargar los respaldos.</string>
|
||||||
|
<string name="restore_invalid_location_message">No pudimos encontrar ningún respaldo en esta ubicación.
|
||||||
|
\n
|
||||||
|
\nElija otra ubicación que contenga una carpeta %s.</string>
|
||||||
|
<string name="restore_invalid_location_title">No se encontraron respaldos</string>
|
||||||
|
<string name="restore_back">No restaurar</string>
|
||||||
|
<string name="restore_restore_set_times">Último Respaldo %1$s · Primero %2$s.</string>
|
||||||
|
<string name="restore_choose_restore_set">Elije un respaldo para restaurar</string>
|
||||||
|
<string name="restore_title">Restaurar desde el respaldo</string>
|
||||||
|
<string name="notification_restore_error_action">Desinstalar Aplicación</string>
|
||||||
|
<string name="notification_restore_error_text">Enchufe su %1$s antes de instalar la aplicación para restaurar sus datos del respaldo.</string>
|
||||||
|
<string name="notification_restore_error_title">No se pudieron restaurar los datos para %1$s</string>
|
||||||
|
<string name="notification_restore_error_channel_title">Error de restauración automática de unidad flash</string>
|
||||||
|
<string name="notification_error_action">Reparar</string>
|
||||||
|
<string name="notification_error_text">No se pudo ejecutar un respaldo del dispositivo.</string>
|
||||||
|
<string name="notification_error_title">Error de Respaldo</string>
|
||||||
|
<string name="notification_error_channel_title">Notificación de Error</string>
|
||||||
|
<string name="notification_failed_title">Respaldo falló</string>
|
||||||
|
<string name="notification_success_title">Respaldo terminó</string>
|
||||||
|
<string name="notification_title">Ejecución de respaldo</string>
|
||||||
|
<string name="recovery_code_error_checksum_word">Tu código no es válido. Por favor revise todas las palabras e intente nuevamente!</string>
|
||||||
|
<string name="recovery_code_error_invalid_word">Palabra equivocada. ¿Quisiste decir %1$s o %2$s\?</string>
|
||||||
|
<string name="recovery_code_error_empty_word">Olvidaste ingresar esta palabra.</string>
|
||||||
|
<string name="recovery_code_input_hint_12">Palabra 12</string>
|
||||||
|
<string name="recovery_code_input_hint_11">Palabra 11</string>
|
||||||
|
<string name="recovery_code_input_hint_10">Palabra 10</string>
|
||||||
|
<string name="recovery_code_input_hint_9">Palabra 9</string>
|
||||||
|
<string name="recovery_code_input_hint_8">Palabra 8</string>
|
||||||
|
<string name="recovery_code_input_hint_7">Palabra 7</string>
|
||||||
|
<string name="recovery_code_input_hint_6">Palabra 6</string>
|
||||||
|
<string name="recovery_code_input_hint_5">Palabra 5</string>
|
||||||
|
<string name="recovery_code_input_hint_4">Palabra 4</string>
|
||||||
|
<string name="recovery_code_input_hint_3">Palabra 3</string>
|
||||||
|
<string name="recovery_code_input_hint_2">Palabra 2</string>
|
||||||
|
<string name="recovery_code_input_hint_1">Palabra 1</string>
|
||||||
|
<string name="recovery_code_confirm_intro">Ingresa tu código de recuperación de 12 palabras para asegurarte de que funcionará cuando lo necesite.</string>
|
||||||
|
<string name="recovery_code_done_button">Terminado</string>
|
||||||
|
<string name="recovery_code_input_intro">Ingresa tu código de recuperación de 12 palabras que escribiste al configurar los respaldos.</string>
|
||||||
|
<string name="recovery_code_confirm_button">Confirma Código</string>
|
||||||
|
<string name="recovery_code_write_it_down">¡Escríbelo en papel ahora!</string>
|
||||||
|
<string name="recovery_code_12_word_intro">Necesita tu código de recuperación de 12 palabras para restaurar los datos respaldados.</string>
|
||||||
|
<string name="recovery_code_title">Código de Recuperación</string>
|
||||||
|
<string name="storage_check_fragment_error_button">Atrás</string>
|
||||||
|
<string name="storage_check_fragment_permission_error">No se pudo obtener el permiso para escribir en la ubicación del respaldo.</string>
|
||||||
|
<string name="storage_check_fragment_backup_error">Se produjo un error al acceder a la ubicación del respaldo.</string>
|
||||||
|
<string name="storage_check_fragment_restore_title">Buscando respaldos…</string>
|
||||||
|
<string name="storage_check_fragment_backup_title">Inicializando la ubicación del respaldo…</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_installed">Haz clic para configurar la cuenta</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary">Haz clic para instalar</string>
|
||||||
|
<string name="storage_fake_drive_summary">Necesita ser enchufado</string>
|
||||||
|
<string name="storage_fake_drive_title">Memoria USB</string>
|
||||||
|
<string name="storage_fragment_warning">Las personas con acceso a su ubicación de almacenamiento pueden saber qué aplicaciones usas, pero no reciben acceso a los datos de las aplicaciones.</string>
|
||||||
|
<string name="storage_fragment_restore_title">¿Dónde encontrar sus respaldos\?</string>
|
||||||
|
<string name="storage_fragment_backup_title">Elija dónde almacenar los respaldos</string>
|
||||||
|
<string name="settings_backup_now">Respaldar ahora</string>
|
||||||
|
<string name="settings_backup_exclude_apps">Excluir aplicaciones</string>
|
||||||
|
<string name="settings_backup_status_summary">Último respaldo: %1$s</string>
|
||||||
|
<string name="settings_backup_status_title">Estado de respaldo de la aplicación</string>
|
||||||
|
<string name="settings_backup_apk_dialog_disable">Deshabilitar respaldo de la aplicación</string>
|
||||||
|
<string name="settings_backup_apk_dialog_cancel">Cancelar</string>
|
||||||
|
<string name="settings_backup_apk_dialog_message">Respaldo deshabilitado de la aplicación aún seguirá haciendo un respaldo de los datos de la aplicación. Sin embargo, no se restaurará automáticamente.
|
||||||
|
\n
|
||||||
|
\nDeberás instalar todas tus aplicaciones manualmente mientras tengas activada la \"Restauración Automática\".</string>
|
||||||
|
<string name="settings_backup_apk_dialog_title">¿Deshabilitar realmente el respaldo de la aplicación\?</string>
|
||||||
|
<string name="settings_backup_apk_summary">Respalde las aplicaciones en sí. De lo contrario, solo se harían respaldos de los datos de la aplicación.</string>
|
||||||
|
<string name="settings_backup_apk_title">Respaldo de la aplicación</string>
|
||||||
|
<string name="settings_category_app_data_backup">Respaldo de datos de la aplicación</string>
|
||||||
|
<string name="settings_auto_restore_summary_usb">Nota: Su %1$s necesita estar enchufado para que esto funcione.</string>
|
||||||
|
<string name="settings_auto_restore_summary">Al reinstalar una aplicación, restaure la configuración y los datos respaldados.</string>
|
||||||
|
<string name="settings_auto_restore_title">Restauración automática</string>
|
||||||
|
<string name="settings_info">Todas las copias de seguridad están encriptadas en su teléfono. Para restaurar desde una copia de seguridad, necesitará su código de recuperación de 12 palabras.</string>
|
||||||
|
<string name="settings_backup_last_backup_never">Nunca</string>
|
||||||
|
<string name="settings_backup_location_internal">Almacenamiento Interno</string>
|
||||||
|
<string name="settings_backup_location_none">Ninguno</string>
|
||||||
|
<string name="settings_backup_location">Ubicación de respaldo</string>
|
||||||
|
<string name="restore_backup_button">Restaurar respaldo</string>
|
||||||
|
<string name="settings_backup">Respalda mi información</string>
|
||||||
|
<string name="backup">Respaldo</string>
|
||||||
|
</resources>
|
121
app/src/main/res/values-es/strings.xml
Normal file
121
app/src/main/res/values-es/strings.xml
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="storage_fragment_warning">Quienes tengan acceso a su ubicación de almacenamiento pueden ver qué aplicaciones utiliza, pero no podrán acceder a los datos de estas.</string>
|
||||||
|
<string name="about_source_code">Código fuente: https://github.com/stevesoltys/seedvault</string>
|
||||||
|
<string name="about_title">Acerca de</string>
|
||||||
|
<string name="storage_internal_warning_use_anyway">Usar de todos modos</string>
|
||||||
|
<string name="storage_internal_warning_title">Atención</string>
|
||||||
|
<string name="restore_finished_button">Finalizar</string>
|
||||||
|
<string name="restore_next">Siguiente</string>
|
||||||
|
<string name="restore_back">No restaurar</string>
|
||||||
|
<string name="notification_restore_error_action">Desinstalar aplicación</string>
|
||||||
|
<string name="notification_error_channel_title">Notificación de error</string>
|
||||||
|
<string name="recovery_code_error_empty_word">Olvidó escribir esta palabra.</string>
|
||||||
|
<string name="recovery_code_input_hint_12">Palabra 12</string>
|
||||||
|
<string name="recovery_code_input_hint_11">Palabra 11</string>
|
||||||
|
<string name="recovery_code_input_hint_10">Palabra 10</string>
|
||||||
|
<string name="recovery_code_input_hint_9">Palabra 9</string>
|
||||||
|
<string name="recovery_code_input_hint_8">Palabra 8</string>
|
||||||
|
<string name="recovery_code_input_hint_7">Palabra 7</string>
|
||||||
|
<string name="recovery_code_input_hint_6">Palabra 6</string>
|
||||||
|
<string name="recovery_code_input_hint_5">Palabra 5</string>
|
||||||
|
<string name="recovery_code_input_hint_4">Palabra 4</string>
|
||||||
|
<string name="recovery_code_input_hint_3">Palabra 3</string>
|
||||||
|
<string name="recovery_code_input_hint_2">Palabra 2</string>
|
||||||
|
<string name="recovery_code_input_hint_1">Palabra 1</string>
|
||||||
|
<string name="recovery_code_done_button">Hecho</string>
|
||||||
|
<string name="recovery_code_title">Código de recuperación</string>
|
||||||
|
<string name="storage_check_fragment_error_button">Atrás</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_installed">Pulse para configurar una cuenta</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary">Pulse para instalar</string>
|
||||||
|
<string name="settings_backup_apk_dialog_cancel">Cancelar</string>
|
||||||
|
<string name="settings_backup_location_none">Ninguna</string>
|
||||||
|
<string name="settings_auto_restore_title">Restauración automática</string>
|
||||||
|
<string name="settings_auto_restore_summary_usb">Nota: su %1$s debe enchufarse para que esto funcione.</string>
|
||||||
|
<string name="settings_backup_last_backup_never">Nunca</string>
|
||||||
|
<string name="settings_backup_location_internal">Almacenamiento interno</string>
|
||||||
|
<string name="notification_restore_error_text">Conecta tus %1$s antes de instalar la aplicación para restaurar sus datos desde la copia de seguridad.</string>
|
||||||
|
<string name="notification_error_action">Arreglar</string>
|
||||||
|
<string name="about_sponsor">Patrocinado por: <a href="https://www.calyxinstitute.org"> Calyx Institute </a> para su uso en <a href="https://calyxos.org"> CalyxOS </a></string>
|
||||||
|
<string name="about_design">Diseño de: <a href="https://www.glennsorrentino.com/"> Glenn Sorrentino </a></string>
|
||||||
|
<string name="about_author">Escrito por: <a href="https://github.com/stevesoltys"> Steve Soltys </a> y <a href="https://blog.grobox.de"> Torsten Grote </a></string>
|
||||||
|
<string name="about_license">Licencia: <a href="https://www.apache.org/licenses/LICENSE-2.0"> Apache2 </a></string>
|
||||||
|
<string name="about_summary">Una aplicación de respaldo que utiliza la API de respaldo interno de Android.</string>
|
||||||
|
<string name="storage_internal_warning_choose_other">Escoger otro</string>
|
||||||
|
<string name="storage_internal_warning_message">Ha elegido almacenamiento interno para su copia de seguridad. Esto no estará disponible cuando su teléfono se pierda o se rompa.</string>
|
||||||
|
<string name="restore_finished_error">Ocurrió un error al restaurar la copia de seguridad.</string>
|
||||||
|
<string name="restore_finished_success">Restauración completada</string>
|
||||||
|
<string name="restore_app_quota_exceeded">Se ha superado la cuota de la copia de seguridad</string>
|
||||||
|
<string name="restore_app_not_installed">La aplicación no está instalada</string>
|
||||||
|
<string name="restore_app_not_allowed">La aplicación no permite copias de seguridad</string>
|
||||||
|
<string name="restore_app_no_data">La aplicación no reportó datos para la copia de seguridad</string>
|
||||||
|
<string name="restore_magic_package">Administrador de paquetes del sistema</string>
|
||||||
|
<string name="restore_restoring">Restaurando copia de seguridad</string>
|
||||||
|
<string name="restore_installing_packages">Re-Instalando aplicaciones</string>
|
||||||
|
<string name="restore_set_empty_result">No se han encontrado copias de seguridad adecuadas en la ubicación determinada.
|
||||||
|
\n
|
||||||
|
\nEsto es más probable debido a un código de recuperación incorrecto o un error de almacenamiento.</string>
|
||||||
|
<string name="restore_set_error">Se produjo un error al cargar las copias de seguridad.</string>
|
||||||
|
<string name="restore_invalid_location_message">No pudimos encontrar ninguna copia de seguridad en esta ubicación.
|
||||||
|
\n
|
||||||
|
\nPor favor, elija otra ubicación que contenga una carpeta %s.</string>
|
||||||
|
<string name="restore_invalid_location_title">No se encontraron copias de seguridad</string>
|
||||||
|
<string name="restore_restore_set_times">Última copia de seguridad %1$s · Primera %2$s.</string>
|
||||||
|
<string name="restore_choose_restore_set">Elija una copia de seguridad para restaurar</string>
|
||||||
|
<string name="restore_title">Restaurar desde copia de seguridad</string>
|
||||||
|
<string name="notification_restore_error_title">No se pudieron restaurar los datos por %1$s</string>
|
||||||
|
<string name="notification_restore_error_channel_title">Error en la restauración automática de la unidad flash</string>
|
||||||
|
<string name="notification_error_text">No se pudo ejecutar una copia de seguridad del dispositivo.</string>
|
||||||
|
<string name="notification_error_title">Error de copia de seguridad</string>
|
||||||
|
<string name="notification_failed_title">La copia de seguridad falló</string>
|
||||||
|
<string name="notification_success_title">Copia de seguridad terminada</string>
|
||||||
|
<string name="notification_title">Copia de seguridad en ejecución</string>
|
||||||
|
<string name="notification_channel_title">Notificación de copia de seguridad</string>
|
||||||
|
<string name="recovery_code_error_checksum_word">Su código es inválido. Por favor, compruebe todas las palabras e inténtelo de nuevo!</string>
|
||||||
|
<string name="recovery_code_error_invalid_word">Palabra equivocada. ¿Quisiste decir %1$s o %2$s \?</string>
|
||||||
|
<string name="recovery_code_input_intro">Ingrese su código de recuperación de 12 palabras que anotó al configurar las copias de seguridad.</string>
|
||||||
|
<string name="recovery_code_confirm_intro">Ingrese su código de recuperación de 12 palabras para asegurarse de que funcionará cuando lo necesite.</string>
|
||||||
|
<string name="recovery_code_confirm_button">Confirma código</string>
|
||||||
|
<string name="recovery_code_write_it_down">¡Escríbalo en papel ahora!</string>
|
||||||
|
<string name="recovery_code_12_word_intro">Necesita su código de recuperación de 12 palabras para restaurar los datos respaldados.</string>
|
||||||
|
<string name="storage_check_fragment_permission_error">No se pudo obtener el permiso para escribir en la ubicación de la copia de seguridad.</string>
|
||||||
|
<string name="storage_check_fragment_backup_error">Se produjo un error al acceder a la ubicación de la copia de seguridad.</string>
|
||||||
|
<string name="storage_check_fragment_restore_title">Buscando copias de seguridad…</string>
|
||||||
|
<string name="storage_check_fragment_backup_title">Iniciando la ubicación de la copia de seguridad…</string>
|
||||||
|
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> libre</string>
|
||||||
|
<string name="storage_fake_drive_summary">Necesita estar enchufado</string>
|
||||||
|
<string name="storage_fake_drive_title">Unidad flash USB</string>
|
||||||
|
<string name="storage_fragment_restore_title">¿Dónde encontrar tus copias de seguridad\?</string>
|
||||||
|
<string name="storage_fragment_backup_title">Elija dónde almacenar las copias de seguridad</string>
|
||||||
|
<string name="settings_backup_now">Hacer copia de seguridad ahora</string>
|
||||||
|
<string name="settings_backup_exclude_apps">Excluir aplicaciones</string>
|
||||||
|
<string name="settings_backup_status_summary">Última copia de seguridad: %1$s</string>
|
||||||
|
<string name="settings_backup_status_title">Estado de la copia de seguridad de la aplicación</string>
|
||||||
|
<string name="settings_backup_apk_dialog_disable">Deshabilitar la copia de seguridad de la aplicación</string>
|
||||||
|
<string name="settings_backup_apk_dialog_message">La copia de seguridad de la aplicación aún desactivada seguirá respaldando los datos de la aplicación. Sin embargo, no se restaurará automáticamente.
|
||||||
|
\n
|
||||||
|
\nDeberá instalar todas sus aplicaciones manualmente mientras tiene activada la \"Restauración automática\".</string>
|
||||||
|
<string name="settings_backup_apk_dialog_title">¿Realmente desactivar la copia de seguridad de la aplicación\?</string>
|
||||||
|
<string name="settings_backup_apk_summary">Realice una copia de seguridad de las aplicaciones. De lo contrario, solo se realizarían copias de seguridad de los datos de la aplicación.</string>
|
||||||
|
<string name="settings_backup_apk_title">Copia de seguridad de la aplicación</string>
|
||||||
|
<string name="settings_category_app_data_backup">Copia de seguridad de datos de la aplicación</string>
|
||||||
|
<string name="settings_auto_restore_summary">Al reinstalar una aplicación, restaure la configuración y los datos respaldados.</string>
|
||||||
|
<string name="settings_info">Todas las copias de seguridad están encriptadas en su teléfono. Para restaurar desde la copia de seguridad, necesitará su código de recuperación de 12 palabras.</string>
|
||||||
|
<string name="settings_backup_location">Ubicación de la copia de seguridad</string>
|
||||||
|
<string name="settings_backup">Respaldar mi información</string>
|
||||||
|
<string name="restore_backup_button">Restaurar la copia de seguridad</string>
|
||||||
|
<string name="backup">Copia de seguridad</string>
|
||||||
|
<string name="notification_success_text">%1$d de %2$d aplicaciones respaldadas. Toque para obtener más información.</string>
|
||||||
|
<string name="notification_backup_already_running">Copia de seguridad en progreso</string>
|
||||||
|
<string name="restore_app_not_yet_backed_up">Sin respaldar aún</string>
|
||||||
|
<string name="restore_app_was_stopped">No está respaldado, ya que no se ha usado recientemente</string>
|
||||||
|
<string name="notification_backup_result_error">Falló la copia de seguridad</string>
|
||||||
|
<string name="notification_backup_result_rejected">No hay copia de seguridad</string>
|
||||||
|
<string name="notification_backup_result_complete">Copia de seguridad completa</string>
|
||||||
|
<string name="storage_fake_nextcloud_summary_unavailable">Cuenta no disponible. Configure una (o deshabilite la contraseña).</string>
|
||||||
|
<string name="settings_backup_location_invalid">La ubicación elegida no puede ser usada.</string>
|
||||||
|
<string name="settings_backup_location_title">Ubicación de la copia de seguridad</string>
|
||||||
|
<string name="settings_backup_location_picker">Elegir ubicación de la copia de seguridad</string>
|
||||||
|
<string name="current_destination_string">Estado de la copia de seguridad y ajustes</string>
|
||||||
|
<string name="data_management_label">Copia de seguridad de Seedvault</string>
|
||||||
|
</resources>
|
2
app/src/main/res/values-et/strings.xml
Normal file
2
app/src/main/res/values-et/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-eu/strings.xml
Normal file
2
app/src/main/res/values-eu/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-fa/strings.xml
Normal file
2
app/src/main/res/values-fa/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
2
app/src/main/res/values-fi/strings.xml
Normal file
2
app/src/main/res/values-fi/strings.xml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources></resources>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue