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.
This commit is contained in:
Torsten Grote 2020-08-07 16:57:27 -03:00 committed by Chirayu Desai
parent 131c5b6b29
commit 18d83767b3
41 changed files with 695 additions and 428 deletions

3
.gitignore vendored
View file

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

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

View file

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
<module name="app" />
<useClassPathOnly />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />
<option name="MAIN_CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="directory" />
<option name="PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
<dir value="$PROJECT_DIR$/app/src/test/java/com/stevesoltys/seedvault" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -7,7 +7,7 @@ apply plugin: 'kotlin-android-extensions'
android { android {
compileSdkVersion 29 compileSdkVersion 29
buildToolsVersion '29.0.2' buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here
defaultConfig { defaultConfig {
minSdkVersion 29 minSdkVersion 29
@ -128,9 +128,9 @@ dependencies {
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' 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" 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 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1' testImplementation 'org.robolectric:robolectric:4.3.1'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version" testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"

View file

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

View file

@ -13,11 +13,11 @@ import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -25,12 +25,11 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject import org.koin.core.inject
import java.io.InputStream
import java.io.OutputStream
import kotlin.random.Random import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Suppress("BlockingMethodInNonBlockingContext")
class PluginTest : KoinComponent { class PluginTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
@ -38,7 +37,7 @@ class PluginTest : KoinComponent {
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val mockedSettingsManager: SettingsManager = mockk() private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager) 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 restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage)
private val token = Random.nextLong() private val token = Random.nextLong()
@ -70,7 +69,7 @@ class PluginTest : KoinComponent {
* that needs to get re-queried to get real results. * that needs to get re-queried to get real results.
*/ */
@Test @Test
fun testInitializationAndRestoreSets() { fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
// no backups available initially // no backups available initially
assertEquals(0, restorePlugin.getAvailableBackups()?.toList()?.size) assertEquals(0, restorePlugin.getAvailableBackups()?.toList()?.size)
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage") val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
@ -104,7 +103,7 @@ class PluginTest : KoinComponent {
} }
@Test @Test
fun testMetadataWriteRead() { fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false
assertTrue(backupPlugin.initializeDevice(newToken = token)) assertTrue(backupPlugin.initializeDevice(newToken = token))
@ -120,7 +119,7 @@ class PluginTest : KoinComponent {
assertFalse(availableBackups[0].error) assertFalse(availableBackups[0].error)
// read metadata matches what was written earlier // 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 // initializing again (without changing storage) keeps restore set with same token
assertFalse(backupPlugin.initializeDevice(newToken = token + 1)) assertFalse(backupPlugin.initializeDevice(newToken = token + 1))
@ -131,11 +130,11 @@ class PluginTest : KoinComponent {
assertFalse(availableBackups[0].error) assertFalse(availableBackups[0].error)
// metadata hasn't changed // metadata hasn't changed
assertEquals(metadata, availableBackups[0].inputStream) assertReadEquals(metadata, availableBackups[0].inputStream)
} }
@Test @Test
fun testApkWriteRead() { fun testApkWriteRead() = runBlocking {
// initialize storage with given token // initialize storage with given token
initStorage(token) initStorage(token)
@ -144,11 +143,11 @@ class PluginTest : KoinComponent {
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk) backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk)
// assert that read APK bytes match what was written // assert that read APK bytes match what was written
assertEquals(apk, restorePlugin.getApkInputStream(token, packageInfo.packageName)) assertReadEquals(apk, restorePlugin.getApkInputStream(token, packageInfo.packageName))
} }
@Test @Test
fun testKvBackupRestore() { fun testKvBackupRestore() = runBlocking {
// define shortcuts // define shortcuts
val kvBackup = backupPlugin.kvBackupPlugin val kvBackup = backupPlugin.kvBackupPlugin
val kvRestore = restorePlugin.kvRestorePlugin val kvRestore = restorePlugin.kvRestorePlugin
@ -178,7 +177,7 @@ class PluginTest : KoinComponent {
var records = kvRestore.listRecords(token, packageInfo) var records = kvRestore.listRecords(token, packageInfo)
assertEquals(1, records.size) assertEquals(1, records.size)
assertEquals(record1.first, records[0]) 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 // write second and third record
kvBackup.ensureRecordStorageForPackage(packageInfo) kvBackup.ensureRecordStorageForPackage(packageInfo)
@ -188,9 +187,9 @@ class PluginTest : KoinComponent {
// all records for package are found and returned properly // all records for package are found and returned properly
records = kvRestore.listRecords(token, packageInfo) records = kvRestore.listRecords(token, packageInfo)
assertEquals(listOf(record1.first, record2.first, record3.first).sorted(), records.sorted()) assertEquals(listOf(record1.first, record2.first, record3.first).sorted(), records.sorted())
assertEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)) assertReadEquals(record1.second, kvRestore.getInputStreamForRecord(token, packageInfo, record1.first))
assertEquals(record2.second, kvRestore.getInputStreamForRecord(token, packageInfo, record2.first)) assertReadEquals(record2.second, kvRestore.getInputStreamForRecord(token, packageInfo, record2.first))
assertEquals(record3.second, kvRestore.getInputStreamForRecord(token, packageInfo, record3.first)) assertReadEquals(record3.second, kvRestore.getInputStreamForRecord(token, packageInfo, record3.first))
// delete record3 and ensure that the other two are still found // delete record3 and ensure that the other two are still found
kvBackup.deleteRecord(packageInfo, record3.first) kvBackup.deleteRecord(packageInfo, record3.first)
@ -204,7 +203,7 @@ class PluginTest : KoinComponent {
} }
@Test @Test
fun testMaxKvKeyLength() { fun testMaxKvKeyLength() = runBlocking {
// define shortcuts // define shortcuts
val kvBackup = backupPlugin.kvBackupPlugin val kvBackup = backupPlugin.kvBackupPlugin
val kvRestore = restorePlugin.kvRestorePlugin val kvRestore = restorePlugin.kvRestorePlugin
@ -222,21 +221,18 @@ class PluginTest : KoinComponent {
// max record is found correctly // max record is found correctly
assertTrue(kvRestore.hasDataForPackage(token, packageInfo)) assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
var records = kvRestore.listRecords(token, packageInfo) val records = kvRestore.listRecords(token, packageInfo)
assertEquals(listOf(recordMax.first), records) assertEquals(listOf(recordMax.first), records)
// write exceeding key length record // write exceeding key length record
kvBackup.ensureRecordStorageForPackage(packageInfo) kvBackup.ensureRecordStorageForPackage(packageInfo)
coAssertThrows(IllegalStateException::class.java) {
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first).writeAndClose(recordOver.second) 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())
} }
@Test @Test
fun testFullBackupRestore() { fun testFullBackupRestore() = runBlocking {
// define shortcuts // define shortcuts
val fullBackup = backupPlugin.fullBackupPlugin val fullBackup = backupPlugin.fullBackupPlugin
val fullRestore = restorePlugin.fullRestorePlugin val fullRestore = restorePlugin.fullRestorePlugin
@ -257,13 +253,13 @@ class PluginTest : KoinComponent {
assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo)) assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo))
// restore data matches backed up data // 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 // write and check data for second package
val data2 = getRandomByteArray(5 * 1024 * 1024) val data2 = getRandomByteArray(5 * 1024 * 1024)
fullBackup.getOutputStream(packageInfo2).writeAndClose(data2) fullBackup.getOutputStream(packageInfo2).writeAndClose(data2)
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2)) 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 // remove data of first package again and ensure that no more data is found
fullBackup.removeDataOfPackage(packageInfo) fullBackup.removeDataOfPackage(packageInfo)
@ -277,17 +273,9 @@ class PluginTest : KoinComponent {
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2)) assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
} }
private fun initStorage(token: Long) { private fun initStorage(token: Long) = runBlocking {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true
assertTrue(backupPlugin.initializeDevice(newToken = token)) 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")
} }

View file

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

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin 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" private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
internal class DocumentsProviderBackupPlugin( internal class DocumentsProviderBackupPlugin(
private val storage: DocumentsStorage, private val context: Context,
packageManager: PackageManager) : BackupPlugin { private val storage: DocumentsStorage) : BackupPlugin {
private val packageManager: PackageManager = context.packageManager
override val kvBackupPlugin: KVBackupPlugin by lazy { override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage) DocumentsProviderKVBackup(storage, context)
} }
override val fullBackupPlugin: FullBackupPlugin by lazy { override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage) DocumentsProviderFullBackup(storage, context)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun initializeDevice(newToken: Long): Boolean { override suspend fun initializeDevice(newToken: Long): Boolean {
// check if storage is already initialized // check if storage is already initialized
if (storage.isInitialized()) return false if (storage.isInitialized()) return false
@ -46,16 +49,16 @@ internal class DocumentsProviderBackupPlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getMetadataOutputStream(): OutputStream { override suspend fun getMetadataOutputStream(): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir() ?: throw IOException()
val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA) val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile) return storage.getOutputStream(metadataFile)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream { override suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir() ?: throw IOException()
val file = setDir.createOrGetFile("${packageInfo.packageName}.apk", MIME_TYPE_APK) val file = setDir.createOrGetFile(context, "${packageInfo.packageName}.apk", MIME_TYPE_APK)
return storage.getOutputStream(file) return storage.getOutputStream(file)
} }

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
@ -10,13 +11,14 @@ import java.io.OutputStream
private val TAG = DocumentsProviderFullBackup::class.java.simpleName private val TAG = DocumentsProviderFullBackup::class.java.simpleName
internal class DocumentsProviderFullBackup( internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage) : FullBackupPlugin { private val storage: DocumentsStorage,
private val context: Context) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
@Throws(IOException::class) @Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream { override suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName) val file = storage.currentFullBackupDir?.createOrGetFile(context, targetPackage.packageName)
?: throw IOException() ?: throw IOException()
return storage.getOutputStream(file) return storage.getOutputStream(file)
} }

