diff --git a/.gitignore b/.gitignore
index 8e59b12e..9e827658 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,7 +7,8 @@ hs_err_pid*
## Intellij
out/
lib/
-.idea/
+.idea/*
+!.idea/runConfigurations*
*.ipr
*.iws
*.iml
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 00000000..7f68460d
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Instrumentation_Tests.xml b/.idea/runConfigurations/Instrumentation_Tests.xml
new file mode 100644
index 00000000..0546643d
--- /dev/null
+++ b/.idea/runConfigurations/Instrumentation_Tests.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_Tests.xml
new file mode 100644
index 00000000..14960c4b
--- /dev/null
+++ b/.idea/runConfigurations/Unit_Tests.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 304c0044..dc885df2 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,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
diff --git a/app/build.gradle b/app/build.gradle
index aaaca6f7..ac753711 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -6,7 +6,7 @@ apply plugin: 'kotlin-android'
android {
compileSdkVersion 29
- buildToolsVersion '29.0.2'
+ buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here
defaultConfig {
minSdkVersion 29
@@ -128,17 +128,20 @@ dependencies {
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"
}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt
index e902b8e6..66687348 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt
@@ -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
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt
deleted file mode 100644
index 3b673dbb..00000000
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt
+++ /dev/null
@@ -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()
- private val settingsManager by inject()
- 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)
- }
-
-}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
new file mode 100644
index 00000000..7bbc04e9
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt
@@ -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
+ }
+
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt
new file mode 100644
index 00000000..3ea8999d
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt
@@ -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()
+ private val settingsManager by inject()
+ 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()
+ 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()
+ 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
+ }
+
+}
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
new file mode 100644
index 00000000..3347638c
--- /dev/null
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
@@ -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")
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index bc4e03c2..d5dde682 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -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.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)
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
index 1b168a69..a398b56d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
@@ -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
-}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
index 89e4c74e..0ba2a537 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt
index 0e6105ae..5ef450bf 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt
@@ -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")
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt
index 3ed21176..2eef1943 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullRestorePlugin.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt
index b0bc45d9..0da6066f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt
index 7cb54f17..be57ca24 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt
@@ -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 {
+ @Throws(IOException::class)
+ override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List {
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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt
index 1b0f84b7..f2c310af 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt
@@ -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 { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
- single { DocumentsProviderRestorePlugin(androidContext(), get()) }
+ single { DocumentsStorage(androidContext(), get()) }
+
+ single { DocumentsProviderKVBackup(androidContext(), get()) }
+ single { DocumentsProviderFullBackup(androidContext(), get()) }
+ single { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) }
+
+ single { DocumentsProviderKVRestorePlugin(androidContext(), get()) }
+ single { DocumentsProviderFullRestorePlugin(androidContext(), get()) }
+ single { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) }
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
index d9375c2a..88af5399 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
@@ -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? {
+ override suspend fun getAvailableBackups(): Sequence? {
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 {
+ private suspend fun getBackups(context: Context, rootDir: DocumentFile): List {
val backupSets = ArrayList()
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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
index ff91fa9d..d1e281d5 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
@@ -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 {
+suspend fun DocumentFile.listFilesBlocking(context: Context): List {
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()
- @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 { 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)
+ }
+ }
+ }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreErrorBroadcastReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreErrorBroadcastReceiver.kt
index 4d24c841..94beee12 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreErrorBroadcastReceiver.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreErrorBroadcastReceiver.kt
@@ -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"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt
index b22dd354..d1b0bd33 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt
@@ -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() {
@@ -50,7 +50,7 @@ internal class RestoreProgressAdapter : Adapter() {
}
}
- 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() {
enum class AppRestoreStatus {
IN_PROGRESS,
SUCCEEDED,
+ NOT_ELIGIBLE,
FAILED,
FAILED_NO_DATA,
FAILED_NOT_ALLOWED,
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
index fbc414ca..6a2f3732 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
index d4eacb38..064493bc 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
index 954cb9d0..5c0ad928 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
index 949bc2d9..2ac3cb44 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -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 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 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
)
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
index a841d2f1..859b016d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
@@ -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 = mAppStatusList
private val mAppEditMode = MutableLiveData()
@@ -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 = liveData(Dispatchers.Main) {
+ private fun getAppStatusResult(): LiveData = 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)
+ }
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
index 06cd2745..cd32d69c 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
@@ -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()
private val restoreCoordinator by inject()
@@ -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? {
- return restoreCoordinator.getAvailableRestoreSets()
+ override fun getAvailableRestoreSets(): Array? = 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 {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
index 688f1026..499311c2 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
index 4ab5f396..7258edec 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
@@ -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): Boolean {
+ private fun signaturesChanged(
+ packageMetadata: PackageMetadata,
+ signatures: List
+ ): 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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index e6df7cf1..af6cc574 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
index 8ed93990..700379e9 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
@@ -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().kvBackupPlugin, get(), get(), get()) }
+ single { KVBackup(get().kvBackupPlugin, get(), get(), get(), get()) }
single { FullBackup(get().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
index 9b01d0b9..f6ffdbca 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
index 0782bcbe..0053a3b9 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt
index e4dc6538..197de844 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackupPlugin.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
index 8c1dceff..f02af892 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
@@ -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>
+ 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 {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt
index fb6fa64d..5ec97537 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackupPlugin.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
index 298ae62f..4b66ebf0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
@@ -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
@@ -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
@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
+ @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) {
+ 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
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt
index 8a1ff785..f4199797 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
index a233a490..bc26ba19 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt
@@ -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")
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestorePlugin.kt
index 4fff7efd..dacd0e0b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestorePlugin.kt
@@ -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
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt
index 38c5d8b4..04f11b3e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt
@@ -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? {
+ private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List? {
val records: List = 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 {
internal val key = base64Key.decodeBase64()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt
index 84b9d9e0..fcf85065 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt
@@ -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
+ suspend fun listRecords(token: Long, packageInfo: PackageInfo): List
/**
* 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
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
index a13a6822..2df92e93 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
@@ -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,
- /**
- * 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,
+ /**
+ * 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? = 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? {
+ suspend fun getAvailableRestoreSets(): Array? {
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList()
val metadataMap = LongSparseArray()
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
index 750c9b11..91843998 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
@@ -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?
+ suspend fun getAvailableBackups(): Sequence?
/**
* 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
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
index 5c633c85..c9cabfe0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
@@ -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)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
similarity index 53%
rename from app/src/main/java/com/stevesoltys/seedvault/BackupNotificationManager.kt
rename to app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
index a5afdf57..3ba8d563 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
@@ -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 {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
similarity index 72%
rename from app/src/main/java/com/stevesoltys/seedvault/NotificationBackupObserver.kt
rename to app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
index b579d4f7..b4a30cf1 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/NotificationBackupObserver.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
@@ -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
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
index 35e9d468..617ab24a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt
@@ -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))
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
index 1df09664..c1372a9a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
@@ -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()
+ }
}
diff --git a/app/src/main/res/drawable/ic_phone_android.xml b/app/src/main/res/drawable/ic_phone_android.xml
index c3cf49d9..bdfea794 100644
--- a/app/src/main/res/drawable/ic_phone_android.xml
+++ b/app/src/main/res/drawable/ic_phone_android.xml
@@ -1,10 +1,10 @@
diff --git a/app/src/main/res/drawable/ic_usb.xml b/app/src/main/res/drawable/ic_usb.xml
index 34ac6149..f14a2572 100644
--- a/app/src/main/res/drawable/ic_usb.xml
+++ b/app/src/main/res/drawable/ic_usb.xml
@@ -1,10 +1,10 @@
diff --git a/app/src/main/res/layout/list_item_storage_root.xml b/app/src/main/res/layout/list_item_storage_root.xml
index 92ff1a32..e9839533 100644
--- a/app/src/main/res/layout/list_item_storage_root.xml
+++ b/app/src/main/res/layout/list_item_storage_root.xml
@@ -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"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 13d7a392..d8fb40bb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -76,9 +76,10 @@
Backup complete
Not backed up
Backup failed
+ Backup already in progress
Backup finished
- %1$d apps could not get backed up
+ %1$d of %2$d apps backed up. Tap to learn more.
Backup failed
Error notification
@@ -104,6 +105,8 @@
Next
Restoring backup
System package manager
+
+ Not yet backed up
App reported no data for backup
App doesn\'t allow backup
App not installed
diff --git a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
index 517daf37..7d1f43e9 100644
--- a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
+++ b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt
@@ -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 = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.'
+private val charPool: List = ('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 = ('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 coAssertThrows(clazz: Class, 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)
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt
new file mode 100644
index 00000000..61a56bd4
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt
@@ -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()
+ private val kvBackupPlugin: KVBackupPlugin = mockk()
+ private val fullBackupPlugin: FullBackupPlugin = mockk()
+
+ 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()
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
new file mode 100644
index 00000000..47bddffb
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt
@@ -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)
+ }
+
+}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
index 577dbd76..26347c13 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
@@ -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()
@@ -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()
private val backupPlugin = mockk()
private val kvBackupPlugin = mockk()
- private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl)
+ private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl, notificationManager)
private val fullBackupPlugin = mockk()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val apkBackup = mockk()
- private val packageService:PackageService = mockk()
- private val notificationManager = mockk()
- 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()
private val kvRestorePlugin = mockk()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk()
- 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()
private val fileDescriptor = mockk(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()
val value2 = CapturingSlot()
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()
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()
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()
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
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
index 93e94326..e0aa4bf7 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
@@ -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)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
index 7a4168de..3a863780 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
@@ -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
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
index 4136e361..8ed92841 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
@@ -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()
@@ -40,7 +44,18 @@ internal class BackupCoordinatorTest : BackupTest() {
private val packageService: PackageService = mockk()
private val notificationManager = mockk()
- 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()
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()
- val documentFile = mockk()
+ fun `no error notification when device initialization fails on unplugged USB storage`() =
+ runBlocking {
+ val storage = mockk()
+ val documentFile = mockk()
- 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
}
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
index 91f3d1aa..4605ec2b 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt
@@ -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()
@@ -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
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
index 66956d92..6e44a73b 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
@@ -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()
private val dataInput = mockk()
+ private val notificationManager = mockk()
- 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
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt
index c8a823ab..69ac2074 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt
@@ -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
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt
index 46f5532e..8fd4ee27 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt
@@ -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()
@@ -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
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt
index db0c29a3..1298543c 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt
@@ -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()
@@ -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()
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 = listOf(key64)) {
- every { plugin.listRecords(token, packageInfo) } returns recordKeys
+ coEvery { plugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
}
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
index fd8b5ca5..ac93ad51 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
@@ -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()
private val metadataReader = mockk()
- 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()
@@ -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()
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()
val result = Random.nextInt()
- every { full.getNextFullRestoreDataChunk(data) } returns result
+ coEvery { full.getNextFullRestoreDataChunk(data) } returns result
assertEquals(result, restore.getNextFullRestoreDataChunk(data))
}
diff --git a/build.gradle b/build.gradle
index 65385e66..449e85d4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,6 +2,8 @@
buildscript {
+ // 1.3.21 Android 10
+ // 1.3.72 AOSP master (2020-08)
ext.kotlin_version = '1.3.61'
repositories {
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..f3edb44c
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx1g
+android.useAndroidX=true
+android.enableJetifier=false
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 39c9f096..9ff0d9fc 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
\ No newline at end of file
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip
+distributionSha256Sum=143a28f54f1ae93ef4f72d862dbc3c438050d81bb45b4601eb7076e998362920