1
0
Fork 0

Merge 'develop' into 'master'

See  for previous discussion

Closes:  (PR)
This commit is contained in:
Chirayu Desai 2020-09-10 22:29:27 +05:30
commit 53ac6f66b2
99 changed files with 3032 additions and 1091 deletions
.gitignore
.idea
.travis.ymlAndroid.bpAndroid.mkREADME.md
app
build.gradle
src
androidTest/java/com/stevesoltys/seedvault
main
sharedTest/java/com/stevesoltys/seedvault
test/java/com/stevesoltys/seedvault
build.gradledeploy-prebuilt.shgradle.properties
gradle/wrapper
libs

3
.gitignore vendored
View file

@ -7,7 +7,8 @@ hs_err_pid*
## Intellij
out/
lib/
.idea/
.idea/*
!.idea/runConfigurations*
*.ipr
*.iws
*.iml

12
.idea/runConfigurations.xml generated Normal file
View 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>

View 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 generated Normal file
View 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>

View file

@ -31,12 +31,3 @@ cache:
- $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/
- $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
View 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,
}

View file

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

View file

@ -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.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `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.
## Contributing

View file

@ -2,12 +2,11 @@ import groovy.xml.XmlUtil
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 29
buildToolsVersion '29.0.2'
buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here
defaultConfig {
minSdkVersion 29
@ -109,6 +108,9 @@ def aospDeps = fileTree(include: [
'libcore.jar'
], 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 {
compileOnly aospDeps
@ -123,20 +125,23 @@ dependencies {
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions: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'
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"
testImplementation aospDeps
def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
def mockk_version = "1.10.0"
testImplementation aospDeps // anything less fails tests run with gradlew
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1'
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.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner: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"
}

View file

@ -1,8 +1,8 @@
package com.stevesoltys.seedvault
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import org.junit.Assert.assertTrue

View file

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

View file

@ -0,0 +1,350 @@
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.ensureRecordStorageForPackage(packageInfo)
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.ensureRecordStorageForPackage(packageInfo)
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
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)
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)
// 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.ensureRecordStorageForPackage(packageInfo)
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
kvBackup.ensureRecordStorageForPackage(packageInfo)
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
}
}

View file

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

View file

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

View file

@ -18,7 +18,7 @@
<uses-permission
android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions" />
g
<!-- This is needed to change system backup settings -->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"

View file

@ -8,6 +8,7 @@ import android.os.Build
import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
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.transport.backup.backupModule
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.storage.BackupStorageViewModel
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.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
@ -36,9 +39,9 @@ class App : Application() {
single { Clock() }
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 { BackupStorageViewModel(this@App, get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
}
@ -48,7 +51,8 @@ class App : Application() {
startKoin {
androidLogger()
androidContext(this@App)
modules(listOf(
modules(
listOf(
cryptoModule,
headerModule,
metadataModule,
@ -56,7 +60,25 @@ class App : Application() {
backupModule,
restoreModule,
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)
}
}

View file

@ -2,8 +2,6 @@ package com.stevesoltys.seedvault.metadata
import android.content.Context
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.util.Log
import androidx.annotation.VisibleForTesting
@ -12,23 +10,26 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.distinctUntilChanged
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.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.transport.backup.isSystemApp
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
private val TAG = MetadataManager::class.java.simpleName
@VisibleForTesting
internal const val METADATA_CACHE_FILE = "metadata.cache"
@WorkerThread
class MetadataManager(
private val context: Context,
private val clock: Clock,
private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader) {
private val context: Context,
private val clock: Clock,
private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader
) {
private val uninitializedMetadata = BackupMetadata(token = 0L)
private var metadata: BackupMetadata = uninitializedMetadata
@ -67,7 +68,11 @@ class MetadataManager(
*/
@Synchronized
@Throws(IOException::class)
fun onApkBackedUp(packageInfo: PackageInfo, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) {
fun onApkBackedUp(
packageInfo: PackageInfo,
packageMetadata: PackageMetadata,
metadataOutputStream: OutputStream
) {
val packageName = packageInfo.packageName
metadata.packageMetadataMap[packageName]?.let {
check(packageMetadata.version != null) {
@ -78,20 +83,21 @@ class MetadataManager(
}
}
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata()
?: PackageMetadata()
// only allow state change if backup of this package is not allowed
val newState = if (packageMetadata.state == NOT_ALLOWED)
val newState = if (packageMetadata.state == NOT_ALLOWED) {
packageMetadata.state
else
} else {
oldPackageMetadata.state
}
modifyMetadata(metadataOutputStream) {
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
state = newState,
system = packageInfo.isSystemApp(),
version = packageMetadata.version,
installer = packageMetadata.installer,
sha256 = packageMetadata.sha256,
signatures = packageMetadata.signatures
state = newState,
system = packageInfo.isSystemApp(),
version = packageMetadata.version,
installer = packageMetadata.installer,
sha256 = packageMetadata.sha256,
signatures = packageMetadata.signatures
)
}
}
@ -114,9 +120,9 @@ class MetadataManager(
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = now,
state = APK_AND_DATA,
system = packageInfo.isSystemApp()
time = now,
state = APK_AND_DATA,
system = packageInfo.isSystemApp()
)
}
}
@ -130,7 +136,11 @@ class MetadataManager(
*/
@Synchronized
@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." }
val packageName = packageInfo.packageName
modifyMetadata(metadataOutputStream) {
@ -138,9 +148,9 @@ class MetadataManager(
metadata.packageMetadataMap[packageName]!!.state = packageState
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = 0L,
state = packageState,
system = packageInfo.isSystemApp()
time = 0L,
state = packageState,
system = packageInfo.isSystemApp()
)
}
}
@ -168,6 +178,7 @@ class MetadataManager(
* If the token is 0L, it is not yet initialized and must not be used for anything.
*/
@Synchronized
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
fun getBackupToken(): Long = metadata.token
/**
@ -187,9 +198,14 @@ class MetadataManager(
}
@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) ->
!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()
}
@ -219,13 +235,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
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
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"
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin {
private val context: Context,
private val storage: DocumentsStorage,
override val kvBackupPlugin: KVBackupPlugin,
override val fullBackupPlugin: FullBackupPlugin
) : BackupPlugin {
override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage)
}
private val packageManager: PackageManager = context.packageManager
@Throws(IOException::class)
override fun initializeDevice(newToken: Long): Boolean {
// check if storage is already initialized
if (storage.isInitialized()) return false
override suspend fun startNewRestoreSet(token: Long) {
// reset current storage
storage.reset(newToken)
storage.reset(token)
// get or create root backup dir
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)
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 metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA)
val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile)
}
@Throws(IOException::class)
override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
override suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
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)
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
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
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage) : FullBackupPlugin {
private val context: Context,
private val storage: DocumentsStorage
) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
@Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName)
?: throw IOException()
override suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.currentFullBackupDir?.createOrGetFile(context, targetPackage.packageName)
?: throw IOException()
return storage.getOutputStream(file)
}
@Throws(IOException::class)
override fun removeDataOfPackage(packageInfo: PackageInfo) {
override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageName = packageInfo.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")
}

View file

@ -1,23 +1,31 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import java.io.IOException
import java.io.InputStream
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullRestorePlugin(
private val documentsStorage: DocumentsStorage) : FullRestorePlugin {
private val context: Context,
private val documentsStorage: DocumentsStorage
) : FullRestorePlugin {
@Throws(IOException::class)
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
return backupDir.findFile(packageInfo.packageName) != null
override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
val backupDir = documentsStorage.getFullBackupDir(token) ?: return false
return backupDir.findFileBlocking(context, packageInfo.packageName) != null
}
@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 packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException()
val packageFile =
backupDir.findFileBlocking(context, packageInfo.packageName) ?: throw IOException()
return documentsStorage.getInputStream(packageFile)
}

View file

@ -1,52 +1,76 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException
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
override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
@Throws(IOException::class)
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName)
override suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
val packageFile =
storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName)
?: return false
return packageFile.listFiles().isNotEmpty()
return packageFile.listFilesBlocking(context).isNotEmpty()
}
@Throws(IOException::class)
override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
override suspend fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
// remember package file for subsequent operations
packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName)
packageFile =
storage.getOrCreateKVBackupDir().createOrGetDirectory(context, packageInfo.packageName)
}
@Throws(IOException::class)
override fun removeDataOfPackage(packageInfo: PackageInfo) {
override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
// we cannot use the cached this.packageFile here,
// because this can be called before [ensureRecordStorageForPackage]
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
val packageFile =
storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName) ?: return
packageFile.delete()
}
@Throws(IOException::class)
override fun deleteRecord(packageInfo: PackageInfo, key: String) {
override suspend fun deleteRecord(packageInfo: PackageInfo, key: String) {
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.findFile(key) ?: return
val keyFile = packageFile.findFileBlocking(context, key) ?: return
keyFile.delete()
}
@Throws(IOException::class)
override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream {
override suspend fun getOutputStreamForRecord(
packageInfo: PackageInfo,
key: String
): OutputStream {
check(key.length <= MAX_KEY_LENGTH) {
"Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
}
if (key.length > MAX_KEY_LENGTH_NEXTCLOUD) {
Log.e(
DocumentsProviderKVBackup::class.simpleName,
"Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
)
}
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.createOrGetFile(key)
val keyFile = packageFile.createOrGetFile(context, key)
return storage.getOutputStream(keyFile)
}

View file

@ -1,39 +1,49 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import java.io.IOException
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
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return try {
val backupDir = storage.getKVBackupDir(token) ?: return false
// remember package file for subsequent operations
packageDir = backupDir.findFile(packageInfo.packageName)
packageDir = backupDir.findFileBlocking(context, packageInfo.packageName)
packageDir != null
} catch (e: IOException) {
false
}
}
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
@Throws(IOException::class)
override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo)
return packageDir.listFiles()
.filter { file -> file.name != null }
.map { file -> file.name!! }
return packageDir.listFilesBlocking(context)
.filter { file -> file.name != null }
.map { file -> file.name!! }
}
@Throws(IOException::class)
override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream {
override suspend fun getInputStreamForRecord(
token: Long,
packageInfo: PackageInfo,
key: String
): InputStream {
val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo)
val keyFile = packageDir.findFile(key) ?: throw IOException()
val keyFile = packageDir.findFileBlocking(context, key) ?: throw IOException()
return storage.getInputStream(keyFile)
}

View file

@ -1,12 +1,22 @@
package com.stevesoltys.seedvault.plugins.saf
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 org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
single { DocumentsStorage(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()) }
}

View file

@ -15,32 +15,29 @@ import java.io.InputStream
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
internal class DocumentsProviderRestorePlugin(
private val context: Context,
private val storage: DocumentsStorage) : RestorePlugin {
private val context: Context,
private val storage: DocumentsStorage,
override val kvRestorePlugin: KVRestorePlugin,
override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(storage)
}
@WorkerThread
override fun hasBackup(uri: Uri): Boolean {
@Throws(IOException::class)
override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(context, rootDir)
return backupSets.isNotEmpty()
}
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
override suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
try {
val stream = storage.getInputStream(backupSet.metadataFile)
@ -52,8 +49,7 @@ internal class DocumentsProviderRestorePlugin(
}
}
@WorkerThread
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results
@ -63,20 +59,16 @@ internal class DocumentsProviderRestorePlugin(
return backupSets
}
for (set in files) {
if (!set.isDirectory || set.name == null) {
if (set.name != FILE_NO_MEDIA) {
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
}
// get current token from set or continue to next file/set
val token = set.getTokenOrNull() ?: continue
// 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) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else {
@ -86,10 +78,26 @@ internal class DocumentsProviderRestorePlugin(
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)
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 file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
val file =
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
return storage.getInputStream(file)
}

View file

@ -1,28 +1,31 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.FileUtils.closeQuietly
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.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
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.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES
import kotlin.coroutines.resume
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full"
@ -34,9 +37,11 @@ private const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager) {
private val context: Context,
private val settingsManager: SettingsManager
) {
private val contentResolver = context.contentResolver
internal var storage: Storage? = null
get() {
@ -45,76 +50,74 @@ internal class DocumentsStorage(
}
internal var rootBackupDir: DocumentFile? = null
get() {
get() = runBlocking {
if (field == null) {
val parent = storage?.getDocumentFile(context) ?: return null
val parent = storage?.getDocumentFile(context)
?: return@runBlocking null
field = try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT)
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup
rootDir.createOrGetFile(FILE_NO_MEDIA)
rootDir
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner
// from trying to index the backup
createOrGetFile(context, FILE_NO_MEDIA)
}
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
}
}
return field
field
}
private var currentToken: Long = 0L
private var currentToken: Long? = null
get() {
if (field == 0L) field = metadataManager.getBackupToken()
if (field == null) field = settingsManager.getToken()
return field
}
private var currentSetDir: DocumentFile? = null
get() {
get() = runBlocking {
if (field == null) {
if (currentToken == 0L) return null
if (currentToken == 0L) return@runBlocking null
field = try {
rootBackupDir?.createOrGetDirectory(currentToken.toString())
rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
return field
field
}
var currentFullBackupDir: DocumentFile? = null
get() {
get() = runBlocking {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP)
currentSetDir?.createOrGetDirectory(context, DIRECTORY_FULL_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e)
null
}
}
return field
field
}
var currentKvBackupDir: DocumentFile? = null
get() {
get() = runBlocking {
if (field == null) {
field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
currentSetDir?.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e)
null
}
}
return field
field
}
fun isInitialized(): Boolean {
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
return kvEmpty && fullEmpty
}
fun reset(newToken: Long) {
/**
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
*/
fun reset(newToken: Long?) {
storage = null
currentToken = newToken
rootBackupDir = null
@ -125,57 +128,80 @@ internal class DocumentsStorage(
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
return rootBackupDir?.findFile(token.toString())
}
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
return rootBackupDir?.findFileBlocking(context, token.toString())
}
@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()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
return getSetDir(token)?.findFileBlocking(context, 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()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP)
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
}
@Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream {
return context.contentResolver.openInputStream(file.uri) ?: throw IOException()
return contentResolver.openInputStream(file.uri) ?: throw IOException()
}
@Throws(IOException::class)
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)
fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile {
return findFile(name) ?: createFile(mimeType, name) ?: throw IOException()
internal suspend fun DocumentFile.createOrGetFile(
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)
fun DocumentFile.createOrGetDirectory(name: String): DocumentFile {
return findFile(name) ?: createDirectory(name) ?: throw IOException()
}
@Throws(IOException::class)
fun DocumentFile.deleteContents() {
for (file in listFiles()) file.delete()
suspend fun DocumentFile.deleteContents(context: Context) {
for (file in listFilesBlocking(context)) file.delete()
}
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.
*/
@Throws(IOException::class)
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver
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>()
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null
var cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
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
try {
getLoadedCursor {
resolver.query(childrenUri, projection, null, null, null)
}
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
closeQuietly(cursor)
// do a new query after content was loaded
@SuppressLint("Recycle") // gets closed after with block
cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
}
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)
} catch (e: TimeoutCancellationException) {
throw IOException(e)
}.use { cursor ->
while (cursor.moveToNext()) {
val documentId = cursor.getString(0)
val documentUri = buildDocumentUriUsingTree(uri, documentId)
result.add(getTreeDocumentFile(this, context, documentUri))
}
}
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 {
listFilesBlocking(context)
} catch (e: IOException) {
@ -244,3 +270,46 @@ fun DocumentFile.findFileBlocking(context: Context, displayName: String): Docume
}
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)
}
}
}