View file

@ -9,13 +9,13 @@ internal class DocumentsProviderFullRestorePlugin(
private val documentsStorage: DocumentsStorage) : FullRestorePlugin { private val documentsStorage: DocumentsStorage) : FullRestorePlugin {
@Throws(IOException::class) @Throws(IOException::class)
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
val backupDir = documentsStorage.getFullBackupDir(token) ?: return false val backupDir = documentsStorage.getFullBackupDir(token) ?: return false
return backupDir.findFile(packageInfo.packageName) != null return backupDir.findFile(packageInfo.packageName) != null
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream { override suspend fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException() val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException()
return documentsStorage.getInputStream(packageFile) return documentsStorage.getInputStream(packageFile)

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
@ -7,7 +8,10 @@ import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin { internal class DocumentsProviderKVBackup(
private val storage: DocumentsStorage,
private val context: Context
) : KVBackupPlugin {
private var packageFile: DocumentFile? = null private var packageFile: DocumentFile? = null
@ -21,9 +25,9 @@ internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) { override suspend fun ensureRecordStorageForPackage(packageInfo: PackageInfo) {
// remember package file for subsequent operations // remember package file for subsequent operations
packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName) packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(context, packageInfo.packageName)
} }
@Throws(IOException::class) @Throws(IOException::class)
@ -43,10 +47,10 @@ internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage)
} }
@Throws(IOException::class) @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() val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo) packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.createOrGetFile(key) val keyFile = packageFile.createOrGetFile(context, key)
return storage.getOutputStream(keyFile) return storage.getOutputStream(keyFile)
} }

View file

@ -10,7 +10,7 @@ internal class DocumentsProviderKVRestorePlugin(private val storage: DocumentsSt
private var packageDir: DocumentFile? = null private var packageDir: DocumentFile? = null
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return try { return try {
val backupDir = storage.getKVBackupDir(token) ?: return false val backupDir = storage.getKVBackupDir(token) ?: return false
// remember package file for subsequent operations // remember package file for subsequent operations

View file

@ -7,6 +7,6 @@ import org.koin.dsl.module
val documentsProviderModule = module { val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get(), get()) } single { DocumentsStorage(androidContext(), get(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) } single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) } single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
} }

View file

