From 1b9a4feddd33ff5d82331b44212f14724327da4f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 31 Aug 2020 17:20:01 -0300 Subject: [PATCH] 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. --- .../Instrumentation_Tests.xml | 48 ++++++++++++ .../seedvault/CipherUniqueNonceTest.kt | 2 +- .../com/stevesoltys/seedvault/PluginTest.kt | 76 +++++++++++++------ .../plugins/saf/DocumentsStorageTest.kt | 3 +- .../java/com/stevesoltys/seedvault/App.kt | 27 ++++++- .../seedvault/metadata/MetadataManager.kt | 1 + .../saf/DocumentsProviderBackupPlugin.kt | 41 +++++----- .../saf/DocumentsProviderFullBackup.kt | 4 +- .../plugins/saf/DocumentsProviderKVBackup.kt | 6 +- .../saf/DocumentsProviderKVRestorePlugin.kt | 5 +- .../plugins/saf/DocumentsProviderModule.kt | 16 +++- .../saf/DocumentsProviderRestorePlugin.kt | 12 +-- .../seedvault/plugins/saf/DocumentsStorage.kt | 76 ++++++++++--------- .../seedvault/settings/SettingsManager.kt | 42 +++++----- .../transport/backup/BackupCoordinator.kt | 55 +++++++++----- .../transport/backup/BackupPlugin.kt | 14 +++- .../seedvault/transport/restore/KVRestore.kt | 2 +- .../transport/restore/KVRestorePlugin.kt | 2 +- .../transport/restore/RestoreCoordinator.kt | 15 ++-- .../ui/storage/BackupStorageViewModel.kt | 52 +++++++++---- .../seedvault/plugins/saf/BackupPluginTest.kt | 65 ++++++++++++++++ .../seedvault/plugins/saf/DocumentFileTest.kt | 43 +++++++++++ .../transport/CoordinatorIntegrationTest.kt | 6 +- .../transport/backup/BackupCoordinatorTest.kt | 28 ++++--- .../transport/restore/KVRestoreTest.kt | 4 +- .../restore/RestoreCoordinatorTest.kt | 4 +- 26 files changed, 454 insertions(+), 195 deletions(-) create mode 100644 .idea/runConfigurations/Instrumentation_Tests.xml create mode 100644 app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt create mode 100644 app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt diff --git a/.idea/runConfigurations/Instrumentation_Tests.xml b/.idea/runConfigurations/Instrumentation_Tests.xml new file mode 100644 index 00000000..0546643d --- /dev/null +++ b/.idea/runConfigurations/Instrumentation_Tests.xml @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt index e902b8e6..66687348 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/CipherUniqueNonceTest.kt @@ -1,8 +1,8 @@ package com.stevesoltys.seedvault import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.runner.AndroidJUnit4 import com.stevesoltys.seedvault.crypto.CipherFactoryImpl import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import org.junit.Assert.assertTrue diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt index 19fdc66c..7bbc04e9 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -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 { diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt index 5162ead7..3ea8999d 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorageTest.kt @@ -45,7 +45,7 @@ class DocumentsStorageTest : KoinComponent { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val metadataManager by inject() private val settingsManager by inject() - private val storage = DocumentsStorage(context, metadataManager, settingsManager) + private 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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 465ba7b8..afff3eed 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.ServiceManager.getService import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule +import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule import com.stevesoltys.seedvault.restore.RestoreViewModel @@ -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) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 03a6e079..bc3d37c5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -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 /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt index c3e38da3..0ba2a537 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderBackupPlugin.kt @@ -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) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt index e2bef69e..5ef450bf 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderFullBackup.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt index e81bcbd5..0da6066f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVBackup.kt @@ -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) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt index d62f6952..be57ca24 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderKVRestorePlugin.kt @@ -26,10 +26,11 @@ internal class DocumentsProviderKVRestorePlugin( } } - override fun listRecords(token: Long, packageInfo: PackageInfo): List { + @Throws(IOException::class) + override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List { val packageDir = this.packageDir ?: throw AssertionError() packageDir.assertRightFile(packageInfo) - return packageDir.listFiles() + return packageDir.listFilesBlocking(context) .filter { file -> file.name != null } .map { file -> file.name!! } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt index 66b22b7f..f2c310af 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderModule.kt @@ -1,12 +1,22 @@ package com.stevesoltys.seedvault.plugins.saf import com.stevesoltys.seedvault.transport.backup.BackupPlugin +import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin +import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin +import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin +import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val documentsProviderModule = module { - single { DocumentsStorage(androidContext(), get(), get()) } - single { DocumentsProviderBackupPlugin(androidContext(), get()) } - single { DocumentsProviderRestorePlugin(androidContext(), get()) } + single { DocumentsStorage(androidContext(), get()) } + + single { DocumentsProviderKVBackup(androidContext(), get()) } + single { DocumentsProviderFullBackup(androidContext(), get()) } + single { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) } + + single { DocumentsProviderKVRestorePlugin(androidContext(), get()) } + single { DocumentsProviderFullRestorePlugin(androidContext(), get()) } + single { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt index 4da189d4..88af5399 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt @@ -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() diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt index a76f76f8..d1e281d5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt @@ -1,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 { +suspend fun DocumentFile.listFilesBlocking(context: Context): List { val resolver = context.contentResolver val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) - val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) + val projection = arrayOf(COLUMN_DOCUMENT_ID) val result = ArrayList() try { @@ -229,20 +224,33 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList 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) + val documentUri = buildDocumentUriUsingTree(uri, documentId) + 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. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 3336cc39..2ac3cb44 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -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() diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 12f40c98..af6cc574 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -4,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,28 +84,27 @@ 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 { - Log.i(TAG, "Initialize Device!") - return try { - val token = clock.time() - if (plugin.initializeDevice(token)) { - Log.d(TAG, "Resetting backup metadata...") - plugin.getMetadataOutputStream().use { - metadataManager.onDeviceInitialization(token, it) - } - } else { - Log.d(TAG, "Storage was already initialized, doing no-op") + suspend fun initializeDevice(): Int = try { + val token = settingsManager.getToken() + if (token == null) { + Log.i(TAG, "No RestoreSet started, initialization is no-op.") + } else { + Log.i(TAG, "Initialize Device!") + plugin.initializeDevice() + Log.d(TAG, "Resetting backup metadata for token $token...") + plugin.getMetadataOutputStream().use { + metadataManager.onDeviceInitialization(token, it) } - // [finishBackup] will only be called when we return [TRANSPORT_OK] here - // so we remember that we initialized successfully - calledInitialize = true - TRANSPORT_OK - } catch (e: IOException) { - Log.e(TAG, "Error initializing device", e) - // Show error notification if we were ready for backups - if (getBackupBackoff() == 0L) nm.onBackupError() - TRANSPORT_ERROR } + // [finishBackup] will only be called when we return [TRANSPORT_OK] here + // so we remember that we initialized successfully + calledInitialize = true + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Error initializing device", e) + // Show error notification if we were ready for backups + if (getBackupBackoff() == 0L) nm.onBackupError() + TRANSPORT_ERROR } fun isAppEligibleForBackup( diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt index c8d36a58..f6ffdbca 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupPlugin.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.transport.backup +import android.app.backup.RestoreSet import android.content.pm.PackageInfo import java.io.IOException import java.io.OutputStream @@ -11,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. diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt index ef86a4a8..04f11b3e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestore.kt @@ -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? { + private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List? { val records: List = try { plugin.listRecords(token, packageInfo) } catch (e: IOException) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt index 2e0d2f7f..fcf85065 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/KVRestorePlugin.kt @@ -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 + suspend fun listRecords(token: Long, packageInfo: PackageInfo): List /** * Return an [InputStream] for the given token, package and key diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt index d0cb8363..2df92e93 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt @@ -13,7 +13,6 @@ import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log import androidx.collection.LongSparseArray -import com.stevesoltys.seedvault.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, + val token: Long, + val packages: Iterator, /** * 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") + } } /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index b78b5613..617ab24a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -8,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 app: Application, + private val backupManager: IBackupManager, + private val backupCoordinator: BackupCoordinator, + settingsManager: SettingsManager +) : StorageViewModel(app, settingsManager) { override val isRestoreOperation = false override fun onLocationSet(uri: Uri) { val isUsb = saveStorage(uri) - settingsManager.forceStorageInitialization() + viewModelScope.launch(Dispatchers.IO) { + try { + // will also generate a new backup token for the new restore set + backupCoordinator.startNewRestoreSet() - // initialize the new location, will also generate a new backup token - val observer = InitializationObserver() - backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer) - - // if storage is on USB and this is not SetupWizard, do a backup right away - if (isUsb && !isSetupWizard) Thread { - requestBackup(app) - }.start() + // initialize the new location + backupManager.initializeTransportsForUser( + UserHandle.myUserId(), + arrayOf(TRANSPORT_ID), + // if storage is on USB and this is not SetupWizard, do a backup right away + InitializationObserver(isUsb && !isSetupWizard) + ) + } catch (e: IOException) { + Log.e(TAG, "Error starting new RestoreSet", e) + onInitializationError() + } + } } @WorkerThread - private inner class InitializationObserver : IBackupObserver.Stub() { + private inner class InitializationObserver(val requestBackup: Boolean) : + IBackupObserver.Stub() { override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { // noop } @@ -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)) + } + } diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt new file mode 100644 index 00000000..61a56bd4 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/BackupPluginTest.kt @@ -0,0 +1,65 @@ +package com.stevesoltys.seedvault.plugins.saf + +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.seedvault.transport.backup.BackupTest +import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin +import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +@Suppress("BlockingMethodInNonBlockingContext") +internal class BackupPluginTest : BackupTest() { + + private val storage = mockk() + private val kvBackupPlugin: KVBackupPlugin = mockk() + private val fullBackupPlugin: FullBackupPlugin = mockk() + + private val plugin = DocumentsProviderBackupPlugin( + context, + storage, + kvBackupPlugin, + fullBackupPlugin + ) + + private val setDir: DocumentFile = mockk() + private val kvDir: DocumentFile = mockk() + private val fullDir: DocumentFile = mockk() + + init { + // to mock extension functions on DocumentFile + mockkStatic("com.stevesoltys.seedvault.plugins.saf.DocumentsStorageKt") + } + + @Test + fun `test startNewRestoreSet`() = runBlocking { + every { storage.reset(token) } just Runs + every { storage getProperty "rootBackupDir" } returns setDir + + plugin.startNewRestoreSet(token) + } + + @Test + fun `test initializeDevice`() = runBlocking { + // get current set dir and for that the current token + every { storage getProperty "currentToken" } returns token + every { settingsManager.getToken() } returns token + coEvery { storage.getSetDir(token) } returns setDir + // delete contents of current set dir + coEvery { setDir.listFilesBlocking(context) } returns listOf(kvDir) + every { kvDir.delete() } returns true + // reset storage + every { storage.reset(null) } just Runs + // create kv and full dir + every { storage getProperty "currentKvBackupDir" } returns kvDir + every { storage getProperty "currentFullBackupDir" } returns fullDir + + plugin.initializeDevice() + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt new file mode 100644 index 00000000..47bddffb --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/DocumentFileTest.kt @@ -0,0 +1,43 @@ +package com.stevesoltys.seedvault.plugins.saf + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.stopKoin + +@RunWith(AndroidJUnit4::class) +internal class DocumentFileTest { + + private val context: Context = mockk() + private val parentUri: Uri = Uri.parse( + "content://com.android.externalstorage.documents/tree/" + + "primary%3A/document/primary%3A.SeedVaultAndroidBackup" + ) + private val parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!! + private val uri: Uri = Uri.parse( + "content://com.android.externalstorage.documents/tree/" + + "primary%3A/document/primary%3A.SeedVaultAndroidBackup%2Ftest" + ) + + @After + fun afterEachTest() { + stopKoin() + } + + @Test + fun `test ugly getTreeDocumentFile reflection hack`() { + assertTrue(DocumentsContract.isTreeUri(uri)) + val file = getTreeDocumentFile(parentFile, context, uri) + assertEquals(uri, file.uri) + assertEquals(parentFile, file.parentFile) + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 6d4a8fbb..26347c13 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -7,7 +7,6 @@ import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription.TYPE_FULL_STREAM import android.os.ParcelFileDescriptor -import com.stevesoltys.seedvault.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() 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() 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( diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 6bbe5c0f..8ed92841 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -8,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() val documentFile = mockk() - 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 diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt index e63c69da..1298543c 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/KVRestoreTest.kt @@ -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 = listOf(key64)) { - every { plugin.listRecords(token, packageInfo) } returns recordKeys + coEvery { plugin.listRecords(token, packageInfo) } returns recordKeys every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt index 6734e020..ac93ad51 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt @@ -9,7 +9,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()) }