From f7df78d2f3ef24b398081df89e5491c0e45142d3 Mon Sep 17 00:00:00 2001 From: Torsten Grote <t@grobox.de> Date: Fri, 7 Aug 2020 16:57:27 -0300 Subject: [PATCH] Check for loading cursor also when checking if files exist Loading cursors can happen with cloud-based documents providers such as Nextcloud. When they return a cursor that is still loading, we might continue with stale information. So now we wait for a loading cursor to be fully loaded before continuing. --- .gitignore | 3 +- .idea/runConfigurations.xml | 12 ++ .idea/runConfigurations/Unit_Tests.xml | 17 ++ app/build.gradle | 6 +- .../seedvault/DocumentsStorageTest.kt | 73 ------- .../com/stevesoltys/seedvault/PluginTest.kt | 60 +++--- .../plugins/saf/DocumentsStorageTest.kt | 174 ++++++++++++++++ .../saf/DocumentsProviderBackupPlugin.kt | 21 +- .../saf/DocumentsProviderFullBackup.kt | 8 +- .../saf/DocumentsProviderFullRestorePlugin.kt | 4 +- .../plugins/saf/DocumentsProviderKVBackup.kt | 14 +- .../saf/DocumentsProviderKVRestorePlugin.kt | 2 +- .../plugins/saf/DocumentsProviderModule.kt | 2 +- .../saf/DocumentsProviderRestorePlugin.kt | 20 +- .../seedvault/plugins/saf/DocumentsStorage.kt | 197 +++++++++++------- .../seedvault/restore/RestoreViewModel.kt | 3 +- .../transport/ConfigurableBackupTransport.kt | 43 ++-- .../seedvault/transport/backup/ApkBackup.kt | 2 +- .../transport/backup/BackupCoordinator.kt | 25 ++- .../transport/backup/BackupPlugin.kt | 6 +- .../seedvault/transport/backup/FullBackup.kt | 13 +- .../transport/backup/FullBackupPlugin.kt | 2 +- .../seedvault/transport/backup/KVBackup.kt | 5 +- .../transport/backup/KVBackupPlugin.kt | 4 +- .../transport/restore/FullRestore.kt | 6 +- .../transport/restore/FullRestorePlugin.kt | 4 +- .../seedvault/transport/restore/KVRestore.kt | 2 +- .../transport/restore/KVRestorePlugin.kt | 2 +- .../transport/restore/RestoreCoordinator.kt | 7 +- .../transport/restore/RestorePlugin.kt | 7 +- .../ui/storage/RestoreStorageViewModel.kt | 32 ++- .../com/stevesoltys/seedvault/TestUtils.kt | 27 +++ .../transport/CoordinatorIntegrationTest.kt | 43 ++-- .../transport/backup/ApkBackupTest.kt | 23 +- .../transport/backup/BackupCoordinatorTest.kt | 70 ++++--- .../transport/backup/FullBackupTest.kt | 37 ++-- .../transport/backup/KVBackupTest.kt | 39 ++-- .../transport/restore/ApkRestoreTest.kt | 12 +- .../transport/restore/FullRestoreTest.kt | 41 ++-- .../transport/restore/KVRestoreTest.kt | 7 +- .../restore/RestoreCoordinatorTest.kt | 48 +++-- 41 files changed, 695 insertions(+), 428 deletions(-) create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/runConfigurations/Unit_Tests.xml delete mode 100644 app/src/androidTest/java/com/stevesoltys/seedvault/DocumentsStorageTest.kt create mode 100644 app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="RunConfigurationProducerService"> + <option name="ignoredProducers"> + <set> + <option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" /> + <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" /> + <option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" /> + </set> + </option> + </component> +</project> \ 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 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit"> + <module name="app" /> + <useClassPathOnly /> + <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" /> + <option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" /> + <option name="MAIN_CLASS_NAME" value="" /> + <option name="METHOD_NAME" value="" /> + <option name="TEST_OBJECT" value="directory" /> + <option name="PARAMETERS" value="" /> + <option name="WORKING_DIRECTORY" value="$MODULE_DIR$" /> + <dir value="$PROJECT_DIR$/app/src/test/java/com/stevesoltys/seedvault" /> + <method v="2"> + <option name="Android.Gradle.BeforeRunTask" enabled="true" /> + </method> + </configuration> +</component> \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index afcf4356..685e0dd3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,7 +7,7 @@ apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 29 - buildToolsVersion '29.0.2' + buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here defaultConfig { minSdkVersion 29 @@ -128,9 +128,9 @@ dependencies { lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' - def junit_version = "5.5.2" + def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!? def mockk_version = "1.10.0" - testImplementation aospDeps + 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" 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 6a9cba82..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.ext.junit.runners.AndroidJUnit4 -import com.stevesoltys.seedvault.metadata.MetadataManager -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage -import com.stevesoltys.seedvault.plugins.saf.createOrGetFile -import com.stevesoltys.seedvault.settings.SettingsManager -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertNotNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.koin.core.KoinComponent -import org.koin.core.inject -import kotlin.random.Random - -private const val filename = "test-file" - -@RunWith(AndroidJUnit4::class) -class DocumentsStorageTest : KoinComponent { - - private val context = InstrumentationRegistry.getInstrumentation().targetContext - private val metadataManager by inject<MetadataManager>() - private val settingsManager by inject<SettingsManager>() - private val storage = DocumentsStorage(context, metadataManager, settingsManager) - - private lateinit var file: DocumentFile - - @Before - fun setup() { - assertNotNull("Select a storage location in the app first!", storage.rootBackupDir) - file = storage.rootBackupDir?.createOrGetFile(filename) - ?: throw RuntimeException("Could not create test file") - } - - @After - fun tearDown() { - file.delete() - } - - @Test - fun testWritingAndReadingFile() { - // write to output stream - val outputStream = storage.getOutputStream(file) - val content = ByteArray(1337).apply { Random.nextBytes(this) } - outputStream.write(content) - outputStream.flush() - outputStream.close() - - // read written data from input stream - val inputStream = storage.getInputStream(file) - val readContent = inputStream.readBytes() - inputStream.close() - assertArrayEquals(content, readContent) - - // write smaller content to same file - val outputStream2 = storage.getOutputStream(file) - val content2 = ByteArray(42).apply { Random.nextBytes(this) } - outputStream2.write(content2) - outputStream2.flush() - outputStream2.close() - - // read written data from input stream - val inputStream2 = storage.getInputStream(file) - val readContent2 = inputStream2.readBytes() - inputStream2.close() - assertArrayEquals(content2, readContent2) - } - -} diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt index d2251636..ed464b03 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -13,11 +13,11 @@ import com.stevesoltys.seedvault.transport.backup.BackupPlugin 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.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -25,12 +25,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.koin.core.KoinComponent import org.koin.core.inject -import java.io.InputStream -import java.io.OutputStream import kotlin.random.Random @RunWith(AndroidJUnit4::class) +@Suppress("BlockingMethodInNonBlockingContext") class PluginTest : KoinComponent { private val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -38,7 +37,7 @@ class PluginTest : KoinComponent { private val settingsManager: SettingsManager by inject() private val mockedSettingsManager: SettingsManager = mockk() private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager) - private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) + private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(context, storage) private val restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage) private val token = Random.nextLong() @@ -70,7 +69,7 @@ class PluginTest : KoinComponent { * that needs to get re-queried to get real results. */ @Test - fun testInitializationAndRestoreSets() { + 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") @@ -104,7 +103,7 @@ class PluginTest : KoinComponent { } @Test - fun testMetadataWriteRead() { + fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) { every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false assertTrue(backupPlugin.initializeDevice(newToken = token)) @@ -120,7 +119,7 @@ class PluginTest : KoinComponent { assertFalse(availableBackups[0].error) // read metadata matches what was written earlier - assertEquals(metadata, availableBackups[0].inputStream) + assertReadEquals(metadata, availableBackups[0].inputStream) // initializing again (without changing storage) keeps restore set with same token assertFalse(backupPlugin.initializeDevice(newToken = token + 1)) @@ -131,11 +130,11 @@ class PluginTest : KoinComponent { assertFalse(availableBackups[0].error) // metadata hasn't changed - assertEquals(metadata, availableBackups[0].inputStream) + assertReadEquals(metadata, availableBackups[0].inputStream) } @Test - fun testApkWriteRead() { + fun testApkWriteRead() = runBlocking { // initialize storage with given token initStorage(token) @@ -144,11 +143,11 @@ class PluginTest : KoinComponent { backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk) // assert that read APK bytes match what was written - assertEquals(apk, restorePlugin.getApkInputStream(token, packageInfo.packageName)) + assertReadEquals(apk, restorePlugin.getApkInputStream(token, packageInfo.packageName)) } @Test - fun testKvBackupRestore() { + fun testKvBackupRestore() = runBlocking { // define shortcuts val kvBackup = backupPlugin.kvBackupPlugin val kvRestore = restorePlugin.kvRestorePlugin @@ -178,7 +177,7 @@ class PluginTest : KoinComponent { var records = kvRestore.listRecords(token, packageInfo) assertEquals(1, records.size) assertEquals(record1.first, records[0]) - assertEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)) + assertReadEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)) // write second and third record kvBackup.ensureRecordStorageForPackage(packageInfo) @@ -188,9 +187,9 @@ class PluginTest : KoinComponent { // 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()) - assertEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)) - assertEquals(record2.second, kvRestore.getInputStreamForRecord(token, packageInfo, record2.first)) - assertEquals(record3.second, kvRestore.getInputStreamForRecord(token, packageInfo, record3.first)) + 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) @@ -204,7 +203,7 @@ class PluginTest : KoinComponent { } @Test - fun testMaxKvKeyLength() { + fun testMaxKvKeyLength() = runBlocking { // define shortcuts val kvBackup = backupPlugin.kvBackupPlugin val kvRestore = restorePlugin.kvRestorePlugin @@ -222,21 +221,18 @@ class PluginTest : KoinComponent { // max record is found correctly assertTrue(kvRestore.hasDataForPackage(token, packageInfo)) - var records = kvRestore.listRecords(token, packageInfo) + val records = kvRestore.listRecords(token, packageInfo) assertEquals(listOf(recordMax.first), records) // write exceeding key length record kvBackup.ensureRecordStorageForPackage(packageInfo) - kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first).writeAndClose(recordOver.second) - - // exceeding record gets truncated - assertTrue(kvRestore.hasDataForPackage(token, packageInfo)) - records = kvRestore.listRecords(token, packageInfo) - assertNotEquals(listOf(recordMax.first, recordOver.first).sorted(), records.sorted()) + coAssertThrows(IllegalStateException::class.java) { + kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first).writeAndClose(recordOver.second) + } } @Test - fun testFullBackupRestore() { + fun testFullBackupRestore() = runBlocking { // define shortcuts val fullBackup = backupPlugin.fullBackupPlugin val fullRestore = restorePlugin.fullRestorePlugin @@ -257,13 +253,13 @@ class PluginTest : KoinComponent { assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo)) // restore data matches backed up data - assertEquals(data, fullRestore.getInputStreamForPackage(token, packageInfo)) + 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)) - assertEquals(data2, fullRestore.getInputStreamForPackage(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) @@ -277,17 +273,9 @@ class PluginTest : KoinComponent { assertFalse(fullRestore.hasDataForPackage(token, packageInfo2)) } - private fun initStorage(token: Long) { + private fun initStorage(token: Long) = runBlocking { every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true assertTrue(backupPlugin.initializeDevice(newToken = token)) } - private fun OutputStream.writeAndClose(data: ByteArray) = use { - it.write(data) - } - - private fun assertEquals(data: ByteArray, inputStream: InputStream?) = inputStream?.use { - assertArrayEquals(data, it.readBytes()) - } ?: error("no input stream") - } 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..8246820f --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt @@ -0,0 +1,174 @@ +package com.stevesoltys.seedvault.plugins.saf + +import android.database.ContentObserver +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract.EXTRA_LOADING +import androidx.documentfile.provider.DocumentFile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.stevesoltys.seedvault.assertReadEquals +import com.stevesoltys.seedvault.coAssertThrows +import com.stevesoltys.seedvault.getRandomBase64 +import com.stevesoltys.seedvault.getRandomByteArray +import com.stevesoltys.seedvault.metadata.MetadataManager +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.writeAndClose +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.KoinComponent +import org.koin.core.inject +import java.io.IOException +import kotlin.random.Random + +@RunWith(AndroidJUnit4::class) +@Suppress("BlockingMethodInNonBlockingContext") +class DocumentsStorageTest : KoinComponent { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val metadataManager by inject<MetadataManager>() + private val settingsManager by inject<SettingsManager>() + private val storage = DocumentsStorage(context, metadataManager, 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) + ?: 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) + } + + @Test + fun testFindFile() = runBlocking(Dispatchers.IO) { + val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!) + assertNotNull(foundFile) + assertEquals(filename, foundFile!!.name) + } + + @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 testGetLoadedCursor() = runBlocking { + // empty cursor extras are like not loading, returns same cursor right away + val cursor1: Cursor = mockk() + every { cursor1.extras } returns Bundle() + assertEquals(cursor1, getLoadedCursor { cursor1 }) + + // explicitly not loading, returns same cursor right away + val cursor2: Cursor = mockk() + every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) } + assertEquals(cursor2, getLoadedCursor { cursor2 }) + + // loading cursor registers content observer, times out and closes cursor + val cursor3: Cursor = mockk() + every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } + every { cursor3.registerContentObserver(any()) } just Runs + every { cursor3.close() } just Runs + coAssertThrows(TimeoutCancellationException::class.java) { + getLoadedCursor(1000) { cursor3 } + } + verify { cursor3.registerContentObserver(any()) } + verify { cursor3.close() } // ensure that cursor gets closed + + // loading cursor registers content observer, but re-query fails + val cursor4: Cursor = mockk() + val observer4 = slot<ContentObserver>() + val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 } + every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } + every { cursor4.registerContentObserver(capture(observer4)) } answers { + observer4.captured.onChange(false, Uri.parse("foo://bar")) + } + every { cursor4.close() } just Runs + coAssertThrows(IOException::class.java) { + getLoadedCursor(10_000, query) + } + assertTrue(observer4.isCaptured) + verify { cursor4.close() } // ensure that cursor gets closed + + // loading cursor registers content observer, re-queries and returns new result + val cursor5: Cursor = mockk() + val result5: Cursor = mockk() + val observer5 = slot<ContentObserver>() + val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 } + every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) } + every { cursor5.registerContentObserver(capture(observer5)) } answers { + observer5.captured.onChange(false, null) + } + every { cursor5.close() } just Runs + assertEquals(result5, getLoadedCursor(10_000, query5)) + assertTrue(observer5.isCaptured) + verify { cursor5.close() } // ensure that initial cursor got closed + } + +} 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..72f2a86a 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 @@ -11,19 +12,21 @@ import java.io.OutputStream private const val MIME_TYPE_APK = "application/vnd.android.package-archive" internal class DocumentsProviderBackupPlugin( - private val storage: DocumentsStorage, - packageManager: PackageManager) : BackupPlugin { + private val context: Context, + private val storage: DocumentsStorage) : BackupPlugin { + + private val packageManager: PackageManager = context.packageManager override val kvBackupPlugin: KVBackupPlugin by lazy { - DocumentsProviderKVBackup(storage) + DocumentsProviderKVBackup(storage, context) } override val fullBackupPlugin: FullBackupPlugin by lazy { - DocumentsProviderFullBackup(storage) + DocumentsProviderFullBackup(storage, context) } @Throws(IOException::class) - override fun initializeDevice(newToken: Long): Boolean { + override suspend fun initializeDevice(newToken: Long): Boolean { // check if storage is already initialized if (storage.isInitialized()) return false @@ -46,16 +49,16 @@ internal class DocumentsProviderBackupPlugin( } @Throws(IOException::class) - override fun getMetadataOutputStream(): OutputStream { + 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..70e0499f 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 @@ -10,13 +11,14 @@ import java.io.OutputStream private val TAG = DocumentsProviderFullBackup::class.java.simpleName internal class DocumentsProviderFullBackup( - private val storage: DocumentsStorage) : FullBackupPlugin { + private val storage: DocumentsStorage, + private val context: Context) : FullBackupPlugin { override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP @Throws(IOException::class) - override fun getOutputStream(targetPackage: PackageInfo): OutputStream { - val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName) + override suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream { + val file = storage.currentFullBackupDir?.createOrGetFile(context, targetPackage.packageName) ?: throw IOException() return storage.getOutputStream(file) } 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 57a0ae18..61b815be 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 @@ -9,13 +9,13 @@ internal class DocumentsProviderFullRestorePlugin( private val documentsStorage: DocumentsStorage) : FullRestorePlugin { @Throws(IOException::class) - override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { val backupDir = documentsStorage.getFullBackupDir(token) ?: return false return backupDir.findFile(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() 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..7e4ad775 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,5 +1,6 @@ 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.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP @@ -7,7 +8,10 @@ import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin import java.io.IOException import java.io.OutputStream -internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin { +internal class DocumentsProviderKVBackup( + private val storage: DocumentsStorage, + private val context: Context +) : KVBackupPlugin { private var packageFile: DocumentFile? = null @@ -21,9 +25,9 @@ internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) } @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) @@ -43,10 +47,10 @@ internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) } @Throws(IOException::class) - override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { + override suspend fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { 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..47253160 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 @@ -10,7 +10,7 @@ internal class DocumentsProviderKVRestorePlugin(private val storage: DocumentsSt 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 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..66b22b7f 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 @@ -7,6 +7,6 @@ import org.koin.dsl.module val documentsProviderModule = module { single { DocumentsStorage(androidContext(), get(), get()) } - single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) } + single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get()) } single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), 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 142046ab..98050d2d 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,6 +15,8 @@ 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 { @@ -27,15 +29,15 @@ internal class DocumentsProviderRestorePlugin( DocumentsProviderFullRestorePlugin(storage) } - @WorkerThread - override fun hasBackup(uri: Uri): Boolean { + @Throws(IOException::class) + override suspend fun hasBackup(uri: Uri): Boolean { val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false val backupSets = getBackups(context, rootDir) return backupSets.isNotEmpty() } - override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { + override suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { val rootDir = storage.rootBackupDir ?: return null val backupSets = getBackups(context, rootDir) val iterator = backupSets.iterator() @@ -52,8 +54,7 @@ internal class DocumentsProviderRestorePlugin( } } - @WorkerThread - private fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> { + private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> { val backupSets = ArrayList<BackupSet>() val files = try { // block until the DocumentsProvider has results @@ -76,7 +77,12 @@ internal class DocumentsProviderRestorePlugin( 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 { @@ -87,7 +93,7 @@ internal class DocumentsProviderRestorePlugin( } @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() 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 702aa429..d300efe3 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,10 +1,13 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE", "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 @@ -14,15 +17,19 @@ 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" @@ -36,7 +43,10 @@ private val TAG = DocumentsStorage::class.java.simpleName internal class DocumentsStorage( private val context: Context, private val metadataManager: MetadataManager, - private val settingsManager: SettingsManager) { + private val settingsManager: SettingsManager +) { + + private val contentResolver = context.contentResolver internal var storage: Storage? = null get() { @@ -45,20 +55,22 @@ 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 @@ -68,47 +80,47 @@ internal class DocumentsStorage( } 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 + if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false return kvEmpty && fullEmpty @@ -125,48 +137,61 @@ internal class DocumentsStorage( fun getAuthority(): String? = storage?.uri?.authority - fun getSetDir(token: Long = currentToken): DocumentFile? { + @Throws(IOException::class) + suspend fun getSetDir(token: Long = currentToken): 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): 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): 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): 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) -fun DocumentFile.createOrGetDirectory(name: String): DocumentFile { - return findFile(name) ?: createDirectory(name) ?: throw IOException() +suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile { + return findFileBlocking(context, name) ?: createDirectory(name) ?: throw IOException() } @Throws(IOException::class) @@ -183,43 +208,22 @@ fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { * This prevents getting an empty list even though there are children to be listed. */ @Throws(IOException::class) -fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { +suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { val resolver = context.contentResolver val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) val result = ArrayList<DocumentFile>() - @SuppressLint("Recycle") // gets closed in with(), only earlier exit when null - var cursor = resolver.query(childrenUri, projection, null, null, null) - ?: throw IOException() - val loading = cursor.extras.getBoolean(EXTRA_LOADING, false) - if (loading) { - Log.d(TAG, "Wait for children to get loaded...") - var loaded = false - cursor.registerContentObserver(object : ContentObserver(null) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - Log.d(TAG, "Children loaded. Continue...") - loaded = true - } - }) - val timeout = MINUTES.toMillis(2) - var time = 0 - // TODO replace loop with callback flow or something similar - 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 + } catch (e: TimeoutCancellationException) { + throw IOException(e) + }.use { cursor -> + while (cursor.moveToNext()) { + val documentId = cursor.getString(0) + val isDirectory = cursor.getString(1) == MIME_TYPE_DIR val file = if (isDirectory) { val treeUri = buildTreeDocumentUri(uri.authority, documentId) DocumentFile.fromTreeUri(context, treeUri)!! @@ -233,7 +237,14 @@ fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { return result } -fun DocumentFile.findFileBlocking(context: Context, displayName: String): 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) { @@ -245,3 +256,45 @@ fun DocumentFile.findFileBlocking(context: Context, displayName: String): Docume } return null } + +/** + * Returns a cursor for the given query while ensuring that the cursor was loaded. + * + * When the SAF backend is a cloud storage provider (e.g. Nextcloud), + * it can happen that the query returns an outdated (e.g. empty) cursor + * which will only be updated in response to this query. + * + * See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html + * + * This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver] + * registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]). + * If the cursor is not loading, it will be returned right away. + * + * @param timeout an optional time-out in milliseconds + * @throws TimeoutCancellationException if there was no result before the time-out + * @throws IOException if the query returns null + */ +@VisibleForTesting +@Throws(IOException::class, TimeoutCancellationException::class) +internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) = withTimeout(timeout) { + suspendCancellableCoroutine<Cursor> { cont -> + val cursor = query() ?: throw IOException() + cont.invokeOnCancellation { closeQuietly(cursor) } + val loading = cursor.extras.getBoolean(EXTRA_LOADING, false) + if (loading) { + Log.d(TAG, "Wait for children to get loaded...") + cursor.registerContentObserver(object : ContentObserver(null) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + Log.d(TAG, "Children loaded. Continue...") + closeQuietly(cursor) + val newCursor = query() + if (newCursor == null) cont.cancel(IOException("query returned no results")) + else cont.resume(newCursor) + } + }) + } else { + // not loading, return cursor right away + cont.resume(cursor) + } + } +} 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..ec688cb9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -296,7 +296,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/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt index 06cd2745..490bb8d4 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt @@ -12,6 +12,7 @@ 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 @@ -57,24 +58,24 @@ 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 { 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 finishBackup(): Int { - return backupCoordinator.finishBackup() + override fun finishBackup(): Int = runBlocking { + backupCoordinator.finishBackup() } // ------------------------------------------------------------------------------------ @@ -85,8 +86,8 @@ 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 { @@ -106,20 +107,20 @@ 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 +128,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont // Restore // - override fun getAvailableRestoreSets(): Array<RestoreSet>? { - return restoreCoordinator.getAvailableRestoreSets() + override fun getAvailableRestoreSets(): Array<RestoreSet>? = runBlocking { + restoreCoordinator.getAvailableRestoreSets() } override fun getCurrentRestoreSet(): Long { @@ -139,12 +140,12 @@ 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 { 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..119db711 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 @@ -36,7 +36,7 @@ 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 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..f3ff72e9 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 @@ -9,6 +9,7 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.ParcelFileDescriptor import android.util.Log +import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER @@ -29,6 +30,8 @@ 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, @@ -67,7 +70,7 @@ 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 { + suspend fun initializeDevice(): Int { Log.i(TAG, "Initialize Device!") return try { val token = clock.time() @@ -107,7 +110,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 +142,7 @@ 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) { @@ -182,12 +185,12 @@ 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,7 +205,7 @@ 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") Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason") @@ -248,7 +251,7 @@ 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 @@ -267,7 +270,7 @@ 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 -> try { @@ -278,7 +281,7 @@ 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) { @@ -292,7 +295,7 @@ internal class BackupCoordinator( } } - private fun onPackageBackedUp(packageInfo: PackageInfo) { + private suspend fun onPackageBackedUp(packageInfo: PackageInfo) { val packageName = packageInfo.packageName try { val outputStream = plugin.getMetadataOutputStream() @@ -302,7 +305,7 @@ internal class BackupCoordinator( } } - 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 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..c8d36a58 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 @@ -17,19 +17,19 @@ interface BackupPlugin { * false if the device was initialized already and initialization should be a no-op. */ @Throws(IOException::class) - fun initializeDevice(newToken: Long): Boolean + suspend fun initializeDevice(newToken: Long): Boolean /** * 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..15b84089 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 @@ -21,7 +21,7 @@ private class FullBackupState( internal val packageInfo: PackageInfo, internal val inputFileDescriptor: ParcelFileDescriptor, internal val inputStream: InputStream, - internal var outputStreamInit: (() -> OutputStream)?) { + internal var outputStreamInit: (suspend () -> OutputStream)?) { internal var outputStream: OutputStream? = null internal val packageName: String = packageInfo.packageName internal var size: Long = 0 @@ -31,6 +31,7 @@ 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, @@ -89,7 +90,7 @@ 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}.") @@ -119,7 +120,7 @@ internal class FullBackup( return TRANSPORT_OK } - fun sendBackupData(numBytes: Int): Int { + suspend fun sendBackupData(numBytes: Int): Int { val state = this.state ?: throw AssertionError("Attempted sendBackupData before performFullBackup") @@ -134,11 +135,11 @@ internal class FullBackup( 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 + 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 // read backup data, encrypt it and write it to output stream 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..e07fbff7 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,7 +10,7 @@ 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. 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..af14e1ec 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 @@ -21,6 +21,7 @@ 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, @@ -35,7 +36,7 @@ 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 @@ -91,7 +92,7 @@ internal class KVBackup( return storeRecords(packageInfo, data) } - private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { + private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { // apply the delta operations for (result in parseBackupStream(data)) { if (result is Result.Error) { 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..416ed978 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 @@ -25,14 +25,14 @@ 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. 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..ad0dfcc5 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 @@ -40,7 +40,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) } 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 4769e3e5..a79d20ea 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,7 +10,7 @@ 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. 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..78100f3f 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 @@ -37,6 +37,7 @@ private class RestoreCoordinatorState( private val TAG = RestoreCoordinator::class.java.simpleName +@Suppress("BlockingMethodInNonBlockingContext") internal class RestoreCoordinator( private val context: Context, private val settingsManager: SettingsManager, @@ -57,7 +58,7 @@ internal class RestoreCoordinator( * @return Descriptions of the set of restore images available for this device, * or null if an error occurred (the attempt should be rescheduled). **/ - fun getAvailableRestoreSets(): Array<RestoreSet>? { + suspend fun getAvailableRestoreSets(): Array<RestoreSet>? { val availableBackups = plugin.getAvailableBackups() ?: return null val restoreSets = ArrayList<RestoreSet>() val metadataMap = LongSparseArray<BackupMetadata>() @@ -169,7 +170,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") @@ -228,7 +229,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<EncryptedBackupMetadata>? + suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? /** * Searches if there's really a backup available in the given location. @@ -27,12 +27,13 @@ interface RestorePlugin { * FIXME: Passing a Uri is maybe too plugin-specific? */ @WorkerThread - fun hasBackup(uri: Uri): Boolean + @Throws(IOException::class) + suspend fun hasBackup(uri: Uri): Boolean /** * Returns an [InputStream] for the given token, for reading an APK that is to be restored. */ @Throws(IOException::class) - fun getApkInputStream(token: Long, packageName: String): InputStream + suspend fun getApkInputStream(token: Long, packageName: String): InputStream } 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/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt index bc0e211b..dafb2c41 100644 --- a/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt +++ b/app/src/sharedTest/java/com/stevesoltys/seedvault/TestUtils.kt @@ -1,5 +1,11 @@ package com.stevesoltys.seedvault +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) { @@ -44,3 +50,24 @@ fun ByteArray.toIntString(): String { } return str } + +fun OutputStream.writeAndClose(data: ByteArray) = use { + it.write(data) +} + +fun assertReadEquals(data: ByteArray, inputStream: InputStream?) = inputStream?.use { + assertArrayEquals(data, it.readBytes()) +} ?: error("no input stream") + +fun <T : Throwable> coAssertThrows(clazz: Class<T>, block: suspend () -> Unit) { + var thrown = false + 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/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 577dbd76..1b14fff7 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -37,9 +37,11 @@ import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestorePlugin import io.mockk.CapturingSlot import io.mockk.Runs +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.fail @@ -48,6 +50,7 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import kotlin.random.Random +@Suppress("BlockingMethodInNonBlockingContext") internal class CoordinatorIntegrationTest : TransportTest() { private val inputFactory = mockk<InputFactory>() @@ -94,7 +97,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { } @Test - fun `test key-value backup and restore with 2 records`() { + fun `test key-value backup and restore with 2 records`() = runBlocking { val value = CapturingSlot<ByteArray>() val value2 = CapturingSlot<ByteArray>() val bOutputStream = ByteArrayOutputStream() @@ -102,7 +105,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { // 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.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,14 +114,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 + 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 + 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 @@ -130,7 +133,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) @@ -153,7 +156,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { } @Test - fun `test key-value backup with huge value`() { + fun `test key-value backup with huge value`() = runBlocking { val value = CapturingSlot<ByteArray>() val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337) val appData = ByteArray(size).apply { Random.nextBytes(this) } @@ -161,7 +164,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { // 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.ensureRecordStorageForPackage(packageInfo) } just Runs every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput every { backupDataInput.readNextHeader() } returns true andThen false every { backupDataInput.key } returns key @@ -170,9 +173,9 @@ 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 +186,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) @@ -202,15 +205,15 @@ 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 + 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 @@ -224,8 +227,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 +237,7 @@ 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/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..08566626 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 @@ -10,6 +10,7 @@ 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 @@ -18,19 +19,22 @@ import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.settings.Storage import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.io.IOException import java.io.OutputStream import kotlin.random.Random +@Suppress("BlockingMethodInNonBlockingContext") internal class BackupCoordinatorTest : BackupTest() { private val plugin = mockk<BackupPlugin>() @@ -48,10 +52,10 @@ internal class BackupCoordinatorTest : BackupTest() { private val storage = Storage(Uri.EMPTY, getRandomString(), false) @Test - fun `device initialization succeeds and delegates to plugin`() { + fun `device initialization succeeds and delegates to plugin`() = runBlocking { every { clock.time() } returns token - every { plugin.initializeDevice(token) } returns true // TODO test when false - every { plugin.getMetadataOutputStream() } returns metadataOutputStream + coEvery { plugin.initializeDevice(token) } returns true // TODO test when false + coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs every { kv.hasState() } returns false every { full.hasState() } returns false @@ -61,9 +65,9 @@ internal class BackupCoordinatorTest : BackupTest() { } @Test - fun `device initialization does no-op when already initialized`() { + fun `device initialization does no-op when already initialized`() = runBlocking { every { clock.time() } returns token - every { plugin.initializeDevice(token) } returns false + coEvery { plugin.initializeDevice(token) } returns false every { kv.hasState() } returns false every { full.hasState() } returns false @@ -72,9 +76,9 @@ internal class BackupCoordinatorTest : BackupTest() { } @Test - fun `error notification when device initialization fails`() { + fun `error notification when device initialization fails`() = runBlocking { every { clock.time() } returns token - every { plugin.initializeDevice(token) } throws IOException() + coEvery { plugin.initializeDevice(token) } throws IOException() every { settingsManager.getStorage() } returns storage every { notificationManager.onBackupError() } just Runs @@ -83,18 +87,18 @@ 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`() { + fun `no error notification when device initialization fails on unplugged USB storage`() = runBlocking { val storage = mockk<Storage>() val documentFile = mockk<DocumentFile>() every { clock.time() } returns token - every { plugin.initializeDevice(token) } throws IOException() + coEvery { plugin.initializeDevice(token) } throws IOException() every { settingsManager.getStorage() } returns storage every { storage.isUsb } returns true every { storage.getDocumentFile(context) } returns documentFile @@ -105,13 +109,13 @@ 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 `getBackupQuota() delegates to right plugin`() { + fun `getBackupQuota() delegates to right plugin`() = runBlocking { val isFullBackup = Random.nextBoolean() val quota = Random.nextLong() @@ -154,7 +158,7 @@ internal class BackupCoordinatorTest : BackupTest() { } @Test - fun `clearing backup data succeeds`() { + fun `clearing backup data succeeds`() = runBlocking { every { kv.clearBackupData(packageInfo) } just Runs every { full.clearBackupData(packageInfo) } just Runs @@ -167,13 +171,13 @@ 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 @@ -181,13 +185,13 @@ internal class BackupCoordinatorTest : BackupTest() { } @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 @@ -195,16 +199,16 @@ internal class BackupCoordinatorTest : BackupTest() { } @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 @@ -228,8 +232,8 @@ internal class BackupCoordinatorTest : BackupTest() { } @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 @@ -252,7 +256,7 @@ internal class BackupCoordinatorTest : BackupTest() { } @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" }, @@ -263,26 +267,26 @@ internal class BackupCoordinatorTest : BackupTest() { 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 + coEvery { 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 + coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } returns packageMetadata + coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata, metadataOutputStream) } just Runs // do actual @pm@ backup - every { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK + coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) - verify { + coVerify { apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } } private fun expectApkBackupAndMetadataWrite() { - every { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata - every { plugin.getMetadataOutputStream() } returns metadataOutputStream + 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..58546342 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<FullBackupPlugin>() @@ -62,7 +65,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `performFullBackup runs ok`() { + fun `performFullBackup runs ok`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectClearState() @@ -73,7 +76,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData first call over quota`() { + fun `sendBackupData first call over quota`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() val numBytes = (quota + 1).toInt() @@ -89,7 +92,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData second call over quota`() { + fun `sendBackupData second call over quota`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() val numBytes1 = quota.toInt() @@ -109,7 +112,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData throws exception when reading from InputStream`() { + fun `sendBackupData throws exception when reading from InputStream`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() every { plugin.getQuota() } returns quota @@ -125,11 +128,11 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData throws exception when getting outputStream`() { + fun `sendBackupData throws exception when getting outputStream`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream every { plugin.getQuota() } returns quota - every { plugin.getOutputStream(packageInfo) } throws IOException() + coEvery { plugin.getOutputStream(packageInfo) } throws IOException() expectClearState() assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) @@ -141,11 +144,11 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData throws exception when writing header`() { + fun `sendBackupData throws exception when writing header`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream every { plugin.getQuota() } returns quota - every { plugin.getOutputStream(packageInfo) } returns outputStream + coEvery { plugin.getOutputStream(packageInfo) } returns outputStream every { inputFactory.getInputStream(data) } returns inputStream every { headerWriter.writeVersion(outputStream, header) } throws IOException() expectClearState() @@ -159,7 +162,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData throws exception when writing encrypted data to OutputStream`() { + fun `sendBackupData throws exception when writing encrypted data to OutputStream`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() every { plugin.getQuota() } returns quota @@ -176,7 +179,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `sendBackupData runs ok`() { + fun `sendBackupData runs ok`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() val numBytes1 = (quota / 2).toInt() @@ -203,7 +206,7 @@ internal class FullBackupTest : BackupTest() { } @Test - fun `cancel full backup runs ok`() { + fun `cancel full backup runs ok`() = runBlocking { every { inputFactory.getInputStream(data) } returns inputStream expectInitializeOutputStream() expectClearState() @@ -216,7 +219,7 @@ 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() @@ -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..31a3de81 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 @@ -11,9 +11,11 @@ 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 kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -22,6 +24,7 @@ import java.io.IOException import java.util.* import kotlin.random.Random +@Suppress("BlockingMethodInNonBlockingContext") internal class KVBackupTest : BackupTest() { private val plugin = mockk<KVBackupPlugin>() @@ -40,7 +43,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,7 +53,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `incremental backup with no data gets rejected`() { + fun `incremental backup with no data gets rejected`() = runBlocking { every { plugin.hasDataForPackage(packageInfo) } returns false assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)) @@ -58,7 +61,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `check for existing data throws exception`() { + fun `check for existing data throws exception`() = runBlocking { every { plugin.hasDataForPackage(packageInfo) } throws IOException() assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) @@ -66,7 +69,7 @@ internal class KVBackupTest : BackupTest() { } @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 @@ -77,7 +80,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `ignoring exception when clearing data when non-incremental backup has data`() { + fun `ignoring exception when clearing data when non-incremental backup has data`() = runBlocking { singleRecordBackup(true) every { plugin.removeDataOfPackage(packageInfo) } throws IOException() @@ -88,16 +91,16 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `ensuring storage throws exception`() { + fun `ensuring storage throws exception`() = runBlocking { every { plugin.hasDataForPackage(packageInfo) } returns false - every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException() + 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 +110,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 +123,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `no data records`() { + fun `no data records`() = runBlocking { initPlugin(false) getDataInput(listOf(false)) @@ -131,10 +134,10 @@ 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() assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) @@ -142,11 +145,11 @@ internal class KVBackupTest : BackupTest() { } @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() @@ -155,7 +158,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `exception while flushing output stream`() { + fun `exception while flushing output stream`() = runBlocking { initPlugin(false) getDataInput(listOf(true)) writeHeaderAndEncrypt() @@ -167,7 +170,7 @@ internal class KVBackupTest : BackupTest() { } @Test - fun `ignoring exception while closing output stream`() { + fun `ignoring exception while closing output stream`() = runBlocking { initPlugin(false) getDataInput(listOf(true, false)) writeHeaderAndEncrypt() @@ -192,7 +195,7 @@ internal class KVBackupTest : BackupTest() { private fun initPlugin(hasDataForPackage: Boolean = false) { every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage - every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs + coEvery { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs } private fun createBackupDataInput() { @@ -208,7 +211,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<FullRestorePlugin>() @@ -38,9 +41,9 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `hasDataForPackage() delegates to plugin`() { + fun `hasDataForPackage() delegates to plugin`() = runBlocking { val result = Random.nextBoolean() - every { plugin.hasDataForPackage(token, packageInfo) } returns result + coEvery { plugin.hasDataForPackage(token, packageInfo) } returns result assertEquals(result, restore.hasDataForPackage(token, packageInfo)) } @@ -54,45 +57,45 @@ internal class FullRestoreTest : RestoreTest() { @Test fun `getting chunks without initializing state throws`() { assertFalse(restore.hasState()) - assertThrows(IllegalStateException::class.java) { + coAssertThrows(IllegalStateException::class.java) { restore.getNextFullRestoreDataChunk(fileDescriptor) } } @Test - fun `getting InputStream for package when getting first chunk throws`() { + fun `getting InputStream for package when getting first chunk throws`() = runBlocking { restore.initializeState(token, packageInfo) - every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException() + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException() assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) } @Test - fun `reading version header when getting first chunk throws`() { + fun `reading version header when getting first chunk throws`() = runBlocking { restore.initializeState(token, packageInfo) - every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream every { headerReader.readVersion(inputStream) } throws IOException() assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) } @Test - fun `reading unsupported version when getting first chunk`() { + fun `reading unsupported version when getting first chunk`() = runBlocking { restore.initializeState(token, packageInfo) - every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion) assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) } @Test - fun `decrypting version header when getting first chunk throws`() { + fun `decrypting version header when getting first chunk throws`() = runBlocking { restore.initializeState(token, packageInfo) - every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream every { headerReader.readVersion(inputStream) } returns VERSION every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException() @@ -100,10 +103,10 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `decrypting version header when getting first chunk throws security exception`() { + fun `decrypting version header when getting first chunk throws security exception`() = runBlocking { restore.initializeState(token, packageInfo) - every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream every { headerReader.readVersion(inputStream) } returns VERSION every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException() @@ -111,7 +114,7 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `decrypting segment throws IOException`() { + fun `decrypting segment throws IOException`() = runBlocking { restore.initializeState(token, packageInfo) initInputStream() @@ -124,7 +127,7 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `decrypting segment throws EOFException`() { + fun `decrypting segment throws EOFException`() = runBlocking { restore.initializeState(token, packageInfo) initInputStream() @@ -137,7 +140,7 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `full chunk gets encrypted`() { + fun `full chunk gets encrypted`() = runBlocking { restore.initializeState(token, packageInfo) initInputStream() @@ -151,7 +154,7 @@ internal class FullRestoreTest : RestoreTest() { } @Test - fun `aborting full restore closes stream, resets state`() { + fun `aborting full restore closes stream, resets state`() = runBlocking { restore.initializeState(token, packageInfo) initInputStream() @@ -166,7 +169,7 @@ internal class FullRestoreTest : RestoreTest() { } private fun initInputStream() { - every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + coEvery { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream every { headerReader.readVersion(inputStream) } returns VERSION every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader } 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..e9692cf8 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 @@ -9,10 +9,12 @@ 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 @@ -20,6 +22,7 @@ import java.io.IOException import java.io.InputStream import kotlin.random.Random +@Suppress("BlockingMethodInNonBlockingContext") internal class KVRestoreTest : RestoreTest() { private val plugin = mockk<KVRestorePlugin>() @@ -34,10 +37,10 @@ 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)) } 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..05cf90b3 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 @@ -10,6 +10,7 @@ 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 @@ -18,10 +19,12 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.transport.TransportTest 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() @@ -57,7 +61,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 +69,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 @@ -137,16 +141,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 +158,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 +170,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,20 +200,20 @@ 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()) } @@ -225,11 +229,11 @@ internal class RestoreCoordinatorTest : TransportTest() { } @Test - fun `getNextFullRestoreDataChunk() delegates to Full`() { + fun `getNextFullRestoreDataChunk() delegates to Full`() = runBlocking { val data = mockk<ParcelFileDescriptor>() val result = Random.nextInt() - every { full.getNextFullRestoreDataChunk(data) } returns result + coEvery { full.getNextFullRestoreDataChunk(data) } returns result assertEquals(result, restore.getNextFullRestoreDataChunk(data)) }