diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index c00438f2..ce730a04 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -41,19 +41,20 @@ class KoinInstrumentationTestApp : App() { viewModel { currentRestoreViewModel = - spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get())) + spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get())) currentRestoreViewModel!! } viewModel { - currentBackupStorageViewModel = - spyk(BackupStorageViewModel(context, get(), get(), get(), get())) + val viewModel = + BackupStorageViewModel(context, get(), get(), get(), get(), get(), get(), get()) + currentBackupStorageViewModel = spyk(viewModel) currentBackupStorageViewModel!! } viewModel { currentRestoreStorageViewModel = - spyk(RestoreStorageViewModel(context, get(), get())) + spyk(RestoreStorageViewModel(context, get(), get(), get(), get())) currentRestoreStorageViewModel!! } } diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt index 39135ed3..39e4be9e 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/PluginTest.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault +import android.net.Uri import androidx.test.core.content.pm.PackageInfoBuilder import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest @@ -28,20 +29,24 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject @RunWith(AndroidJUnit4::class) -@Suppress("BlockingMethodInNonBlockingContext") @MediumTest class PluginTest : KoinComponent { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val settingsManager: SettingsManager by inject() private val mockedSettingsManager: SettingsManager = mockk() - private val storage = DocumentsStorage(context, mockedSettingsManager) + private val storage = DocumentsStorage( + appContext = context, + settingsManager = mockedSettingsManager, + safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"), + ) - private val storagePlugin: StoragePlugin = DocumentsProviderStoragePlugin(context, storage) + private val storagePlugin: StoragePlugin = DocumentsProviderStoragePlugin(context, storage) @Suppress("Deprecation") - private val legacyStoragePlugin: LegacyStoragePlugin = - DocumentsProviderLegacyPlugin(context, storage) + private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) { + storage + } private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build() @@ -76,8 +81,6 @@ class PluginTest : KoinComponent { fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) { // no backups available initially assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size) - val s = settingsManager.getSafStorage() ?: error("no storage") - assertFalse(storagePlugin.hasBackup(s)) // prepare returned tokens requested when initializing device every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1) @@ -92,7 +95,6 @@ class PluginTest : KoinComponent { // one backup available now assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size) - assertTrue(storagePlugin.hasBackup(s)) // initializing again (with another restore set) does add a restore set storagePlugin.startNewRestoreSet(token + 1) @@ -100,7 +102,6 @@ class PluginTest : KoinComponent { storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA) .writeAndClose(getRandomByteArray()) assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size) - assertTrue(storagePlugin.hasBackup(s)) // initializing again (without new restore set) doesn't change number of restore sets storagePlugin.initializeDevice() 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 353d7680..77375cbf 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 @@ -39,13 +39,16 @@ import java.io.IOException import kotlin.random.Random @RunWith(AndroidJUnit4::class) -@Suppress("BlockingMethodInNonBlockingContext") @MediumTest class DocumentsStorageTest : KoinComponent { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val settingsManager by inject() - private val storage = DocumentsStorage(context, settingsManager) + private val storage = DocumentsStorage( + appContext = context, + settingsManager = settingsManager, + safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"), + ) private val filename = getRandomBase64() private lateinit var file: DocumentFile diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt index ca54566d..d49ea9c6 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt @@ -24,7 +24,7 @@ class PackageServiceTest : KoinComponent { private val settingsManager: SettingsManager by inject() - private val storagePlugin: StoragePlugin by inject() + private val storagePlugin: StoragePlugin<*> by inject() @Test fun testNotAllowedPackages() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 372c86fc..385985d6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -13,13 +13,14 @@ import android.os.StrictMode import android.os.UserHandle import android.os.UserManager import android.provider.Settings -import androidx.work.WorkManager import androidx.work.ExistingPeriodicWorkPolicy.UPDATE +import androidx.work.WorkManager 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.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.settings.AppListRetriever @@ -54,15 +55,41 @@ open class App : Application() { private val appModule = module { single { SettingsManager(this@App) } single { BackupNotificationManager(this@App) } + single { StoragePluginManager(this@App, get(), get()) } single { Clock() } factory { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory { AppListRetriever(this@App, get(), get(), get()) } - viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } + viewModel { + SettingsViewModel( + app = this@App, + settingsManager = get(), + keyManager = get(), + pluginManager = get(), + metadataManager = get(), + appListRetriever = get(), + storageBackup = get(), + backupManager = get(), + backupInitializer = get(), + ) + } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } - viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) } - viewModel { RestoreStorageViewModel(this@App, get(), get()) } - viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) } + viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get(), get(), get()) } + viewModel { RestoreStorageViewModel(this@App, get(), get(), get()) } + viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } + viewModel { + BackupStorageViewModel( + app = this@App, + backupManager = get(), + backupInitializer = get(), + storageBackup = get(), + safHandler = get(), + settingsManager = get(), + storagePluginManager = get(), + ) + } + viewModel { RestoreStorageViewModel(this@App, get(), get(), get()) } + viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } viewModel { FileSelectionViewModel(this@App, get()) } } @@ -100,7 +127,7 @@ open class App : Application() { cryptoModule, headerModule, metadataModule, - documentsProviderModule, // storage plugin + storagePluginModuleSaf, // storage plugin backupModule, restoreModule, installModule, diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt index b7519cae..f5109add 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePlugin.kt @@ -1,13 +1,11 @@ package com.stevesoltys.seedvault.plugins import android.app.backup.RestoreSet -import androidx.annotation.WorkerThread -import com.stevesoltys.seedvault.plugins.saf.SafStorage import java.io.IOException import java.io.InputStream import java.io.OutputStream -interface StoragePlugin { +interface StoragePlugin { /** * Returns true if the plugin is working, or false if it isn't. @@ -53,14 +51,6 @@ interface StoragePlugin { @Throws(IOException::class) suspend fun removeData(token: Long, name: String) - /** - * Searches if there's really a backup available in the given storage location. - * Returns true if at least one was found and false otherwise. - */ - @WorkerThread - @Throws(IOException::class) - suspend fun hasBackup(safStorage: SafStorage): Boolean - /** * Get the set of all backups currently available for restore. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt new file mode 100644 index 00000000..b69d8af4 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt @@ -0,0 +1,137 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import androidx.annotation.WorkerThread +import com.stevesoltys.seedvault.getStorageContext +import com.stevesoltys.seedvault.permitDiskReads +import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin +import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage +import com.stevesoltys.seedvault.plugins.saf.SafFactory +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.settings.StoragePluginEnum + +abstract class StorageProperties { + abstract val config: T + abstract val name: String + abstract val isUsb: Boolean + abstract val requiresNetwork: Boolean + + @WorkerThread + abstract fun isUnavailableUsb(context: Context): Boolean + + /** + * Returns true if this is storage that requires network access, + * but it isn't available right now. + */ + fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { + return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) + } + + private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean { + val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false + val isMetered = cm.isActiveNetworkMetered + val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false + return capabilities.hasCapability(NET_CAPABILITY_INTERNET) && + (allowMetered || !isMetered) + } +} + +class StoragePluginManager( + private val context: Context, + private val settingsManager: SettingsManager, + safFactory: SafFactory, +) { + + private var _appPlugin: StoragePlugin<*>? + private var _filesPlugin: org.calyxos.backup.storage.api.StoragePlugin? + private var _storageProperties: StorageProperties<*>? + + val appPlugin: StoragePlugin<*> + @Synchronized + get() { + return _appPlugin ?: error("App plugin was loaded, but still null") + } + + val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin + @Synchronized + get() { + return _filesPlugin ?: error("Files plugin was loaded, but still null") + } + + val storageProperties: StorageProperties<*>? + @Synchronized + get() { + return _storageProperties + } + + init { + when (settingsManager.storagePluginType) { + StoragePluginEnum.SAF -> { + val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved") + val documentsStorage = DocumentsStorage(context, settingsManager, safStorage) + _appPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage) + _filesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage) + _storageProperties = safStorage + } + + null -> { + _appPlugin = null + _filesPlugin = null + _storageProperties = null + } + } + } + + fun isValidAppPluginSet(): Boolean { + if (_appPlugin == null || _filesPlugin == null) return false + if (_appPlugin is DocumentsProviderStoragePlugin) { + val storage = settingsManager.getSafStorage() ?: return false + if (storage.isUsb) return true + return permitDiskReads { + storage.getDocumentFile(context).isDirectory + } + } + return true + } + + /** + * Changes the storage plugins and current [StorageProperties]. + * + * IMPORTANT: Do no call this while current plugins are being used, + * e.g. while backup/restore operation is still running. + */ + fun changePlugins( + storageProperties: StorageProperties, + appPlugin: StoragePlugin, + filesPlugin: org.calyxos.backup.storage.api.StoragePlugin, + ) { + settingsManager.setStoragePlugin(appPlugin) + _storageProperties = storageProperties + _appPlugin = appPlugin + _filesPlugin = filesPlugin + } + + /** + * Check if we are able to do backups now by examining possible pre-conditions + * such as plugged-in flash drive or internet access. + * + * Should be run off the UI thread (ideally I/O) because of disk access. + * + * @return true if a backup is possible, false if not. + */ + @WorkerThread + fun canDoBackupNow(): Boolean { + val storage = storageProperties ?: return false + val systemContext = context.getStorageContext { storage.isUsb } + return !storage.isUnavailableUsb(systemContext) && + !storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt index 1a1d075f..e5f4eb64 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderLegacyPlugin.kt @@ -10,12 +10,13 @@ import java.io.IOException import java.io.InputStream @WorkerThread -@Suppress("BlockingMethodInNonBlockingContext", "Deprecation") // all methods do I/O +@Suppress("Deprecation") internal class DocumentsProviderLegacyPlugin( private val context: Context, - private val storage: DocumentsStorage, + private val storageGetter: () -> DocumentsStorage, ) : LegacyStoragePlugin { + private val storage get() = storageGetter() private var packageDir: DocumentFile? = null private var packageChildren: List? = null 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 638ee990..4c6590e3 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,14 +1,22 @@ package com.stevesoltys.seedvault.plugins.saf import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.settings.SettingsManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module -val documentsProviderModule = module { - single { DocumentsStorage(androidContext(), get()) } +val storagePluginModuleSaf = module { + single { SafFactory(androidContext(), get(), get()) } + single { SafHandler(androidContext(), get(), get(), get()) } - single { DocumentsProviderStoragePlugin(androidContext(), get()) } @Suppress("Deprecation") - single { DocumentsProviderLegacyPlugin(androidContext(), get()) } + single { + DocumentsProviderLegacyPlugin( + context = androidContext(), + storageGetter = { + val safStorage = get().getSafStorage() ?: error("No SAF storage") + DocumentsStorage(androidContext(), get(), safStorage) + }, + ) + } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt index 9f3dab51..e1d88f54 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.plugins.saf import android.content.Context import android.content.pm.PackageManager +import android.net.Uri import android.util.Log import androidx.documentfile.provider.DocumentFile import com.stevesoltys.seedvault.getStorageContext @@ -16,19 +17,15 @@ import java.io.OutputStream private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class DocumentsProviderStoragePlugin( private val appContext: Context, private val storage: DocumentsStorage, -) : StoragePlugin { +) : StoragePlugin { /** * Attention: This context might be from a different user. Use with care. */ - private val context: Context - get() = appContext.getStorageContext { - storage.safStorage?.isUsb == true - } + private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb } private val packageManager: PackageManager = appContext.packageManager @@ -77,16 +74,6 @@ internal class DocumentsProviderStoragePlugin( if (!file.delete()) throw IOException("Failed to delete $name") } - @Throws(IOException::class) - override suspend fun hasBackup(safStorage: SafStorage): Boolean { - // potentially get system user context if needed here - val c = appContext.getStorageContext { safStorage.isUsb } - val parent = DocumentFile.fromTreeUri(c, safStorage.uri) ?: throw AssertionError() - val rootDir = parent.findFileBlocking(c, DIRECTORY_ROOT) ?: return false - val backupSets = getBackups(c, rootDir) - return backupSets.isNotEmpty() - } - override suspend fun getAvailableBackups(): Sequence? { val rootDir = storage.rootBackupDir ?: return null val backupSets = getBackups(context, rootDir) @@ -110,7 +97,6 @@ internal class DocumentsProviderStoragePlugin( class BackupSet(val token: Long, val metadataFile: DocumentFile) -@Suppress("BlockingMethodInNonBlockingContext") internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List { val backupSets = ArrayList() val files = try { 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 e96b29e2..ce1b7dd3 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,5 +1,3 @@ -@file:Suppress("BlockingMethodInNonBlockingContext") - package com.stevesoltys.seedvault.plugins.saf import android.content.ContentResolver @@ -43,27 +41,19 @@ private val TAG = DocumentsStorage::class.java.simpleName internal class DocumentsStorage( private val appContext: Context, private val settingsManager: SettingsManager, + internal val safStorage: SafStorage, ) { - internal var safStorage: SafStorage? = null - get() { - if (field == null) field = settingsManager.getSafStorage() - return field - } /** * Attention: This context might be from a different user. Use with care. */ - private val context: Context - get() = appContext.getStorageContext { - safStorage?.isUsb == true - } + private val context: Context get() = appContext.getStorageContext { safStorage.isUsb } private val contentResolver: ContentResolver get() = context.contentResolver internal var rootBackupDir: DocumentFile? = null get() = runBlocking { if (field == null) { - val parent = safStorage?.getDocumentFile(context) - ?: return@runBlocking null + val parent = safStorage.getDocumentFile(context) field = try { parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply { // create .nomedia file to prevent Android's MediaScanner @@ -103,13 +93,12 @@ internal class DocumentsStorage( * Resets this storage abstraction, forcing it to re-fetch cached values on next access. */ fun reset(newToken: Long?) { - safStorage = null currentToken = newToken rootBackupDir = null currentSetDir = null } - fun getAuthority(): String? = safStorage?.uri?.authority + fun getAuthority(): String? = safStorage.uri.authority @Throws(IOException::class) suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? { diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt new file mode 100644 index 00000000..af46f6aa --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafFactory.kt @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.saf + +import android.content.Context +import android.net.Uri +import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin + +class SafFactory( + private val context: Context, + private val keyManager: KeyManager, + private val settingsManager: SettingsManager, +) { + + internal fun createAppStoragePlugin( + safStorage: SafStorage, + documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage), + ): StoragePlugin { + return DocumentsProviderStoragePlugin(context, documentsStorage) + } + + internal fun createFilesStoragePlugin( + safStorage: SafStorage, + documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage), + ): org.calyxos.backup.storage.api.StoragePlugin { + return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt new file mode 100644 index 00000000..3b8dc78c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafHandler.kt @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.saf + +import android.content.Context +import android.content.Context.USB_SERVICE +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import android.hardware.usb.UsbManager +import android.net.Uri +import android.util.Log +import androidx.annotation.WorkerThread +import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.isMassStorage +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.settings.FlashDrive +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.ui.storage.StorageOption +import java.io.IOException + +private const val TAG = "SafHandler" + +internal class SafHandler( + private val context: Context, + private val safFactory: SafFactory, + private val settingsManager: SettingsManager, + private val storagePluginManager: StoragePluginManager, +) { + + fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafStorage { + // persist permission to access backup folder across reboots + val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + + val name = if (safOption.isInternal()) { + "${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})" + } else { + safOption.title + } + return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork) + } + + /** + * Searches if there's really an app backup available in the given storage location. + * Returns true if at least one was found and false otherwise. + */ + @WorkerThread + @Throws(IOException::class) + suspend fun hasAppBackup(safStorage: SafStorage): Boolean { + val storage = DocumentsStorage(context, settingsManager, safStorage) + val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage) + val backups = appPlugin.getAvailableBackups() + return backups != null && backups.iterator().hasNext() + } + + fun save(safStorage: SafStorage) { + settingsManager.setSafStorage(safStorage) + + if (safStorage.isUsb) { + Log.d(TAG, "Selected storage is a removable USB device.") + val wasSaved = saveUsbDevice() + // reset stored flash drive, if we did not update it + if (!wasSaved) settingsManager.setFlashDrive(null) + } else { + settingsManager.setFlashDrive(null) + } + Log.d(TAG, "New storage location saved: ${safStorage.uri}") + } + + private fun saveUsbDevice(): Boolean { + val manager = context.getSystemService(USB_SERVICE) as UsbManager + manager.deviceList.values.forEach { device -> + if (device.isMassStorage()) { + val flashDrive = FlashDrive.from(device) + settingsManager.setFlashDrive(flashDrive) + Log.d(TAG, "Saved flash drive: $flashDrive") + return true + } + } + Log.e(TAG, "No USB device found even though we were expecting one.") + return false + } + + fun setPlugin(safStorage: SafStorage) { + val storage = DocumentsStorage(context, settingsManager, safStorage) + storagePluginManager.changePlugins( + storageProperties = safStorage, + appPlugin = safFactory.createAppStoragePlugin(safStorage, storage), + filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage), + ) + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt index 5b9cdfb8..35d58fba 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.kt @@ -6,19 +6,21 @@ package com.stevesoltys.seedvault.plugins.saf import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.net.Uri import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.seedvault.plugins.StorageProperties data class SafStorage( - val uri: Uri, - val name: String, - val isUsb: Boolean, - val requiresNetwork: Boolean, -) { - fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri) + override val config: Uri, + override val name: String, + override val isUsb: Boolean, + override val requiresNetwork: Boolean, +) : StorageProperties() { + + val uri: Uri = config + + fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config) ?: throw AssertionError("Should only happen on API < 21.") /** @@ -27,23 +29,7 @@ data class SafStorage( * Must be run off UI thread (ideally I/O). */ @WorkerThread - fun isUnavailableUsb(context: Context): Boolean { + override fun isUnavailableUsb(context: Context): Boolean { return isUsb && !getDocumentFile(context).isDirectory } - - /** - * Returns true if this is storage that requires network access, - * but it isn't available right now. - */ - fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { - return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) - } - - private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean { - val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false - val isMetered = cm.isActiveNetworkMetered - val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - (allowMetered || !isMetered) - } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt index 8126e48e..9273823b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt @@ -1,17 +1,10 @@ package com.stevesoltys.seedvault.plugins.webdav -import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderLegacyPlugin -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val webDavModule = module { // TODO PluginManager should create the plugin on demand - single { WebDavStoragePlugin(androidContext(), WebDavConfig("", "", "")) } - - single { DocumentsStorage(androidContext(), get()) } - @Suppress("Deprecation") - single { DocumentsProviderLegacyPlugin(androidContext(), get()) } + single> { WebDavStoragePlugin(androidContext(), WebDavConfig("", "", "")) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt index 075d7f87..0d9986cd 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt @@ -13,7 +13,6 @@ import com.stevesoltys.seedvault.plugins.chunkFolderRegex import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA import com.stevesoltys.seedvault.plugins.tokenRegex -import com.stevesoltys.seedvault.plugins.saf.SafStorage import okhttp3.HttpUrl.Companion.toHttpUrl import java.io.IOException import java.io.InputStream @@ -25,7 +24,7 @@ internal class WebDavStoragePlugin( context: Context, webDavConfig: WebDavConfig, root: String = DIRECTORY_ROOT, -) : WebDavStorage(webDavConfig, root), StoragePlugin { +) : WebDavStorage(webDavConfig, root), StoragePlugin { override suspend fun test(): Boolean { val location = baseUrl.toHttpUrl() @@ -134,12 +133,6 @@ internal class WebDavStoragePlugin( } } - @Throws(IOException::class) - override suspend fun hasBackup(safStorage: SafStorage): Boolean { - // TODO this requires refactoring - return true - } - override suspend fun getAvailableBackups(): Sequence? { return try { doGetAvailableBackups() diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index c7eaee3a..cdcfe0a5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -28,6 +28,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.metadata.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES @@ -81,8 +82,9 @@ internal class RestoreViewModel( private val restoreCoordinator: RestoreCoordinator, private val apkRestore: ApkRestore, storageBackup: StorageBackup, + pluginManager: StoragePluginManager, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, -) : RequireProvisioningViewModel(app, settingsManager, keyManager), +) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager), RestorableBackupClickListener, SnapshotViewModel { override val isRestoreOperation = true diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 36326e0a..aa9e5f30 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS @@ -28,7 +29,7 @@ private val TAG = ApkRestore::class.java.simpleName internal class ApkRestore( private val context: Context, - private val storagePlugin: StoragePlugin, + private val pluginManager: StoragePluginManager, @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin, private val crypto: Crypto, @@ -37,6 +38,7 @@ internal class ApkRestore( ) { private val pm = context.packageManager + private val storagePlugin get() = pluginManager.appPlugin fun restore(backup: RestorableBackup) = flow { // we don't filter out apps without APK, so the user can manually install them @@ -87,7 +89,7 @@ internal class ApkRestore( emit(installResult) } - @Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO + @Suppress("ThrowsCount") @Throws(IOException::class, SecurityException::class) private suspend fun restore( collector: FlowCollector, @@ -212,7 +214,6 @@ internal class ApkRestore( * @return a [Pair] of the cached [File] and SHA-256 hash. */ @Throws(IOException::class) - @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO private suspend fun cacheApk( version: Byte, token: Long, diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/BackupManagerSettings.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/BackupManagerSettings.kt deleted file mode 100644 index 622693c1..00000000 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/BackupManagerSettings.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.stevesoltys.seedvault.settings - -import android.content.ContentResolver -import android.provider.Settings - -private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS - -object BackupManagerSettings { - - /** - * This clears the backup settings, so that default values will be used. - * - * Before end of 2020 (Android 11) we changed the settings in an attempt - * to prevent automatic backups when flash drives are not plugged in. - * This turned out to not work reliably, so reset to defaults again here. - * - * We can remove this code after the last users can be expected - * to have changed storage at least once with this code deployed. - */ - fun resetDefaults(resolver: ContentResolver) { - if (Settings.Secure.getString(resolver, SETTING) != null) { - // setting this to null will cause the BackupManagerConstants to use default values - Settings.Secure.putString(resolver, SETTING, null) - } - } - -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt index f1bcdc39..3837c112 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt @@ -10,6 +10,7 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads +import com.stevesoltys.seedvault.plugins.StoragePluginManager import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -18,6 +19,7 @@ class SchedulingFragment : PreferenceFragmentCompat(), private val viewModel: SettingsViewModel by sharedViewModel() private val settingsManager: SettingsManager by inject() + private val storagePluginManager: StoragePluginManager by inject() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { @@ -29,7 +31,7 @@ class SchedulingFragment : PreferenceFragmentCompat(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val storage = settingsManager.getSafStorage() + val storage = storagePluginManager.storageProperties if (storage?.isUsb == true) { findPreference("scheduling_category_conditions")?.isEnabled = false } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 7d821147..58832235 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -22,7 +22,8 @@ import androidx.preference.TwoStatePreference import androidx.work.WorkInfo import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.saf.SafStorage +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.StorageProperties import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.toRelativeTime import org.koin.android.ext.android.inject @@ -34,7 +35,7 @@ private val TAG = SettingsFragment::class.java.name class SettingsFragment : PreferenceFragmentCompat() { private val viewModel: SettingsViewModel by sharedViewModel() - private val settingsManager: SettingsManager by inject() + private val storagePluginManager: StoragePluginManager by inject() private val backupManager: IBackupManager by inject() private lateinit var backup: TwoStatePreference @@ -49,7 +50,8 @@ class SettingsFragment : PreferenceFragmentCompat() { private var menuBackupNow: MenuItem? = null private var menuRestore: MenuItem? = null - private var safStorage: SafStorage? = null + private val storageProperties: StorageProperties<*>? + get() = storagePluginManager.storageProperties override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { @@ -165,7 +167,6 @@ class SettingsFragment : PreferenceFragmentCompat() { // we need to re-set the title when returning to this fragment activity?.setTitle(R.string.backup) - safStorage = settingsManager.getSafStorage() setBackupEnabledState() setBackupLocationSummary() setAutoRestoreState() @@ -242,7 +243,7 @@ class SettingsFragment : PreferenceFragmentCompat() { activity?.contentResolver?.let { autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1 } - val storage = this.safStorage + val storage = this.storageProperties if (storage?.isUsb == true) { autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" + getString(R.string.settings_auto_restore_summary_usb, storage.name) @@ -253,7 +254,8 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setBackupLocationSummary() { // get name of storage location - backupLocation.summary = safStorage?.name ?: getString(R.string.settings_backup_location_none) + backupLocation.summary = + storageProperties?.name ?: getString(R.string.settings_backup_location_none) } private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) { @@ -272,7 +274,7 @@ class SettingsFragment : PreferenceFragmentCompat() { * says that nothing is scheduled which can happen when backup destination is on flash drive. */ private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) { - if (safStorage?.isUsb == true) { + if (storageProperties?.isUsb == true) { backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb) return } 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 dfaf42da..53891a40 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -3,15 +3,14 @@ package com.stevesoltys.seedvault.settings import android.content.Context import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.hardware.usb.UsbDevice -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.net.Uri import androidx.annotation.UiThread import androidx.annotation.WorkerThread -import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.permitDiskReads +import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import java.util.concurrent.ConcurrentSkipListSet @@ -23,6 +22,12 @@ internal const val PREF_KEY_SCHED_FREQ = "scheduling_frequency" internal const val PREF_KEY_SCHED_METERED = "scheduling_metered" internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging" +private const val PREF_KEY_STORAGE_PLUGIN = "storagePlugin" + +internal enum class StoragePluginEnum { // don't rename, will break existing installs + SAF, +} + private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_NAME = "storageName" private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb" @@ -90,6 +95,25 @@ class SettingsManager(private val context: Context) { } // FIXME SafStorage is currently plugin specific and not generic + internal val storagePluginType: StoragePluginEnum? + get() = prefs.getString(PREF_KEY_STORAGE_PLUGIN, StoragePluginEnum.SAF.name)?.let { + try { + StoragePluginEnum.valueOf(it) + } catch (e: IllegalArgumentException) { + null + } + } + + fun setStoragePlugin(plugin: StoragePlugin<*>) { + val value = when (plugin) { + is DocumentsProviderStoragePlugin -> StoragePluginEnum.SAF + else -> error("Unsupported plugin: ${plugin::class.java.simpleName}") + }.name + prefs.edit() + .putString(PREF_KEY_STORAGE_PLUGIN, value) + .apply() + } + fun setSafStorage(safStorage: SafStorage) { prefs.edit() .putString(PREF_KEY_STORAGE_URI, safStorage.uri.toString()) @@ -196,42 +220,6 @@ class SettingsManager(private val context: Context) { } } -data class Storage( - val uri: Uri, - val name: String, - val isUsb: Boolean, - val requiresNetwork: Boolean, -) { - fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri) - ?: throw AssertionError("Should only happen on API < 21.") - - /** - * Returns true if this is USB storage that is not available, false otherwise. - * - * Must be run off UI thread (ideally I/O). - */ - @WorkerThread - fun isUnavailableUsb(context: Context): Boolean { - return isUsb && !getDocumentFile(context).isDirectory - } - - /** - * Returns true if this is storage that requires network access, - * but it isn't available right now. - */ - fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { - return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) - } - - private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean { - val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false - val isMetered = cm.isActiveNetworkMetered - val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - (allowMetered || !isMetered) - } -} - data class FlashDrive( val name: String, val serialNumber: String?, diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 2fd78d1f..f247e24e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -34,6 +34,8 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.permitDiskReads +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP @@ -59,12 +61,13 @@ internal class SettingsViewModel( app: Application, settingsManager: SettingsManager, keyManager: KeyManager, + private val pluginManager: StoragePluginManager, private val metadataManager: MetadataManager, private val appListRetriever: AppListRetriever, private val storageBackup: StorageBackup, private val backupManager: IBackupManager, private val backupInitializer: BackupInitializer, -) : RequireProvisioningViewModel(app, settingsManager, keyManager) { +) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) { private val contentResolver = app.contentResolver private val connectivityManager: ConnectivityManager? = @@ -131,9 +134,9 @@ internal class SettingsViewModel( } override fun onStorageLocationChanged() { - val storage = settingsManager.getSafStorage() ?: return + val storage = pluginManager.storageProperties ?: return - Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb}") + Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb})") if (storage.isUsb) { // disable storage backup if new storage is on USB cancelAppBackup() @@ -149,24 +152,27 @@ internal class SettingsViewModel( fun onWorkerStateChanged() { viewModelScope.launch(Dispatchers.IO) { - val canDo = settingsManager.canDoBackupNow() && + val canDo = pluginManager.canDoBackupNow() && appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING mBackupPossible.postValue(canDo) } } private fun onStoragePropertiesChanged() { - val storage = settingsManager.getSafStorage() ?: return + val storage = pluginManager.storageProperties ?: return Log.d(TAG, "onStoragePropertiesChanged") - // register storage observer - try { - contentResolver.unregisterContentObserver(storageObserver) - contentResolver.registerContentObserver(storage.uri, false, storageObserver) - } catch (e: SecurityException) { - // This can happen if the app providing the storage was uninstalled. - // validLocationIsSet() gets called elsewhere and prompts for a new storage location. - Log.e(TAG, "Error registering content observer for ${storage.uri}", e) + if (storage is SafStorage) { + // register storage observer + try { + contentResolver.unregisterContentObserver(storageObserver) + contentResolver.registerContentObserver(storage.uri, false, storageObserver) + } catch (e: SecurityException) { + // This can happen if the app providing the storage was uninstalled. + // validLocationIsSet() gets called elsewhere + // and prompts for a new storage location. + Log.e(TAG, "Error registering content observer for ${storage.uri}", e) + } } // register network observer if needed @@ -301,7 +307,7 @@ internal class SettingsViewModel( } } - fun cancelAppBackup() { + private fun cancelAppBackup() { AppBackupWorker.unschedule(app) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt index e78dd907..7a3e8a1f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt @@ -16,12 +16,8 @@ internal class SeedvaultSafStoragePlugin( /** * Attention: This context might be from a different user. Use with care. */ - override val context: Context - get() = appContext.getStorageContext { - storage.safStorage?.isUsb == true - } - override val root: DocumentFile - get() = storage.rootBackupDir ?: error("No storage set") + override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb } + override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set") override fun getMasterKey(): SecretKey = keyManager.getMainKey() override fun hasMasterKey(): Boolean = keyManager.hasMainKey() diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt index f7b0c828..3eba826d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -1,7 +1,7 @@ package com.stevesoltys.seedvault.storage import android.content.Intent -import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.worker.AppBackupWorker import org.calyxos.backup.storage.api.BackupObserver import org.calyxos.backup.storage.api.RestoreObserver @@ -34,7 +34,7 @@ internal class StorageBackupService : BackupService() { } override val storageBackup: StorageBackup by inject() - private val settingsManager: SettingsManager by inject() + private val storagePluginManager: StoragePluginManager by inject() // use lazy delegate because context isn't available during construction time override val backupObserver: BackupObserver by lazy { @@ -43,7 +43,7 @@ internal class StorageBackupService : BackupService() { override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - val isUsb = settingsManager.getSafStorage()?.isUsb ?: false + val isUsb = storagePluginManager.storageProperties?.isUsb ?: false AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt index 223ccca3..11b8b593 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/StorageModule.kt @@ -1,10 +1,9 @@ package com.stevesoltys.seedvault.storage +import com.stevesoltys.seedvault.plugins.StoragePluginManager import org.calyxos.backup.storage.api.StorageBackup -import org.calyxos.backup.storage.api.StoragePlugin import org.koin.dsl.module val storageModule = module { - single { SeedvaultSafStoragePlugin(get(), get(), get()) } - single { StorageBackup(get(), get()) } + single { StorageBackup(get(), { get().filesPlugin }) } } 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 8bf4d4ba..d0358877 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 @@ -25,6 +25,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.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -54,11 +55,10 @@ private class CoordinatorState( * @author Steve Soltys * @author Torsten Grote */ -@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok -@Suppress("BlockingMethodInNonBlockingContext") +@WorkerThread internal class BackupCoordinator( private val context: Context, - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, private val kv: KVBackup, private val full: FullBackup, private val clock: Clock, @@ -68,6 +68,7 @@ internal class BackupCoordinator( private val nm: BackupNotificationManager, ) { + private val plugin get() = pluginManager.appPlugin private val state = CoordinatorState( calledInitialize = false, calledClearBackupData = false, @@ -126,7 +127,7 @@ internal class BackupCoordinator( } catch (e: Exception) { Log.e(TAG, "Error initializing device", e) // Show error notification if we needed init or were ready for backups - if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError() + if (metadataManager.requiresInit || pluginManager.canDoBackupNow()) nm.onBackupError() TRANSPORT_ERROR } @@ -354,7 +355,7 @@ internal class BackupCoordinator( if (result == TRANSPORT_OK) { val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER // call onPackageBackedUp for @pm@ only if we can do backups right now - if (isNormalBackup || settingsManager.canDoBackupNow()) { + if (isNormalBackup || pluginManager.canDoBackupNow()) { try { onPackageBackedUp(packageInfo, BackupType.KV, size) } catch (e: Exception) { @@ -411,7 +412,7 @@ internal class BackupCoordinator( val longBackoff = DAYS.toMillis(30) // back off if there's no storage set - val storage = settingsManager.getSafStorage() ?: return longBackoff + val storage = pluginManager.storageProperties ?: return longBackoff return when { // back off if storage is removable and not available right now storage.isUnavailableUsb(context) -> longBackoff @@ -425,7 +426,9 @@ internal class BackupCoordinator( } } - private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { + private suspend fun StoragePlugin<*>.getMetadataOutputStream( + token: Long? = null, + ): OutputStream { val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") return getOutputStream(t, FILE_BACKUP_METADATA) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 6bb6f6e2..6128c1e7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -11,38 +11,38 @@ val backupModule = module { context = androidContext(), backupManager = get(), settingsManager = get(), - plugin = get() + pluginManager = get(), ) } single { KvDbManagerImpl(androidContext()) } single { KVBackup( - plugin = get(), + pluginManager = get(), settingsManager = get(), inputFactory = get(), crypto = get(), - dbManager = get() + dbManager = get(), ) } single { FullBackup( - plugin = get(), + pluginManager = get(), settingsManager = get(), inputFactory = get(), - crypto = get() + crypto = get(), ) } single { BackupCoordinator( context = androidContext(), - plugin = get(), + pluginManager = get(), kv = get(), full = get(), clock = get(), packageService = get(), metadataManager = get(), settingsManager = get(), - nm = get() + nm = get(), ) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt index d44cdebf..5b76abb5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt @@ -11,7 +11,7 @@ import android.util.Log import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForFull -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.SettingsManager import libcore.io.IoUtils.closeQuietly import java.io.EOFException @@ -39,12 +39,13 @@ private val TAG = FullBackup::class.java.simpleName @Suppress("BlockingMethodInNonBlockingContext") internal class FullBackup( - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, private val settingsManager: SettingsManager, private val inputFactory: InputFactory, private val crypto: Crypto, ) { + private val plugin get() = pluginManager.appPlugin private var state: FullBackupState? = null fun hasState() = state != null diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt index 44678157..c867144f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt @@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.SettingsManager import java.io.IOException import java.util.zip.GZIPOutputStream @@ -31,15 +31,15 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() private val TAG = KVBackup::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class KVBackup( - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, private val settingsManager: SettingsManager, private val inputFactory: InputFactory, private val crypto: Crypto, private val dbManager: KvDbManager, ) { + private val plugin get() = pluginManager.appPlugin private var state: KVBackupState? = null fun hasState() = state != null @@ -138,7 +138,7 @@ internal class KVBackup( // K/V backups (typically starting with package manager metadata - @pm@) // are scheduled with JobInfo.Builder#setOverrideDeadline() // and thus do not respect backoff. - settingsManager.canDoBackupNow() + pluginManager.canDoBackupNow() } else { // all other packages always need upload true diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt index 2aa126cc..ac803769 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt @@ -18,6 +18,7 @@ import android.util.Log.INFO import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.SettingsManager private val TAG = PackageService::class.java.simpleName @@ -32,11 +33,12 @@ internal class PackageService( private val context: Context, private val backupManager: IBackupManager, private val settingsManager: SettingsManager, - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, ) { private val packageManager: PackageManager = context.packageManager private val myUserId = UserHandle.myUserId() + private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin val eligiblePackages: List @WorkerThread diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt index 91c83e34..76800762 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/FullRestore.kt @@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import libcore.io.IoUtils.closeQuietly import java.io.EOFException import java.io.IOException @@ -32,9 +32,8 @@ private class FullRestoreState( private val TAG = FullRestore::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class FullRestore( - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, @Suppress("Deprecation") private val legacyPlugin: LegacyStoragePlugin, private val outputFactory: OutputFactory, @@ -42,6 +41,7 @@ internal class FullRestore( private val crypto: Crypto, ) { + private val plugin get() = pluginManager.appPlugin private var state: FullRestoreState? = null fun hasState() = state != null 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 c529c0ad..6b3e9618 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 @@ -16,13 +16,12 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import libcore.io.IoUtils.closeQuietly import java.io.IOException import java.security.GeneralSecurityException -import java.util.ArrayList import java.util.zip.GZIPInputStream import javax.crypto.AEADBadTagException @@ -39,9 +38,8 @@ private class KVRestoreState( private val TAG = KVRestore::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class KVRestore( - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, @Suppress("Deprecation") private val legacyPlugin: LegacyStoragePlugin, private val outputFactory: OutputFactory, @@ -50,6 +48,7 @@ internal class KVRestore( private val dbManager: KvDbManager, ) { + private val plugin get() = pluginManager.appPlugin private var state: KVRestoreState? = null /** 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 1111a750..147907d1 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 @@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS @@ -49,19 +50,19 @@ private data class RestoreCoordinatorState( private val TAG = RestoreCoordinator::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class RestoreCoordinator( private val context: Context, private val crypto: Crypto, private val settingsManager: SettingsManager, private val metadataManager: MetadataManager, private val notificationManager: BackupNotificationManager, - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, private val kv: KVRestore, private val full: FullRestore, private val metadataReader: MetadataReader, ) { + private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin private var state: RestoreCoordinatorState? = null private var backupMetadata: BackupMetadata? = null private val failedPackages = ArrayList() @@ -169,7 +170,7 @@ internal class RestoreCoordinator( // check if we even have a backup of that app if (metadataManager.getPackageMetadata(pmPackageName) != null) { // remind user to plug in storage device - val storageName = settingsManager.getSafStorage()?.name + val storageName = pluginManager.storageProperties?.name ?: context.getString(R.string.settings_backup_location_none) notificationManager.onRemovableStorageNotAvailableForRestore( pmPackageName, @@ -363,9 +364,8 @@ internal class RestoreCoordinator( fun isFailedPackage(packageName: String) = packageName in failedPackages - // TODO this is plugin specific, needs to be factored out when supporting different plugins private fun isStorageRemovableAndNotAvailable(): Boolean { - val storage = settingsManager.getSafStorage() ?: return false + val storage = pluginManager.storageProperties ?: return false return storage.isUnavailableUsb(context) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt index 28064441..5dd6faf7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt @@ -3,13 +3,14 @@ package com.stevesoltys.seedvault.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.ui.storage.StorageViewModel abstract class RequireProvisioningViewModel( protected val app: Application, protected val settingsManager: SettingsManager, protected val keyManager: KeyManager, + private val pluginManager: StoragePluginManager, ) : AndroidViewModel(app) { abstract val isRestoreOperation: Boolean @@ -18,7 +19,7 @@ abstract class RequireProvisioningViewModel( internal val chooseBackupLocation: LiveEvent get() = mChooseBackupLocation internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) - internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app, settingsManager) + internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet() internal fun recoveryCodeIsSet() = keyManager.hasBackupKey() 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 76f08757..455dc780 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 @@ -3,11 +3,13 @@ package com.stevesoltys.seedvault.ui.storage import android.app.Application import android.app.backup.IBackupManager import android.app.job.JobInfo -import android.net.Uri import android.util.Log import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.saf.SafHandler +import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.transport.backup.BackupInitializer @@ -26,14 +28,17 @@ internal class BackupStorageViewModel( private val backupManager: IBackupManager, private val backupInitializer: BackupInitializer, private val storageBackup: StorageBackup, + safHandler: SafHandler, settingsManager: SettingsManager, -) : StorageViewModel(app, settingsManager) { + storagePluginManager: StoragePluginManager, +) : StorageViewModel(app, safHandler, settingsManager, storagePluginManager) { override val isRestoreOperation = false - override fun onSafUriSet(uri: Uri) { - val isUsb = saveStorage(uri) - if (isUsb) { + override fun onSafUriSet(safStorage: SafStorage) { + safHandler.save(safStorage) + safHandler.setPlugin(safStorage) + if (safStorage.isUsb) { // disable storage backup if new storage is on USB cancelBackupWorkers() } else { @@ -41,12 +46,16 @@ internal class BackupStorageViewModel( // also to update the network requirement of the new storage scheduleBackupWorkers() } + onStorageLocationSet(safStorage.isUsb) + } + + private fun onStorageLocationSet(isUsb: Boolean) { viewModelScope.launch(Dispatchers.IO) { - // remove old storage snapshots and clear cache - // TODO is this needed? It also does create all 255 chunk folders which takes time - // pass a flag to getCurrentBackupSnapshots() to not create missing folders? - storageBackup.init() try { + // remove old storage snapshots and clear cache + // TODO For SAF, this also does create all 255 chunk folders which takes time + // pass a flag to getCurrentBackupSnapshots() to not create missing folders? + storageBackup.init() // initialize the new location (if backups are enabled) if (backupManager.isBackupEnabled) { val onError = { @@ -74,7 +83,7 @@ internal class BackupStorageViewModel( } private fun scheduleBackupWorkers() { - val storage = settingsManager.getSafStorage() ?: error("no storage available") + val storage = storagePluginManager.storageProperties ?: error("no storage available") if (!storage.isUsb) { if (backupManager.isBackupEnabled) { AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt index b1ea418e..a938176a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt @@ -1,12 +1,13 @@ package com.stevesoltys.seedvault.ui.storage import android.app.Application -import android.net.Uri import android.util.Log import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT +import com.stevesoltys.seedvault.plugins.saf.SafHandler +import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -16,26 +17,27 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName internal class RestoreStorageViewModel( private val app: Application, - private val storagePlugin: StoragePlugin, + safHandler: SafHandler, settingsManager: SettingsManager, -) : StorageViewModel(app, settingsManager) { + storagePluginManager: StoragePluginManager, +) : StorageViewModel(app, safHandler, settingsManager, storagePluginManager) { override val isRestoreOperation = true - override fun onSafUriSet(uri: Uri) { + override fun onSafUriSet(safStorage: SafStorage) { viewModelScope.launch(Dispatchers.IO) { - val storage = createStorage(uri) val hasBackup = try { - storagePlugin.hasBackup(storage) + safHandler.hasAppBackup(safStorage) } catch (e: IOException) { - Log.e(TAG, "Error reading URI: $uri", e) + Log.e(TAG, "Error reading URI: ${safStorage.uri}", e) false } if (hasBackup) { - saveStorage(storage) + safHandler.save(safStorage) + safHandler.setPlugin(safStorage) mLocationChecked.postEvent(LocationResult()) } else { - Log.w(TAG, "Location was rejected: $uri") + Log.w(TAG, "Location was rejected: ${safStorage.uri}") // notify the UI that the location was invalid val errorMsg = @@ -44,5 +46,4 @@ internal class RestoreStorageViewModel( } } } - } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt index afe35a00..5ca55039 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt @@ -62,9 +62,7 @@ internal class StorageOptionFetcher(private val context: Context, private val is internal fun getRemovableStorageListener() = listener internal fun getStorageOptions(): List { - val roots = ArrayList().apply { - add(WebDavOption(context)) - } + val roots = ArrayList() val intent = Intent(PROVIDER_INTERFACE) val providers = packageManager.queryIntentContentProviders(intent, 0) for (info in providers) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt index b3673d2a..2be75a0b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt @@ -2,23 +2,15 @@ package com.stevesoltys.seedvault.ui.storage import android.annotation.UiThread import android.app.Application -import android.content.Context -import android.content.Context.USB_SERVICE -import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION -import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION -import android.hardware.usb.UsbManager import android.net.Uri -import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.isMassStorage -import com.stevesoltys.seedvault.permitDiskReads +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafStorage -import com.stevesoltys.seedvault.settings.BackupManagerSettings -import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent @@ -26,11 +18,11 @@ import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -private val TAG = StorageViewModel::class.java.simpleName - internal abstract class StorageViewModel( private val app: Application, + protected val safHandler: SafHandler, protected val settingsManager: SettingsManager, + protected val storagePluginManager: StoragePluginManager, ) : AndroidViewModel(app), RemovableStorageListener { private val mStorageOptions = MutableLiveData>() @@ -47,22 +39,9 @@ internal abstract class StorageViewModel( internal var isSetupWizard: Boolean = false internal val hasStorageSet: Boolean - get() = settingsManager.getSafStorage() != null + get() = storagePluginManager.storageProperties != null abstract val isRestoreOperation: Boolean - companion object { - internal fun validLocationIsSet( - context: Context, - settingsManager: SettingsManager, - ): Boolean { - val storage = settingsManager.getSafStorage() ?: return false - if (storage.isUsb) return true - return permitDiskReads { - storage.getDocumentFile(context).isDirectory - } - } - } - internal fun loadStorageRoots() { if (storageOptionFetcher.getRemovableStorageListener() == null) { storageOptionFetcher.setRemovableStorageListener(this) @@ -74,6 +53,10 @@ internal abstract class StorageViewModel( override fun onStorageChanged() = loadStorageRoots() + /** + * Remembers that the user chose SAF. + * Usually followed by a call of [onUriPermissionResultReceived]. + */ fun onSafOptionChosen(option: SafOption) { safOption = option } @@ -84,71 +67,18 @@ internal abstract class StorageViewModel( mLocationChecked.setEvent(LocationResult(msg)) return } + require(safOption?.uri == uri) { "URIs differ: ${safOption?.uri} != $uri" } + + val root = safOption ?: throw IllegalStateException("no storage root") + val safStorage = safHandler.onConfigReceived(uri, root) // inform UI that a location has been successfully selected mLocationSet.setEvent(true) - // persist permission to access backup folder across reboots - val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION - app.contentResolver.takePersistableUriPermission(uri, takeFlags) - - onSafUriSet(uri) + onSafUriSet(safStorage) } - /** - * Saves the storage behind the given [Uri] (and saved [safOption]). - * - * @return true if the storage is a USB flash drive, false otherwise. - */ - protected fun saveStorage(uri: Uri): Boolean { - // store backup storage location in settings - val storage = createStorage(uri) - return saveStorage(storage) - } - - protected fun createStorage(uri: Uri): SafStorage { - val root = safOption ?: throw IllegalStateException("no storage root") - val name = if (root.isInternal()) { - "${root.title} (${app.getString(R.string.settings_backup_location_internal)})" - } else { - root.title - } - return SafStorage(uri, name, root.isUsb, root.requiresNetwork) - } - - protected fun saveStorage(safStorage: SafStorage): Boolean { - settingsManager.setSafStorage(safStorage) - - if (safStorage.isUsb) { - Log.d(TAG, "Selected storage is a removable USB device.") - val wasSaved = saveUsbDevice() - // reset stored flash drive, if we did not update it - if (!wasSaved) settingsManager.setFlashDrive(null) - } else { - settingsManager.setFlashDrive(null) - } - BackupManagerSettings.resetDefaults(app.contentResolver) - - Log.d(TAG, "New storage location saved: ${safStorage.uri}") - - return safStorage.isUsb - } - - private fun saveUsbDevice(): Boolean { - val manager = app.getSystemService(USB_SERVICE) as UsbManager - manager.deviceList.values.forEach { device -> - if (device.isMassStorage()) { - val flashDrive = FlashDrive.from(device) - settingsManager.setFlashDrive(flashDrive) - Log.d(TAG, "Saved flash drive: $flashDrive") - return true - } - } - Log.e(TAG, "No USB device found even though we were expecting one.") - return false - } - - abstract fun onSafUriSet(uri: Uri) + abstract fun onSafUriSet(safStorage: SafStorage) override fun onCleared() { storageOptionFetcher.setRemovableStorageListener(null) diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt index 1f39c3c0..991039ad 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt @@ -30,7 +30,6 @@ import java.security.MessageDigest private val TAG = ApkBackup::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class ApkBackup( private val pm: PackageManager, private val crypto: Crypto, diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt index be1942c1..00f707e0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.PackageService @@ -28,7 +29,7 @@ internal class ApkBackupManager( private val metadataManager: MetadataManager, private val packageService: PackageService, private val apkBackup: ApkBackup, - private val plugin: StoragePlugin, + private val pluginManager: StoragePluginManager, private val nm: BackupNotificationManager, ) { @@ -50,7 +51,7 @@ internal class ApkBackupManager( keepTrying { // upload all local changes only at the end, // so we don't have to re-upload the metadata - plugin.getMetadataOutputStream().use { outputStream -> + pluginManager.appPlugin.getMetadataOutputStream().use { outputStream -> metadataManager.uploadMetadata(outputStream) } } @@ -102,7 +103,7 @@ internal class ApkBackupManager( return try { apkBackup.backupApkIfNecessary(packageInfo) { name -> val token = settingsManager.getToken() ?: throw IOException("no current token") - plugin.getOutputStream(token, name) + pluginManager.appPlugin.getOutputStream(token, name) }?.let { packageMetadata -> metadataManager.onApkBackedUp(packageInfo, packageMetadata) true @@ -125,7 +126,9 @@ internal class ApkBackupManager( } } - private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { + private suspend fun StoragePlugin<*>.getMetadataOutputStream( + token: Long? = null, + ): OutputStream { val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") return getOutputStream(t, FILE_BACKUP_METADATA) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index dce45be2..b7ff36de 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -31,7 +31,7 @@ val workerModule = module { metadataManager = get(), packageService = get(), apkBackup = get(), - plugin = get(), + pluginManager = get(), nm = get() ) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt index 1ad185aa..724ba41e 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt @@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.metadataModule -import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule +import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.backupModule @@ -42,7 +42,7 @@ class TestApp : App() { testCryptoModule, headerModule, metadataModule, - documentsProviderModule, // storage plugin + storagePluginModuleSaf, // storage plugin backupModule, restoreModule, installModule, diff --git a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt index d06004fd..982124de 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/plugins/saf/StoragePluginTest.kt @@ -11,7 +11,6 @@ import io.mockk.mockkStatic import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test -@Suppress("BlockingMethodInNonBlockingContext") internal class StoragePluginTest : BackupTest() { private val storage = mockk() @@ -39,7 +38,7 @@ internal class StoragePluginTest : BackupTest() { // get current set dir and for that the current token every { storage getProperty "currentToken" } returns token every { settingsManager.getToken() } returns token - every { storage getProperty "storage" } returns null // just to check if isUsb + every { storage getProperty "safStorage" } returns null // just to check if isUsb coEvery { storage.getSetDir(token) } returns setDir // delete contents of current set dir coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile) diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index 33a244aa..c22c4a38 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.worker.ApkBackup @@ -38,7 +39,6 @@ import java.nio.file.Path import kotlin.random.Random @ExperimentalCoroutinesApi -@Suppress("BlockingMethodInNonBlockingContext") internal class ApkBackupRestoreTest : TransportTest() { private val pm: PackageManager = mockk() @@ -46,16 +46,17 @@ internal class ApkBackupRestoreTest : TransportTest() { every { packageManager } returns pm } + private val storagePluginManager: StoragePluginManager = mockk() @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = mockk() - private val storagePlugin: StoragePlugin = mockk() + private val storagePlugin: StoragePlugin<*> = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val apkInstaller: ApkInstaller = mockk() private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) private val apkRestore: ApkRestore = ApkRestore( context = strictContext, - storagePlugin = storagePlugin, + pluginManager = storagePluginManager, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, splitCompatChecker = splitCompatChecker, @@ -90,6 +91,7 @@ internal class ApkBackupRestoreTest : TransportTest() { init { mockkStatic(PackageUtils::class) + every { storagePluginManager.appPlugin } returns storagePlugin } @Test diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index 6eb1e969..e6017adc 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP @@ -41,7 +42,6 @@ import java.io.IOException import java.nio.file.Path import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") @ExperimentalCoroutinesApi internal class ApkRestoreTest : TransportTest() { @@ -49,18 +49,19 @@ internal class ApkRestoreTest : TransportTest() { private val strictContext: Context = mockk().apply { every { packageManager } returns pm } - private val storagePlugin: StoragePlugin = mockk() + private val storagePluginManager: StoragePluginManager = mockk() + private val storagePlugin: StoragePlugin<*> = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val apkInstaller: ApkInstaller = mockk() private val apkRestore: ApkRestore = ApkRestore( - strictContext, - storagePlugin, - legacyStoragePlugin, - crypto, - splitCompatChecker, - apkInstaller + context = strictContext, + pluginManager = storagePluginManager, + legacyStoragePlugin = legacyStoragePlugin, + crypto = crypto, + splitCompatChecker = splitCompatChecker, + apkInstaller = apkInstaller, ) private val icon: Drawable = mockk() @@ -85,6 +86,8 @@ internal class ApkRestoreTest : TransportTest() { init { // as we don't do strict signature checking, we can use a relaxed mock packageInfo.signingInfo = mockk(relaxed = true) + + every { storagePluginManager.appPlugin } returns storagePlugin } @Test 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 94d3ea8c..70b8b35d 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -17,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.FullBackup @@ -46,7 +47,6 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class CoordinatorIntegrationTest : TransportTest() { private val inputFactory = mockk() @@ -58,18 +58,20 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val metadataReader = MetadataReaderImpl(cryptoImpl) private val notificationManager = mockk() private val dbManager = TestKvDbManager() + private val storagePluginManager: StoragePluginManager = mockk() @Suppress("Deprecation") private val legacyPlugin = mockk() - private val backupPlugin = mockk() + private val backupPlugin = mockk>() private val kvBackup = - KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager) - private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl) + KVBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl, dbManager) + private val fullBackup = + FullBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl) private val apkBackup = mockk() private val packageService: PackageService = mockk() private val backup = BackupCoordinator( context, - backupPlugin, + storagePluginManager, kvBackup, fullBackup, clock, @@ -80,7 +82,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { ) private val kvRestore = KVRestore( - backupPlugin, + storagePluginManager, legacyPlugin, outputFactory, headerReader, @@ -88,14 +90,14 @@ internal class CoordinatorIntegrationTest : TransportTest() { dbManager ) private val fullRestore = - FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl) + FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) private val restore = RestoreCoordinator( context, crypto, settingsManager, metadataManager, notificationManager, - backupPlugin, + storagePluginManager, kvRestore, fullRestore, metadataReader @@ -113,6 +115,10 @@ internal class CoordinatorIntegrationTest : TransportTest() { // as we use real crypto, we need a real name for packageInfo private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName) + init { + every { storagePluginManager.appPlugin } returns backupPlugin + } + @Test fun `test key-value backup and restore with 2 records`() = runBlocking { val value = CapturingSlot() 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 9d39cf2c..eeb48ded 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 @@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -33,10 +34,9 @@ import java.io.IOException import java.io.OutputStream import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class BackupCoordinatorTest : BackupTest() { - private val plugin = mockk() + private val pluginManager = mockk() private val kv = mockk() private val full = mockk() private val apkBackup = mockk() @@ -44,27 +44,32 @@ internal class BackupCoordinatorTest : BackupTest() { private val packageService = mockk() private val backup = BackupCoordinator( - context, - plugin, - kv, - full, - clock, - packageService, - metadataManager, - settingsManager, - notificationManager + context = context, + pluginManager = pluginManager, + kv = kv, + full = full, + clock = clock, + packageService = packageService, + metadataManager = metadataManager, + settingsManager = settingsManager, + nm = notificationManager, ) + private val plugin = mockk>() private val metadataOutputStream = mockk() private val fileDescriptor: ParcelFileDescriptor = mockk() private val packageMetadata: PackageMetadata = mockk() private val safStorage = SafStorage( - uri = Uri.EMPTY, + config = Uri.EMPTY, name = getRandomString(), isUsb = false, - requiresNetwork = false + requiresNetwork = false, ) + init { + every { pluginManager.appPlugin } returns plugin + } + @Test fun `device initialization succeeds and delegates to plugin`() = runBlocking { expectStartNewRestoreSet() @@ -90,7 +95,7 @@ internal class BackupCoordinatorTest : BackupTest() { expectStartNewRestoreSet() coEvery { plugin.initializeDevice() } throws IOException() every { metadataManager.requiresInit } returns maybeTrue - every { settingsManager.canDoBackupNow() } returns !maybeTrue + every { pluginManager.canDoBackupNow() } returns !maybeTrue every { notificationManager.onBackupError() } just Runs assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) @@ -109,7 +114,7 @@ internal class BackupCoordinatorTest : BackupTest() { expectStartNewRestoreSet() coEvery { plugin.initializeDevice() } throws IOException() every { metadataManager.requiresInit } returns false - every { settingsManager.canDoBackupNow() } returns false + every { pluginManager.canDoBackupNow() } returns false assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) @@ -125,7 +130,7 @@ internal class BackupCoordinatorTest : BackupTest() { fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking { val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - every { settingsManager.canDoBackupNow() } returns true + every { pluginManager.canDoBackupNow() } returns true every { metadataManager.requiresInit } returns true // start new restore set @@ -224,7 +229,7 @@ internal class BackupCoordinatorTest : BackupTest() { every { kv.getCurrentSize() } returns 42L coEvery { kv.finishBackup() } returns TRANSPORT_OK - every { settingsManager.canDoBackupNow() } returns false + every { pluginManager.canDoBackupNow() } returns false assertEquals(TRANSPORT_OK, backup.finishBackup()) } @@ -290,7 +295,7 @@ internal class BackupCoordinatorTest : BackupTest() { ) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs - every { settingsManager.getSafStorage() } returns safStorage + every { pluginManager.storageProperties } returns safStorage every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs @@ -340,7 +345,7 @@ internal class BackupCoordinatorTest : BackupTest() { ) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs - every { settingsManager.getSafStorage() } returns safStorage + every { pluginManager.storageProperties } returns safStorage every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt index 2d03dd7e..1681b6f5 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/FullBackupTest.kt @@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import io.mockk.Runs import io.mockk.coEvery import io.mockk.every @@ -21,16 +22,20 @@ import java.io.FileInputStream import java.io.IOException import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class FullBackupTest : BackupTest() { - private val plugin = mockk() - private val backup = FullBackup(plugin, settingsManager, inputFactory, crypto) + private val storagePluginManager: StoragePluginManager = mockk() + private val plugin = mockk>() + private val backup = FullBackup(storagePluginManager, settingsManager, inputFactory, crypto) private val bytes = ByteArray(23).apply { Random.nextBytes(this) } private val inputStream = mockk() private val ad = getADForFull(VERSION, packageInfo.packageName) + init { + every { storagePluginManager.appPlugin } returns plugin + } + @Test fun `has no initial state`() { assertFalse(backup.hasState()) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt index e370f080..f9bbe264 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt @@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import io.mockk.CapturingSlot import io.mockk.Runs import io.mockk.coEvery @@ -30,22 +31,26 @@ import java.io.ByteArrayInputStream import java.io.IOException import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class KVBackupTest : BackupTest() { - private val plugin = mockk() + private val pluginManager = mockk() private val dataInput = mockk() private val dbManager = mockk() - private val backup = KVBackup(plugin, settingsManager, inputFactory, crypto, dbManager) + private val backup = KVBackup(pluginManager, settingsManager, inputFactory, crypto, dbManager) private val db = mockk() + private val plugin = mockk>() private val packageName = packageInfo.packageName private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val dataValue = Random.nextBytes(23) private val dbBytes = Random.nextBytes(42) private val inputStream = ByteArrayInputStream(dbBytes) + init { + every { pluginManager.appPlugin } returns plugin + } + @Test fun `has no initial state`() { assertFalse(backup.hasState()) @@ -231,7 +236,7 @@ internal class KVBackupTest : BackupTest() { every { dbManager.existsDb(pmPackageInfo.packageName) } returns false every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name every { dbManager.getDb(pmPackageInfo.packageName) } returns db - every { settingsManager.canDoBackupNow() } returns false + every { pluginManager.canDoBackupNow() } returns false every { db.put(key, dataValue) } just Runs getDataInput(listOf(true, false)) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt index 73044e0d..3986c069 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/FullRestoreTest.kt @@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import io.mockk.CapturingSlot import io.mockk.Runs import io.mockk.coEvery @@ -31,17 +32,27 @@ import java.io.IOException import java.security.GeneralSecurityException import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class FullRestoreTest : RestoreTest() { - private val plugin = mockk() + private val storagePluginManager: StoragePluginManager = mockk() + private val plugin = mockk>() private val legacyPlugin = mockk() - private val restore = FullRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto) + private val restore = FullRestore( + pluginManager = storagePluginManager, + legacyPlugin = legacyPlugin, + outputFactory = outputFactory, + headerReader = headerReader, + crypto = crypto, + ) private val encrypted = getRandomByteArray() private val outputStream = ByteArrayOutputStream() private val ad = getADForFull(VERSION, packageInfo.packageName) + init { + every { storagePluginManager.appPlugin } returns plugin + } + @Test fun `has no initial state`() { assertFalse(restore.hasState()) 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 678d6b63..955d0c6b 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 @@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KvDbManager import io.mockk.Runs @@ -33,15 +34,22 @@ import java.security.GeneralSecurityException import java.util.zip.GZIPOutputStream import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class KVRestoreTest : RestoreTest() { - private val plugin = mockk() + private val storagePluginManager: StoragePluginManager = mockk() + private val plugin = mockk>() + @Suppress("DEPRECATION") private val legacyPlugin = mockk() private val dbManager = mockk() private val output = mockk() - private val restore = - KVRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto, dbManager) + private val restore = KVRestore( + pluginManager = storagePluginManager, + legacyPlugin = legacyPlugin, + outputFactory = outputFactory, + headerReader = headerReader, + crypto = crypto, + dbManager = dbManager, + ) private val db = mockk() private val ad = getADForKV(VERSION, packageInfo.packageName) @@ -60,6 +68,8 @@ internal class KVRestoreTest : RestoreTest() { init { // for InputStream#readBytes() mockkStatic("kotlin.io.ByteStreamsKt") + + every { storagePluginManager.appPlugin } returns plugin } @Test @@ -180,7 +190,6 @@ internal class KVRestoreTest : RestoreTest() { } @Test - @Suppress("Deprecation") fun `v0 listing records throws`() = runBlocking { restore.initializeState(0x00, token, name, packageInfo) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt index f752ad72..9b1e609e 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 @@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.SafStorage import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -35,25 +36,25 @@ import java.io.IOException import java.io.InputStream import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class RestoreCoordinatorTest : TransportTest() { private val notificationManager: BackupNotificationManager = mockk() - private val plugin = mockk() + private val storagePluginManager: StoragePluginManager = mockk() + private val plugin = mockk>() private val kv = mockk() private val full = mockk() private val metadataReader = mockk() private val restore = RestoreCoordinator( - context, - crypto, - settingsManager, - metadataManager, - notificationManager, - plugin, - kv, - full, - metadataReader + context = context, + crypto = crypto, + settingsManager = settingsManager, + metadataManager = metadataManager, + notificationManager = notificationManager, + pluginManager = storagePluginManager, + kv = kv, + full = full, + metadataReader = metadataReader, ) private val inputStream = mockk() @@ -71,6 +72,8 @@ internal class RestoreCoordinatorTest : TransportTest() { init { metadata.packageMetadataMap[packageInfo2.packageName] = PackageMetadata(backupType = BackupType.FULL) + + every { storagePluginManager.appPlugin } returns plugin } @Test @@ -164,7 +167,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() optimized auto-restore with removed storage shows notification`() = runBlocking { - every { settingsManager.getSafStorage() } returns safStorage + every { storagePluginManager.storageProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns true every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L) every { safStorage.name } returns storageName @@ -188,7 +191,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() optimized auto-restore with available storage shows no notification`() = runBlocking { - every { settingsManager.getSafStorage() } returns safStorage + every { storagePluginManager.storageProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns false restore.beforeStartRestore(metadata) @@ -204,7 +207,7 @@ internal class RestoreCoordinatorTest : TransportTest() { @Test fun `startRestore() with removed storage shows no notification`() = runBlocking { - every { settingsManager.getSafStorage() } returns safStorage + every { storagePluginManager.storageProperties } returns safStorage every { safStorage.isUnavailableUsb(context) } returns true every { metadataManager.getPackageMetadata(packageName) } returns null diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt index 07df8f34..d38ee2f9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreV0IntegrationTest.kt @@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.toByteArrayFromHex import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.KvDbManager @@ -35,7 +36,6 @@ import javax.crypto.spec.SecretKeySpec /** * Tests that we can still restore Version 0 backups with current code. */ -@Suppress("BlockingMethodInNonBlockingContext") internal class RestoreV0IntegrationTest : TransportTest() { private val outputFactory = mockk() @@ -49,30 +49,31 @@ internal class RestoreV0IntegrationTest : TransportTest() { private val dbManager = mockk() private val metadataReader = MetadataReaderImpl(cryptoImpl) private val notificationManager = mockk() + private val storagePluginManager: StoragePluginManager = mockk() @Suppress("Deprecation") private val legacyPlugin = mockk() - private val backupPlugin = mockk() + private val backupPlugin = mockk>() private val kvRestore = KVRestore( - backupPlugin, - legacyPlugin, - outputFactory, - headerReader, - cryptoImpl, - dbManager + pluginManager = storagePluginManager, + legacyPlugin = legacyPlugin, + outputFactory = outputFactory, + headerReader = headerReader, + crypto = cryptoImpl, + dbManager = dbManager, ) private val fullRestore = - FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl) + FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl) private val restore = RestoreCoordinator( - context, - crypto, - settingsManager, - metadataManager, - notificationManager, - backupPlugin, - kvRestore, - fullRestore, - metadataReader + context = context, + crypto = crypto, + settingsManager = settingsManager, + metadataManager = metadataManager, + notificationManager = notificationManager, + pluginManager = storagePluginManager, + kv = kvRestore, + full = fullRestore, + metadataReader = metadataReader, ).apply { beforeStartRestore(metadata.copy(version = 0x00)) } private val fileDescriptor = mockk(relaxed = true) @@ -116,6 +117,10 @@ internal class RestoreV0IntegrationTest : TransportTest() { private val key2 = "RestoreKey2" private val key264 = key2.encodeBase64() + init { + every { storagePluginManager.appPlugin } returns backupPlugin + } + @Test fun `test key-value backup and restore with 2 records`() = runBlocking { val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" + diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt index f12f4def..d38ae1d6 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.PackageService @@ -32,7 +33,8 @@ internal class ApkBackupManagerTest : TransportTest() { private val packageService: PackageService = mockk() private val apkBackup: ApkBackup = mockk() - private val plugin: StoragePlugin = mockk() + private val storagePluginManager: StoragePluginManager = mockk() + private val plugin: StoragePlugin<*> = mockk() private val nm: BackupNotificationManager = mockk() private val apkBackupManager = ApkBackupManager( @@ -41,13 +43,17 @@ internal class ApkBackupManagerTest : TransportTest() { metadataManager = metadataManager, packageService = packageService, apkBackup = apkBackup, - plugin = plugin, + pluginManager = storagePluginManager, nm = nm, ) private val metadataOutputStream = mockk() private val packageMetadata: PackageMetadata = mockk() + init { + every { storagePluginManager.appPlugin } returns plugin + } + @Test fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking { every { nm.onAppsNotBackedUp() } just Runs diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt index c56fcd24..c79f007b 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt @@ -38,7 +38,6 @@ import java.io.OutputStream import java.nio.file.Path import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class ApkBackupTest : BackupTest() { private val pm: PackageManager = mockk() diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt index 6aaae909..11bfebd9 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/App.kt @@ -18,7 +18,7 @@ class App : Application() { val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) } val storageBackup: StorageBackup by lazy { val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() } - StorageBackup(this, plugin) + StorageBackup(this, { plugin }) } override fun onCreate() { diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt index d5975978..9b5e1252 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/api/StorageBackup.kt @@ -36,10 +36,9 @@ import java.util.concurrent.atomic.AtomicBoolean private const val TAG = "StorageBackup" -@Suppress("BlockingMethodInNonBlockingContext") public class StorageBackup( private val context: Context, - private val plugin: StoragePlugin, + private val pluginGetter: () -> StoragePlugin, private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) { @@ -50,18 +49,18 @@ public class StorageBackup( private val uriStore by lazy { db.getUriStore() } private val mediaScanner by lazy { MediaScanner(context) } - private val snapshotRetriever = SnapshotRetriever(plugin) - private val chunksCacheRepopulater = ChunksCacheRepopulater(db, plugin, snapshotRetriever) + private val snapshotRetriever = SnapshotRetriever(pluginGetter) + private val chunksCacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever) private val backup by lazy { val documentScanner = DocumentScanner(context) val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner) - Backup(context, db, fileScanner, plugin, chunksCacheRepopulater) + Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater) } private val restore by lazy { - Restore(context, plugin, snapshotRetriever, FileRestore(context, mediaScanner)) + Restore(context, pluginGetter, snapshotRetriever, FileRestore(context, mediaScanner)) } private val retention = RetentionManager(context) - private val pruner by lazy { Pruner(db, retention, plugin, snapshotRetriever) } + private val pruner by lazy { Pruner(db, retention, pluginGetter, snapshotRetriever) } private val backupRunning = AtomicBoolean(false) private val restoreRunning = AtomicBoolean(false) @@ -109,7 +108,7 @@ public class StorageBackup( * (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]). */ public suspend fun init() { - plugin.init() + pluginGetter().init() deleteAllSnapshots() clearCache() } @@ -123,9 +122,9 @@ public class StorageBackup( */ public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) { try { - plugin.getCurrentBackupSnapshots().forEach { + pluginGetter().getCurrentBackupSnapshots().forEach { try { - plugin.deleteBackupSnapshot(it) + pluginGetter().deleteBackupSnapshot(it) } catch (e: IOException) { Log.e(TAG, "Error deleting snapshot $it", e) } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt index d1451f2b..167f78f5 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt @@ -35,12 +35,11 @@ internal class BackupResult( ) } -@Suppress("BlockingMethodInNonBlockingContext") internal class Backup( private val context: Context, private val db: Db, private val fileScanner: FileScanner, - private val storagePlugin: StoragePlugin, + private val storagePluginGetter: () -> StoragePlugin, private val cacheRepopulater: ChunksCacheRepopulater, chunkSizeMax: Int = CHUNK_SIZE_MAX, private val streamCrypto: StreamCrypto = StreamCrypto, @@ -54,6 +53,7 @@ internal class Backup( } private val contentResolver = context.contentResolver + private val storagePlugin get() = storagePluginGetter() private val filesCache = db.getFilesCache() private val chunksCache = db.getChunksCache() diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt index b29dd346..b29ca369 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulater.kt @@ -19,10 +19,9 @@ import kotlin.time.toDuration private const val TAG = "ChunksCacheRepopulater" -@Suppress("BlockingMethodInNonBlockingContext") internal class ChunksCacheRepopulater( private val db: Db, - private val storagePlugin: StoragePlugin, + private val storagePlugin: () -> StoragePlugin, private val snapshotRetriever: SnapshotRetriever, ) { @@ -43,7 +42,7 @@ internal class ChunksCacheRepopulater( availableChunkIds: HashSet, ) { val start = System.currentTimeMillis() - val snapshots = storagePlugin.getCurrentBackupSnapshots().mapNotNull { storedSnapshot -> + val snapshots = storagePlugin().getCurrentBackupSnapshots().mapNotNull { storedSnapshot -> try { snapshotRetriever.getSnapshot(streamKey, storedSnapshot) } catch (e: GeneralSecurityException) { @@ -63,7 +62,7 @@ internal class ChunksCacheRepopulater( // delete chunks that are not references by any snapshot anymore val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id }) val deletionDuration = measure { - storagePlugin.deleteChunks(chunksToDelete.toList()) + storagePlugin().deleteChunks(chunksToDelete.toList()) } Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration") } diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt index 3137b444..0eb63764 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/SnapshotRetriever.kt @@ -14,9 +14,8 @@ import org.calyxos.backup.storage.restore.readVersion import java.io.IOException import java.security.GeneralSecurityException -@Suppress("BlockingMethodInNonBlockingContext") internal class SnapshotRetriever( - private val storagePlugin: StoragePlugin, + private val storagePlugin: () -> StoragePlugin, private val streamCrypto: StreamCrypto = StreamCrypto, ) { @@ -26,7 +25,7 @@ internal class SnapshotRetriever( InvalidProtocolBufferException::class, ) suspend fun getSnapshot(streamKey: ByteArray, storedSnapshot: StoredSnapshot): BackupSnapshot { - return storagePlugin.getBackupSnapshotInputStream(storedSnapshot).use { inputStream -> + return storagePlugin().getBackupSnapshotInputStream(storedSnapshot).use { inputStream -> val version = inputStream.readVersion() val timestamp = storedSnapshot.timestamp val ad = streamCrypto.getAssociatedDataForSnapshot(timestamp, version.toByte()) diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt index e25f0b76..c6c91f35 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/prune/Pruner.kt @@ -19,15 +19,15 @@ import kotlin.time.ExperimentalTime private val TAG = Pruner::class.java.simpleName -@Suppress("BlockingMethodInNonBlockingContext") internal class Pruner( private val db: Db, private val retentionManager: RetentionManager, - private val storagePlugin: StoragePlugin, + private val storagePluginGetter: () -> StoragePlugin, private val snapshotRetriever: SnapshotRetriever, streamCrypto: StreamCrypto = StreamCrypto, ) { + private val storagePlugin get() = storagePluginGetter() private val chunksCache = db.getChunksCache() private val streamKey = try { streamCrypto.deriveStreamKey(storagePlugin.getMasterKey()) diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt index 5871d222..9c207629 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/AbstractChunkRestore.kt @@ -14,14 +14,15 @@ import java.io.InputStream import java.io.OutputStream import java.security.GeneralSecurityException -@Suppress("BlockingMethodInNonBlockingContext") internal abstract class AbstractChunkRestore( - private val storagePlugin: StoragePlugin, + private val storagePluginGetter: () -> StoragePlugin, private val fileRestore: FileRestore, private val streamCrypto: StreamCrypto, private val streamKey: ByteArray, ) { + private val storagePlugin get() = storagePluginGetter() + @Throws(IOException::class, GeneralSecurityException::class) protected suspend fun getAndDecryptChunk( version: Int, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt index 16fc96f5..2b5f8e6d 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/MultiChunkRestore.kt @@ -24,7 +24,7 @@ private const val TAG = "MultiChunkRestore" @Suppress("BlockingMethodInNonBlockingContext") internal class MultiChunkRestore( private val context: Context, - storagePlugin: StoragePlugin, + storagePlugin: () -> StoragePlugin, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt index 16776135..e5d2ca4f 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt @@ -28,12 +28,13 @@ private const val TAG = "Restore" internal class Restore( context: Context, - private val storagePlugin: StoragePlugin, + private val storagePluginGetter: () -> StoragePlugin, private val snapshotRetriever: SnapshotRetriever, fileRestore: FileRestore, streamCrypto: StreamCrypto = StreamCrypto, ) { + private val storagePlugin get() = storagePluginGetter() private val streamKey by lazy { // This class might get instantiated before the StoragePlugin had time to provide the key // so we need to get it lazily here to prevent crashes. We can still crash later, @@ -47,13 +48,13 @@ internal class Restore( // lazily instantiate these, so they don't try to get the streamKey too early private val zipChunkRestore by lazy { - ZipChunkRestore(storagePlugin, fileRestore, streamCrypto, streamKey) + ZipChunkRestore(storagePluginGetter, fileRestore, streamCrypto, streamKey) } private val singleChunkRestore by lazy { - SingleChunkRestore(storagePlugin, fileRestore, streamCrypto, streamKey) + SingleChunkRestore(storagePluginGetter, fileRestore, streamCrypto, streamKey) } private val multiChunkRestore by lazy { - MultiChunkRestore(context, storagePlugin, fileRestore, streamCrypto, streamKey) + MultiChunkRestore(context, storagePluginGetter, fileRestore, streamCrypto, streamKey) } fun getBackupSnapshots(): Flow = flow { diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt index 75d4d3bc..a9fa530b 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/SingleChunkRestore.kt @@ -13,9 +13,8 @@ import org.calyxos.backup.storage.crypto.StreamCrypto private const val TAG = "SingleChunkRestore" -@Suppress("BlockingMethodInNonBlockingContext") internal class SingleChunkRestore( - storagePlugin: StoragePlugin, + storagePlugin: () -> StoragePlugin, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt index c6c995b0..608668da 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/ZipChunkRestore.kt @@ -17,9 +17,8 @@ import java.util.zip.ZipInputStream private const val TAG = "ZipChunkRestore" -@Suppress("BlockingMethodInNonBlockingContext") internal class ZipChunkRestore( - storagePlugin: StoragePlugin, + storagePlugin: () -> StoragePlugin, fileRestore: FileRestore, streamCrypto: StreamCrypto, streamKey: ByteArray, diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt index 0f93d35e..dd53e98c 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/BackupRestoreTest.kt @@ -58,7 +58,6 @@ import java.io.OutputStream import javax.crypto.spec.SecretKeySpec import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class BackupRestoreTest { @get:Rule @@ -71,9 +70,10 @@ internal class BackupRestoreTest { private val contentResolver: ContentResolver = mockk() private val fileScanner: FileScanner = mockk() + private val pluginGetter: () -> StoragePlugin = mockk() private val plugin: StoragePlugin = mockk() private val fileRestore: FileRestore = mockk() - private val snapshotRetriever = SnapshotRetriever(plugin) + private val snapshotRetriever = SnapshotRetriever(pluginGetter) private val cacheRepopulater: ChunksCacheRepopulater = mockk() init { @@ -84,6 +84,7 @@ internal class BackupRestoreTest { mockkStatic("org.calyxos.backup.storage.UriUtilsKt") + every { pluginGetter() } returns plugin every { db.getFilesCache() } returns filesCache every { db.getChunksCache() } returns chunksCache every { plugin.getMasterKey() } returns SecretKeySpec( @@ -94,11 +95,11 @@ internal class BackupRestoreTest { every { context.contentResolver } returns contentResolver } - private val restore = Restore(context, plugin, snapshotRetriever, fileRestore) + private val restore = Restore(context, pluginGetter, snapshotRetriever, fileRestore) @Test fun testZipAndSingleRandom(): Unit = runBlocking { - val backup = Backup(context, db, fileScanner, plugin, cacheRepopulater) + val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater) val smallFileMBytes = Random.nextBytes(Random.nextInt(SMALL_FILE_SIZE_MAX)) val smallFileM = getRandomMediaFile(smallFileMBytes.size) @@ -235,7 +236,7 @@ internal class BackupRestoreTest { @Test fun testMultiChunks(): Unit = runBlocking { - val backup = Backup(context, db, fileScanner, plugin, cacheRepopulater, 4) + val backup = Backup(context, db, fileScanner, pluginGetter, cacheRepopulater, 4) val chunk1 = byteArrayOf(0x00, 0x01, 0x02, 0x03) val chunk2 = byteArrayOf(0x04, 0x05, 0x06, 0x07) diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt index 51c3a554..eb668d3b 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/ChunksCacheRepopulaterTest.kt @@ -26,18 +26,19 @@ import org.junit.Assert.assertTrue import org.junit.Test import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class ChunksCacheRepopulaterTest { private val db: Db = mockk() private val chunksCache: ChunksCache = mockk() + private val pluginGetter: () -> StoragePlugin = mockk() private val plugin: StoragePlugin = mockk() private val snapshotRetriever: SnapshotRetriever = mockk() private val streamKey = "This is a backup key for testing".toByteArray() - private val cacheRepopulater = ChunksCacheRepopulater(db, plugin, snapshotRetriever) + private val cacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever) init { mockLog() + every { pluginGetter() } returns plugin every { db.getChunksCache() } returns chunksCache } diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt index f9a2c478..7da12a29 100644 --- a/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt +++ b/storage/lib/src/test/java/org/calyxos/backup/storage/prune/PrunerTest.kt @@ -32,11 +32,11 @@ import org.junit.Test import javax.crypto.spec.SecretKeySpec import kotlin.random.Random -@Suppress("BlockingMethodInNonBlockingContext") internal class PrunerTest { private val db: Db = mockk() private val chunksCache: ChunksCache = mockk() + private val pluginGetter: () -> StoragePlugin = mockk() private val plugin: StoragePlugin = mockk() private val snapshotRetriever: SnapshotRetriever = mockk() private val retentionManager: RetentionManager = mockk() @@ -46,12 +46,13 @@ internal class PrunerTest { init { mockLog(false) + every { pluginGetter() } returns plugin every { db.getChunksCache() } returns chunksCache every { plugin.getMasterKey() } returns masterKey every { streamCrypto.deriveStreamKey(masterKey) } returns streamKey } - private val pruner = Pruner(db, retentionManager, plugin, snapshotRetriever, streamCrypto) + private val pruner = Pruner(db, retentionManager, pluginGetter, snapshotRetriever, streamCrypto) @Test fun test() = runBlocking {