@ -15,6 +15,8 @@ import java.io.InputStream
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
internal class DocumentsProviderRestorePlugin( internal class DocumentsProviderRestorePlugin(
private val context: Context, private val context: Context,
private val storage: DocumentsStorage) : RestorePlugin { private val storage: DocumentsStorage) : RestorePlugin {
@ -27,15 +29,15 @@ internal class DocumentsProviderRestorePlugin(
DocumentsProviderFullRestorePlugin(storage) DocumentsProviderFullRestorePlugin(storage)
} }
@WorkerThread @Throws(IOException::class)
override fun hasBackup(uri: Uri): Boolean { override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(context, rootDir) val backupSets = getBackups(context, rootDir)
return backupSets.isNotEmpty() return backupSets.isNotEmpty()
} }
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { override suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir) val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator() val iterator = backupSets.iterator()
@ -52,8 +54,7 @@ internal class DocumentsProviderRestorePlugin(
} }
} }
@WorkerThread private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
private fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>() val backupSets = ArrayList<BackupSet>()
val files = try { val files = try {
// block until the DocumentsProvider has results // block until the DocumentsProvider has results
@ -76,7 +77,12 @@ internal class DocumentsProviderRestorePlugin(
continue continue
} }
// block until children of set are available // block until children of set are available
val metadata = set.findFileBlocking(context, FILE_BACKUP_METADATA) val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
Log.e(TAG, "Error reading metadata file in backup set folder: ${set.name}", e)
null
}
if (metadata == null) { if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}") Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else { } else {
@ -87,7 +93,7 @@ internal class DocumentsProviderRestorePlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getApkInputStream(token: Long, packageName: String): InputStream { override suspend fun getApkInputStream(token: Long, packageName: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException() val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException() val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
return storage.getInputStream(file) return storage.getInputStream(file)

View file

@ -1,10 +1,13 @@
@file:Suppress("EXPERIMENTAL_API_USAGE", "BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.FileUtils.closeQuietly
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR 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.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId import android.provider.DocumentsContract.getDocumentId
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES import kotlin.coroutines.resume
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
@ -36,7 +43,10 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val context: Context, private val context: Context,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager) { private val settingsManager: SettingsManager
) {
private val contentResolver = context.contentResolver
internal var storage: Storage? = null internal var storage: Storage? = null
get() { get() {
@ -45,20 +55,22 @@ internal class DocumentsStorage(
} }
internal var rootBackupDir: DocumentFile? = null internal var rootBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
val parent = storage?.getDocumentFile(context) ?: return null val parent = storage?.getDocumentFile(context)
?: return@runBlocking null
field = try { field = try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT) parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup // create .nomedia file to prevent Android's MediaScanner
rootDir.createOrGetFile(FILE_NO_MEDIA) // from trying to index the backup
rootDir createOrGetFile(context, FILE_NO_MEDIA)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e) Log.e(TAG, "Error creating root backup dir.", e)
null null
} }
} }
return field field
} }
private var currentToken: Long = 0L private var currentToken: Long = 0L
@ -68,43 +80,43 @@ internal class DocumentsStorage(
} }
private var currentSetDir: DocumentFile? = null private var currentSetDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
if (currentToken == 0L) return null if (currentToken == 0L) return@runBlocking null
field = try { field = try {
rootBackupDir?.createOrGetDirectory(currentToken.toString()) rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e) Log.e(TAG, "Error creating current restore set dir.", e)
null null
} }
} }
return field field
} }
var currentFullBackupDir: DocumentFile? = null var currentFullBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
field = try { field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) currentSetDir?.createOrGetDirectory(context, DIRECTORY_FULL_BACKUP)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e) Log.e(TAG, "Error creating full backup dir.", e)
null null
} }
} }
return field field
} }
var currentKvBackupDir: DocumentFile? = null var currentKvBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
field = try { field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) currentSetDir?.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e) Log.e(TAG, "Error creating K/V backup dir.", e)
null null
} }
} }
return field field
} }
fun isInitialized(): Boolean { fun isInitialized(): Boolean {
@ -125,48 +137,61 @@ internal class DocumentsStorage(
fun getAuthority(): String? = storage?.uri?.authority fun getAuthority(): String? = storage?.uri?.authority
fun getSetDir(token: Long = currentToken): DocumentFile? { @Throws(IOException::class)
suspend fun getSetDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentSetDir if (token == currentToken) return currentSetDir
return rootBackupDir?.findFile(token.toString()) return rootBackupDir?.findFileBlocking(context, token.toString())
}
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile { suspend fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException() if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException() return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} }
fun getFullBackupDir(token: Long = currentToken): DocumentFile? { @Throws(IOException::class)
suspend fun getOrCreateKVBackupDir(token: Long = currentToken): 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() if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP) return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream { fun getInputStream(file: DocumentFile): InputStream {
return context.contentResolver.openInputStream(file.uri) ?: throw IOException() return contentResolver.openInputStream(file.uri) ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream { fun getOutputStream(file: DocumentFile): OutputStream {
return context.contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException() return contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
} }
} }
/**
* Checks if a file exists and if not, creates it.
*
* If we were trying to create it right away, some providers create "filename (1)".
*/
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile { internal suspend fun DocumentFile.createOrGetFile(context: Context, name: String, mimeType: String = MIME_TYPE): DocumentFile {
return findFile(name) ?: createFile(mimeType, name) ?: throw IOException() 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) @Throws(IOException::class)
fun DocumentFile.createOrGetDirectory(name: String): DocumentFile { suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFile(name) ?: createDirectory(name) ?: throw IOException() return findFileBlocking(context, name) ?: createDirectory(name) ?: throw IOException()
} }
@Throws(IOException::class) @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. * This prevents getting an empty list even though there are children to be listed.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
val resolver = context.contentResolver val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE)
val result = ArrayList<DocumentFile>() val result = ArrayList<DocumentFile>()
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null try {
var cursor = resolver.query(childrenUri, projection, null, null, null) getLoadedCursor {
?: throw IOException() resolver.query(childrenUri, projection, null, null, null)
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
var loaded = false
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
loaded = true
} }
}) } catch (e: TimeoutCancellationException) {
val timeout = MINUTES.toMillis(2) throw IOException(e)
var time = 0 }.use { cursor ->
// TODO replace loop with callback flow or something similar while (cursor.moveToNext()) {
while (!loaded && time < timeout) { val documentId = cursor.getString(0)
Thread.sleep(50) val isDirectory = cursor.getString(1) == MIME_TYPE_DIR
time += 50
}
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
closeQuietly(cursor)
// do a new query after content was loaded
@SuppressLint("Recycle") // gets closed after with block
cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
}
with(cursor) {
while (moveToNext()) {
val documentId = getString(0)
val isDirectory = getString(1) == MIME_TYPE_DIR
val file = if (isDirectory) { val file = if (isDirectory) {
val treeUri = buildTreeDocumentUri(uri.authority, documentId) val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!! DocumentFile.fromTreeUri(context, treeUri)!!
@ -233,7 +237,14 @@ fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
return result 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 { val files = try {
listFilesBlocking(context) listFilesBlocking(context)
} catch (e: IOException) { } catch (e: IOException) {
@ -245,3 +256,45 @@ fun DocumentFile.findFileBlocking(context: Context, displayName: String): Docume
} }
return null return null
} }
/**
* Returns a cursor for the given query while ensuring that the cursor was loaded.
*
* When the SAF backend is a cloud storage provider (e.g. Nextcloud),
* it can happen that the query returns an outdated (e.g. empty) cursor
* which will only be updated in response to this query.
*
* See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
*
* This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver]
* registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]).
* If the cursor is not loading, it will be returned right away.
*
* @param timeout an optional time-out in milliseconds
* @throws TimeoutCancellationException if there was no result before the time-out
* @throws IOException if the query returns null
*/
@VisibleForTesting
@Throws(IOException::class, TimeoutCancellationException::class)
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) = withTimeout(timeout) {
suspendCancellableCoroutine<Cursor> { cont ->
val cursor = query() ?: throw IOException()
cont.invokeOnCancellation { closeQuietly(cursor) }
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
closeQuietly(cursor)
val newCursor = query()
if (newCursor == null) cont.cancel(IOException("query returned no results"))
else cont.resume(newCursor)
}
})
} else {
// not loading, return cursor right away
cont.resume(cursor)
}
}
}

View file

@ -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) continuation.resume(result)

View file

@ -12,6 +12,7 @@ import android.util.Log
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import kotlinx.coroutines.runBlocking
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject import org.koin.core.inject
@ -57,24 +58,24 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
// General backup methods // General backup methods
// //
override fun initializeDevice(): Int { override fun initializeDevice(): Int = runBlocking {
return backupCoordinator.initializeDevice() backupCoordinator.initializeDevice()
} }
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean { override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
} }
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
return backupCoordinator.getBackupQuota(packageName, isFullBackup) backupCoordinator.getBackupQuota(packageName, isFullBackup)
} }
override fun clearBackupData(packageInfo: PackageInfo): Int { override fun clearBackupData(packageInfo: PackageInfo): Int {
return backupCoordinator.clearBackupData(packageInfo) return backupCoordinator.clearBackupData(packageInfo)
} }
override fun finishBackup(): Int { override fun finishBackup(): Int = runBlocking {
return backupCoordinator.finishBackup() backupCoordinator.finishBackup()
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@ -85,8 +86,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.requestBackupTime() return backupCoordinator.requestBackupTime()
} }
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int { override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int = runBlocking {
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags) backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
} }
override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int {
@ -106,20 +107,20 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.checkFullBackupSize(size) return backupCoordinator.checkFullBackupSize(size)
} }
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int { override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int = runBlocking {
return backupCoordinator.performFullBackup(targetPackage, socket, flags) backupCoordinator.performFullBackup(targetPackage, socket, flags)
} }
override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int = runBlocking {
Log.w(TAG, "Warning: Legacy performFullBackup() method called.") Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0) backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
} }
override fun sendBackupData(numBytes: Int): Int { override fun sendBackupData(numBytes: Int): Int = runBlocking {
return backupCoordinator.sendBackupData(numBytes) backupCoordinator.sendBackupData(numBytes)
} }
override fun cancelFullBackup() { override fun cancelFullBackup() = runBlocking {
backupCoordinator.cancelFullBackup() backupCoordinator.cancelFullBackup()
} }
@ -127,8 +128,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
// Restore // Restore
// //
override fun getAvailableRestoreSets(): Array<RestoreSet>? { override fun getAvailableRestoreSets(): Array<RestoreSet>? = runBlocking {
return restoreCoordinator.getAvailableRestoreSets() restoreCoordinator.getAvailableRestoreSets()
} }
override fun getCurrentRestoreSet(): Long { override fun getCurrentRestoreSet(): Long {
@ -139,12 +140,12 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return restoreCoordinator.startRestore(token, packages) return restoreCoordinator.startRestore(token, packages)
} }
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int = runBlocking {
return restoreCoordinator.getNextFullRestoreDataChunk(socket) restoreCoordinator.getNextFullRestoreDataChunk(socket)
} }
override fun nextRestorePackage(): RestoreDescription? { override fun nextRestorePackage(): RestoreDescription? = runBlocking {
return restoreCoordinator.nextRestorePackage() restoreCoordinator.nextRestorePackage()
} }
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int { override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int {

View file

@ -36,7 +36,7 @@ class ApkBackup(
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made. * @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun backupApkIfNecessary(packageInfo: PackageInfo, packageState: PackageState, streamGetter: () -> OutputStream): PackageMetadata? { suspend fun backupApkIfNecessary(packageInfo: PackageInfo, packageState: PackageState, streamGetter: suspend () -> OutputStream): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return null if (packageName == MAGIC_PACKAGE_MANAGER) return null

View file

@ -9,6 +9,7 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
@ -29,6 +30,8 @@ private val TAG = BackupCoordinator::class.java.simpleName
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinator( internal class BackupCoordinator(
private val context: Context, private val context: Context,
private val plugin: BackupPlugin, private val plugin: BackupPlugin,
@ -67,7 +70,7 @@ internal class BackupCoordinator(
* @return One of [TRANSPORT_OK] (OK so far) or * @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure). * [TRANSPORT_ERROR] (to retry following network error or other failure).
*/ */
fun initializeDevice(): Int { suspend fun initializeDevice(): Int {
Log.i(TAG, "Initialize Device!") Log.i(TAG, "Initialize Device!")
return try { return try {
val token = clock.time() val token = clock.time()
@ -107,7 +110,7 @@ internal class BackupCoordinator(
* otherwise for key-value backup. * otherwise for key-value backup.
* @return Current limit on backup size in bytes. * @return Current limit on backup size in bytes.
*/ */
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
if (packageName != MAGIC_PACKAGE_MANAGER) { if (packageName != MAGIC_PACKAGE_MANAGER) {
// try to back up APK here as later methods are sometimes not called called // try to back up APK here as later methods are sometimes not called called
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES)) backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
@ -139,7 +142,7 @@ internal class BackupCoordinator(
Log.i(TAG, "Request incremental backup time. Returned $this") Log.i(TAG, "Request incremental backup time. Returned $this")
} }
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { suspend fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) { if (packageName == MAGIC_PACKAGE_MANAGER) {
@ -182,12 +185,12 @@ internal class BackupCoordinator(
return result return result
} }
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int { suspend fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags) return full.performFullBackup(targetPackage, fileDescriptor, flags)
} }
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes) suspend fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
/** /**
* Tells the transport to cancel the currently-ongoing full backup operation. * Tells the transport to cancel the currently-ongoing full backup operation.
@ -202,7 +205,7 @@ internal class BackupCoordinator(
* If the transport receives this callback, it will *not* receive a call to [finishBackup]. * If the transport receives this callback, it will *not* receive a call to [finishBackup].
* It needs to tear down any ongoing backup state here. * It needs to tear down any ongoing backup state here.
*/ */
fun cancelFullBackup() { suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage() val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package") ?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason") Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
@ -248,7 +251,7 @@ internal class BackupCoordinator(
* *
* @return the same error codes as [performIncrementalBackup] or [performFullBackup]. * @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/ */
fun finishBackup(): Int = when { suspend fun finishBackup(): Int = when {
kv.hasState() -> { kv.hasState() -> {
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" } check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state
@ -267,7 +270,7 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()") 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...") Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
packageService.notAllowedPackages.forEach { optOutPackageInfo -> packageService.notAllowedPackages.forEach { optOutPackageInfo ->
try { 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 val packageName = packageInfo.packageName
try { try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) { 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 val packageName = packageInfo.packageName
try { try {
val outputStream = plugin.getMetadataOutputStream() 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 // don't bother with system apps that have no data
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
val packageName = packageInfo.packageName val packageName = packageInfo.packageName

View file

@ -17,19 +17,19 @@ interface BackupPlugin {
* false if the device was initialized already and initialization should be a no-op. * false if the device was initialized already and initialization should be a no-op.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun initializeDevice(newToken: Long): Boolean suspend fun initializeDevice(newToken: Long): Boolean
/** /**
* Returns an [OutputStream] for writing backup metadata. * Returns an [OutputStream] for writing backup metadata.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream suspend fun getMetadataOutputStream(): OutputStream
/** /**
* Returns an [OutputStream] for writing an APK to be backed up. * Returns an [OutputStream] for writing an APK to be backed up.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getApkOutputStream(packageInfo: PackageInfo): OutputStream suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream
/** /**
* Returns the package name of the app that provides the backend storage * Returns the package name of the app that provides the backend storage

View file

@ -21,7 +21,7 @@ private class FullBackupState(
internal val packageInfo: PackageInfo, internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor, internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream, internal val inputStream: InputStream,
internal var outputStreamInit: (() -> OutputStream)?) { internal var outputStreamInit: (suspend () -> OutputStream)?) {
internal var outputStream: OutputStream? = null internal var outputStream: OutputStream? = null
internal val packageName: String = packageInfo.packageName internal val packageName: String = packageInfo.packageName
internal var size: Long = 0 internal var size: Long = 0
@ -31,6 +31,7 @@ const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
private val TAG = FullBackup::class.java.simpleName private val TAG = FullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackup( internal class FullBackup(
private val plugin: FullBackupPlugin, private val plugin: FullBackupPlugin,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
@ -89,7 +90,7 @@ internal class FullBackup(
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data; * [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time. * [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
*/ */
fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int { suspend fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int {
if (state != null) throw AssertionError() if (state != null) throw AssertionError()
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.") Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
@ -119,7 +120,7 @@ internal class FullBackup(
return TRANSPORT_OK return TRANSPORT_OK
} }
fun sendBackupData(numBytes: Int): Int { suspend fun sendBackupData(numBytes: Int): Int {
val state = this.state val state = this.state
?: throw AssertionError("Attempted sendBackupData before performFullBackup") ?: throw AssertionError("Attempted sendBackupData before performFullBackup")
@ -134,11 +135,11 @@ internal class FullBackup(
return try { return try {
// get output stream or initialize it, if it does not yet exist // get output stream or initialize it, if it does not yet exist
check((state.outputStream != null) xor (state.outputStreamInit != null)) { "No OutputStream xor no StreamGetter" } check((state.outputStream != null) xor (state.outputStreamInit != null)) { "No OutputStream xor no StreamGetter" }
val outputStream = state.outputStream ?: { val outputStream = state.outputStream ?: suspend {
val stream = state.outputStreamInit!!.invoke() // not-null due to check above val stream = state.outputStreamInit!!() // not-null due to check above
state.outputStream = stream state.outputStream = stream
stream stream
}.invoke() }()
state.outputStreamInit = null // the stream init lambda is not needed beyond that point state.outputStreamInit = null // the stream init lambda is not needed beyond that point
// read backup data, encrypt it and write it to output stream // read backup data, encrypt it and write it to output stream

View file

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

View file

@ -21,6 +21,7 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName private val TAG = KVBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup( internal class KVBackup(
private val plugin: KVBackupPlugin, private val plugin: KVBackupPlugin,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
@ -35,7 +36,7 @@ internal class KVBackup(
fun getQuota(): Long = plugin.getQuota() fun getQuota(): Long = plugin.getQuota()
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { suspend fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
val isIncremental = flags and FLAG_INCREMENTAL != 0 val isIncremental = flags and FLAG_INCREMENTAL != 0
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
@ -91,7 +92,7 @@ internal class KVBackup(
return storeRecords(packageInfo, data) return storeRecords(packageInfo, data)
} }
private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
// apply the delta operations // apply the delta operations
for (result in parseBackupStream(data)) { for (result in parseBackupStream(data)) {
if (result is Result.Error) { if (result is Result.Error) {

View file

@ -25,14 +25,14 @@ interface KVBackupPlugin {
* E.g. file-based plugins should a create a directory for the package, if none exists. * E.g. file-based plugins should a create a directory for the package, if none exists.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun ensureRecordStorageForPackage(packageInfo: PackageInfo) suspend fun ensureRecordStorageForPackage(packageInfo: PackageInfo)
/** /**
* Return an [OutputStream] for the given package and key * Return an [OutputStream] for the given package and key
* which will receive the record's encrypted value. * which will receive the record's encrypted value.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream suspend fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
/** /**
* Delete the record for the given package identified by the given key. * Delete the record for the given package identified by the given key.

View file

@ -23,6 +23,7 @@ private class FullRestoreState(
private val TAG = FullRestore::class.java.simpleName private val TAG = FullRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestore( internal class FullRestore(
private val plugin: FullRestorePlugin, private val plugin: FullRestorePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
@ -37,7 +38,7 @@ internal class FullRestore(
* Return true if there is data stored for the given package. * Return true if there is data stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo) return plugin.hasDataForPackage(token, packageInfo)
} }
@ -78,7 +79,7 @@ internal class FullRestore(
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition * Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
* that aborts all further restore operations on the current dataset. * that aborts all further restore operations on the current dataset.
*/ */
fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { suspend fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
val state = this.state ?: throw IllegalStateException("no state") val state = this.state ?: throw IllegalStateException("no state")
val packageName = state.packageInfo.packageName val packageName = state.packageInfo.packageName
@ -113,6 +114,7 @@ internal class FullRestore(
try { try {
// read segment from input stream and decrypt it // read segment from input stream and decrypt it
val decrypted = try { val decrypted = try {
// TODO handle IOException
crypto.decryptSegment(inputStream) crypto.decryptSegment(inputStream)
} catch (e: EOFException) { } catch (e: EOFException) {
Log.i(TAG, " EOF") Log.i(TAG, " EOF")

View file

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

View file

@ -40,7 +40,7 @@ internal class KVRestore(
* Return true if there are records stored for the given package. * Return true if there are records stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo) return plugin.hasDataForPackage(token, packageInfo)
} }

View file

@ -10,7 +10,7 @@ interface KVRestorePlugin {
* Return true if there is data stored for the given package. * Return true if there is data stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
/** /**
* Return all record keys for the given token and package. * Return all record keys for the given token and package.

View file

@ -37,6 +37,7 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName private val TAG = RestoreCoordinator::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinator( internal class RestoreCoordinator(
private val context: Context, private val context: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
@ -57,7 +58,7 @@ internal class RestoreCoordinator(
* @return Descriptions of the set of restore images available for this device, * @return Descriptions of the set of restore images available for this device,
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableRestoreSets(): Array<RestoreSet>? { suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
val availableBackups = plugin.getAvailableBackups() ?: return null val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>() val restoreSets = ArrayList<RestoreSet>()
val metadataMap = LongSparseArray<BackupMetadata>() val metadataMap = LongSparseArray<BackupMetadata>()
@ -169,7 +170,7 @@ internal class RestoreCoordinator(
* or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session; * or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
* or null to indicate a transport-level error. * or null to indicate a transport-level error.
*/ */
fun nextRestorePackage(): RestoreDescription? { suspend fun nextRestorePackage(): RestoreDescription? {
Log.i(TAG, "Next restore package!") Log.i(TAG, "Next restore package!")
val state = this.state ?: throw IllegalStateException("no state") val state = this.state ?: throw IllegalStateException("no state")
@ -228,7 +229,7 @@ internal class RestoreCoordinator(
* After this method returns zero, the system will then call [nextRestorePackage] * After this method returns zero, the system will then call [nextRestorePackage]
* to begin the restore process for the next application, and the sequence begins again. * to begin the restore process for the next application, and the sequence begins again.
*/ */
fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int { suspend fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
return full.getNextFullRestoreDataChunk(outputFileDescriptor) return full.getNextFullRestoreDataChunk(outputFileDescriptor)
} }

View file

@ -18,7 +18,7 @@ interface RestorePlugin {
* @return metadata for the set of restore images available, * @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
/** /**
* Searches if there's really a backup available in the given location. * Searches if there's really a backup available in the given location.
@ -27,12 +27,13 @@ interface RestorePlugin {
* FIXME: Passing a Uri is maybe too plugin-specific? * FIXME: Passing a Uri is maybe too plugin-specific?
*/ */
@WorkerThread @WorkerThread
fun hasBackup(uri: Uri): Boolean @Throws(IOException::class)
suspend fun hasBackup(uri: Uri): Boolean
/** /**
* Returns an [InputStream] for the given token, for reading an APK that is to be restored. * Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getApkInputStream(token: Long, packageName: String): InputStream suspend fun getApkInputStream(token: Long, packageName: String): InputStream
} }

View file

@ -3,10 +3,14 @@ package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
private val TAG = RestoreStorageViewModel::class.java.simpleName private val TAG = RestoreStorageViewModel::class.java.simpleName
@ -17,8 +21,15 @@ internal class RestoreStorageViewModel(
override val isRestoreOperation = true override val isRestoreOperation = true
override fun onLocationSet(uri: Uri) = Thread { override fun onLocationSet(uri: Uri) {
if (restorePlugin.hasBackup(uri)) { viewModelScope.launch(Dispatchers.IO) {
val hasBackup = try {
restorePlugin.hasBackup(uri)
} catch (e: IOException) {
Log.e(TAG, "Error reading URI: $uri", e)
false
}
if (hasBackup) {
saveStorage(uri) saveStorage(uri)
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
@ -29,6 +40,7 @@ internal class RestoreStorageViewModel(
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT) val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.postEvent(LocationResult(errorMsg)) mLocationChecked.postEvent(LocationResult(errorMsg))
} }
}.start() }
}
} }

View file

@ -1,5 +1,11 @@
package com.stevesoltys.seedvault 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 import kotlin.random.Random
fun assertContains(stack: String?, needle: String) { fun assertContains(stack: String?, needle: String) {
@ -44,3 +50,24 @@ fun ByteArray.toIntString(): String {
} }
return str 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)
}

View file

@ -37,9 +37,11 @@ import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail
@ -48,6 +50,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class CoordinatorIntegrationTest : TransportTest() { internal class CoordinatorIntegrationTest : TransportTest() {
private val inputFactory = mockk<InputFactory>() private val inputFactory = mockk<InputFactory>()
@ -94,7 +97,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
} }
@Test @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 value = CapturingSlot<ByteArray>()
val value2 = CapturingSlot<ByteArray>() val value2 = CapturingSlot<ByteArray>()
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
@ -102,7 +105,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// read one key/value record and write it to output stream // read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false 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 { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen true andThen false every { backupDataInput.readNextHeader() } returns true andThen true andThen false
every { backupDataInput.key } returns key andThen key2 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.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size 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 { every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers {
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size appData2.size
} }
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2 coEvery { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo, 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))) assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup // 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() val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -153,7 +156,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
} }
@Test @Test
fun `test key-value backup with huge value`() { fun `test key-value backup with huge value`() = runBlocking {
val value = CapturingSlot<ByteArray>() val value = CapturingSlot<ByteArray>()
val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337) val size = Random.nextInt(5) * MAX_SEGMENT_CLEARTEXT_LENGTH + Random.nextInt(0, 1337)
val appData = ByteArray(size).apply { Random.nextBytes(this) } 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 // read one key/value record and write it to output stream
every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false 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 { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput
every { backupDataInput.readNextHeader() } returns true andThen false every { backupDataInput.readNextHeader() } returns true andThen false
every { backupDataInput.key } returns key 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.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size appData.size
} }
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream coEvery { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup // start and finish K/V backup
@ -183,7 +186,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data for K/V backup // 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() val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -202,15 +205,15 @@ internal class CoordinatorIntegrationTest : TransportTest() {
} }
@Test @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 // return streams from plugin and app data
val bOutputStream = ByteArrayOutputStream() val bOutputStream = ByteArrayOutputStream()
val bInputStream = ByteArrayInputStream(appData) val bInputStream = ByteArrayInputStream(appData)
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream coEvery { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo, 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))) assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo)))
// find data only for full backup // find data only for full backup
every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false coEvery { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false
every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true coEvery { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true
val restoreDescription = restore.nextRestorePackage() ?: fail() val restoreDescription = restore.nextRestorePackage() ?: fail()
assertEquals(packageInfo.packageName, restoreDescription.packageName) assertEquals(packageInfo.packageName, restoreDescription.packageName)
@ -234,7 +237,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// reverse the backup streams into restore input // reverse the backup streams into restore input
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rOutputStream = ByteArrayOutputStream() val rOutputStream = ByteArrayOutputStream()
every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream coEvery { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream
every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream
// restore data // restore data

View file

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

View file

@ -10,6 +10,7 @@ import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED 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.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse 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.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinatorTest : BackupTest() { internal class BackupCoordinatorTest : BackupTest() {
private val plugin = mockk<BackupPlugin>() private val plugin = mockk<BackupPlugin>()
@ -48,10 +52,10 @@ internal class BackupCoordinatorTest : BackupTest() {
private val storage = Storage(Uri.EMPTY, getRandomString(), false) private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test @Test
fun `device initialization succeeds and delegates to plugin`() { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
every { clock.time() } returns token every { clock.time() } returns token
every { plugin.initializeDevice(token) } returns true // TODO test when false coEvery { plugin.initializeDevice(token) } returns true // TODO test when false
every { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
@ -61,9 +65,9 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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 { clock.time() } returns token
every { plugin.initializeDevice(token) } returns false coEvery { plugin.initializeDevice(token) } returns false
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
@ -72,9 +76,9 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @Test
fun `error notification when device initialization fails`() { fun `error notification when device initialization fails`() = runBlocking {
every { clock.time() } returns token every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException() coEvery { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs 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 // finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) { coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup() backup.finishBackup()
} }
} }
@Test @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 storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>() val documentFile = mockk<DocumentFile>()
every { clock.time() } returns token every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException() coEvery { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile 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 // finish will only be called when TRANSPORT_OK is returned, so it should throw
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
assertThrows(IllegalStateException::class.java) { coAssertThrows(IllegalStateException::class.java) {
backup.finishBackup() backup.finishBackup()
} }
} }
@Test @Test
fun `getBackupQuota() delegates to right plugin`() { fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean() val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong() val quota = Random.nextLong()
@ -154,7 +158,7 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @Test
fun `clearing backup data succeeds`() { fun `clearing backup data succeeds`() = runBlocking {
every { kv.clearBackupData(packageInfo) } just Runs every { kv.clearBackupData(packageInfo) } just Runs
every { full.clearBackupData(packageInfo) } just Runs every { full.clearBackupData(packageInfo) } just Runs
@ -167,13 +171,13 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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() val result = Random.nextInt()
every { kv.hasState() } returns true every { kv.hasState() } returns true
every { full.hasState() } returns false every { full.hasState() } returns false
every { kv.getCurrentPackage() } returns packageInfo every { kv.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { kv.finishBackup() } returns result every { kv.finishBackup() } returns result
@ -181,13 +185,13 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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() val result = Random.nextInt()
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns true every { full.hasState() } returns true
every { full.getCurrentPackage() } returns packageInfo every { full.getCurrentPackage() } returns packageInfo
every { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { full.finishBackup() } returns result every { full.finishBackup() } returns result
@ -195,16 +199,16 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @Test
fun `metadata does not get updated when no APK was backed up`() { fun `metadata does not get updated when no APK was backed up`() = runBlocking {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
} }
@Test @Test
fun `app exceeding quota gets cancelled and reason written to metadata`() { fun `app exceeding quota gets cancelled and reason written to metadata`() = runBlocking {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite() expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1) } returns TRANSPORT_QUOTA_EXCEEDED every { full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1) } returns TRANSPORT_QUOTA_EXCEEDED
@ -228,8 +232,8 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @Test
fun `app with no data gets cancelled and reason written to metadata`() { fun `app with no data gets cancelled and reason written to metadata`() = runBlocking {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite() expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED
@ -252,7 +256,7 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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 packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
val notAllowedPackages = listOf( val notAllowedPackages = listOf(
PackageInfo().apply { packageName = "org.example.1" }, 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 { settingsManager.getStorage() } returns storage // to check for removable storage
every { packageService.notAllowedPackages } returns notAllowedPackages every { packageService.notAllowedPackages } returns notAllowedPackages
// no backup needed // 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 // was backed up, get new packageMetadata
every { apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata, metadataOutputStream) } just Runs every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata, metadataOutputStream) } just Runs
// do actual @pm@ backup // 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, assertEquals(TRANSPORT_OK,
backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
verify { coVerify {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any())
apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any()) apkBackup.backupApkIfNecessary(notAllowedPackages[1], NOT_ALLOWED, any())
} }
} }
private fun expectApkBackupAndMetadataWrite() { private fun expectApkBackupAndMetadataWrite() {
every { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs
} }

View file

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

View file

@ -11,9 +11,11 @@ import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@ -22,6 +24,7 @@ import java.io.IOException
import java.util.* import java.util.*
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackupTest : BackupTest() { internal class KVBackupTest : BackupTest() {
private val plugin = mockk<KVBackupPlugin>() private val plugin = mockk<KVBackupPlugin>()
@ -40,7 +43,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `simple backup with one record`() { fun `simple backup with one record`() = runBlocking {
singleRecordBackup() singleRecordBackup()
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0))
@ -50,7 +53,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `incremental backup with no data gets rejected`() { fun `incremental backup with no data gets rejected`() = runBlocking {
every { plugin.hasDataForPackage(packageInfo) } returns false every { plugin.hasDataForPackage(packageInfo) } returns false
assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)) assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL))
@ -58,7 +61,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `check for existing data throws exception`() { fun `check for existing data throws exception`() = runBlocking {
every { plugin.hasDataForPackage(packageInfo) } throws IOException() every { plugin.hasDataForPackage(packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
@ -66,7 +69,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `non-incremental backup with data clears old data first`() { fun `non-incremental backup with data clears old data first`() = runBlocking {
singleRecordBackup(true) singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } just Runs every { plugin.removeDataOfPackage(packageInfo) } just Runs
@ -77,7 +80,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @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) singleRecordBackup(true)
every { plugin.removeDataOfPackage(packageInfo) } throws IOException() every { plugin.removeDataOfPackage(packageInfo) } throws IOException()
@ -88,16 +91,16 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `ensuring storage throws exception`() { fun `ensuring storage throws exception`() = runBlocking {
every { plugin.hasDataForPackage(packageInfo) } returns false 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)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
assertFalse(backup.hasState()) assertFalse(backup.hasState())
} }
@Test @Test
fun `exception while reading next header`() { fun `exception while reading next header`() = runBlocking {
initPlugin(false) initPlugin(false)
createBackupDataInput() createBackupDataInput()
every { dataInput.readNextHeader() } throws IOException() every { dataInput.readNextHeader() } throws IOException()
@ -107,7 +110,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `exception while reading value`() { fun `exception while reading value`() = runBlocking {
initPlugin(false) initPlugin(false)
createBackupDataInput() createBackupDataInput()
every { dataInput.readNextHeader() } returns true every { dataInput.readNextHeader() } returns true
@ -120,7 +123,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `no data records`() { fun `no data records`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(false)) getDataInput(listOf(false))
@ -131,10 +134,10 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `exception while writing version header`() { fun `exception while writing version header`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true)) getDataInput(listOf(true))
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException() every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException()
assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0))
@ -142,11 +145,11 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `exception while writing encrypted value to output stream`() { fun `exception while writing encrypted value to output stream`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true)) getDataInput(listOf(true))
writeHeaderAndEncrypt() writeHeaderAndEncrypt()
every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream coEvery { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream
every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptMultipleSegments(outputStream, any()) } throws IOException() every { crypto.encryptMultipleSegments(outputStream, any()) } throws IOException()
@ -155,7 +158,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `exception while flushing output stream`() { fun `exception while flushing output stream`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true)) getDataInput(listOf(true))
writeHeaderAndEncrypt() writeHeaderAndEncrypt()
@ -167,7 +170,7 @@ internal class KVBackupTest : BackupTest() {
} }
@Test @Test
fun `ignoring exception while closing output stream`() { fun `ignoring exception while closing output stream`() = runBlocking {
initPlugin(false) initPlugin(false)
getDataInput(listOf(true, false)) getDataInput(listOf(true, false))
writeHeaderAndEncrypt() writeHeaderAndEncrypt()
@ -192,7 +195,7 @@ internal class KVBackupTest : BackupTest() {
private fun initPlugin(hasDataForPackage: Boolean = false) { private fun initPlugin(hasDataForPackage: Boolean = false) {
every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage
every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs coEvery { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs
} }
private fun createBackupDataInput() { private fun createBackupDataInput() {
@ -208,7 +211,7 @@ internal class KVBackupTest : BackupTest() {
} }
private fun writeHeaderAndEncrypt() { 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 { headerWriter.writeVersion(outputStream, versionHeader) } just Runs
every { crypto.encryptHeader(outputStream, versionHeader) } just Runs every { crypto.encryptHeader(outputStream, versionHeader) } just Runs
every { crypto.encryptMultipleSegments(outputStream, any()) } just Runs every { crypto.encryptMultipleSegments(outputStream, any()) } just Runs

View file

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

View file

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

View file

@ -9,10 +9,12 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.VersionHeader
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verifyAll import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -20,6 +22,7 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestoreTest : RestoreTest() { internal class KVRestoreTest : RestoreTest() {
private val plugin = mockk<KVRestorePlugin>() private val plugin = mockk<KVRestorePlugin>()
@ -34,10 +37,10 @@ internal class KVRestoreTest : RestoreTest() {
private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2) private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2)
@Test @Test
fun `hasDataForPackage() delegates to plugin`() { fun `hasDataForPackage() delegates to plugin`() = runBlocking {
val result = Random.nextBoolean() val result = Random.nextBoolean()
every { plugin.hasDataForPackage(token, packageInfo) } returns result coEvery { plugin.hasDataForPackage(token, packageInfo) } returns result
assertEquals(result, restore.hasDataForPackage(token, packageInfo)) assertEquals(result, restore.hasDataForPackage(token, packageInfo))
} }

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata 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.settings.Storage
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@ -32,6 +35,7 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinatorTest : TransportTest() { internal class RestoreCoordinatorTest : TransportTest() {
private val notificationManager: BackupNotificationManager = mockk() private val notificationManager: BackupNotificationManager = mockk()
@ -57,7 +61,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val storageName = getRandomString() private val storageName = getRandomString()
@Test @Test
fun `getAvailableRestoreSets() builds set from plugin response`() { fun `getAvailableRestoreSets() builds set from plugin response`() = runBlocking {
val encryptedMetadata = EncryptedBackupMetadata(token, inputStream) val encryptedMetadata = EncryptedBackupMetadata(token, inputStream)
val metadata = BackupMetadata( val metadata = BackupMetadata(
token = token, token = token,
@ -65,7 +69,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
androidIncremental = getRandomString(), androidIncremental = getRandomString(),
deviceName = 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 { metadataReader.readMetadata(inputStream, token) } returns metadata
every { inputStream.close() } just Runs every { inputStream.close() } just Runs
@ -137,16 +141,16 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `nextRestorePackage() throws without startRestore()`() { fun `nextRestorePackage() throws without startRestore()`() {
assertThrows(IllegalStateException::class.javaObjectType) { coAssertThrows(IllegalStateException::class.javaObjectType) {
restore.nextRestorePackage() restore.nextRestorePackage()
} }
} }
@Test @Test
fun `nextRestorePackage() returns KV description and takes precedence`() { fun `nextRestorePackage() returns KV description and takes precedence`() = runBlocking {
restore.startRestore(token, packageInfoArray) 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 every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
@ -154,11 +158,11 @@ internal class RestoreCoordinatorTest : TransportTest() {
} }
@Test @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) restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns true coEvery { full.hasDataForPackage(token, packageInfo) } returns true
every { full.initializeState(token, packageInfo) } just Runs every { full.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM) val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM)
@ -166,27 +170,27 @@ internal class RestoreCoordinatorTest : TransportTest() {
} }
@Test @Test
fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() { fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() = runBlocking {
restore.startRestore(token, packageInfoArray) restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } returns false coEvery { full.hasDataForPackage(token, packageInfo) } returns false
assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage())
} }
@Test @Test
fun `nextRestorePackage() returns all packages from startRestore()`() { fun `nextRestorePackage() returns all packages from startRestore()`() = runBlocking {
restore.startRestore(token, packageInfoArray2) 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 every { kv.initializeState(token, packageInfo) } just Runs
val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE)
assertEquals(expected, restore.nextRestorePackage()) assertEquals(expected, restore.nextRestorePackage())
every { kv.hasDataForPackage(token, packageInfo2) } returns false coEvery { kv.hasDataForPackage(token, packageInfo2) } returns false
every { full.hasDataForPackage(token, packageInfo2) } returns true coEvery { full.hasDataForPackage(token, packageInfo2) } returns true
every { full.initializeState(token, packageInfo2) } just Runs every { full.initializeState(token, packageInfo2) } just Runs
val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM)
@ -196,20 +200,20 @@ internal class RestoreCoordinatorTest : TransportTest() {
} }
@Test @Test
fun `when kv#hasDataForPackage() throws return null`() { fun `when kv#hasDataForPackage() throws return null`() = runBlocking {
restore.startRestore(token, packageInfoArray) restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } throws IOException() coEvery { kv.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage()) assertNull(restore.nextRestorePackage())
} }
@Test @Test
fun `when full#hasDataForPackage() throws return null`() { fun `when full#hasDataForPackage() throws return null`() = runBlocking {
restore.startRestore(token, packageInfoArray) restore.startRestore(token, packageInfoArray)
every { kv.hasDataForPackage(token, packageInfo) } returns false coEvery { kv.hasDataForPackage(token, packageInfo) } returns false
every { full.hasDataForPackage(token, packageInfo) } throws IOException() coEvery { full.hasDataForPackage(token, packageInfo) } throws IOException()
assertNull(restore.nextRestorePackage()) assertNull(restore.nextRestorePackage())
} }
@ -225,11 +229,11 @@ internal class RestoreCoordinatorTest : TransportTest() {
} }
@Test @Test
fun `getNextFullRestoreDataChunk() delegates to Full`() { fun `getNextFullRestoreDataChunk() delegates to Full`() = runBlocking {
val data = mockk<ParcelFileDescriptor>() val data = mockk<ParcelFileDescriptor>()
val result = Random.nextInt() val result = Random.nextInt()
every { full.getNextFullRestoreDataChunk(data) } returns result coEvery { full.getNextFullRestoreDataChunk(data) } returns result
assertEquals(result, restore.getNextFullRestoreDataChunk(data)) assertEquals(result, restore.getNextFullRestoreDataChunk(data))
} }