View file

@ -4,16 +4,19 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.InstallResult
import com.stevesoltys.seedvault.transport.restore.getInProgress
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() {
@ -23,9 +26,23 @@ class InstallProgressFragment : Fragment() {
private val layoutManager = LinearLayoutManager(context)
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?,
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?) {

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import androidx.core.net.toUri
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.koin.core.context.GlobalContext.get
internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL"

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
import java.util.*
import java.util.LinkedList
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) {
appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
@ -71,6 +71,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
enum class AppRestoreStatus {
IN_PROGRESS,
SUCCEEDED,
NOT_ELIGIBLE,
FAILED,
FAILED_NO_DATA,
FAILED_NOT_ALLOWED,

View file

@ -6,14 +6,17 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() {
@ -23,9 +26,23 @@ class RestoreProgressFragment : Fragment() {
private val layoutManager = LinearLayoutManager(context)
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?,
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?) {

View file

@ -7,19 +7,33 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_restore_set.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreSetFragment : Fragment() {
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?,
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?) {

View file

@ -19,7 +19,7 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.ui.notification.getAppName
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
@ -166,6 +166,12 @@ internal class RestoreViewModel(
private suspend fun startRestore(token: Long) {
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
val restoreSetResult = getAvailableRestoreSets()
if (restoreSetResult.hasError()) {
@ -296,7 +302,8 @@ internal class RestoreViewModel(
}
}
}
RestoreSetResult(restorableBackups)
if (restorableBackups.isEmpty()) RestoreSetResult(app.getString(R.string.restore_set_empty_result))
else RestoreSetResult(restorableBackups)
}
}
continuation.resume(result)

View file

@ -5,18 +5,31 @@ import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_about.*
class AboutDialogFragment : DialogFragment() {
private lateinit var licenseView: TextView
private lateinit var authorView: TextView
private lateinit var designView: TextView
private lateinit var sponsorView: TextView
companion object {
internal val TAG = AboutDialogFragment::class.java.simpleName
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_about, container, false)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val v: View = inflater.inflate(R.layout.fragment_about, container, false)
licenseView = v.findViewById(R.id.licenseView)
authorView = v.findViewById(R.id.authorView)
designView = v.findViewById(R.id.designView)
sponsorView = v.findViewById(R.id.sponsorView)
return v
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -9,11 +9,12 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_app_status.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal interface AppStatusToggleListener {
@ -26,12 +27,20 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppStatusAdapter(this)
private lateinit var appEditMenuItem: MenuItem
private lateinit var list: RecyclerView
private lateinit var progressBar: ProgressBar
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
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?) {

View file

@ -5,7 +5,7 @@ import androidx.annotation.CallSuper
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
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.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel

View file

@ -70,6 +70,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
val enabled = newValue as Boolean
try {
backupManager.isBackupEnabled = enabled
if (enabled) viewModel.enableCallLogBackup()
return@OnPreferenceChangeListener true
} catch (e: RemoteException) {
e.printStackTrace()
@ -171,6 +172,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
// enable call log backups for existing installs (added end of 2020)
if (backup.isChecked) viewModel.enableCallLogBackup()
} catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e)
backup.isEnabled = false

View file

@ -6,8 +6,10 @@ import android.net.Uri
import androidx.annotation.UiThread
import androidx.documentfile.provider.DocumentFile
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"
private const val PREF_KEY_STORAGE_URI = "storageUri"
@ -25,49 +27,66 @@ class SettingsManager(context: 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
fun setStorage(storage: Storage) {
prefs.edit()
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
isStorageChanging.set(true)
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply()
}
fun getStorage(): Storage? {
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
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)
return Storage(uri, name, isUsb)
}
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()
.remove(PREF_KEY_FLASH_DRIVE_NAME)
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
.apply()
.remove(PREF_KEY_FLASH_DRIVE_NAME)
.remove(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER)
.remove(PREF_KEY_FLASH_DRIVE_VENDOR_ID)
.remove(PREF_KEY_FLASH_DRIVE_PRODUCT_ID)
.apply()
} else {
prefs.edit()
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
.apply()
.putString(PREF_KEY_FLASH_DRIVE_NAME, usb.name)
.putString(PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER, usb.serialNumber)
.putInt(PREF_KEY_FLASH_DRIVE_VENDOR_ID, usb.vendorId)
.putInt(PREF_KEY_FLASH_DRIVE_PRODUCT_ID, usb.productId)
.apply()
}
}
@ -95,24 +114,26 @@ class SettingsManager(context: Context) {
}
data class Storage(
val uri: Uri,
val name: String,
val isUsb: Boolean) {
val uri: Uri,
val name: String,
val isUsb: Boolean
) {
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(
val name: String,
val serialNumber: String?,
val vendorId: Int,
val productId: Int) {
val name: String,
val serialNumber: String?,
val vendorId: Int,
val productId: Int
) {
companion object {
fun from(device: UsbDevice) = FlashDrive(
name = "${device.manufacturerName} ${device.productName}",
serialNumber = device.serialNumber,
vendorId = device.vendorId,
productId = device.productId
name = "${device.manufacturerName} ${device.productName}",
serialNumber = device.serialNumber,
vendorId = device.vendorId,
productId = device.productId
)
}
}

View file

@ -2,7 +2,10 @@ package com.stevesoltys.seedvault.settings
import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.annotation.UiThread
import androidx.core.content.ContextCompat.getDrawable
import androidx.lifecycle.LiveData
@ -14,39 +17,48 @@ import androidx.recyclerview.widget.DiffUtil.calculateDiff
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
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_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_ELIGIBLE
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.requestBackup
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.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
class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val metadataManager: MetadataManager
internal class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val notificationManager: BackupNotificationManager,
private val metadataManager: MetadataManager,
private val packageService: PackageService
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
override val isRestoreOperation = false
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
private val mAppEditMode = MutableLiveData<Boolean>()
@ -60,49 +72,51 @@ class SettingsViewModel(
}
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 locale = Locale.getDefault()
val list = pm.getInstalledPackages(0)
.filter { !it.isSystemApp() }
.map {
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
getDrawable(app, R.drawable.ic_launcher_default)!!
} else {
try {
pm.getApplicationIcon(it.packageName)
} catch (e: NameNotFoundException) {
getDrawable(app, R.drawable.ic_launcher_default)!!
}
}
val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0
val status = when (metadata?.state) {
null -> {
Log.w(TAG, "No metadata available for: ${it.packageName}")
FAILED
}
NO_DATA -> FAILED_NO_DATA
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> SUCCEEDED
}
if (metadata?.hasApk() == false) {
Log.w(TAG, "No APK stored for: ${it.packageName}")
}
AppStatus(
packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName),
icon = icon,
name = getAppName(app, it.packageName).toString(),
time = time,
status = status
)
}.sortedBy { it.name.toLowerCase(locale) }
val list = packageService.userApps.map {
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
getDrawable(app, R.drawable.ic_launcher_default)!!
} else {
try {
pm.getApplicationIcon(it.packageName)
} catch (e: NameNotFoundException) {
getDrawable(app, R.drawable.ic_launcher_default)!!
}
}
val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0
val status = when (metadata?.state) {
null -> {
Log.w(TAG, "No metadata available for: ${it.packageName}")
NOT_ELIGIBLE
}
NO_DATA -> FAILED_NO_DATA
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> SUCCEEDED
}
if (metadata?.hasApk() == false) {
Log.w(TAG, "No APK stored for: ${it.packageName}")
}
AppStatus(
packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName),
icon = icon,
name = getAppName(app, it.packageName).toString(),
time = time,
status = status
)
}.sortedBy { it.name.toLowerCase(locale) }
val oldList = mAppStatusList.value?.appStatusList ?: emptyList()
val diff = calculateDiff(AppStatusDiff(oldList, list))
emit(AppStatusResult(list, diff))
@ -118,4 +132,18 @@ class SettingsViewModel(
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)
}
}
}

View file

