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:
parent
131c5b6b29
commit
18d83767b3
41 changed files with 695 additions and 428 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -7,7 +7,8 @@ hs_err_pid*
|
||||||
## Intellij
|
## Intellij
|
||||||
out/
|
out/
|
||||||
lib/
|
lib/
|
||||||
.idea/
|
.idea/*
|
||||||
|
!.idea/runConfigurations*
|
||||||
*.ipr
|
*.ipr
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
|
|
12
.idea/runConfigurations.xml
Normal file
12
.idea/runConfigurations.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
17
.idea/runConfigurations/Unit_Tests.xml
Normal file
17
.idea/runConfigurations/Unit_Tests.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
|
||||||
|
<module name="app" />
|
||||||
|
<useClassPathOnly />
|
||||||
|
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
|
||||||
|
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />
|
||||||
|
<option name="MAIN_CLASS_NAME" value="" />
|
||||||
|
<option name="METHOD_NAME" value="" />
|
||||||
|
<option name="TEST_OBJECT" value="directory" />
|
||||||
|
<option name="PARAMETERS" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
|
||||||
|
<dir value="$PROJECT_DIR$/app/src/test/java/com/stevesoltys/seedvault" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -23,6 +23,7 @@ private class FullRestoreState(
|
||||||
|
|
||||||
private val TAG = FullRestore::class.java.simpleName
|
private val TAG = FullRestore::class.java.simpleName
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class FullRestore(
|
internal class FullRestore(
|
||||||
private val plugin: FullRestorePlugin,
|
private val plugin: FullRestorePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -37,7 +38,7 @@ internal class FullRestore(
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
|
||||||
return plugin.hasDataForPackage(token, packageInfo)
|
return plugin.hasDataForPackage(token, packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,7 +79,7 @@ internal class FullRestore(
|
||||||
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
|
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition
|
||||||
* that aborts all further restore operations on the current dataset.
|
* that aborts all further restore operations on the current dataset.
|
||||||
*/
|
*/
|
||||||
fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
|
suspend fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
|
||||||
val state = this.state ?: throw IllegalStateException("no state")
|
val state = this.state ?: throw IllegalStateException("no state")
|
||||||
val packageName = state.packageInfo.packageName
|
val packageName = state.packageInfo.packageName
|
||||||
|
|
||||||
|
@ -113,6 +114,7 @@ internal class FullRestore(
|
||||||
try {
|
try {
|
||||||
// read segment from input stream and decrypt it
|
// read segment from input stream and decrypt it
|
||||||
val decrypted = try {
|
val decrypted = try {
|
||||||
|
// TODO handle IOException
|
||||||
crypto.decryptSegment(inputStream)
|
crypto.decryptSegment(inputStream)
|
||||||
} catch (e: EOFException) {
|
} catch (e: EOFException) {
|
||||||
Log.i(TAG, " EOF")
|
Log.i(TAG, " EOF")
|
||||||
|
|
|
@ -10,9 +10,9 @@ interface FullRestorePlugin {
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
|
suspend fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue