Clean up backup transport initialization logic

This commit makes creating new RestoreSets explicit.
Initializing a backup transport now actually cleans its data as the AOSP
documentation demands. This should be fine as we usually do a fresh
backup after a new initialization.
Contrary to before, an initialization does not create new RestoreSets
anymore, but works within the existing set. For now, only manually
choosing a new storage location creates a new RestoreSet.
This commit is contained in:
Torsten Grote 2020-08-31 17:20:01 -03:00 committed by Chirayu Desai
parent 80187c8c70
commit 1b9a4feddd
26 changed files with 454 additions and 195 deletions

View file

@ -0,0 +1,48 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="app" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="-e notAnnotation androidx.test.filters.LargeTest" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

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

View file

@ -3,8 +3,11 @@ package com.stevesoltys.seedvault
import androidx.test.core.content.pm.PackageInfoBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH
@ -12,6 +15,10 @@ import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH_NEXTCLOUD
import com.stevesoltys.seedvault.plugins.saf.deleteContents
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.every
import io.mockk.mockk
@ -35,27 +42,41 @@ import kotlin.random.Random
class PluginTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager: MetadataManager by inject()
private val settingsManager: SettingsManager by inject()
private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager)
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(context, storage)
private val restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage)
private val storage = DocumentsStorage(context, mockedSettingsManager)
private val kvBackupPlugin: KVBackupPlugin = DocumentsProviderKVBackup(context, storage)
private val fullBackupPlugin: FullBackupPlugin = DocumentsProviderFullBackup(context, storage)
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(
context,
storage,
kvBackupPlugin,
fullBackupPlugin
)
private val kvRestorePlugin: KVRestorePlugin =
DocumentsProviderKVRestorePlugin(context, storage)
private val fullRestorePlugin: FullRestorePlugin =
DocumentsProviderFullRestorePlugin(context, storage)
private val restorePlugin: RestorePlugin =
DocumentsProviderRestorePlugin(context, storage, kvRestorePlugin, fullRestorePlugin)
private val token = Random.nextLong()
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
@Before
fun setup() {
fun setup() = runBlocking {
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
storage.rootBackupDir?.deleteContents()
storage.rootBackupDir?.deleteContents(context)
?: error("Select a storage location in the app first!")
}
@After
fun tearDown() {
storage.rootBackupDir?.deleteContents()
fun tearDown() = runBlocking {
storage.rootBackupDir?.deleteContents(context)
Unit
}
@Test
@ -77,13 +98,12 @@ class PluginTest : KoinComponent {
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
assertFalse(restorePlugin.hasBackup(uri))
// define storage changing state for later
every {
mockedSettingsManager.getAndResetIsStorageChanging()
} returns true andThen true andThen false
// prepare returned tokens requested when initializing device
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
// device needs initialization, because new and storage is changing
assertTrue(backupPlugin.initializeDevice(newToken = token))
// start new restore set and initialize device afterwards
backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata (needed for backup to be recognized)
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
@ -92,22 +112,29 @@ class PluginTest : KoinComponent {
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (while changing storage) does add a restore set
assertTrue(backupPlugin.initializeDevice(newToken = token + 1))
// initializing again (with another restore set) does add a restore set
backupPlugin.startNewRestoreSet(token + 1)
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (without changing storage) doesn't change number of restore sets
assertFalse(backupPlugin.initializeDevice(newToken = token + 2))
// initializing again (without new restore set) doesn't change number of restore sets
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
// ensure that the new backup dirs exist
assertTrue(storage.currentKvBackupDir!!.exists())
assertTrue(storage.currentFullBackupDir!!.exists())
}
@Test
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false
assertTrue(backupPlugin.initializeDevice(newToken = token))
every { mockedSettingsManager.getToken() } returns token
backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata
val metadata = getRandomByteArray()
@ -124,7 +151,8 @@ class PluginTest : KoinComponent {
assertReadEquals(metadata, availableBackups[0].inputStream)
// initializing again (without changing storage) keeps restore set with same token
assertFalse(backupPlugin.initializeDevice(newToken = token + 1))
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
availableBackups = restorePlugin.getAvailableBackups()?.toList()
check(availableBackups != null)
assertEquals(1, availableBackups.size)
@ -311,8 +339,8 @@ class PluginTest : KoinComponent {
}
private fun initStorage(token: Long) = runBlocking {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true
assertTrue(backupPlugin.initializeDevice(newToken = token))
every { mockedSettingsManager.getToken() } returns token
backupPlugin.initializeDevice()
}
private fun isNextcloud(): Boolean {

View file

@ -45,7 +45,7 @@ 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 storage = DocumentsStorage(context, settingsManager)
private val filename = getRandomBase64()
private lateinit var file: DocumentFile
@ -96,6 +96,7 @@ class DocumentsStorageTest : KoinComponent {
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
assertNotNull(foundFile)
assertEquals(filename, foundFile!!.name)
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
}
@Test

View file

@ -8,6 +8,7 @@ import android.os.Build
import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.restore.RestoreViewModel
@ -19,6 +20,7 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
@ -39,7 +41,7 @@ class App : Application() {
viewModel { SettingsViewModel(this@App, get(), get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
}
@ -49,7 +51,8 @@ class App : Application() {
startKoin {
androidLogger()
androidContext(this@App)
modules(listOf(
modules(
listOf(
cryptoModule,
headerModule,
metadataModule,
@ -57,7 +60,25 @@ class App : Application() {
backupModule,
restoreModule,
appModule
))
)
)
}
migrateTokenFromMetadataToSettingsManager()
}
private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject()
/**
* The responsibility for the current token was moved to the [SettingsManager]
* in the end of 2020.
* This method migrates the token for existing installs and can be removed
* after sufficient time has passed.
*/
private fun migrateTokenFromMetadataToSettingsManager() {
val token = metadataManager.getBackupToken()
if (token != 0L && settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
}

View file

@ -178,6 +178,7 @@ class MetadataManager(
* If the token is 0L, it is not yet initialized and must not be used for anything.
*/
@Synchronized
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
fun getBackupToken(): Long = metadata.token
/**

View file

@ -14,41 +14,34 @@ private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderBackupPlugin(
private val context: Context,
private val storage: DocumentsStorage
private val storage: DocumentsStorage,
override val kvBackupPlugin: KVBackupPlugin,
override val fullBackupPlugin: FullBackupPlugin
) : BackupPlugin {
private val packageManager: PackageManager = context.packageManager
override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage, context)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage, context)
}
@Throws(IOException::class)
override suspend fun initializeDevice(newToken: Long): Boolean {
// check if storage is already initialized
if (storage.isInitialized()) return false
// TODO consider not creating new RestoreSets, but continue working within the existing one.
override suspend fun startNewRestoreSet(token: Long) {
// reset current storage
storage.reset(newToken)
storage.reset(token)
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
}
@Throws(IOException::class)
override suspend fun initializeDevice() {
// wipe existing data
storage.getSetDir()?.deleteContents(context)
// reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around
storage.reset(null)
// create backup folders
val kvDir = storage.currentKvBackupDir
val fullDir = storage.currentFullBackupDir
// wipe existing data
storage.getSetDir()?.findFileBlocking(context, FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
return true
storage.currentKvBackupDir ?: throw IOException()
storage.currentFullBackupDir ?: throw IOException()
}
@Throws(IOException::class)

View file

@ -12,8 +12,8 @@ private val TAG = DocumentsProviderFullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage,
private val context: Context
private val context: Context,
private val storage: DocumentsStorage
) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP

View file

@ -14,8 +14,8 @@ const val MAX_KEY_LENGTH_NEXTCLOUD = 225
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderKVBackup(
private val storage: DocumentsStorage,
private val context: Context
private val context: Context,
private val storage: DocumentsStorage
) : KVBackupPlugin {
private var packageFile: DocumentFile? = null
@ -27,7 +27,7 @@ internal class DocumentsProviderKVBackup(
val packageFile =
storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName)
?: return false
return packageFile.listFiles().isNotEmpty()
return packageFile.listFilesBlocking(context).isNotEmpty()
}
@Throws(IOException::class)

View file

@ -26,10 +26,11 @@ internal class DocumentsProviderKVRestorePlugin(
}
}
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
@Throws(IOException::class)
override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo)
return packageDir.listFiles()
return packageDir.listFilesBlocking(context)
.filter { file -> file.name != null }
.map { file -> file.name!! }
}

View file

@ -1,12 +1,22 @@
package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
single { DocumentsStorage(androidContext(), get()) }
single<KVBackupPlugin> { DocumentsProviderKVBackup(androidContext(), get()) }
single<FullBackupPlugin> { DocumentsProviderFullBackup(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) }
single<KVRestorePlugin> { DocumentsProviderKVRestorePlugin(androidContext(), get()) }
single<FullRestorePlugin> { DocumentsProviderFullRestorePlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) }
}

View file

@ -19,17 +19,11 @@ private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
internal class DocumentsProviderRestorePlugin(
private val context: Context,
private val storage: DocumentsStorage
private val storage: DocumentsStorage,
override val kvRestorePlugin: KVRestorePlugin,
override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(context, storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(context, storage)
}
@Throws(IOException::class)
override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()

View file

@ -1,4 +1,4 @@
@file:Suppress("EXPERIMENTAL_API_USAGE", "BlockingMethodInNonBlockingContext")
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf
@ -9,17 +9,13 @@ import android.database.Cursor
import android.net.Uri
import android.os.FileUtils.closeQuietly
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import kotlinx.coroutines.TimeoutCancellationException
@ -42,7 +38,6 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager
) {
@ -73,9 +68,9 @@ internal class DocumentsStorage(
field
}
private var currentToken: Long = 0L
private var currentToken: Long? = null
get() {
if (field == 0L) field = metadataManager.getBackupToken()
if (field == null) field = settingsManager.getToken()
return field
}
@ -119,14 +114,10 @@ internal class DocumentsStorage(
field
}
fun isInitialized(): Boolean {
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
return kvEmpty && fullEmpty
}
fun reset(newToken: Long) {
/**
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
*/
fun reset(newToken: Long?) {
storage = null
currentToken = newToken
rootBackupDir = null
@ -138,26 +129,28 @@ internal class DocumentsStorage(
fun getAuthority(): String? = storage?.uri?.authority
@Throws(IOException::class)
suspend fun getSetDir(token: Long = currentToken): DocumentFile? {
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentSetDir
return rootBackupDir?.findFileBlocking(context, token.toString())
}
@Throws(IOException::class)
suspend fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
suspend fun getKVBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
suspend fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile {
suspend fun getOrCreateKVBackupDir(
token: Long = currentToken ?: error("no token")
): DocumentFile {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
suspend fun getFullBackupDir(token: Long = currentToken): DocumentFile? {
suspend fun getFullBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
}
@ -195,12 +188,14 @@ internal suspend fun DocumentFile.createOrGetFile(
*/
@Throws(IOException::class)
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectory(name) ?: throw IOException()
return findFileBlocking(context, name) ?: createDirectory(name)?.apply {
check(this.name == name) { "Directory named ${this.name}, but should be $name" }
} ?: throw IOException()
}
@Throws(IOException::class)
fun DocumentFile.deleteContents() {
for (file in listFiles()) file.delete()
suspend fun DocumentFile.deleteContents(context: Context) {
for (file in listFilesBlocking(context)) file.delete()
}
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
@ -214,10 +209,10 @@ fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
* This prevents getting an empty list even though there are children to be listed.
*/
@Throws(IOException::class)
suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE)
val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>()
try {
@ -229,20 +224,33 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<Document
}.use { cursor ->
while (cursor.moveToNext()) {
val documentId = cursor.getString(0)
val isDirectory = cursor.getString(1) == MIME_TYPE_DIR
val file = if (isDirectory) {
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!!
} else {
val documentUri = buildDocumentUriUsingTree(uri, documentId)
DocumentFile.fromSingleUri(context, documentUri)!!
}
result.add(file)
result.add(getTreeDocumentFile(this, context, documentUri))
}
}
return result
}
/**
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
*
* All other public ways to get a TreeDocumentFile only work from [Uri]s
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
*
* We have a test for this method to ensure CI will alert us when this reflection breaks.
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
*/
@VisibleForTesting
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
@SuppressWarnings("MagicNumber")
val constructor = parent.javaClass.declaredConstructors.find {
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
}
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
constructor.isAccessible = true
return constructor.newInstance(parent, context, uri) as DocumentFile
}
/**
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
*

View file

@ -1,16 +1,15 @@
package com.stevesoltys.seedvault.settings
import android.app.backup.RestoreSet
import android.content.Context
import android.hardware.usb.UsbDevice
import android.net.Uri
import androidx.annotation.UiThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet
import java.util.concurrent.atomic.AtomicBoolean
internal const val PREF_KEY_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
private const val PREF_KEY_STORAGE_URI = "storageUri"
@ -28,7 +27,8 @@ class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false)
@Volatile
private var token: Long? = null
/**
* This gets accessed by non-UI threads when saving with [PreferenceManager]
@ -39,6 +39,21 @@ class SettingsManager(context: Context) {
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
}
fun getToken(): Long? = token ?: {
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
if (value == 0L) null else value
}()
/**
* Sets a new RestoreSet token.
* Should only be called by the [BackupCoordinator]
* to ensure that related work is performed after moving to a new token.
*/
fun setNewToken(newToken: Long) {
prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
token = newToken
}
// FIXME Storage is currently plugin specific and not generic
fun setStorage(storage: Storage) {
prefs.edit()
@ -57,25 +72,6 @@ class SettingsManager(context: Context) {
return Storage(uri, name, isUsb)
}
/**
* When [ConfigurableBackupTransport.initializeDevice] we try to avoid deleting all stored data,
* as this gets frequently called after network errors by SAF cloud providers.
*
* This method allows us to force a re-initialization of the underlying storage root
* when we change to a new storage provider.
* Currently, this causes us to create a new [RestoreSet].
*
* As part of the initialization, [getAndResetIsStorageChanging] should get called
* to prevent future calls from causing re-initializations.
*/
fun forceStorageInitialization() {
isStorageChanging.set(true)
}
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) {
prefs.edit()

View file

@ -4,13 +4,13 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.app.backup.RestoreSet
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager
@ -20,6 +20,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
@ -52,6 +53,19 @@ internal class BackupCoordinator(
// Transport initialization and quota
//
/**
* Starts a new [RestoreSet] with a new token (the current unix epoch in milliseconds).
* Call this at least once before calling [initializeDevice]
* which must be called after this method to properly initialize the backup transport.
*/
@Throws(IOException::class)
suspend fun startNewRestoreSet() {
val token = clock.time()
Log.i(TAG, "Starting new RestoreSet with token $token...")
settingsManager.setNewToken(token)
plugin.startNewRestoreSet(token)
}
/**
* Initialize the storage for this device, erasing all stored data.
* The transport may send the request immediately, or may buffer it.
@ -70,17 +84,17 @@ internal class BackupCoordinator(
* @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure).
*/
suspend fun initializeDevice(): Int {
suspend fun initializeDevice(): Int = try {
val token = settingsManager.getToken()
if (token == null) {
Log.i(TAG, "No RestoreSet started, initialization is no-op.")
} else {
Log.i(TAG, "Initialize Device!")
return try {
val token = clock.time()
if (plugin.initializeDevice(token)) {
Log.d(TAG, "Resetting backup metadata...")
plugin.initializeDevice()
Log.d(TAG, "Resetting backup metadata for token $token...")
plugin.getMetadataOutputStream().use {
metadataManager.onDeviceInitialization(token, it)
}
} else {
Log.d(TAG, "Storage was already initialized, doing no-op")
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
@ -92,7 +106,6 @@ internal class BackupCoordinator(
if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR
}
}
fun isAppEligibleForBackup(
targetPackage: PackageInfo,

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import java.io.IOException
import java.io.OutputStream
@ -11,13 +12,18 @@ interface BackupPlugin {
val fullBackupPlugin: FullBackupPlugin
/**
* Initialize the storage for this device, erasing all stored data.
* Start a new [RestoreSet] with the given token.
*
* @return true if the device needs initialization or
* false if the device was initialized already and initialization should be a no-op.
* This is typically followed by a call to [initializeDevice].
*/
@Throws(IOException::class)
suspend fun initializeDevice(newToken: Long): Boolean
suspend fun startNewRestoreSet(token: Long)
/**
* Initialize the storage for this device, erasing all stored data in the current [RestoreSet].
*/
@Throws(IOException::class)
suspend fun initializeDevice()
/**
* Returns an [OutputStream] for writing backup metadata.

View file

@ -112,7 +112,7 @@ internal class KVRestore(
* Return a list of the records (represented by key files) in the given directory,
* sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
*/
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
val records: List<String> = try {
plugin.listRecords(token, packageInfo)
} catch (e: IOException) {

View file

@ -20,7 +20,7 @@ interface KVRestorePlugin {
* For file-based plugins, this is usually a list of file names in the package directory.
*/
@Throws(IOException::class)
fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
/**
* Return an [InputStream] for the given token, package and key

View file

@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.header.UnsupportedVersionException
@ -22,18 +21,19 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
private class RestoreCoordinatorState(
internal val token: Long,
internal val packages: Iterator<PackageInfo>,
val token: Long,
val packages: Iterator<PackageInfo>,
/**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/
internal val pmPackageInfo: PackageInfo?
val pmPackageInfo: PackageInfo?
) {
internal var currentPackage: String? = null
var currentPackage: String? = null
}
private val TAG = RestoreCoordinator::class.java.simpleName
@ -106,8 +106,9 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long {
return metadataManager.getBackupToken()
.apply { Log.i(TAG, "Got current restore set token: $this") }
return (settingsManager.getToken() ?: 0L).apply {
Log.i(TAG, "Got current restore set token: $this")
}
}
/**

View file

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

View file

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

View file

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

View file

@ -7,7 +7,6 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.CryptoImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
@ -35,6 +34,7 @@ import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.OutputFactory
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.coEvery
@ -186,7 +186,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
coEvery {
kvRestorePlugin.getInputStreamForRecord(
@ -255,7 +255,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
coEvery {
kvRestorePlugin.getInputStreamForRecord(

View file

@ -8,7 +8,6 @@ import android.content.pm.PackageInfo
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
@ -18,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
@ -63,9 +63,18 @@ internal class BackupCoordinatorTest : BackupTest() {
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
fun `starting a new restore set works as expected`() = runBlocking {
every { clock.time() } returns token
coEvery { plugin.initializeDevice(token) } returns true // TODO test when false
every { settingsManager.setNewToken(token) } just Runs
coEvery { plugin.startNewRestoreSet(token) } just Runs
backup.startNewRestoreSet()
}
@Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } just Runs
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
every { kv.hasState() } returns false
@ -79,9 +88,8 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@Test
fun `device initialization does no-op when already initialized`() = runBlocking {
every { clock.time() } returns token
coEvery { plugin.initializeDevice(token) } returns false
fun `device initialization does no-op when no token available`() = runBlocking {
every { settingsManager.getToken() } returns null
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -91,8 +99,8 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test
fun `error notification when device initialization fails`() = runBlocking {
every { clock.time() } returns token
coEvery { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs
@ -112,8 +120,8 @@ internal class BackupCoordinatorTest : BackupTest() {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { clock.time() } returns token
coEvery { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile

View file

@ -56,7 +56,7 @@ internal class KVRestoreTest : RestoreTest() {
fun `listing records throws`() = runBlocking {
restore.initializeState(token, packageInfo)
every { plugin.listRecords(token, packageInfo) } throws IOException()
coEvery { plugin.listRecords(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
}
@ -208,7 +208,7 @@ internal class KVRestoreTest : RestoreTest() {
}
private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
every { plugin.listRecords(token, packageInfo) } returns recordKeys
coEvery { plugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
}

View file

@ -9,7 +9,6 @@ import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata
@ -18,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
@ -91,7 +91,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
every { metadataManager.getBackupToken() } returns token
every { settingsManager.getToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet())
}