@ -12,19 +12,22 @@ import android.util.Log
import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import kotlinx.coroutines.runBlocking
import org.koin.core.KoinComponent
import org.koin.core.inject
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
/**
* @author Steve Soltys
* @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 restoreCoordinator by inject<RestoreCoordinator>()
@ -57,24 +60,27 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
// General backup methods
//
override fun initializeDevice(): Int {
return backupCoordinator.initializeDevice()
override fun initializeDevice(): Int = runBlocking {
backupCoordinator.initializeDevice()
}
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
override fun isAppEligibleForBackup(
targetPackage: PackageInfo,
isFullBackup: Boolean
): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
}
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
backupCoordinator.getBackupQuota(packageName, isFullBackup)
}
override fun clearBackupData(packageInfo: PackageInfo): Int {
return backupCoordinator.clearBackupData(packageInfo)
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
backupCoordinator.clearBackupData(packageInfo)
}
override fun finishBackup(): Int {
return backupCoordinator.finishBackup()
override fun finishBackup(): Int = runBlocking {
backupCoordinator.finishBackup()
}
// ------------------------------------------------------------------------------------
@ -85,11 +91,18 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.requestBackupTime()
}
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
override fun performBackup(
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.")
return performBackup(targetPackage, fileDescriptor, 0)
}
@ -106,20 +119,27 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.checkFullBackupSize(size)
}
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int {
return backupCoordinator.performFullBackup(targetPackage, socket, flags)
override fun performFullBackup(
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.")
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
}
override fun sendBackupData(numBytes: Int): Int {
return backupCoordinator.sendBackupData(numBytes)
override fun sendBackupData(numBytes: Int): Int = runBlocking {
backupCoordinator.sendBackupData(numBytes)
}
override fun cancelFullBackup() {
override fun cancelFullBackup() = runBlocking {
backupCoordinator.cancelFullBackup()
}
@ -127,8 +147,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
// Restore
//
override fun getAvailableRestoreSets(): Array<RestoreSet>? {
return restoreCoordinator.getAvailableRestoreSets()
override fun getAvailableRestoreSets(): Array<RestoreSet>? = runBlocking {
restoreCoordinator.getAvailableRestoreSets()
}
override fun getCurrentRestoreSet(): Long {
@ -139,16 +159,16 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return restoreCoordinator.startRestore(token, packages)
}
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
return restoreCoordinator.getNextFullRestoreDataChunk(socket)
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int = runBlocking {
restoreCoordinator.getNextFullRestoreDataChunk(socket)
}
override fun nextRestorePackage(): RestoreDescription? {
return restoreCoordinator.nextRestorePackage()
override fun nextRestorePackage(): RestoreDescription? = runBlocking {
restoreCoordinator.nextRestorePackage()
}
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {
return restoreCoordinator.getRestoreData(outputFileDescriptor)
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int = runBlocking {
restoreCoordinator.getRestoreData(outputFileDescriptor)
}
override fun abortFullRestore(): Int {

View file

@ -13,10 +13,12 @@ import android.os.RemoteException
import android.util.Log
import androidx.annotation.WorkerThread
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.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.inject
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
@ -24,10 +26,12 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
* @author Steve Soltys
* @author Torsten Grote
*/
class ConfigurableBackupTransportService : Service() {
class ConfigurableBackupTransportService : Service(), KoinComponent {
private var transport: ConfigurableBackupTransport? = null
private val notificationManager: BackupNotificationManager by inject()
override fun onCreate() {
super.onCreate()
transport = ConfigurableBackupTransport(applicationContext)
@ -43,6 +47,7 @@ class ConfigurableBackupTransportService : Service() {
override fun onDestroy() {
super.onDestroy()
notificationManager.onBackupBackgroundFinished()
transport = null
Log.d(TAG, "Service destroyed.")
}
@ -53,8 +58,9 @@ class ConfigurableBackupTransportService : Service() {
fun requestBackup(context: Context) {
val packageService: PackageService = get().koin.get()
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 backupManager: IBackupManager = get().koin.get()
backupManager.requestBackup(packages, observer, BackupMonitor(), FLAG_USER_INITIATED)

View file

@ -11,8 +11,6 @@ import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata
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 java.io.File
import java.io.FileNotFoundException
@ -23,9 +21,10 @@ import java.security.MessageDigest
private val TAG = ApkBackup::class.java.simpleName
class ApkBackup(
private val pm: PackageManager,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager) {
private val pm: PackageManager,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager
) {
/**
* 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.
*/
@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@
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return null
@ -45,7 +48,7 @@ class ApkBackup(
if (!settingsManager.backupApks()) return null
// 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.")
return null
}
@ -65,15 +68,19 @@ class ApkBackup(
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata()
?: PackageMetadata()
// get version codes
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
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
}
@ -91,7 +98,7 @@ class ApkBackup(
// copy the APK to the storage's output and calculate SHA-256 hash while at it
val messageDigest = MessageDigest.getInstance("SHA-256")
streamGetter.invoke().use { outputStream ->
streamGetter().use { outputStream ->
inputStream.use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
@ -107,15 +114,18 @@ class ApkBackup(
// return updated metadata
return PackageMetadata(
state = packageState,
version = version,
installer = pm.getInstallerPackageName(packageName),
sha256 = sha256,
signatures = signatures
state = packageState,
version = version,
installer = pm.getInstallerPackageName(packageName),
sha256 = sha256,
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
if (packageMetadata.signatures == null) return false
// TODO to support multiple signers check if lists differ

View file

@ -4,12 +4,13 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.app.backup.RestoreSet
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager
@ -18,8 +19,8 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
@ -29,17 +30,20 @@ private val TAG = BackupCoordinator::class.java.simpleName
* @author Steve Soltys
* @author Torsten Grote
*/
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinator(
private val context: Context,
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val apkBackup: ApkBackup,
private val clock: Clock,
private val packageService: PackageService,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
private val context: Context,
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val apkBackup: ApkBackup,
private val clock: Clock,
private val packageService: PackageService,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager
) {
private var calledInitialize = false
private var calledClearBackupData = false
@ -49,6 +53,19 @@ internal class BackupCoordinator(
// 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.
* The transport may send the request immediately, or may buffer it.
@ -67,29 +84,33 @@ internal class BackupCoordinator(
* @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure).
*/
fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!")
return try {
val token = clock.time()
if (plugin.initializeDevice(token)) {
Log.d(TAG, "Resetting backup metadata...")
metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream())
} else {
Log.d(TAG, "Storage was already initialized, doing no-op")
suspend fun initializeDevice(): Int = try {
val token = settingsManager.getToken()
if (token == null) {
Log.i(TAG, "No RestoreSet started, initialization is no-op.")
} else {
Log.i(TAG, "Initialize Device!")
plugin.initializeDevice()
Log.d(TAG, "Resetting backup metadata for token $token...")
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
// Check that the app is not blacklisted by the user
val enabled = settingsManager.isBackupEnabled(packageName)
@ -107,7 +128,7 @@ internal class BackupCoordinator(
* otherwise for key-value backup.
* @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) {
// try to back up APK here as later methods are sometimes not called called
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
@ -139,7 +160,11 @@ internal class BackupCoordinator(
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
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) {
@ -148,10 +173,13 @@ internal class BackupCoordinator(
if (getBackupBackoff() != 0L) {
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
backUpNotAllowedPackages()
}
return kv.performBackup(packageInfo, data, flags)
return result
}
// ------------------------------------------------------------------------------------
@ -182,12 +210,16 @@ internal class BackupCoordinator(
return result
}
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
suspend fun performFullBackup(
targetPackage: PackageInfo,
fileDescriptor: ParcelFileDescriptor,
flags: Int
): Int {
cancelReason = UNKNOWN_ERROR
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.
@ -202,9 +234,9 @@ internal class BackupCoordinator(
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
* It needs to tear down any ongoing backup state here.
*/
fun cancelFullBackup() {
suspend fun cancelFullBackup() {
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")
onPackageBackupError(packageInfo)
full.cancelFullBackup()
@ -221,7 +253,7 @@ internal class BackupCoordinator(
*
* @return the same error codes as [performFullBackup].
*/
fun clearBackupData(packageInfo: PackageInfo): Int {
suspend fun clearBackupData(packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName
Log.i(TAG, "Clear Backup Data of $packageName.")
try {
@ -248,15 +280,15 @@ internal class BackupCoordinator(
*
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/
fun finishBackup(): Int = when {
suspend fun finishBackup(): Int = when {
kv.hasState() -> {
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()
}
full.hasState() -> {
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()
}
calledInitialize || calledClearBackupData -> {
@ -267,10 +299,12 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()")
}
private fun backUpNotAllowedPackages() {
private suspend fun backUpNotAllowedPackages() {
Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
packageService.notAllowedPackages.forEach { optOutPackageInfo ->
val notAllowedPackages = packageService.notAllowedPackages
notAllowedPackages.forEachIndexed { i, optOutPackageInfo ->
try {
nm.onOptOutAppBackup(optOutPackageInfo.packageName, i + 1, notAllowedPackages.size)
backUpApk(optOutPackageInfo, NOT_ALLOWED)
} catch (e: IOException) {
Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e)
@ -278,37 +312,43 @@ internal class BackupCoordinator(
}
}
private fun backUpApk(packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR) {
private suspend fun backUpApk(
packageInfo: PackageInfo,
packageState: PackageState = UNKNOWN_ERROR
) {
val packageName = packageInfo.packageName
try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream)
plugin.getMetadataOutputStream().use {
metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)
}
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
}
}
private fun onPackageBackedUp(packageInfo: PackageInfo) {
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackedUp(packageInfo, outputStream)
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, it)
}
} catch (e: IOException) {
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
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackupError(packageInfo, cancelReason, outputStream)
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, cancelReason, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
}

View file

@ -5,9 +5,9 @@ import org.koin.dsl.module
val backupModule = module {
single { InputFactory() }
single { PackageService(androidContext().packageManager, get()) }
single { PackageService(androidContext(), 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 { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.OutputStream
@ -11,25 +12,30 @@ interface BackupPlugin {
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
* false if the device was initialized already and initialization should be a no-op.
* This is typically followed by a call to [initializeDevice].
*/
@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.
*/
@Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream
suspend fun getMetadataOutputStream(): OutputStream
/**
* Returns an [OutputStream] for writing an APK to be backed up.
*/
@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

View file

@ -18,10 +18,11 @@ import java.io.InputStream
import java.io.OutputStream
private class FullBackupState(
internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream,
internal var outputStreamInit: (() -> OutputStream)?) {
internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream,
internal var outputStreamInit: (suspend () -> OutputStream)?
) {
internal var outputStream: OutputStream? = null
internal val packageName: String = packageInfo.packageName
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
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackup(
private val plugin: FullBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto) {
private val plugin: FullBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto
) {
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_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()
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
@ -101,7 +108,9 @@ internal class FullBackup(
val outputStream = try {
plugin.getOutputStream(targetPackage)
} 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)
}
// store version header
@ -115,31 +124,36 @@ internal class FullBackup(
throw(e)
}
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
}
fun sendBackupData(numBytes: Int): Int {
suspend fun sendBackupData(numBytes: Int): Int {
val state = this.state
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
// check if size fits quota
state.size += numBytes
val quota = plugin.getQuota()
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 try {
// get output stream or initialize it, if it does not yet exist
check((state.outputStream != null) xor (state.outputStreamInit != null)) { "No OutputStream xor no StreamGetter" }
val outputStream = state.outputStream ?: {
val stream = state.outputStreamInit!!.invoke() // not-null due to check above
check((state.outputStream != null) xor (state.outputStreamInit != null)) {
"No OutputStream xor no StreamGetter"
}
val outputStream = state.outputStream ?: suspend {
val stream = state.outputStreamInit!!() // not-null due to check above
state.outputStream = stream
stream
}.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
val payload = IOUtils.readFully(state.inputStream, numBytes)
@ -152,11 +166,11 @@ internal class FullBackup(
}
@Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) {
suspend fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
fun cancelFullBackup() {
suspend fun cancelFullBackup() {
Log.i(TAG, "Cancel full backup")
val state = this.state ?: throw AssertionError("No state when canceling")
try {

View file

@ -10,12 +10,12 @@ interface FullBackupPlugin {
// TODO consider using a salted hash for the package name to not leak it to the storage server
@Throws(IOException::class)
fun getOutputStream(targetPackage: PackageInfo): OutputStream
suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream
/**
* Remove all data associated with the given package.
*/
@Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo)
suspend fun removeDataOfPackage(packageInfo: PackageInfo)
}

View file

@ -8,6 +8,8 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
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.encodeBase64
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
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup(
private val plugin: KVBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto) {
private val plugin: KVBackupPlugin,
private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter,
private val crypto: Crypto,
private val nm: BackupNotificationManager
) {
private var state: KVBackupState? = null
@ -35,7 +40,11 @@ internal class KVBackup(
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 isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName
@ -64,7 +73,10 @@ internal class KVBackup(
return backupError(TRANSPORT_ERROR)
}
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)
}
@ -91,35 +103,75 @@ internal class KVBackup(
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
for (result in parseBackupStream(data)) {
var i = 1
for (result in backupSequence) {
if (result is Result.Error) {
Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR)
}
val op = (result as Result.Ok).result
try {
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)
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)
}
storeRecord(packageInfo, op, i++, pmRecordNumber)
} catch (e: IOException) {
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 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
*/
@ -132,7 +184,7 @@ internal class KVBackup(
return generateSequence {
// read the next header or end the sequence in case of error or no more headers
try {
if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence
if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence
} catch (e: IOException) {
Log.e(TAG, "Error reading next header", e)
return@generateSequence Result.Error(e)
@ -163,7 +215,7 @@ internal class KVBackup(
}
@Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) {
suspend fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo)
}
@ -178,18 +230,20 @@ internal class KVBackup(
* because [finishBackup] is not called when we don't return [TRANSPORT_OK].
*/
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)
}
state = null
return result
}
private class KVOperation(
internal val key: String,
internal val base64Key: String,
/**
* value is null when this is a deletion operation
*/
internal val value: ByteArray?
val key: String,
val base64Key: String,
/**
* value is null when this is a deletion operation
*/
val value: ByteArray?
)
private sealed class Result<out T> {

View file

@ -16,7 +16,7 @@ interface KVBackupPlugin {
* Return true if there are records stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(packageInfo: PackageInfo): Boolean
suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean
/**
* This marks the beginning of a backup operation.
@ -25,25 +25,25 @@ interface KVBackupPlugin {
* E.g. file-based plugins should a create a directory for the package, if none exists.
*/
@Throws(IOException::class)
fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
suspend fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
/**
* Return an [OutputStream] for the given package and key
* which will receive the record's encrypted value.
*/
@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.
*/
@Throws(IOException::class)
fun deleteRecord(packageInfo: PackageInfo, key: String)
suspend fun deleteRecord(packageInfo: PackageInfo, key: String)
/**
* Remove all data associated with the given package.
*/
@Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo)
suspend fun removeDataOfPackage(packageInfo: PackageInfo)
}

View file

@ -1,8 +1,14 @@
package com.stevesoltys.seedvault.transport.backup
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.PackageManager
import android.content.pm.PackageManager.GET_INSTRUMENTATION
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.RemoteException
import android.os.UserHandle
@ -20,9 +26,11 @@ private const val LOG_MAX_PACKAGES = 100
* @author Torsten Grote
*/
internal class PackageService(
private val packageManager: PackageManager,
private val backupManager: IBackupManager) {
private val context: Context,
private val backupManager: IBackupManager
) {
private val packageManager: PackageManager = context.packageManager
private val myUserId = UserHandle.myUserId()
val eligiblePackages: Array<String>
@ -30,8 +38,8 @@ internal class PackageService(
@Throws(RemoteException::class)
get() {
val packages = packageManager.getInstalledPackages(0)
.map { packageInfo -> packageInfo.packageName }
.sorted()
.map { packageInfo -> packageInfo.packageName }
.sorted()
// log packages
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
if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
eligibleApps.toList().chunked(LOG_MAX_PACKAGES).forEach {
Log.i(TAG, it.toString())
}
logPackages(eligibleApps.toList())
}
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
@ -61,16 +68,92 @@ internal class PackageService(
val notAllowedPackages: List<PackageInfo>
@WorkerThread
get() {
val installed = packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
val installedArray = installed.map { packageInfo ->
packageInfo.packageName
}.toTypedArray()
val eligible = backupManager.filterAppsEligibleForBackupForUser(myUserId, installedArray)
return installed.filter { packageInfo ->
packageInfo.packageName !in eligible
}.sortedBy { it.packageName }
// We need the GET_SIGNING_CERTIFICATES flag here,
// because the package info is used by [ApkBackup] which needs signing info.
return packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
.filter { packageInfo ->
packageInfo.doesNotGetBackedUp() && // only apps that do not allow backup
!packageInfo.isNotUpdatedSystemApp() && // and are not vanilla system apps
packageInfo.packageName != context.packageName // not this app
}.sortedBy { packageInfo ->
packageInfo.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)
}
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
}

View file

@ -9,8 +9,8 @@ import android.util.Log
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.PackageMetadata
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.isSystemApp
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED

View file

@ -23,6 +23,7 @@ private class FullRestoreState(
private val TAG = FullRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestore(
private val plugin: FullRestorePlugin,
private val outputFactory: OutputFactory,
@ -37,7 +38,7 @@ internal class FullRestore(
* Return true if there is data stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
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
* 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 packageName = state.packageInfo.packageName
@ -113,6 +114,7 @@ internal class FullRestore(
try {
// read segment from input stream and decrypt it
val decrypted = try {
// TODO handle IOException
crypto.decryptSegment(inputStream)
} catch (e: EOFException) {
Log.i(TAG, " EOF")

View file

@ -10,9 +10,9 @@ interface FullRestorePlugin {
* Return true if there is data stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
@Throws(IOException::class)
fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
suspend fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
}

View file

@ -15,24 +15,27 @@ import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
import java.util.*
import java.util.ArrayList
import javax.crypto.AEADBadTagException
private class KVRestoreState(
internal val token: Long,
internal val packageInfo: PackageInfo,
/**
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
*/
internal val pmPackageInfo: PackageInfo?)
internal val token: Long,
internal val packageInfo: PackageInfo,
/**
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
*/
internal val pmPackageInfo: PackageInfo?
)
private val TAG = KVRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestore(
private val plugin: KVRestorePlugin,
private val outputFactory: OutputFactory,
private val headerReader: HeaderReader,
private val crypto: Crypto) {
private val plugin: KVRestorePlugin,
private val outputFactory: OutputFactory,
private val headerReader: HeaderReader,
private val crypto: Crypto
) {
private var state: KVRestoreState? = null
@ -40,7 +43,7 @@ internal class KVRestore(
* Return true if there are records stored for the given package.
*/
@Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo)
}
@ -63,7 +66,7 @@ internal class KVRestore(
* @return One of [TRANSPORT_OK]
* 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")
// 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,
* 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 {
plugin.listRecords(token, packageInfo)
} catch (e: IOException) {
@ -122,11 +125,12 @@ internal class KVRestore(
for (recordKey in records) contents.add(DecodedKey(recordKey))
// remove keys that are not needed for single package @pm@ restore
val pmPackageName = state?.pmPackageInfo?.packageName
val sortedKeys = if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
contents.filterTo(ArrayList()) { it.key in keys }
} else contents
val sortedKeys =
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
contents.filterTo(ArrayList()) { it.key in keys }
} else contents
sortedKeys.sort()
return sortedKeys
}
@ -135,9 +139,12 @@ internal class KVRestore(
* Read the encrypted value for the given key and write it to the given [BackupDataOutput].
*/
@Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) {
val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
try {
private suspend fun readAndWriteValue(
state: KVRestoreState,
dKey: DecodedKey,
out: BackupDataOutput
) = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
.use { inputStream ->
val version = headerReader.readVersion(inputStream)
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
val value = crypto.decryptMultipleSegments(inputStream)
@ -146,10 +153,8 @@ internal class KVRestore(
out.writeEntityHeader(dKey.key, size)
out.writeEntityData(value, size)
} finally {
closeQuietly(inputStream)
Unit
}
}
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {
internal val key = base64Key.decodeBase64()

View file

@ -10,21 +10,25 @@ interface KVRestorePlugin {
* Return true if there is data stored for the given package.
*/
@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.
*
* Note: Implementations might expect that you call [hasDataForPackage] before.
*
* For file-based plugins, this is usually a list of file names in the package directory.
*/
@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
* which will provide the record's encrypted value.
*
* Note: Implementations might expect that you call [hasDataForPackage] before.
*/
@Throws(IOException::class)
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
suspend fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
}

View file

@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
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.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
private class RestoreCoordinatorState(
internal val token: Long,
internal val packages: Iterator<PackageInfo>,
/**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/
internal val pmPackageInfo: PackageInfo?) {
internal var currentPackage: String? = null
val token: Long,
val packages: Iterator<PackageInfo>,
/**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/
val pmPackageInfo: PackageInfo?
) {
var currentPackage: String? = null
}
private val TAG = RestoreCoordinator::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinator(
private val context: Context,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val notificationManager: BackupNotificationManager,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore,
private val metadataReader: MetadataReader) {
private val context: Context,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val notificationManager: BackupNotificationManager,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore,
private val metadataReader: MetadataReader
) {
private var state: RestoreCoordinatorState? = 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,
* 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 restoreSets = ArrayList<RestoreSet>()
val metadataMap = LongSparseArray<BackupMetadata>()
@ -67,7 +70,10 @@ internal class RestoreCoordinator(
"No error when getting encrypted metadata, but stream is still missing."
}
try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
val metadata = metadataReader.readMetadata(
encryptedMetadata.inputStream,
encryptedMetadata.token
)
metadataMap.put(encryptedMetadata.token, metadata)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
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.
*/
fun getCurrentRestoreSet(): Long {
return metadataManager.getBackupToken()
.apply { Log.i(TAG, "Got current restore set token: $this") }
return (settingsManager.getToken() ?: 0L).apply {
Log.i(TAG, "Got current restore set token: $this")
}
}
/**
@ -121,22 +128,26 @@ internal class RestoreCoordinator(
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
val pmPackageInfo = if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
val pmPackageName = packages[1].packageName
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
// check if the backup is on removable storage that is not plugged in
if (isStorageRemovableAndNotAvailable()) {
// check if we even have a backup of that app
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
// remind user to plug in storage device
val storageName = settingsManager.getStorage()?.name
val pmPackageInfo =
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
val pmPackageName = packages[1].packageName
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
// check if the backup is on removable storage that is not plugged in
if (isStorageRemovableAndNotAvailable()) {
// check if we even have a backup of that app
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
// remind user to plug in storage device
val storageName = settingsManager.getStorage()?.name
?: 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)
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 null to indicate a transport-level error.
*/
fun nextRestorePackage(): RestoreDescription? {
suspend fun nextRestorePackage(): RestoreDescription? {
Log.i(TAG, "Next restore package!")
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.
* @return the same error codes as [startRestore].
*/
fun getRestoreData(data: ParcelFileDescriptor): Int {
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
return kv.getRestoreData(data).apply {
if (this != TRANSPORT_OK) {
// add current package to failed ones
@ -228,7 +239,7 @@ internal class RestoreCoordinator(
* 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.
*/
fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
suspend fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
return full.getNextFullRestoreDataChunk(outputFileDescriptor)
}

View file

@ -18,7 +18,7 @@ interface RestorePlugin {
* @return metadata for the set of restore images available,
* 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.
@ -27,12 +27,13 @@ interface RestorePlugin {
* FIXME: Passing a Uri is maybe too plugin-specific?
*/
@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.
*/
@Throws(IOException::class)
fun getApkInputStream(token: Long, packageName: String): InputStream
suspend fun getApkInputStream(token: Long, packageName: String): InputStream
}

View file

@ -19,6 +19,7 @@ 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_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_ELIGIBLE
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
@ -63,6 +64,7 @@ internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHold
}
private fun AppRestoreStatus.getInfo(): String = when (this) {
NOT_ELIGIBLE -> context.getString(R.string.restore_app_not_eligible)
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)

View file

@ -1,4 +1,4 @@
package com.stevesoltys.seedvault
package com.stevesoltys.seedvault.ui.notification
import android.app.NotificationChannel
import android.app.NotificationManager
@ -10,16 +10,20 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import androidx.core.app.NotificationCompat.Action
import androidx.core.app.NotificationCompat.Builder
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
import androidx.core.app.NotificationCompat.PRIORITY_HIGH
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.EXTRA_PACKAGE_NAME
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
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_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_ERROR = 2
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 {
createNotificationChannel(getObserverChannel())
createNotificationChannel(getErrorChannel())
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 {
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)
}
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 {
setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title))
setContentText(app)
setContentText(percentageStr)
setOngoing(true)
setShowWhen(false)
setWhen(System.currentTimeMillis())
setProgress(expected, transferred, false)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
priority = PRIORITY_DEFAULT
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
}
fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) {
if (!userInitiated) {
nm.cancel(NOTIFICATION_ID_OBSERVER)
return
}
val titleRes = if (success) R.string.notification_success_title else R.string.notification_failed_title
val contentText = if (notBackedUp == null) null else {
context.getString(R.string.notification_success_num_not_backed_up, notBackedUp)
private fun updateBackgroundBackupNotification(infoText: CharSequence) {
Log.i(TAG, "$infoText")
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title))
setShowWhen(false)
setWhen(System.currentTimeMillis())
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 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 notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
@ -94,6 +197,20 @@ class BackupNotificationManager(private val context: Context) {
priority = PRIORITY_LOW
}.build()
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() {
@ -128,7 +245,8 @@ class BackupNotificationManager(private val context: Context) {
setPackage(context.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 action = Action(R.drawable.ic_warning, actionText, pendingIntent)
val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {

View file

@ -1,4 +1,4 @@
package com.stevesoltys.seedvault
package com.stevesoltys.seedvault.ui.notification
import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver
@ -7,16 +7,20 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import android.util.Log.INFO
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.transport.backup.ExpectedAppTotals
import org.koin.core.KoinComponent
import org.koin.core.inject
private val TAG = NotificationBackupObserver::class.java.simpleName
class NotificationBackupObserver(
private val context: Context,
private val expectedPackages: Int,
private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent {
internal class NotificationBackupObserver(
private val context: Context,
private val expectedPackages: Int,
appTotals: ExpectedAppTotals
) : IBackupObserver.Stub(), KoinComponent {
private val nm: BackupNotificationManager by inject()
private val metadataManager: MetadataManager by inject()
@ -24,14 +28,18 @@ class NotificationBackupObserver(
private var numPackages: Int = 0
init {
// we need to show this manually as [onUpdate] isn't called for first @pm@ package
nm.onBackupUpdate(getAppName(MAGIC_PACKAGE_MANAGER), 0, expectedPackages, userInitiated)
// Inform the notification manager that a backup has started
// 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.
* 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 backupProgress Current progress of backup for the package.
*/
@ -69,20 +77,22 @@ class NotificationBackupObserver(
Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
}
val success = status == 0
val notBackedUp = if (success) metadataManager.getPackagesNumNotBackedUp() else null
nm.onBackupFinished(success, notBackedUp, userInitiated)
val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
nm.onBackupFinished(success, numBackedUp)
}
private fun showProgressNotification(packageName: String) {
if (currentPackage == packageName) return
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
val app = getAppName(packageName)
numPackages += 1
nm.onBackupUpdate(app, numPackages, expectedPackages, userInitiated)
nm.onBackupUpdate(app, numPackages)
}
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
@ -90,7 +100,9 @@ class NotificationBackupObserver(
}
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 {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
context.packageManager.getApplicationLabel(appInfo) ?: packageId

View file

@ -8,25 +8,63 @@ import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment
import com.google.android.material.textfield.TextInputLayout
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.isDebugBuild
import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.WordNotFoundException
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
class RecoveryCodeInputFragment : Fragment() {
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?,
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?) {

View file

@ -5,20 +5,28 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_recovery_code_output.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RecoveryCodeOutputFragment : Fragment() {
private val viewModel: RecoveryCodeViewModel by sharedViewModel()
private lateinit var wordList: RecyclerView
private lateinit var confirmCodeButton: Button
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
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?) {

View file

@ -8,35 +8,51 @@ import android.net.Uri
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
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
internal class BackupStorageViewModel(
private val app: Application,
private val backupManager: IBackupManager,
settingsManager: SettingsManager) : StorageViewModel(app, settingsManager) {
private val app: Application,
private val backupManager: IBackupManager,
private val backupCoordinator: BackupCoordinator,
settingsManager: SettingsManager
) : StorageViewModel(app, settingsManager) {
override val isRestoreOperation = false
override fun onLocationSet(uri: 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
val observer = InitializationObserver()
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)
// if storage is on USB and this is not SetupWizard, do a backup right away
if (isUsb && !isSetupWizard) Thread {
requestBackup(app)
}.start()
// initialize the new location
backupManager.initializeTransportsForUser(
UserHandle.myUserId(),
arrayOf(TRANSPORT_ID),
// if storage is on USB and this is not SetupWizard, do a backup right away
InitializationObserver(isUsb && !isSetupWizard)
)
} catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e)
onInitializationError()
}
}
}
@WorkerThread
private inner class InitializationObserver : IBackupObserver.Stub() {
private inner class InitializationObserver(val requestBackup: Boolean) :
IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
@ -52,12 +68,19 @@ internal class BackupStorageViewModel(
if (status == 0) {
// notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult())
if (requestBackup) {
requestBackup(app)
}
} else {
// notify the UI that the location was invalid
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg))
onInitializationError()
}
}
}
private fun onInitializationError() {
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
}

View file

@ -3,10 +3,14 @@ package com.stevesoltys.seedvault.ui.storage
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.settings.SettingsManager
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
@ -17,18 +21,26 @@ internal class RestoreStorageViewModel(
override val isRestoreOperation = true
override fun onLocationSet(uri: Uri) = Thread {
if (restorePlugin.hasBackup(uri)) {
saveStorage(uri)
override fun onLocationSet(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
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())
} else {
Log.w(TAG, "Location was rejected: $uri")
mLocationChecked.postEvent(LocationResult())
} else {
Log.w(TAG, "Location was rejected: $uri")
// notify the UI that the location was invalid
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.postEvent(LocationResult(errorMsg))
// notify the UI that the location was invalid
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
}
}.start()
}
}

View file

@ -6,15 +6,22 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_storage_check.*
private const val TITLE = "title"
private const val ERROR_MSG = "errorMsg"
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 {
fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
val f = StorageCheckFragment()
@ -28,7 +35,14 @@ class StorageCheckFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
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?) {

View file

@ -10,13 +10,16 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
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
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
@ -32,12 +35,29 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
}
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) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
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?) {

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<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" />
</vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<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" />
</vector>

View file

@ -13,7 +13,6 @@
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:tint="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"

View file

@ -1,6 +1,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="app_name">Seedvault</string>
<string name="app_name" translatable="false">Seedvault</string>
<string name="backup">Backup</string>
<string name="restore_backup_button">Restore backup</string>
@ -9,10 +9,10 @@
<string name="settings_backup">Backup my data</string>
<string name="settings_backup_location">Backup location</string>
<string name="settings_backup_location_picker">Choose backup location</string>
<string name="settings_backup_location_title">Backup Location</string>
<string name="settings_backup_location_title">Backup location</string>
<string name="settings_backup_location_invalid">The chosen location can not be used.</string>
<string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal Storage</string>
<string name="settings_backup_location_internal">Internal storage</string>
<string name="settings_backup_last_backup_never">Never</string>
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
<string name="settings_auto_restore_title">Automatic restore</string>
@ -26,7 +26,7 @@
<string name="settings_backup_apk_dialog_cancel">Cancel</string>
<string name="settings_backup_apk_dialog_disable">Disable app backup</string>
<string name="settings_backup_status_title">App backup status</string>
<string name="settings_backup_status_summary">Last Backup: %1$s</string>
<string name="settings_backup_status_summary">Last backup: %1$s</string>
<string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</string>
@ -34,10 +34,10 @@
<string name="storage_fragment_backup_title">Choose where to store backups</string>
<string name="storage_fragment_restore_title">Where to find your backups?</string>
<string name="storage_fragment_warning">People with access to your storage location can learn which apps you use, but do not get access to the apps\' data.</string>
<string name="storage_fake_drive_title">USB Flash Drive</string>
<string name="storage_fake_drive_title">USB flash drive</string>
<string name="storage_fake_drive_summary">Needs to be plugged in</string>
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> free</string>
<string name="storage_fake_nextcloud_title">Nextcloud</string>
<string name="storage_fake_nextcloud_title" translatable="false">Nextcloud</string>
<string name="storage_fake_nextcloud_summary">Click to install</string>
<string name="storage_fake_nextcloud_summary_installed">Click to set up account</string>
<string name="storage_check_fragment_backup_title">Initializing backup location…</string>
@ -47,10 +47,10 @@
<string name="storage_check_fragment_error_button">Back</string>
<!-- Recovery Code -->
<string name="recovery_code_title">Recovery Code</string>
<string name="recovery_code_title">Recovery code</string>
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>
<string name="recovery_code_write_it_down">Write it down on paper now!</string>
<string name="recovery_code_confirm_button">Confirm Code</string>
<string name="recovery_code_confirm_button">Confirm code</string>
<string name="recovery_code_confirm_intro">Enter your 12-word recovery code to ensure that it will work when you need it.</string>
<string name="recovery_code_input_intro">Enter your 12-word recovery code that you wrote down when setting up backups.</string>
<string name="recovery_code_done_button">Done</string>
@ -71,39 +71,42 @@
<string name="recovery_code_error_checksum_word">Your code is invalid. Please check all words and try again!</string>
<!-- Notification -->
<string name="notification_channel_title">Backup Notification</string>
<string name="notification_channel_title">Backup notification</string>
<string name="notification_title">Backup running</string>
<string name="notification_backup_result_complete">Backup complete</string>
<string name="notification_backup_result_rejected">Not backed up</string>
<string name="notification_backup_result_error">Backup failed</string>
<string name="notification_backup_already_running">Backup already in progress</string>
<string name="notification_success_title">Backup finished</string>
<string name="notification_success_num_not_backed_up">%1$d apps could not get backed up</string>
<string name="notification_success_text">%1$d of %2$d apps backed up. Tap to learn more.</string>
<string name="notification_failed_title">Backup failed</string>
<string name="notification_error_channel_title">Error Notification</string>
<string name="notification_error_title">Backup Error</string>
<string name="notification_error_channel_title">Error notification</string>
<string name="notification_error_title">Backup error</string>
<string name="notification_error_text">A device backup failed to run.</string>
<string name="notification_error_action">Fix</string>
<string name="notification_restore_error_channel_title">Auto Restore Flash Drive Error</string>
<string name="notification_restore_error_channel_title">Auto restore flash drive error</string>
<string name="notification_restore_error_title">Could not restore data for %1$s</string>
<string name="notification_restore_error_text">Plug in your %1$s before installing the app to restore its data from backup.</string>
<string name="notification_restore_error_action">Uninstall App</string>
<string name="notification_restore_error_action">Uninstall app</string>
<!-- Restore -->
<string name="restore_title">Restore from Backup</string>
<string name="restore_title">Restore from backup</string>
<string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_restore_set_times">Last Backup %1$s · First %2$s.</string>
<string name="restore_restore_set_times">Last backup %1$s · First %2$s.</string>
<string name="restore_back">Don\'t restore</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_installing_packages">Re-installing Apps</string>
<string name="restore_installing_packages">Re-installing apps</string>
<string name="restore_next">Next</string>
<string name="restore_restoring">Restoring Backup</string>
<string name="restore_magic_package">System Package Manager</string>
<string name="restore_restoring">Restoring backup</string>
<string name="restore_magic_package">System package manager</string>
<!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet -->
<string name="restore_app_not_eligible">Not yet backed up</string>
<string name="restore_app_no_data">App reported no data for backup</string>
<string name="restore_app_not_allowed">App doesn\'t allow backup</string>
<string name="restore_app_not_installed">App not installed</string>
@ -113,7 +116,7 @@
<string name="restore_finished_button">Finish</string>
<string name="storage_internal_warning_title">Warning</string>
<string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>
<string name="storage_internal_warning_choose_other">Choose Other</string>
<string name="storage_internal_warning_choose_other">Choose other</string>
<string name="storage_internal_warning_use_anyway">Use anyway</string>
<!-- About -->

View file

@ -1,17 +1,26 @@
package com.stevesoltys.seedvault
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH_NEXTCLOUD
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import java.io.InputStream
import java.io.OutputStream
import kotlin.random.Random
fun assertContains(stack: String?, needle: String) {
if (stack?.contains(needle) != true) throw AssertionError()
}
@Suppress("MagicNumber")
fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply {
Random.nextBytes(this)
}
private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
private val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
@Suppress("MagicNumber")
fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
return (1..size)
.map { Random.nextInt(0, charPool.size) }
@ -19,6 +28,17 @@ fun getRandomString(size: Int = Random.nextInt(1, 255)): String {
.joinToString("")
}
// URL-save version (RFC 4648)
private val base64CharPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9') // + '+' + '_' + '='
@Suppress("MagicNumber")
fun getRandomBase64(size: Int = Random.nextInt(1, MAX_KEY_LENGTH_NEXTCLOUD)): String {
return (1..size)
.map { Random.nextInt(0, base64CharPool.size) }
.map(base64CharPool::get)
.joinToString("")
}
fun ByteArray.toHexString(): String {
var str = ""
for (b in this) {
@ -34,3 +54,25 @@ fun ByteArray.toIntString(): String {
}
return str
}
fun OutputStream.writeAndClose(data: ByteArray) = use {
it.write(data)
}
fun assertReadEquals(data: ByteArray, inputStream: InputStream?) = inputStream?.use {
assertArrayEquals(data, it.readBytes())
} ?: error("no input stream")
fun <T : Throwable> coAssertThrows(clazz: Class<T>, block: suspend () -> Unit) {
var thrown = false
@Suppress("TooGenericExceptionCaught")
try {
runBlocking {
block()
}
} catch (e: Throwable) {
assertEquals(clazz, e.javaClass)
thrown = true
}
if (!thrown) fail("Exception was not thrown: " + clazz.name)
}

View file

@ -0,0 +1,65 @@
package com.stevesoltys.seedvault.plugins.saf
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.BackupTest
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupPluginTest : BackupTest() {
private val storage = mockk<DocumentsStorage>()
private val kvBackupPlugin: KVBackupPlugin = mockk<DocumentsProviderKVBackup>()
private val fullBackupPlugin: FullBackupPlugin = mockk<DocumentsProviderFullBackup>()
private val plugin = DocumentsProviderBackupPlugin(
context,
storage,
kvBackupPlugin,
fullBackupPlugin
)
private val setDir: DocumentFile = mockk()
private val kvDir: DocumentFile = mockk()
private val fullDir: DocumentFile = mockk()
init {
// to mock extension functions on DocumentFile
mockkStatic("com.stevesoltys.seedvault.plugins.saf.DocumentsStorageKt")
}
@Test
fun `test startNewRestoreSet`() = runBlocking {
every { storage.reset(token) } just Runs
every { storage getProperty "rootBackupDir" } returns setDir
plugin.startNewRestoreSet(token)
}
@Test
fun `test initializeDevice`() = runBlocking {
// get current set dir and for that the current token
every { storage getProperty "currentToken" } returns token
every { settingsManager.getToken() } returns token
coEvery { storage.getSetDir(token) } returns setDir
// delete contents of current set dir
coEvery { setDir.listFilesBlocking(context) } returns listOf(kvDir)
every { kvDir.delete() } returns true
// reset storage
every { storage.reset(null) } just Runs
// create kv and full dir
every { storage getProperty "currentKvBackupDir" } returns kvDir
every { storage getProperty "currentFullBackupDir" } returns fullDir
plugin.initializeDevice()
}
}

View file

@ -0,0 +1,43 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.stopKoin
@RunWith(AndroidJUnit4::class)
internal class DocumentFileTest {
private val context: Context = mockk()
private val parentUri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup"
)
private val parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!!
private val uri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup%2Ftest"
)
@After
fun afterEachTest() {
stopKoin()
}
@Test
fun `test ugly getTreeDocumentFile reflection hack`() {
assertTrue(DocumentsContract.isTreeUri(uri))
val file = getTreeDocumentFile(parentFile, context, uri)
assertEquals(uri, file.uri)
assertEquals(parentFile, file.parentFile)
}
}

View file

@ -7,7 +7,6 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.CryptoImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
@ -35,11 +34,14 @@ import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.OutputFactory
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail
@ -48,6 +50,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class CoordinatorIntegrationTest : TransportTest() {
private val inputFactory = mockk<InputFactory>()
@ -58,23 +61,44 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>()
private val backupPlugin = mockk<BackupPlugin>()
private val kvBackupPlugin = mockk<KVBackupPlugin>()
private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl, notificationManager)
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val apkBackup = mockk<ApkBackup>()
private val packageService:PackageService = mockk()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, apkBackup, clock, packageService, metadataManager, settingsManager, notificationManager)
private val packageService: PackageService = mockk()
private val backup = BackupCoordinator(
context,
backupPlugin,
kvBackup,
fullBackup,
apkBackup,
clock,
packageService,
metadataManager,
settingsManager,
notificationManager
)
private val restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(context, settingsManager, metadataManager, notificationManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val fullRestore =
FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(
context,
settingsManager,
metadataManager,
notificationManager,
restorePlugin,
kvRestore,
fullRestore,
metadataReader
)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
@ -94,15 +118,15 @@ internal class CoordinatorIntegrationTest : TransportTest() {
}
@Test
fun `test key-value backup and restore with 2 records`() {
fun `test key-value backup and restore with 2 records`() = runBlocking {
val value = CapturingSlot<ByteArray>()
val value2 = CapturingSlot<ByteArray>()
val bOutputStream = ByteArrayOutputStream()
val bOutputStream2 = ByteArrayOutputStream()
// read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
coEvery { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen true andThen false
every { backupDataInput.key } returns key andThen key2
@ -111,15 +135,37 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
coEvery {
kvBackupPlugin.getOutputStreamForRecord(
packageInfo,
key64
)
} returns bOutputStream
every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers {
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
coEvery {
kvBackupPlugin.getOutputStreamForRecord(
packageInfo,
key264
)
} returns bOutputStream2
coEvery {
apkBackup.backupApkIfNecessary(
packageInfo,
UNKNOWN_ERROR,
any()
)
} returns packageMetadata
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every {
metadataManager.onApkBackedUp(
packageInfo,
packageMetadata,
metadataOutputStream
)
} just Runs
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup
@ -130,7 +176,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
coEvery { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -140,12 +186,24 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream
coEvery {
kvRestorePlugin.getInputStreamForRecord(
token,
packageInfo,
key64
)
} returns rInputStream
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key264) } returns rInputStream2
coEvery {
kvRestorePlugin.getInputStreamForRecord(
token,
packageInfo,
key264
)
} returns rInputStream2
every { backupDataOutput.writeEntityHeader(key2, appData2.size) } returns 1137
every { backupDataOutput.writeEntityData(appData2, appData2.size) } returns appData2.size
@ -153,15 +211,15 @@ internal class CoordinatorIntegrationTest : TransportTest() {
}
@Test
fun `test key-value backup with huge value`() {
fun `test key-value backup with huge value`() = runBlocking {
val value = CapturingSlot<ByteArray>()
val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337)
val appData = ByteArray(size).apply { Random.nextBytes(this) }
val bOutputStream = ByteArrayOutputStream()
// read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
coEvery { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false
coEvery { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs
every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen false
every { backupDataInput.key } returns key
@ -170,9 +228,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
coEvery {
kvBackupPlugin.getOutputStreamForRecord(
packageInfo,
key64
)
} returns bOutputStream
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup
@ -183,7 +246,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
coEvery { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -192,9 +255,15 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream
coEvery {
kvRestorePlugin.getInputStreamForRecord(
token,
packageInfo,
key64
)
} returns rInputStream
every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137
every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size
@ -202,16 +271,28 @@ internal class CoordinatorIntegrationTest : TransportTest() {
}
@Test
fun `test full backup and restore with two chunks`() {
fun `test full backup and restore with two chunks`() = runBlocking {
// return streams from plugin and app data
val bOutputStream = ByteArrayOutputStream()
val bInputStream = ByteArrayInputStream(appData)
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
coEvery { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
coEvery {
apkBackup.backupApkIfNecessary(
packageInfo,
UNKNOWN_ERROR,
any()
)
} returns packageMetadata
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every {
metadataManager.onApkBackedUp(
packageInfo,
packageMetadata,
metadataOutputStream
)
} just Runs
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// perform backup to output stream
@ -224,8 +305,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data only for full backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false
every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
coEvery { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false
coEvery { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -234,7 +315,12 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// reverse the backup streams into restore input
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rOutputStream = ByteArrayOutputStream()
every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream
coEvery {
fullRestorePlugin.getInputStreamForPackage(
token,
packageInfo
)
} returns rInputStream
every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
// restore data

View file

@ -8,6 +8,7 @@ import android.content.pm.PackageInfo
import android.content.pm.SigningInfo
import android.util.Log
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
@ -36,6 +37,9 @@ abstract class TransportTest {
}
signingInfo = sigInfo
}
protected val pmPackageInfo = PackageInfo().apply {
packageName = MAGIC_PACKAGE_MANAGER
}
init {
mockkStatic(Log::class)

View file

@ -11,10 +11,12 @@ import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
@ -30,10 +32,11 @@ import java.nio.file.Path
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk()
private val streamGetter: () -> OutputStream = mockk()
private val streamGetter: suspend () -> OutputStream = mockk()
private val apkBackup = ApkBackup(pm, settingsManager, metadataManager)
@ -51,20 +54,20 @@ internal class ApkBackupTest : BackupTest() {
}
@Test
fun `does not back up @pm@`() {
fun `does not back up @pm@`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does not back up when setting disabled`() {
fun `does not back up when setting disabled`() = runBlocking {
every { settingsManager.backupApks() } returns false
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
@Test
fun `does not back up system apps`() {
fun `does not back up system apps`() = runBlocking {
packageInfo.applicationInfo.flags = FLAG_SYSTEM
every { settingsManager.backupApks() } returns true
@ -73,7 +76,7 @@ internal class ApkBackupTest : BackupTest() {
}
@Test
fun `does not back up the same version`() {
fun `does not back up the same version`() = runBlocking {
packageInfo.applicationInfo.flags = FLAG_UPDATED_SYSTEM_APP
val packageMetadata = packageMetadata.copy(
version = packageInfo.longVersionCode
@ -91,12 +94,14 @@ internal class ApkBackupTest : BackupTest() {
expectChecks()
assertThrows(IOException::class.java) {
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
runBlocking {
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
}
}
}
@Test
fun `do not accept empty signature`() {
fun `do not accept empty signature`() = runBlocking {
every { settingsManager.backupApks() } returns true
every { metadataManager.getPackageMetadata(packageInfo.packageName) } returns packageMetadata
every { sigInfo.hasMultipleSigners() } returns false
@ -106,7 +111,7 @@ internal class ApkBackupTest : BackupTest() {
}
@Test
fun `test successful APK backup`(@TempDir tmpDir: Path) {
fun `test successful APK backup`(@TempDir tmpDir: Path) = runBlocking {
val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
val tmpFile = File(tmpDir.toAbsolutePath().toString())
packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply {
@ -124,7 +129,7 @@ internal class ApkBackupTest : BackupTest() {
)
expectChecks()
every { streamGetter.invoke() } returns apkOutputStream
coEvery { streamGetter.invoke() } returns apkOutputStream
every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer
every { metadataManager.onApkBackedUp(packageInfo, updatedMetadata, outputStream) } just Runs

View file

@ -8,8 +8,8 @@ import android.content.pm.PackageInfo
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
@ -17,20 +17,24 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.OutputStream
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinatorTest : BackupTest() {
private val plugin = mockk<BackupPlugin>()
@ -40,7 +44,18 @@ internal class BackupCoordinatorTest : BackupTest() {
private val packageService: PackageService = mockk()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, packageService, metadataManager, settingsManager, notificationManager)
private val backup = BackupCoordinator(
context,
plugin,
kv,
full,
apkBackup,
clock,
packageService,
metadataManager,
settingsManager,
notificationManager
)
private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk()
@ -48,22 +63,33 @@ internal class BackupCoordinatorTest : BackupTest() {
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test
fun `device initialization succeeds and delegates to plugin`() {
fun `starting a new restore set works as expected`() = runBlocking {
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns true // TODO test when false
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { settingsManager.setNewToken(token) } just Runs
coEvery { plugin.startNewRestoreSet(token) } just Runs
backup.startNewRestoreSet()
}
@Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } just Runs
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
every { kv.hasState() } returns false
every { full.hasState() } returns false
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK, backup.initializeDevice())
assertEquals(TRANSPORT_OK, backup.finishBackup())
verify { metadataOutputStream.close() }
}
@Test
fun `device initialization does no-op when already initialized`() {
every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns false
fun `device initialization does no-op when no token available`() = runBlocking {
every { settingsManager.getToken() } returns null
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -72,9 +98,9 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@Test
fun `error notification when device initialization fails`() {
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
fun `error notification when device initialization fails`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs
@ -83,35 +109,36 @@ internal class BackupCoordinatorTest : BackupTest() {
// finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) {
coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup()
}
}
@Test
fun `no error notification when device initialization fails on unplugged USB storage`() {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
fun `no error notification when device initialization fails on unplugged USB storage`() =
runBlocking {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
// finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false
every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) {
backup.finishBackup()
// finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false
every { full.hasState() } returns false
coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup()
}
}
}
@Test
fun `getBackupQuota() delegates to right plugin`() {
fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong()
@ -121,7 +148,10 @@ internal class BackupCoordinatorTest : BackupTest() {
} else {
every { kv.getQuota() } returns quota
}
every { metadataOutputStream.close() } just Runs
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
verify { metadataOutputStream.close() }
}
@Test
@ -139,24 +169,24 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@Test
fun `clearing KV backup data throws`() {
every { kv.clearBackupData(packageInfo) } throws IOException()
fun `clearing KV backup data throws`() = runBlocking {
coEvery { kv.clearBackupData(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
}
@Test
fun `clearing full backup data throws`() {
every { kv.clearBackupData(packageInfo) } just Runs
every { full.clearBackupData(packageInfo) } throws IOException()
fun `clearing full backup data throws`() = runBlocking {
coEvery { kv.clearBackupData(packageInfo) } just Runs
coEvery { full.clearBackupData(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo))
}
@Test
fun `clearing backup data succeeds`() {
every { kv.clearBackupData(packageInfo) } just Runs
every { full.clearBackupData(packageInfo) } just Runs
fun `clearing backup data succeeds`() = runBlocking {
coEvery { kv.clearBackupData(packageInfo) } just Runs
coEvery { full.clearBackupData(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo))
@ -167,81 +197,112 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@Test
fun `finish backup delegates to KV plugin if it has state`() {
fun `finish backup delegates to KV plugin if it has state`() = runBlocking {
val result = Random.nextInt()
every { kv.hasState() } returns true
every { full.hasState() } returns false
every { kv.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { kv.finishBackup() } returns result
every { metadataOutputStream.close() } just Runs
assertEquals(result, backup.finishBackup())
verify { metadataOutputStream.close() }
}
@Test
fun `finish backup delegates to full plugin if it has state`() {
fun `finish backup delegates to full plugin if it has state`() = runBlocking {
val result = Random.nextInt()
every { kv.hasState() } returns false
every { full.hasState() } returns true
every { full.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { full.finishBackup() } returns result
every { metadataOutputStream.close() } just Runs
assertEquals(result, backup.finishBackup())
verify { metadataOutputStream.close() }
}
@Test
fun `metadata does not get updated when no APK was backed up`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
fun `metadata does not get updated when no APK was backed up`() = runBlocking {
coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
}
@Test
fun `app exceeding quota gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
fun `app exceeding quota gets cancelled and reason written to metadata`() = runBlocking {
coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1) } returns TRANSPORT_QUOTA_EXCEEDED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo, QUOTA_EXCEEDED, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every {
metadataManager.onPackageBackupError(
packageInfo,
QUOTA_EXCEEDED,
metadataOutputStream
)
} just Runs
coEvery { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(TRANSPORT_QUOTA_EXCEEDED,
backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1))
assertEquals(
TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0)
)
assertEquals(
DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true)
)
assertEquals(
TRANSPORT_QUOTA_EXCEEDED,
backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1)
)
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo, QUOTA_EXCEEDED, metadataOutputStream)
}
verify { metadataOutputStream.close() }
}
@Test
fun `app with no data gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
fun `app with no data gets cancelled and reason written to metadata`() = runBlocking {
coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo, NO_DATA, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every {
metadataManager.onPackageBackupError(
packageInfo,
NO_DATA,
metadataOutputStream
)
} just Runs
coEvery { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(
TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0)
)
assertEquals(
DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true)
)
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
@ -249,41 +310,93 @@ internal class BackupCoordinatorTest : BackupTest() {
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo, NO_DATA, metadataOutputStream)
}
verify { metadataOutputStream.close() }
}
@Test
fun `not allowed apps get their APKs backed up during @pm@ backup`() {
fun `not allowed apps get their APKs backed up during @pm@ backup`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
val notAllowedPackages = listOf(
PackageInfo().apply { packageName = "org.example.1" },
PackageInfo().apply { packageName = "org.example.2" }
PackageInfo().apply { packageName = "org.example.1" },
PackageInfo().apply { packageName = "org.example.2" }
)
val packageMetadata: PackageMetadata = mockk()
every { settingsManager.getStorage() } returns storage // to check for removable storage
every { packageService.notAllowedPackages } returns notAllowedPackages
// no backup needed
every { apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) } returns null
// was backed up, get new packageMetadata
every { apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata, metadataOutputStream) } just Runs
every { settingsManager.getStorage() } returns storage // to check for removable storage
// do actual @pm@ backup
every { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
// now check if we have opt-out apps that we need to back up APKs for
every { packageService.notAllowedPackages } returns notAllowedPackages
// update notification
every {
notificationManager.onOptOutAppBackup(
notAllowedPackages[0].packageName,
1,
notAllowedPackages.size
)
} just Runs
// no backup needed
coEvery {
apkBackup.backupApkIfNecessary(
notAllowedPackages[0],
NOT_ALLOWED,
any()
)
} returns null
// update notification
every {
notificationManager.onOptOutAppBackup(
notAllowedPackages[1].packageName,
2,
notAllowedPackages.size
)
} just Runs
// was backed up, get new packageMetadata
coEvery {
apkBackup.backupApkIfNecessary(
notAllowedPackages[1],
NOT_ALLOWED,
any()
)
} returns packageMetadata
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every {
metadataManager.onApkBackedUp(
notAllowedPackages[1],
packageMetadata,
metadataOutputStream
)
} just Runs
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK,
backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
assertEquals(
TRANSPORT_OK,
backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)
)
verify {
coVerify {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any())
apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any())
metadataOutputStream.close()
}
}
private fun expectApkBackupAndMetadataWrite() {
every { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs
coEvery {
apkBackup.backupApkIfNecessary(
any(),
UNKNOWN_ERROR,
any()
)
} returns packageMetadata
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every {
metadataManager.onApkBackedUp(
any(),
packageMetadata,
metadataOutputStream
)
} just Runs
}
}

View file

@ -5,9 +5,11 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@ -16,6 +18,7 @@ import java.io.FileInputStream
import java.io.IOException
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackupTest : BackupTest() {
private val plugin = mockk<FullBackupPlugin>()
@ -62,7 +65,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `performFullBackup runs ok`() {
fun `performFullBackup runs ok`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectClearState()
@ -73,7 +76,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData first call over quota`() {
fun `sendBackupData first call over quota`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = (quota + 1).toInt()
@ -89,7 +92,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData second call over quota`() {
fun `sendBackupData second call over quota`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = quota.toInt()
@ -109,7 +112,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData throws exception when reading from InputStream`() {
fun `sendBackupData throws exception when reading from InputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { plugin.getQuota() } returns quota
@ -125,11 +128,11 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData throws exception when getting outputStream`() {
fun `sendBackupData throws exception when getting outputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
every { plugin.getQuota() } returns quota
every { plugin.getOutputStream(packageInfo) } throws IOException()
coEvery { plugin.getOutputStream(packageInfo) } throws IOException()
expectClearState()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
@ -141,11 +144,11 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData throws exception when writing header`() {
fun `sendBackupData throws exception when writing header`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
every { plugin.getQuota() } returns quota
every { plugin.getOutputStream(packageInfo) } returns outputStream
coEvery { plugin.getOutputStream(packageInfo) } returns outputStream
every { inputFactory.getInputStream(data) } returns inputStream
every { headerWriter.writeVersion(outputStream, header) } throws IOException()
expectClearState()
@ -159,7 +162,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData throws exception when writing encrypted data to OutputStream`() {
fun `sendBackupData throws exception when writing encrypted data to OutputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { plugin.getQuota() } returns quota
@ -176,7 +179,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `sendBackupData runs ok`() {
fun `sendBackupData runs ok`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes1 = (quota / 2).toInt()
@ -196,18 +199,18 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `clearBackupData delegates to plugin`() {
every { plugin.removeDataOfPackage(packageInfo) } just Runs
fun `clearBackupData delegates to plugin`() = runBlocking {
coEvery { plugin.removeDataOfPackage(packageInfo) } just Runs
backup.clearBackupData(packageInfo)
}
@Test
fun `cancel full backup runs ok`() {
fun `cancel full backup runs ok`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } just Runs
coEvery { plugin.removeDataOfPackage(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
@ -216,11 +219,11 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `cancel full backup ignores exception when calling plugin`() {
fun `cancel full backup ignores exception when calling plugin`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
expectClearState()
every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
coEvery { plugin.removeDataOfPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data))
assertTrue(backup.hasState())
@ -229,7 +232,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `clearState throws exception when flushing OutputStream`() {
fun `clearState throws exception when flushing OutputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
val numBytes = 42
@ -245,7 +248,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `clearState ignores exception when closing OutputStream`() {
fun `clearState ignores exception when closing OutputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
@ -260,7 +263,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `clearState ignores exception when closing InputStream`() {
fun `clearState ignores exception when closing InputStream`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
@ -275,7 +278,7 @@ internal class FullBackupTest : BackupTest() {
}
@Test
fun `clearState ignores exception when closing ParcelFileDescriptor`() {
fun `clearState ignores exception when closing ParcelFileDescriptor`() = runBlocking {
every { inputFactory.getInputStream(data) } returns inputStream
expectInitializeOutputStream()
every { outputStream.flush() } just Runs
@ -290,7 +293,7 @@ internal class FullBackupTest : BackupTest() {
}
private fun expectInitializeOutputStream() {
every { plugin.getOutputStream(packageInfo) } returns outputStream
coEvery { plugin.getOutputStream(packageInfo) } returns outputStream
every { headerWriter.writeVersion(outputStream, header) } just Runs
every { crypto.encryptHeader(outputStream, header) } just Runs
}

View file

@ -6,28 +6,36 @@ import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.IOException
import java.util.*
import java.util.Base64
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackupTest : BackupTest() {
private val plugin = mockk<KVBackupPlugin>()
private val dataInput = mockk<BackupDataInput>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto)
private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto, notificationManager)
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8))
@ -40,7 +48,7 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `simple backup with one record`() {
fun `simple backup with one record`() = runBlocking {
singleRecordBackup()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
@ -50,25 +58,71 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `incremental backup with no data gets rejected`() {
every { plugin.hasDataForPackage(packageInfo) } returns false
fun `@pm@ backup shows notification`() = runBlocking {
// init plugin and give back two keys
initPlugin(true, pmPackageInfo)
createBackupDataInput()
every { dataInput.readNextHeader() } returnsMany listOf(true, true, false)
every { dataInput.key } returnsMany listOf("key1", "key2")
// we don't care about values, so just use the same one always
every { dataInput.dataSize } returns value.size
every { dataInput.readEntityData(any(), 0, value.size) } returns value.size
assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL))
// store first record and show notification for it
every { notificationManager.onPmKvBackup("key1", 1, 2) } just Runs
coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5MQ") } returns outputStream
val versionHeader1 = VersionHeader(packageName = pmPackageInfo.packageName, key = "key1")
every { headerWriter.writeVersion(outputStream, versionHeader1) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader1) } just Runs
// store second record and show notification for it
every { notificationManager.onPmKvBackup("key2", 2, 2) } just Runs
coEvery { plugin.getOutputStreamForRecord(pmPackageInfo, "a2V5Mg") } returns outputStream
val versionHeader2 = VersionHeader(packageName = pmPackageInfo.packageName, key = "key2")
every { headerWriter.writeVersion(outputStream, versionHeader2) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader2) } just Runs
// encrypt to and close output stream
every { crypto.encryptMultipleSegments(outputStream, any()) } just Runs
every { outputStream.write(value) } just Runs
every { outputStream.flush() } just Runs
every { outputStream.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(pmPackageInfo, data, 0))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
// verify that notifications were shown
verifyOrder {
notificationManager.onPmKvBackup("key1", 1, 2)
notificationManager.onPmKvBackup("key2", 2, 2)
}
}
@Test
fun `incremental backup with no data gets rejected`() = runBlocking {
coEvery { plugin.hasDataForPackage(packageInfo) } returns false
assertEquals(
TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED,
backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)
)
assertFalse(backup.hasState())
}
@Test
fun `check for existing data throws exception`() {
every { plugin.hasDataForPackage(packageInfo) } throws IOException()
fun `check for existing data throws exception`() = runBlocking {
coEvery { plugin.hasDataForPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `non-incremental backup with data clears old data first`() {
fun `non-incremental backup with data clears old data first`() = runBlocking {
singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } just Runs
coEvery { plugin.removeDataOfPackage(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
assertTrue(backup.hasState())
@ -77,27 +131,31 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `ignoring exception when clearing data when non-incremental backup has data`() {
singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
fun `ignoring exception when clearing data when non-incremental backup has data`() =
runBlocking {
singleRecordBackup(true)
coEvery { plugin.removeDataOfPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL))
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
assertEquals(
TRANSPORT_OK,
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
)
assertTrue(backup.hasState())
assertEquals(TRANSPORT_OK, backup.finishBackup())
assertFalse(backup.hasState())
}
@Test
fun `ensuring storage throws exception`() {
every { plugin.hasDataForPackage(packageInfo) } returns false
every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException()
fun `ensuring storage throws exception`() = runBlocking {
coEvery { plugin.hasDataForPackage(packageInfo) } returns false
coEvery { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
}
@Test
fun `exception while reading next header`() {
fun `exception while reading next header`() = runBlocking {
initPlugin(false)
createBackupDataInput()
every { dataInput.readNextHeader() } throws IOException()
@ -107,7 +165,7 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `exception while reading value`() {
fun `exception while reading value`() = runBlocking {
initPlugin(false)
createBackupDataInput()
every { dataInput.readNextHeader() } returns true
@ -120,7 +178,7 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `no data records`() {
fun `no data records`() = runBlocking {
initPlugin(false)
getDataInput(listOf(false))
@ -131,43 +189,52 @@ internal class KVBackupTest : BackupTest() {
}
@Test
fun `exception while writing version header`() {
fun `exception while writing version header`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true))
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException()
every { outputStream.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
verify { outputStream.close() }
}
@Test
fun `exception while writing encrypted value to output stream`() {
fun `exception while writing encrypted value to output stream`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true))
writeHeaderAndEncrypt()
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptMultipleSegments(outputStream, any()) } throws IOException()
every { outputStream.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
verify { outputStream.close() }
}
@Test
fun `exception while flushing output stream`() {
fun `exception while flushing output stream`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true))
writeHeaderAndEncrypt()
every { outputStream.write(value) } just Runs
every { outputStream.flush() } throws IOException()
every { outputStream.close() } just Runs
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState())
verify { outputStream.close() }
}
@Test
fun `ignoring exception while closing output stream`() {
fun `ignoring exception while closing output stream`() = runBlocking {
initPlugin(false)
getDataInput(listOf(true, false))
writeHeaderAndEncrypt()
@ -190,9 +257,9 @@ internal class KVBackupTest : BackupTest() {
every { outputStream.close() } just Runs
}
private fun initPlugin(hasDataForPackage: Boolean = false) {
every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage
every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs
private fun initPlugin(hasDataForPackage: Boolean = false, pi: PackageInfo = packageInfo) {
coEvery { plugin.hasDataForPackage(pi) } returns hasDataForPackage
coEvery { plugin.ensureRecordStorageForPackage(pi) } just Runs
}
private fun createBackupDataInput() {
@ -208,7 +275,7 @@ internal class KVBackupTest : BackupTest() {
}
private fun writeHeaderAndEncrypt() {
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader) } just Runs
every { crypto.encryptMultipleSegments(outputStream, any()) } just Runs

View file

@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -31,6 +32,7 @@ import java.io.File
import java.nio.file.Path
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
@ExperimentalCoroutinesApi
internal class ApkRestoreTest : RestoreTest() {
@ -71,7 +73,7 @@ internal class ApkRestoreTest : RestoreTest() {
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
@ -96,7 +98,7 @@ internal class ApkRestoreTest : RestoreTest() {
packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
@ -119,7 +121,7 @@ internal class ApkRestoreTest : RestoreTest() {
@Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
@ -155,7 +157,7 @@ internal class ApkRestoreTest : RestoreTest() {
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
@ -199,7 +201,7 @@ internal class ApkRestoreTest : RestoreTest() {
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
coEvery { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { packageInfo.applicationInfo.loadIcon(pm) } returns icon

View file

@ -4,18 +4,20 @@ import android.app.backup.BackupTransport.NO_MORE_DATA
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
@ -23,6 +25,7 @@ import java.io.EOFException
import java.io.IOException
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestoreTest : RestoreTest() {
private val plugin = mockk<FullRestorePlugin>()
@ -38,9 +41,9 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `hasDataForPackage() delegates to plugin`() {
fun `hasDataForPackage() delegates to plugin`() = runBlocking {
val result = Random.nextBoolean()
every { plugin.hasDataForPackage(token, packageInfo) } returns result
coEvery { plugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
}
@ -54,45 +57,45 @@ internal class FullRestoreTest : RestoreTest() {
@Test
fun `getting chunks without initializing state throws`() {
assertFalse(restore.hasState())
assertThrows(IllegalStateException::class.java) {
coAssertThrows(IllegalStateException::class.java) {
restore.getNextFullRestoreDataChunk(fileDescriptor)
}
}
@Test
fun `getting InputStream for package when getting first chunk throws`() {
fun `getting InputStream for package when getting first chunk throws`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException()
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `reading version header when getting first chunk throws`() {
fun `reading version header when getting first chunk throws`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } throws IOException()
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `reading unsupported version when getting first chunk`() {
fun `reading unsupported version when getting first chunk`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor))
}
@Test
fun `decrypting version header when getting first chunk throws`() {
fun `decrypting version header when getting first chunk throws`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException()
@ -100,10 +103,10 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `decrypting version header when getting first chunk throws security exception`() {
fun `decrypting version header when getting first chunk throws security exception`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException()
@ -111,7 +114,7 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `decrypting segment throws IOException`() {
fun `decrypting segment throws IOException`() = runBlocking {
restore.initializeState(token, packageInfo)
initInputStream()
@ -124,7 +127,7 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `decrypting segment throws EOFException`() {
fun `decrypting segment throws EOFException`() = runBlocking {
restore.initializeState(token, packageInfo)
initInputStream()
@ -137,7 +140,7 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `full chunk gets encrypted`() {
fun `full chunk gets encrypted`() = runBlocking {
restore.initializeState(token, packageInfo)
initInputStream()
@ -151,7 +154,7 @@ internal class FullRestoreTest : RestoreTest() {
}
@Test
fun `aborting full restore closes stream, resets state`() {
fun `aborting full restore closes stream, resets state`() = runBlocking {
restore.initializeState(token, packageInfo)
initInputStream()
@ -166,7 +169,7 @@ internal class FullRestoreTest : RestoreTest() {
}
private fun initInputStream() {
every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader
}

View file

@ -3,23 +3,26 @@ package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupDataOutput
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestoreTest : RestoreTest() {
private val plugin = mockk<KVRestorePlugin>()
@ -34,36 +37,36 @@ internal class KVRestoreTest : RestoreTest() {
private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2)
@Test
fun `hasDataForPackage() delegates to plugin`() {
fun `hasDataForPackage() delegates to plugin`() = runBlocking {
val result = Random.nextBoolean()
every { plugin.hasDataForPackage(token, packageInfo) } returns result
coEvery { plugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo))
}
@Test
fun `getRestoreData() throws without initializing state`() {
assertThrows(IllegalStateException::class.java) {
coAssertThrows(IllegalStateException::class.java) {
restore.getRestoreData(fileDescriptor)
}
}
@Test
fun `listing records throws`() {
fun `listing records throws`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.listRecords(token, packageInfo) } throws IOException()
coEvery { plugin.listRecords(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
}
@Test
fun `reading VersionHeader with unsupported version throws`() {
fun `reading VersionHeader with unsupported version throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion)
streamsGetClosed()
@ -72,11 +75,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `error reading VersionHeader throws`() {
fun `error reading VersionHeader throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } throws IOException()
streamsGetClosed()
@ -85,11 +88,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `decrypting segment throws`() {
fun `decrypting segment throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptMultipleSegments(inputStream) } throws IOException()
@ -100,11 +103,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `decrypting header throws`() {
fun `decrypting header throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws IOException()
streamsGetClosed()
@ -114,11 +117,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `decrypting header throws security exception`() {
fun `decrypting header throws security exception`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws SecurityException()
streamsGetClosed()
@ -128,11 +131,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `writing header throws`() {
fun `writing header throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptMultipleSegments(inputStream) } returns data
@ -144,11 +147,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `writing value throws`() {
fun `writing value throws`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptMultipleSegments(inputStream) } returns data
@ -161,11 +164,11 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `writing value succeeds`() {
fun `writing value succeeds`() = runBlocking {
restore.initializeState(token, packageInfo)
getRecordsAndOutput()
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptMultipleSegments(inputStream) } returns data
@ -178,21 +181,21 @@ internal class KVRestoreTest : RestoreTest() {
}
@Test
fun `writing two values succeeds`() {
fun `writing two values succeeds`() = runBlocking {
val data2 = getRandomByteArray()
val inputStream2 = mockk<InputStream>()
restore.initializeState(token, packageInfo)
getRecordsAndOutput(listOf(key64, key264))
// first key/value
every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream
every { headerReader.readVersion(inputStream) } returns VERSION
every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader
every { crypto.decryptMultipleSegments(inputStream) } returns data
every { output.writeEntityHeader(key, data.size) } returns 42
every { output.writeEntityData(data, data.size) } returns data.size
// second key/value
every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2
coEvery { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2
every { headerReader.readVersion(inputStream2) } returns VERSION
every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2
every { crypto.decryptMultipleSegments(inputStream2) } returns data2
@ -205,7 +208,7 @@ internal class KVRestoreTest : RestoreTest() {
}
private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
every { plugin.listRecords(token, packageInfo) } returns recordKeys
coEvery { plugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
}

View file

@ -9,7 +9,7 @@ import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
@ -17,11 +17,14 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
@ -32,6 +35,7 @@ import java.io.IOException
import java.io.InputStream
import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinatorTest : TransportTest() {
private val notificationManager: BackupNotificationManager = mockk()
@ -40,7 +44,16 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(context, settingsManager, metadataManager, notificationManager, plugin, kv, full, metadataReader)
private val restore = RestoreCoordinator(
context,
settingsManager,
metadataManager,
notificationManager,
plugin,
kv,
full,
metadataReader
)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
@ -57,7 +70,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val storageName = getRandomString()
@Test
fun `getAvailableRestoreSets() builds set from plugin response`() {
fun `getAvailableRestoreSets() builds set from plugin response`() = runBlocking {
val encryptedMetadata = EncryptedBackupMetadata(token, inputStream)
val metadata = BackupMetadata(
token = token,
@ -65,7 +78,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
androidIncremental = getRandomString(),
deviceName = getRandomString())
every { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
coEvery { plugin.getAvailableBackups() } returns sequenceOf(encryptedMetadata, encryptedMetadata)
every { metadataReader.readMetadata(inputStream, token) } returns metadata
every { inputStream.close() } just Runs
@ -78,7 +91,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
every { metadataManager.getBackupToken() } returns token
every { settingsManager.getToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet())
}
@ -137,16 +150,16 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `nextRestorePackage() throws without startRestore()`() {
assertThrows(IllegalStateException::class.javaObjectType) {
coAssertThrows(IllegalStateException::class.javaObjectType) {
restore.nextRestorePackage()
}
}
@Test
fun `nextRestorePackage() returns KV description and takes precedence`() {
fun `nextRestorePackage() returns KV description and takes precedence`() = runBlocking {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns true
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
@ -154,11 +167,11 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
@Test
fun `nextRestorePackage() returns full description if no KV data found`() {
fun `nextRestorePackage() returns full description if no KV data found`() = runBlocking {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns true
coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
coEvery { full.hasDataForPackage(token, packageInfo) } returns true
every { full.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM)
@ -166,27 +179,27 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
@Test
fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() {
fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() = runBlocking {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns false
coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
coEvery { full.hasDataForPackage(token, packageInfo) } returns false
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
}
@Test
fun `nextRestorePackage() returns all packages from startRestore()`() {
fun `nextRestorePackage() returns all packages from startRestore()`() = runBlocking {
restore.startRestore(token, packageInfoArray2)
every { kv.hasDataForPackage(token, packageInfo) } returns true
coEvery { kv.hasDataForPackage(token, packageInfo) } returns true
every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage())
every { kv.hasDataForPackage(token, packageInfo2) } returns false
every { full.hasDataForPackage(token, packageInfo2) } returns true
coEvery { kv.hasDataForPackage(token, packageInfo2) } returns false
coEvery { full.hasDataForPackage(token, packageInfo2) } returns true
every { full.initializeState(token, packageInfo2) } just Runs
val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
@ -196,40 +209,40 @@ internal class RestoreCoordinatorTest : TransportTest() {
}
@Test
fun `when kv#hasDataForPackage() throws return null`() {
fun `when kv#hasDataForPackage() throws return null`() = runBlocking {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } throws IOException()
coEvery { kv.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage())
}
@Test
fun `when full#hasDataForPackage() throws return null`() {
fun `when full#hasDataForPackage() throws return null`() = runBlocking {
restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } throws IOException()
coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
coEvery { full.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage())
}
@Test
fun `getRestoreData() delegates to KV`() {
fun `getRestoreData() delegates to KV`() = runBlocking {
val data = mockk<ParcelFileDescriptor>()
val result = Random.nextInt()
every { kv.getRestoreData(data) } returns result
coEvery { kv.getRestoreData(data) } returns result
assertEquals(result, restore.getRestoreData(data))
}
@Test
fun `getNextFullRestoreDataChunk() delegates to Full`() {
fun `getNextFullRestoreDataChunk() delegates to Full`() = runBlocking {
val data = mockk<ParcelFileDescriptor>()
val result = Random.nextInt()
every { full.getNextFullRestoreDataChunk(data) } returns result
coEvery { full.getNextFullRestoreDataChunk(data) } returns result
assertEquals(result, restore.getNextFullRestoreDataChunk(data))
}

View file

@ -2,6 +2,8 @@
buildscript {
// 1.3.21 Android 10
// 1.3.72 AOSP master (2020-08)
ext.kotlin_version = '1.3.61'
repositories {

View file

@ -1,27 +0,0 @@
#!/bin/bash
#
# Script to deploy to a prebuilt repo.
REPO_URL="https://stevesoltys:$GITHUB_API_KEY@github.com/stevesoltys/seedvault-prebuilt"
TAG=$(git tag -l --points-at HEAD)
git config --global user.email "github@stevesoltys.com"
git config --global user.name "Steve Soltys"
git clone --quiet $REPO_URL
cd seedvault-prebuilt
git checkout $TRAVIS_BRANCH || git checkout -b $TRAVIS_BRANCH
rm -Rf ./*
cp $TRAVIS_BUILD_DIR/Android.mk .
cp $TRAVIS_BUILD_DIR/app/build/outputs/apk/release/app-release-unsigned.apk ./Seedvault.apk
cp $TRAVIS_BUILD_DIR/permissions_com.stevesoltys.seedvault.xml .
cp $TRAVIS_BUILD_DIR/whitelist_com.stevesoltys.seedvault.xml .
git add .
git commit -m "Travis build $TRAVIS_BUILD_NUMBER" -m "https://github.com/stevesoltys/seedvault/commit/$TRAVIS_COMMIT"
git push origin $TRAVIS_BRANCH
if [ ! -z ${TAG} ]; then
git tag ${TAG}
git push origin --tags
fi

3
gradle.properties Normal file
View file

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1g
android.useAndroidX=true
android.enableJetifier=false

View file

@ -1,7 +1,7 @@
#Thu Nov 08 02:00:38 GMT 2018
#Tue Aug 04 14:40:48 BRT 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
distributionSha256Sum=14cd15fc8cc8705bd69dcfa3c8fefb27eb7027f5de4b47a8b279218f76895a91
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip
distributionSha256Sum=143a28f54f1ae93ef4f72d862dbc3c438050d81bb45b4601eb7076e998362920

84
libs/Android.bp Normal file
View file

@ -0,0 +1,84 @@
android_library_import {
name: "seedvault-lib-androidx-core-ktx",
aars: ["core-ktx-1.1.0.aar"],
sdk_version: "current",
}
android_library_import {
name: "seedvault-lib-androidx-lifecycle-livedata-core-ktx",
aars: ["lifecycle-livedata-core-ktx-2.3.0-alpha05.aar"],
sdk_version: "current",
}
android_library_import {
name: "seedvault-lib-androidx-lifecycle-livedata-ktx",
aars: ["lifecycle-livedata-ktx-2.3.0-alpha05.aar"],
sdk_version: "current",
}
android_library_import {
name: "seedvault-lib-androidx-lifecycle-viewmodel-ktx",
aars: ["lifecycle-viewmodel-ktx-2.1.0.aar"],
sdk_version: "current",
}
android_library_import {
name: "seedvault-lib-koin-android",
aars: ["koin-android-2.0.1.aar"],
sdk_version: "current",
}
android_library_import {
name: "seedvault-lib-koin-androidx-viewmodel",
aars: ["koin-androidx-viewmodel-2.0.1.aar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-commons-io",
host_supported: true,
jars: ["commons-io-2.6.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-koin-core",
host_supported: true,
jars: ["koin-core-2.0.1.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-kotlinx-coroutines-android",
host_supported: true,
jars: ["kotlinx-coroutines-android-1.1.1.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-kotlinx-coroutines-core",
host_supported: true,
jars: ["kotlinx-coroutines-core-1.3.0.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-novacrypto-bip39",
host_supported: true,
jars: ["BIP39-2019.01.27.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-novacrypto-sha256",
host_supported: true,
jars: ["SHA256-2019.01.27.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-novacrypto-toruntime",
host_supported: true,
jars: ["ToRuntime-0.9.0.jar"],
sdk_version: "current",
}

BIN
libs/BIP39-2019.01.27.jar Normal file

Binary file not shown.

BIN
libs/SHA256-2019.01.27.jar Normal file

Binary file not shown.

BIN
libs/ToRuntime-0.9.0.jar Normal file

Binary file not shown.

BIN
libs/commons-io-2.6.jar Normal file

Binary file not shown.

BIN
libs/core-ktx-1.1.0.aar Normal file

Binary file not shown.

BIN
libs/koin-android-2.0.1.aar Normal file

Binary file not shown.

Binary file not shown.

BIN
libs/koin-core-2.0.1.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.