diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f2e5486..347c612c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,10 @@ jobs: cache: 'gradle' - name: Build + env: + NEXTCLOUD_URL: ${{ vars.NEXTCLOUD_URL }} + NEXTCLOUD_USER: ${{ secrets.NEXTCLOUD_USER }} + NEXTCLOUD_PASS: ${{ secrets.NEXTCLOUD_PASS }} run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck - name: Upload APKs diff --git a/Android.bp b/Android.bp index d61e977c..cdf2e5ac 100644 --- a/Android.bp +++ b/Android.bp @@ -44,6 +44,10 @@ android_app { "seedvault-lib-koin-android", // bip39 "seedvault-lib-kotlin-bip39", + // WebDAV + "seedvault-lib-dav4jvm", + "seedvault-lib-okhttp", + "seedvault-lib-okio", ], manifest: "app/src/main/AndroidManifest.xml", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ec43fa21..215f0a68 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -159,6 +159,9 @@ dependencies { implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar")) + // dav4jvm - later versions of okhttp need kotlin > 1.9.0 + implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar")) + /** * Test Dependencies (do not concern the AOSP build) */ 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 f5dddd70..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() @@ -49,7 +54,7 @@ class PluginTest : KoinComponent { @Before fun setup() = runBlocking { - every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage() + every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage() storage.rootBackupDir?.deleteContents(context) ?: error("Select a storage location in the app first!") } @@ -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.getStorage() ?: 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/e2e/LargeTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt index 69d0cf62..050d3c2b 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt @@ -22,7 +22,6 @@ import com.stevesoltys.seedvault.e2e.screen.impl.DocumentPickerScreen import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.permitDiskReads -import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.PackageService @@ -69,8 +68,6 @@ internal interface LargeTestBase : KoinComponent { val keyManager: KeyManager get() = get() - val documentsStorage: DocumentsStorage get() = get() - val spyMetadataManager: MetadataManager get() = get() val backupManager: IBackupManager get() = get() @@ -84,7 +81,6 @@ internal interface LargeTestBase : KoinComponent { fun resetApplicationState() { backupManager.setAutoRestore(false) settingsManager.setNewToken(null) - documentsStorage.reset(null) val sharedPreferences = permitDiskReads { PreferenceManager.getDefaultSharedPreferences(targetContext) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt index 83e638b6..78c0ab7b 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/impl/BackupRestoreTest.kt @@ -17,7 +17,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() { confirmCode() } - if (settingsManager.getStorage() == null) { + if (settingsManager.getSafStorage() == null) { chooseStorageLocation() } else { changeBackupLocation() 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..7bf20f42 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 @@ -5,6 +5,7 @@ import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.settings.AppStatus import com.stevesoltys.seedvault.settings.SettingsManager import io.mockk.every @@ -24,7 +25,9 @@ class PackageServiceTest : KoinComponent { private val settingsManager: SettingsManager by inject() - private val storagePlugin: StoragePlugin by inject() + private val storagePluginManager: StoragePluginManager by inject() + + private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin @Test fun testNotAllowedPackages() { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2278814..a24ebdac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,6 +16,9 @@ + + + @@ -97,7 +100,8 @@ + android:theme="@style/AppTheme.NoActionBar" + android:windowSoftInputMode="adjustResize" /> { 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(), + backupStateManager = 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( + app = this@App, + backupManager = get(), + backupInitializer = get(), + storageBackup = get(), + safHandler = get(), + webDavHandler = get(), + settingsManager = get(), + storagePluginManager = get(), + ) + } + viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) } + viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } viewModel { FileSelectionViewModel(this@App, get()) } } @@ -100,7 +128,8 @@ open class App : Application() { cryptoModule, headerModule, metadataModule, - documentsProviderModule, // storage plugin + storagePluginModuleSaf, + storagePluginModuleWebDav, backupModule, restoreModule, installModule, diff --git a/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt new file mode 100644 index 00000000..036a4387 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/BackupStateManager.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault + +import android.content.Context +import android.util.Log +import androidx.work.WorkInfo.State.RUNNING +import androidx.work.WorkManager +import com.stevesoltys.seedvault.storage.StorageBackupService +import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService +import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +private const val TAG = "BackupStateManager" + +class BackupStateManager( + context: Context, +) { + + private val workManager = WorkManager.getInstance(context) + + val isBackupRunning: Flow = combine( + flow = ConfigurableBackupTransportService.isRunning, + flow2 = StorageBackupService.isRunning, + flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME), + ) { appBackupRunning, filesBackupRunning, workInfos -> + val workInfoState = workInfos.getOrNull(0)?.state + Log.i( + TAG, "appBackupRunning: $appBackupRunning, " + + "filesBackupRunning: $filesBackupRunning, " + + "workInfoState: ${workInfoState?.name}" + ) + appBackupRunning || filesBackupRunning || workInfoState == RUNNING + } + +} 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 53becfac..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,17 @@ package com.stevesoltys.seedvault.plugins import android.app.backup.RestoreSet -import androidx.annotation.WorkerThread -import com.stevesoltys.seedvault.settings.Storage 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. + * @throws Exception any kind of exception to provide more info on the error + */ + suspend fun test(): Boolean /** * Start a new [RestoreSet] with the given token. @@ -47,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(storage: Storage): Boolean - /** * Get the set of all backups currently available for restore. * @@ -75,4 +71,7 @@ interface StoragePlugin { } -class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream) +class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream) + +internal val tokenRegex = Regex("([0-9]{13})") // good until the year 2286 +internal val chunkFolderRegex = Regex("[a-f0-9]{2}") 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..509c0f5d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/StoragePluginManager.kt @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins + +import android.content.Context +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.plugins.webdav.WebDavFactory +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.settings.StoragePluginType + +class StoragePluginManager( + private val context: Context, + private val settingsManager: SettingsManager, + safFactory: SafFactory, + webDavFactory: WebDavFactory, +) { + + private var mAppPlugin: StoragePlugin<*>? + private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin? + private var mStorageProperties: StorageProperties<*>? + + val appPlugin: StoragePlugin<*> + @Synchronized + get() { + return mAppPlugin ?: error("App plugin was loaded, but still null") + } + + val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin + @Synchronized + get() { + return mFilesPlugin ?: error("Files plugin was loaded, but still null") + } + + val storageProperties: StorageProperties<*>? + @Synchronized + get() { + return mStorageProperties + } + val isOnRemovableDrive: Boolean get() = storageProperties?.isUsb == true + + init { + when (settingsManager.storagePluginType) { + StoragePluginType.SAF -> { + val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved") + val documentsStorage = DocumentsStorage(context, settingsManager, safStorage) + mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage) + mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage) + mStorageProperties = safStorage + } + + StoragePluginType.WEB_DAV -> { + val webDavProperties = + settingsManager.webDavProperties ?: error("No WebDAV config saved") + mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config) + mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config) + mStorageProperties = webDavProperties + } + + null -> { + mAppPlugin = null + mFilesPlugin = null + mStorageProperties = null + } + } + } + + fun isValidAppPluginSet(): Boolean { + if (mAppPlugin == null || mFilesPlugin == null) return false + if (mAppPlugin 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) + mStorageProperties = storageProperties + mAppPlugin = appPlugin + mFilesPlugin = 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 + return !isOnUnavailableUsb() && + !storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork) + } + + /** + * Checks if storage is on a flash drive. + * + * Should be run off the UI thread (ideally I/O) because of disk access. + * + * @return true if flash drive is not plugged in, + * false if storage isn't on flash drive or it isn't plugged in. + */ + @WorkerThread + fun isOnUnavailableUsb(): Boolean { + val storage = storageProperties ?: return false + val systemContext = context.getStorageContext { storage.isUsb } + return storage.isUnavailableUsb(systemContext) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt new file mode 100644 index 00000000..eca9fb7f --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/StorageProperties.kt @@ -0,0 +1,36 @@ +/* + * 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 + +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) + } +} 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 e8e02baa..691921d8 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,12 +2,15 @@ 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 import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.StoragePlugin -import com.stevesoltys.seedvault.settings.Storage +import com.stevesoltys.seedvault.plugins.chunkFolderRegex +import com.stevesoltys.seedvault.plugins.tokenRegex +import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream @@ -15,22 +18,23 @@ 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.storage?.isUsb == true - } + private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb } private val packageManager: PackageManager = appContext.packageManager + override suspend fun test(): Boolean { + val dir = storage.rootBackupDir + return dir != null && dir.exists() + } + @Throws(IOException::class) override suspend fun startNewRestoreSet(token: Long) { // reset current storage @@ -71,16 +75,6 @@ internal class DocumentsProviderStoragePlugin( if (!file.delete()) throw IOException("Failed to delete $name") } - @Throws(IOException::class) - override suspend fun hasBackup(storage: Storage): Boolean { - // potentially get system user context if needed here - val c = appContext.getStorageContext { storage.isUsb } - val parent = DocumentFile.fromTreeUri(c, storage.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) @@ -104,7 +98,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 { @@ -137,9 +130,6 @@ internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List { + 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 new file mode 100644 index 00000000..35d58fba --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorage.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 androidx.annotation.WorkerThread +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.seedvault.plugins.StorageProperties + +data class SafStorage( + 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.") + + /** + * Returns true if this is USB storage that is not available, false otherwise. + * + * Must be run off UI thread (ideally I/O). + */ + @WorkerThread + override fun isUnavailableUsb(context: Context): Boolean { + return isUsb && !getDocumentFile(context).isDirectory + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/SafStorageOptions.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt similarity index 86% rename from app/src/main/java/com/stevesoltys/seedvault/ui/storage/SafStorageOptions.kt rename to app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt index 6d86e860..b20511c1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/SafStorageOptions.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/SafStorageOptions.kt @@ -1,4 +1,9 @@ -package com.stevesoltys.seedvault.ui.storage +/* + * 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.Intent @@ -9,8 +14,13 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5 +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE +import com.stevesoltys.seedvault.ui.storage.StorageOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption -import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon private const val DAVX5_PACKAGE = "at.bitfire.davdroid" private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity" @@ -29,15 +39,15 @@ internal class SafStorageOptions( private val packageManager = context.packageManager - internal fun checkOrAddExtraRoots(roots: ArrayList) { + internal fun checkOrAddExtraRoots(roots: ArrayList) { checkOrAddUsbRoot(roots) checkOrAddDavX5Root(roots) checkOrAddNextCloudRoot(roots) checkOrAddRoundSyncRoots(roots) } - private fun checkOrAddUsbRoot(roots: ArrayList) { - if (doNotInclude(AUTHORITY_STORAGE, roots) { it.isUsb }) return + private fun checkOrAddUsbRoot(roots: ArrayList) { + if (doNotInclude(AUTHORITY_STORAGE, roots) { it is SafOption && it.isUsb }) return val root = SafOption( authority = AUTHORITY_STORAGE, @@ -57,11 +67,11 @@ internal class SafStorageOptions( /** * Add a storage root for each child directory at the RoundSync root, if it exists. */ - private fun checkOrAddRoundSyncRoots(roots: ArrayList) { + private fun checkOrAddRoundSyncRoots(roots: ArrayList) { val roundSyncRoot = roots.firstOrNull { - it.authority == AUTHORITY_ROUND_SYNC - } ?: return + it is SafOption && it.authority == AUTHORITY_ROUND_SYNC + } as? SafOption ?: return roots.remove(roundSyncRoot) @@ -105,7 +115,7 @@ internal class SafStorageOptions( * * If it *is* installed and this is restore, the user can set up a new account by clicking. */ - private fun checkOrAddDavX5Root(roots: ArrayList) { + private fun checkOrAddDavX5Root(roots: ArrayList) { if (doNotInclude(AUTHORITY_DAVX5, roots)) return val intent = Intent().apply { @@ -155,7 +165,7 @@ internal class SafStorageOptions( * because we don't know if there's just no account or an activated passcode * (which hides existing accounts). */ - private fun checkOrAddNextCloudRoot(roots: ArrayList) { + private fun checkOrAddNextCloudRoot(roots: ArrayList) { if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return val intent = Intent().apply { @@ -202,11 +212,12 @@ internal class SafStorageOptions( private fun doNotInclude( authority: String, - roots: ArrayList, - doNotIncludeIfTrue: ((SafOption) -> Boolean)? = null, + roots: ArrayList, + doNotIncludeIfTrue: ((StorageOption) -> Boolean)? = null, ): Boolean { if (!isAuthoritySupported(authority)) return true for (root in roots) { + if (root !is SafOption) continue if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) { return true } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt similarity index 87% rename from app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt rename to app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt index 9b9c95bb..d69e7de8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/StorageRootResolver.kt @@ -1,4 +1,9 @@ -package com.stevesoltys.seedvault.ui.storage +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.saf import android.Manifest.permission.MANAGE_DOCUMENTS import android.content.Context @@ -19,8 +24,16 @@ import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD import android.util.Log +import androidx.appcompat.content.res.AppCompatResources.getDrawable import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.getStorageContext +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5 +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DOWNLOADS +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC +import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE +import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE +import com.stevesoltys.seedvault.ui.storage.ROOT_ID_HOME import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption internal object StorageRootResolver { @@ -126,23 +139,23 @@ internal object StorageRootResolver { fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? { return getPackageIcon(context, authority, icon) ?: when { authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> { - context.getDrawable(R.drawable.ic_phone_android) + getDrawable(context, R.drawable.ic_phone_android) } authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> { - context.getDrawable(R.drawable.ic_usb) + getDrawable(context, R.drawable.ic_usb) } authority == AUTHORITY_NEXTCLOUD -> { - context.getDrawable(R.drawable.nextcloud) + getDrawable(context, R.drawable.nextcloud) } authority == AUTHORITY_DAVX5 -> { - context.getDrawable(R.drawable.davx5) + getDrawable(context, R.drawable.davx5) } authority == AUTHORITY_ROUND_SYNC -> { - context.getDrawable(R.drawable.round_sync) + getDrawable(context, R.drawable.round_sync) } else -> null diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt new file mode 100644 index 00000000..b95ff71b --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavConfig.kt @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.webdav + +data class WebDavConfig( + val url: String, + val username: String, + val password: String, +) diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt new file mode 100644 index 00000000..f11728e1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavFactory.kt @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.webdav + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings +import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.plugins.StoragePlugin + +class WebDavFactory( + private val context: Context, + private val keyManager: KeyManager, +) { + + fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin { + return WebDavStoragePlugin(context, config) + } + + fun createFilesStoragePlugin( + config: WebDavConfig, + ): org.calyxos.backup.storage.api.StoragePlugin { + @SuppressLint("HardwareIds") + val androidId = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + return com.stevesoltys.seedvault.storage.WebDavStoragePlugin( + keyManager = keyManager, + androidId = androidId, + webDavConfig = config, + ) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt new file mode 100644 index 00000000..667b7568 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavHandler.kt @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.webdav + +import android.content.Context +import android.util.Log +import androidx.annotation.WorkerThread +import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.settings.SettingsManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException + +internal sealed interface WebDavConfigState { + object Empty : WebDavConfigState + object Checking : WebDavConfigState + class Success( + val properties: WebDavProperties, + val plugin: WebDavStoragePlugin, + ) : WebDavConfigState + + class Error(val e: Exception?) : WebDavConfigState +} + +private val TAG = WebDavHandler::class.java.simpleName + +internal class WebDavHandler( + private val context: Context, + private val webDavFactory: WebDavFactory, + private val settingsManager: SettingsManager, + private val storagePluginManager: StoragePluginManager, +) { + + companion object { + fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties { + val host = config.url.toHttpUrl().host + return WebDavProperties( + config = config, + name = context.getString(R.string.storage_webdav_name, host), + ) + } + } + + private val mConfigState = MutableStateFlow(WebDavConfigState.Empty) + val configState = mConfigState.asStateFlow() + + suspend fun onConfigReceived(config: WebDavConfig) { + mConfigState.value = WebDavConfigState.Checking + val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin + try { + if (plugin.test()) { + val properties = createWebDavProperties(context, config) + mConfigState.value = WebDavConfigState.Success(properties, plugin) + } else { + mConfigState.value = WebDavConfigState.Error(null) + } + } catch (e: Exception) { + Log.e(TAG, "Error testing WebDAV config at ${config.url}", e) + mConfigState.value = WebDavConfigState.Error(e) + } + } + + fun resetConfigState() { + mConfigState.value = WebDavConfigState.Empty + } + + /** + * 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(appPlugin: WebDavStoragePlugin): Boolean { + val backups = appPlugin.getAvailableBackups() + return backups != null && backups.iterator().hasNext() + } + + fun save(properties: WebDavProperties) { + settingsManager.saveWebDavConfig(properties.config) + } + + fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + storagePluginManager.changePlugins( + storageProperties = properties, + appPlugin = plugin, + filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config), + ) + } + +} 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 new file mode 100644 index 00000000..807c976a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavModule.kt @@ -0,0 +1,9 @@ +package com.stevesoltys.seedvault.plugins.webdav + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val storagePluginModuleWebDav = module { + single { WebDavFactory(androidContext(), get()) } + single { WebDavHandler(androidContext(), get(), get(), get()) } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt new file mode 100644 index 00000000..29ea84a9 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavProperties.kt @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.webdav + +import android.content.Context +import com.stevesoltys.seedvault.plugins.StorageProperties + +data class WebDavProperties( + override val config: WebDavConfig, + override val name: String, +) : StorageProperties() { + override val isUsb: Boolean = false + override val requiresNetwork: Boolean = true + override fun isUnavailableUsb(context: Context): Boolean = false +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt new file mode 100644 index 00000000..e1174278 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStorage.kt @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.plugins.webdav + +import android.util.Log +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.ResourceType +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.internal.closeQuietly +import okio.BufferedSink +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +const val DEBUG_LOG = true +const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" + +@OptIn(DelicateCoroutinesApi::class) +internal abstract class WebDavStorage( + webDavConfig: WebDavConfig, + root: String = DIRECTORY_ROOT, +) { + + companion object { + val TAG: String = WebDavStorage::class.java.simpleName + } + + private val authHandler = BasicDigestAuthHandler( + domain = null, // Optional, to only authenticate against hosts with this domain. + username = webDavConfig.username, + password = webDavConfig.password, + ) + protected val okHttpClient = OkHttpClient.Builder() + .followRedirects(false) + .authenticator(authHandler) + .addNetworkInterceptor(authHandler) + .connectTimeout(15, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .pingInterval(45, TimeUnit.SECONDS) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) + .retryOnConnectionFailure(true) + .build() + + protected val baseUrl = webDavConfig.url + protected val url = "${webDavConfig.url}/$root" + + @Throws(IOException::class) + protected suspend fun getOutputStream(location: HttpUrl): OutputStream { + val davCollection = DavCollection(okHttpClient, location) + + val pipedInputStream = PipedInputStream() + val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream) + + val body = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaType() + override fun writeTo(sink: BufferedSink) { + pipedInputStream.use { inputStream -> + sink.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + } + val deferred = GlobalScope.async(Dispatchers.IO) { + davCollection.put(body) { response -> + debugLog { "getOutputStream($location) = $response" } + } + } + pipedOutputStream.doOnClose { + runBlocking { // blocking i/o wait + deferred.await() + } + } + return pipedOutputStream + } + + @Throws(IOException::class) + protected fun getInputStream(location: HttpUrl): InputStream { + val davCollection = DavCollection(okHttpClient, location) + + val pipedInputStream = PipedExceptionInputStream() + val pipedOutputStream = PipedOutputStream(pipedInputStream) + + GlobalScope.launch(Dispatchers.IO) { + try { + davCollection.get(accept = "", headers = null) { response -> + val inputStream = response.body?.byteStream() + ?: throw IOException("No response body") + debugLog { "getInputStream($location) = $response" } + pipedOutputStream.use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: Exception) { + debugLog { "Exception while getting input stream: $e" } + // pass exception to stream, so it gets thrown when stream is closed + // if we'd just throw it here, it would be uncaught, on a different thread + pipedInputStream.throwable = e + pipedOutputStream.closeQuietly() + } + } + return pipedInputStream + } + + protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response { + return try { + suspendCoroutine { cont -> + mkCol(xmlBody) { response -> + cont.resume(response) + } + } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + protected inline fun debugLog(block: () -> String) { + if (DEBUG_LOG) Log.d(TAG, block()) + } + + protected fun Response.isFolder(): Boolean { + return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true + } + + private class PipedCloseActionOutputStream( + inputStream: PipedInputStream, + ) : PipedOutputStream(inputStream) { + + private var onClose: (() -> Unit)? = null + + @Throws(IOException::class) + override fun close() { + super.close() + try { + onClose?.invoke() + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + fun doOnClose(function: () -> Unit) { + this.onClose = function + } + } + + private class PipedExceptionInputStream : PipedInputStream() { + var throwable: Throwable? = null + + override fun close() { + super.close() + throwable?.let { e -> + if (e is IOException) throw e + else throw IOException(e) + } + } + } + +} 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 new file mode 100644 index 00000000..f88e2d95 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/webdav/WebDavStoragePlugin.kt @@ -0,0 +1,200 @@ +package com.stevesoltys.seedvault.plugins.webdav + +import android.content.Context +import android.util.Log +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.Response.HrefRelation.SELF +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.ResourceType +import com.stevesoltys.seedvault.plugins.EncryptedMetadata +import com.stevesoltys.seedvault.plugins.StoragePlugin +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 okhttp3.HttpUrl.Companion.toHttpUrl +import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal class WebDavStoragePlugin( + context: Context, + webDavConfig: WebDavConfig, + root: String = DIRECTORY_ROOT, +) : WebDavStorage(webDavConfig, root), StoragePlugin { + + override suspend fun test(): Boolean { + val location = baseUrl.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val webDavSupported = suspendCoroutine { cont -> + davCollection.options { davCapabilities, response -> + debugLog { "test() = $davCapabilities $response" } + if (davCapabilities.contains("1")) cont.resume(true) + else if (davCapabilities.contains("2")) cont.resume(true) + else if (davCapabilities.contains("3")) cont.resume(true) + else cont.resume(false) + } + } + return webDavSupported + } + + @Throws(IOException::class) + override suspend fun startNewRestoreSet(token: Long) { + val location = "$url/$token".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + val response = davCollection.createFolder() + debugLog { "startNewRestoreSet($token) = $response" } + } + + @Throws(IOException::class) + override suspend fun initializeDevice() { + // TODO does it make sense to delete anything + // when [startNewRestoreSet] is always called first? Maybe unify both calls? + val location = url.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + try { + davCollection.head { response -> + debugLog { "Root exists: $response" } + } + } catch (e: NotFoundException) { + val response = davCollection.createFolder() + debugLog { "initializeDevice() = $response" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + @Throws(IOException::class) + override suspend fun hasData(token: Long, name: String): Boolean { + val location = "$url/$token/$name".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + return try { + val response = suspendCoroutine { cont -> + davCollection.head { response -> + cont.resume(response) + } + } + debugLog { "hasData($token, $name) = $response" } + response.isSuccessful + } catch (e: NotFoundException) { + debugLog { "hasData($token, $name) = $e" } + false + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + @Throws(IOException::class) + override suspend fun getOutputStream(token: Long, name: String): OutputStream { + val location = "$url/$token/$name".toHttpUrl() + return try { + getOutputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting OutputStream for $token and $name: ", e) + } + } + + @Throws(IOException::class) + override suspend fun getInputStream(token: Long, name: String): InputStream { + val location = "$url/$token/$name".toHttpUrl() + return try { + getInputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting InputStream for $token and $name: ", e) + } + } + + @Throws(IOException::class) + override suspend fun removeData(token: Long, name: String) { + val location = "$url/$token/$name".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + try { + val response = suspendCoroutine { cont -> + davCollection.delete { response -> + cont.resume(response) + } + } + debugLog { "removeData($token, $name) = $response" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + override suspend fun getAvailableBackups(): Sequence? { + return try { + doGetAvailableBackups() + } catch (e: Exception) { + Log.e(TAG, "Error getting available backups: ", e) + null + } + } + + private suspend fun doGetAvailableBackups(): Sequence { + val location = url.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + // get all restore set tokens in root folder + val tokens = ArrayList() + davCollection.propfind( + depth = 2, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), + ) { response, relation -> + debugLog { "getAvailableBackups() = $response" } + // This callback will be called for every file in the folder + if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 && + response.hrefName() == FILE_BACKUP_METADATA + ) { + val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2] + getTokenOrNull(tokenName)?.let { token -> + tokens.add(token) + } + } + } + val tokenIterator = tokens.iterator() + return generateSequence { + if (!tokenIterator.hasNext()) return@generateSequence null // end sequence + val token = tokenIterator.next() + EncryptedMetadata(token) { + getInputStream(token, FILE_BACKUP_METADATA) + } + } + } + + private fun getTokenOrNull(name: String): Long? { + val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name) + if (looksLikeToken) { + return try { + name.toLong() + } catch (e: NumberFormatException) { + throw AssertionError(e) // regex must be wrong + } + } + if (isUnexpectedFile(name)) { + Log.w(TAG, "Found invalid backup set folder: $name") + } + return null + } + + private fun isUnexpectedFile(name: String): Boolean { + return name != FILE_NO_MEDIA && + !chunkFolderRegex.matches(name) && + !name.endsWith(SNAPSHOT_EXT) + } + + override val providerPackageName: String = context.packageName // 100% built-in plugin + +} 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 be3796a6..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.getStorage() + 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 83589026..904e16f9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -22,6 +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.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 @@ -33,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 @@ -48,7 +50,8 @@ class SettingsFragment : PreferenceFragmentCompat() { private var menuBackupNow: MenuItem? = null private var menuRestore: MenuItem? = null - private var storage: Storage? = null + private val storageProperties: StorageProperties<*>? + get() = storagePluginManager.storageProperties override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { @@ -88,8 +91,14 @@ class SettingsFragment : PreferenceFragmentCompat() { backupLocation = findPreference("backup_location")!! backupLocation.setOnPreferenceClickListener { - viewModel.chooseBackupLocation() - true + if (viewModel.isBackupRunning.value) { + // don't allow changing backup destination while backup is running + // TODO we could show toast or snackbar here + false + } else { + viewModel.chooseBackupLocation() + true + } } autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!! @@ -148,7 +157,6 @@ class SettingsFragment : PreferenceFragmentCompat() { setAppBackupStatusSummary(time) } viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo -> - viewModel.onWorkerStateChanged() setAppBackupSchedulingSummary(workInfo) } @@ -164,7 +172,6 @@ class SettingsFragment : PreferenceFragmentCompat() { // we need to re-set the title when returning to this fragment activity?.setTitle(R.string.backup) - storage = settingsManager.getStorage() setBackupEnabledState() setBackupLocationSummary() setAutoRestoreState() @@ -241,7 +248,7 @@ class SettingsFragment : PreferenceFragmentCompat() { activity?.contentResolver?.let { autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1 } - val storage = this.storage + 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) @@ -252,7 +259,8 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setBackupLocationSummary() { // get name of storage location - backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none) + backupLocation.summary = + storageProperties?.name ?: getString(R.string.settings_backup_location_none) } private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) { @@ -271,7 +279,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 (storage?.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 9584ffea..966276bf 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,17 @@ 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.plugins.webdav.WebDavConfig +import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties +import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties +import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import java.util.concurrent.ConcurrentSkipListSet @@ -22,6 +24,13 @@ 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 StoragePluginType { // don't rename, will break existing installs + SAF, + WEB_DAV, +} + 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" @@ -32,6 +41,10 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber" private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId" private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId" +private const val PREF_KEY_WEBDAV_URL = "webDavUrl" +private const val PREF_KEY_WEBDAV_USER = "webDavUser" +private const val PREF_KEY_WEBDAV_PASS = "webDavPass" + private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist" private const val PREF_KEY_BACKUP_STORAGE = "backup_storage" @@ -88,24 +101,55 @@ class SettingsManager(private val context: Context) { token = newToken } - // FIXME Storage is currently plugin specific and not generic - fun setStorage(storage: Storage) { + internal val storagePluginType: StoragePluginType? + get() { + val savedType = prefs.getString(PREF_KEY_STORAGE_PLUGIN, null) + return if (savedType == null) { + // check if this is an existing user that needs to be migrated + // this check could be removed after a reasonable migration time (added 2024) + if (prefs.getString(PREF_KEY_STORAGE_URI, null) != null) { + prefs.edit() + .putString(PREF_KEY_STORAGE_PLUGIN, StoragePluginType.SAF.name) + .apply() + StoragePluginType.SAF + } else null + } else savedType.let { + try { + StoragePluginType.valueOf(it) + } catch (e: IllegalArgumentException) { + null + } + } + } + + fun setStoragePlugin(plugin: StoragePlugin<*>) { + val value = when (plugin) { + is DocumentsProviderStoragePlugin -> StoragePluginType.SAF + is WebDavStoragePlugin -> StoragePluginType.WEB_DAV + else -> error("Unsupported plugin: ${plugin::class.java.simpleName}") + }.name prefs.edit() - .putString(PREF_KEY_STORAGE_URI, storage.uri.toString()) - .putString(PREF_KEY_STORAGE_NAME, storage.name) - .putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) - .putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork) + .putString(PREF_KEY_STORAGE_PLUGIN, value) .apply() } - fun getStorage(): Storage? { + fun setSafStorage(safStorage: SafStorage) { + prefs.edit() + .putString(PREF_KEY_STORAGE_URI, safStorage.uri.toString()) + .putString(PREF_KEY_STORAGE_NAME, safStorage.name) + .putBoolean(PREF_KEY_STORAGE_IS_USB, safStorage.isUsb) + .putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, safStorage.requiresNetwork) + .apply() + } + + fun getSafStorage(): SafStorage? { val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null val uri = Uri.parse(uriStr) val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException("no storage name") val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false) - return Storage(uri, name, isUsb, requiresNetwork) + return SafStorage(uri, name, isUsb, requiresNetwork) } fun setFlashDrive(usb: FlashDrive?) { @@ -134,20 +178,22 @@ class SettingsManager(private val context: Context) { return FlashDrive(name, serialNumber, vendorId, productId) } - /** - * 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 = getStorage() ?: return false - val systemContext = context.getStorageContext { storage.isUsb } - return !storage.isUnavailableUsb(systemContext) && - !storage.isUnavailableNetwork(context, useMeteredNetwork) + val webDavProperties: WebDavProperties? + get() { + val config = WebDavConfig( + url = prefs.getString(PREF_KEY_WEBDAV_URL, null) ?: return null, + username = prefs.getString(PREF_KEY_WEBDAV_USER, null) ?: return null, + password = prefs.getString(PREF_KEY_WEBDAV_PASS, null) ?: return null, + ) + return createWebDavProperties(context, config) + } + + fun saveWebDavConfig(config: WebDavConfig) { + prefs.edit() + .putString(PREF_KEY_WEBDAV_URL, config.url) + .putString(PREF_KEY_WEBDAV_USER, config.username) + .putString(PREF_KEY_WEBDAV_PASS, config.password) + .apply() } fun backupApks(): Boolean { @@ -195,42 +241,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 99310110..072a0675 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -28,12 +28,14 @@ import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.DiffUtil.calculateDiff import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE -import androidx.work.WorkInfo import androidx.work.WorkManager +import com.stevesoltys.seedvault.BackupStateManager 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 @@ -44,6 +46,9 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.calyxos.backup.storage.api.StorageBackup @@ -59,12 +64,14 @@ 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) { + backupStateManager: BackupStateManager, +) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) { private val contentResolver = app.contentResolver private val connectivityManager: ConnectivityManager? = @@ -73,6 +80,7 @@ internal class SettingsViewModel( override val isRestoreOperation = false + val isBackupRunning: StateFlow private val mBackupPossible = MutableLiveData(false) val backupPossible: LiveData = mBackupPossible @@ -91,11 +99,11 @@ internal class SettingsViewModel( private val mAppEditMode = MutableLiveData() internal val appEditMode: LiveData = mAppEditMode - private val _filesSummary = MutableLiveData() - internal val filesSummary: LiveData = _filesSummary + private val mFilesSummary = MutableLiveData() + internal val filesSummary: LiveData = mFilesSummary - private val _initEvent = MutableLiveEvent() - val initEvent: LiveEvent = _initEvent + private val mInitEvent = MutableLiveEvent() + val initEvent: LiveEvent = mInitEvent private val storageObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) { @@ -122,18 +130,27 @@ internal class SettingsViewModel( // this shouldn't cause disk reads, but it still does viewModelScope } + isBackupRunning = backupStateManager.isBackupRunning.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) scope.launch { // ensures the lastBackupTime LiveData gets set metadataManager.getLastBackupTime() + // update running state + isBackupRunning.collect { + onBackupRunningStateChanged() + } } onStoragePropertiesChanged() loadFilesSummary() } override fun onStorageLocationChanged() { - val storage = settingsManager.getStorage() ?: 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() @@ -147,26 +164,29 @@ internal class SettingsViewModel( onStoragePropertiesChanged() } - fun onWorkerStateChanged() { - viewModelScope.launch(Dispatchers.IO) { - val canDo = settingsManager.canDoBackupNow() && - appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING + private fun onBackupRunningStateChanged() { + if (isBackupRunning.value) mBackupPossible.postValue(false) + else viewModelScope.launch(Dispatchers.IO) { + val canDo = !isBackupRunning.value && !pluginManager.isOnUnavailableUsb() mBackupPossible.postValue(canDo) } } private fun onStoragePropertiesChanged() { - val storage = settingsManager.getStorage() ?: 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 @@ -174,6 +194,7 @@ internal class SettingsViewModel( connectivityManager?.unregisterNetworkCallback(networkCallback) networkCallback.registered = false } else if (!networkCallback.registered && storage.requiresNetwork) { + // TODO we may want to warn the user when they start a backup on a metered connection val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() @@ -181,7 +202,7 @@ internal class SettingsViewModel( networkCallback.registered = true } // update whether we can do backups right now or not - onWorkerStateChanged() + onBackupRunningStateChanged() } override fun onCleared() { @@ -200,8 +221,7 @@ internal class SettingsViewModel( i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(app, i) } else { - val isUsb = settingsManager.getStorage()?.isUsb ?: false - AppBackupWorker.scheduleNow(app, reschedule = !isUsb) + AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive) } } } @@ -232,7 +252,7 @@ internal class SettingsViewModel( @UiThread fun loadFilesSummary() = viewModelScope.launch { val uriSummary = storageBackup.getUriSummaryString() - _filesSummary.value = uriSummary.ifEmpty { + mFilesSummary.value = uriSummary.ifEmpty { app.getString(R.string.settings_backup_files_summary) } } @@ -248,10 +268,10 @@ internal class SettingsViewModel( } viewModelScope.launch(Dispatchers.IO) { backupInitializer.initialize(onError) { - _initEvent.postEvent(false) + mInitEvent.postEvent(false) scheduleAppBackup(CANCEL_AND_REENQUEUE) } - _initEvent.postEvent(true) + mInitEvent.postEvent(true) } } // enable call log backups for existing installs (added end of 2020) @@ -280,20 +300,19 @@ internal class SettingsViewModel( } fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) { - val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb && backupManager.isBackupEnabled) { + if (!pluginManager.isOnRemovableDrive && backupManager.isBackupEnabled) { AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy) } } fun scheduleFilesBackup() { - val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) { + if (!pluginManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) { + val requiresNetwork = pluginManager.storageProperties?.requiresNetwork == true BackupJobService.scheduleJob( context = app, jobServiceClass = StorageBackupJobService::class.java, periodMillis = HOURS.toMillis(24), - networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED + networkType = if (requiresNetwork) NETWORK_TYPE_UNMETERED else NETWORK_TYPE_NONE, deviceIdle = false, charging = true @@ -301,7 +320,7 @@ internal class SettingsViewModel( } } - fun cancelAppBackup() { + private fun cancelAppBackup() { AppBackupWorker.unschedule(app) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt similarity index 73% rename from app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt rename to app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt index d9657380..7a3e8a1f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultSafStoragePlugin.kt @@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin import javax.crypto.SecretKey -internal class SeedvaultStoragePlugin( +internal class SeedvaultSafStoragePlugin( private val appContext: Context, private val storage: DocumentsStorage, private val keyManager: KeyManager, @@ -16,12 +16,8 @@ internal class SeedvaultStoragePlugin( /** * Attention: This context might be from a different user. Use with care. */ - override val context: Context - get() = appContext.getStorageContext { - storage.storage?.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 5a9096d3..7a2c36af 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -1,8 +1,10 @@ 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import org.calyxos.backup.storage.api.BackupObserver import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.StorageBackup @@ -31,19 +33,31 @@ internal class StorageBackupService : BackupService() { companion object { internal const val EXTRA_START_APP_BACKUP = "startAppBackup" + private val mIsRunning = MutableStateFlow(false) + val isRunning = mIsRunning.asStateFlow() } 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 { NotificationBackupObserver(applicationContext) } + override fun onCreate() { + super.onCreate() + mIsRunning.value = true + } + + override fun onDestroy() { + super.onDestroy() + mIsRunning.value = false + } + override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - val isUsb = settingsManager.getStorage()?.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 68254e88..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 { SeedvaultStoragePlugin(get(), get(), get()) } - single { StorageBackup(get(), get()) } + single { StorageBackup(get(), { get().filesPlugin }) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt new file mode 100644 index 00000000..b7bdcc05 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/WebDavStoragePlugin.kt @@ -0,0 +1,291 @@ +package com.stevesoltys.seedvault.storage + +import android.util.Log +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.Response.HrefRelation.SELF +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.ResourceType +import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.plugins.chunkFolderRegex +import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT +import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig +import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.calyxos.backup.storage.api.StoragePlugin +import org.calyxos.backup.storage.api.StoredSnapshot +import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT +import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex +import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex +import org.koin.core.time.measureDuration +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import javax.crypto.SecretKey +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal class WebDavStoragePlugin( + private val keyManager: KeyManager, + /** + * The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + */ + androidId: String, + webDavConfig: WebDavConfig, + root: String = DIRECTORY_ROOT, +) : WebDavStorage(webDavConfig, root), StoragePlugin { + + /** + * The folder name is our user ID plus .sv extension (for SeedVault). + * The user or `androidId` is unique to each combination of app-signing key, user, and device + * so we don't leak anything by not hashing this and can use it as is. + */ + private val folder: String = "$androidId.sv" + + @Throws(IOException::class) + override suspend fun init() { + val location = url.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + try { + davCollection.head { response -> + debugLog { "Root exists: $response" } + } + } catch (e: NotFoundException) { + val response = davCollection.createFolder() + debugLog { "init() = $response" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + @Throws(IOException::class) + override suspend fun getAvailableChunkIds(): List { + val location = "$url/$folder".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + debugLog { "getAvailableChunkIds($location)" } + + val expectedChunkFolders = (0x00..0xff).map { + Integer.toHexString(it).padStart(2, '0') + }.toHashSet() + val chunkIds = ArrayList() + try { + val duration = measureDuration { + davCollection.propfind( + depth = 2, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), + ) { response, relation -> + debugLog { "getAvailableChunkIds() = $response" } + // This callback will be called for every file in the folder + if (relation != SELF && response.isFolder()) { + val name = response.hrefName() + if (chunkFolderRegex.matches(name)) { + expectedChunkFolders.remove(name) + } + } else if (relation != SELF && response.href.pathSize >= 2) { + val folderName = + response.href.pathSegments[response.href.pathSegments.size - 2] + if (folderName != folder && chunkFolderRegex.matches(folderName)) { + val name = response.hrefName() + if (chunkRegex.matches(name)) chunkIds.add(name) + } + } + } + } + Log.i(TAG, "Retrieving chunks took $duration") + } catch (e: NotFoundException) { + debugLog { "Folder not found: $location" } + davCollection.createFolder() + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error populating chunk folders: ", e) + } + Log.i(TAG, "Got ${chunkIds.size} available chunks") + createMissingChunkFolders(expectedChunkFolders) + return chunkIds + } + + @Throws(IOException::class) + private suspend fun createMissingChunkFolders( + missingChunkFolders: Set, + ) { + val s = missingChunkFolders.size + for ((i, chunkFolderName) in missingChunkFolders.withIndex()) { + val location = "$url/$folder/$chunkFolderName".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + val response = davCollection.createFolder() + debugLog { "Created missing folder $chunkFolderName (${i + 1}/$s) $response" } + } + } + + override fun getMasterKey(): SecretKey = keyManager.getMainKey() + override fun hasMasterKey(): Boolean = keyManager.hasMainKey() + + @Throws(IOException::class) + override suspend fun getChunkOutputStream(chunkId: String): OutputStream { + val chunkFolderName = chunkId.substring(0, 2) + val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() + debugLog { "getChunkOutputStream($location) for $chunkId" } + return try { + getOutputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting OutputStream for $chunkId: ", e) + } + } + + @Throws(IOException::class) + override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { + val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl() + debugLog { "getBackupSnapshotOutputStream($location)" } + return try { + getOutputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting OutputStream for $timestamp$SNAPSHOT_EXT: ", e) + } + } + + /************************* Restore *******************************/ + + @Throws(IOException::class) + override suspend fun getBackupSnapshotsForRestore(): List { + val location = url.toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + debugLog { "getBackupSnapshotsForRestore($location)" } + + val snapshots = ArrayList() + try { + davCollection.propfind( + depth = 2, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), + ) { response, relation -> + debugLog { "getBackupSnapshotsForRestore() = $response" } + // This callback will be called for every file in the folder + if (relation != SELF && !response.isFolder()) { + val name = response.hrefName() + val match = snapshotRegex.matchEntire(name) + if (match != null) { + val timestamp = match.groupValues[1].toLong() + val folderName = + response.href.pathSegments[response.href.pathSegments.size - 2] + val storedSnapshot = StoredSnapshot(folderName, timestamp) + snapshots.add(storedSnapshot) + } + } + } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting snapshots for restore: ", e) + } + return snapshots + } + + @Throws(IOException::class) + override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream { + val timestamp = storedSnapshot.timestamp + val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() + debugLog { "getBackupSnapshotInputStream($location)" } + return try { + getInputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting InputStream for $storedSnapshot: ", e) + } + } + + @Throws(IOException::class) + override suspend fun getChunkInputStream( + snapshot: StoredSnapshot, + chunkId: String, + ): InputStream { + val chunkFolderName = chunkId.substring(0, 2) + val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl() + debugLog { "getChunkInputStream($location) for $chunkId" } + return try { + getInputStream(location) + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e) + } + } + + /************************* Pruning *******************************/ + + @Throws(IOException::class) + override suspend fun getCurrentBackupSnapshots(): List { + val location = "$url/$folder".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + debugLog { "getCurrentBackupSnapshots($location)" } + + val snapshots = ArrayList() + try { + val duration = measureDuration { + davCollection.propfind( + depth = 1, + reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME), + ) { response, relation -> + debugLog { "getCurrentBackupSnapshots() = $response" } + // This callback will be called for every file in the folder + if (relation != SELF && !response.isFolder()) { + val match = snapshotRegex.matchEntire(response.hrefName()) + if (match != null) { + val timestamp = match.groupValues[1].toLong() + val storedSnapshot = StoredSnapshot(folder, timestamp) + snapshots.add(storedSnapshot) + } + } + } + } + Log.i(TAG, "getCurrentBackupSnapshots took $duration") + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException("Error getting current snapshots: ", e) + } + Log.i(TAG, "Got ${snapshots.size} snapshots.") + return snapshots + } + + @Throws(IOException::class) + override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) { + val timestamp = storedSnapshot.timestamp + Log.d(TAG, "Deleting snapshot $timestamp") + + val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + try { + val response = suspendCoroutine { cont -> + davCollection.delete { response -> + cont.resume(response) + } + } + debugLog { "deleteBackupSnapshot() = $response" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + + @Throws(IOException::class) + override suspend fun deleteChunks(chunkIds: List) { + chunkIds.forEach { chunkId -> + val chunkFolderName = chunkId.substring(0, 2) + val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl() + val davCollection = DavCollection(okHttpClient, location) + + try { + val response = suspendCoroutine { cont -> + davCollection.delete { response -> + cont.resume(response) + } + } + debugLog { "deleteChunks($chunkId) = $response" } + } catch (e: Exception) { + if (e is IOException) throw e + else throw IOException(e) + } + } + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt index 9d81d3e5..d564aea8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt @@ -7,6 +7,8 @@ import android.os.IBinder import android.util.Log import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -18,6 +20,11 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName */ class ConfigurableBackupTransportService : Service(), KoinComponent { + companion object { + private val mIsRunning = MutableStateFlow(false) + val isRunning = mIsRunning.asStateFlow() + } + private var transport: ConfigurableBackupTransport? = null private val keyManager: KeyManager by inject() @@ -27,6 +34,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent { override fun onCreate() { super.onCreate() transport = ConfigurableBackupTransport(applicationContext) + mIsRunning.value = true Log.d(TAG, "Service created.") } @@ -47,6 +55,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent { super.onDestroy() notificationManager.onServiceDestroyed() transport = null + mIsRunning.value = false Log.d(TAG, "Service destroyed.") } 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 5846c4aa..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.getStorage() ?: 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 68431258..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.getStorage()?.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.getStorage() ?: 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/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt index 274187c3..a6fb3eff 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt @@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel( // TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify? GlobalScope.launch(Dispatchers.IO) { // remove old storage snapshots and clear cache - storageBackup.deleteAllSnapshots() - storageBackup.clearCache() + storageBackup.init() try { // initialize the new location if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) { 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 bf6c729b..8cf4ce8b 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,16 @@ 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.plugins.webdav.WebDavHandler +import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties +import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.transport.backup.BackupInitializer @@ -26,14 +31,18 @@ internal class BackupStorageViewModel( private val backupManager: IBackupManager, private val backupInitializer: BackupInitializer, private val storageBackup: StorageBackup, + safHandler: SafHandler, + webDavHandler: WebDavHandler, settingsManager: SettingsManager, -) : StorageViewModel(app, settingsManager) { + storagePluginManager: StoragePluginManager, +) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) { override val isRestoreOperation = false - override fun onLocationSet(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,13 +50,23 @@ internal class BackupStorageViewModel( // also to update the network requirement of the new storage scheduleBackupWorkers() } + onStorageLocationSet(safStorage.isUsb) + } + + override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + webdavHandler.save(properties) + webdavHandler.setPlugin(properties, plugin) + scheduleBackupWorkers() + onStorageLocationSet(isUsb = false) + } + + 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.deleteAllSnapshots() - storageBackup.clearCache() 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 = { @@ -75,7 +94,7 @@ internal class BackupStorageViewModel( } private fun scheduleBackupWorkers() { - val storage = settingsManager.getStorage() ?: 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 916ca3e0..179c7ae9 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,16 @@ 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.plugins.webdav.WebDavHandler +import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties +import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -16,26 +20,28 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName internal class RestoreStorageViewModel( private val app: Application, - private val storagePlugin: StoragePlugin, + safHandler: SafHandler, + webDavHandler: WebDavHandler, settingsManager: SettingsManager, -) : StorageViewModel(app, settingsManager) { + storagePluginManager: StoragePluginManager, +) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) { override val isRestoreOperation = true - override fun onLocationSet(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 = @@ -45,4 +51,26 @@ internal class RestoreStorageViewModel( } } + override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + viewModelScope.launch(Dispatchers.IO) { + val hasBackup = try { + webdavHandler.hasAppBackup(plugin) + } catch (e: IOException) { + Log.e(TAG, "Error reading: ${properties.config.url}", e) + false + } + if (hasBackup) { + webdavHandler.save(properties) + webdavHandler.setPlugin(properties, plugin) + mLocationChecked.postEvent(LocationResult()) + } else { + Log.w(TAG, "Location was rejected: ${properties.config.url}") + + // notify the UI that the location was invalid + val errorMsg = + app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT) + mLocationChecked.postEvent(LocationResult(errorMsg)) + } + } + } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt index ca743c6f..75658f8e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt @@ -9,11 +9,11 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.pm.PackageManager.PERMISSION_GRANTED import android.net.Uri import android.os.Bundle -import android.util.Log import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver import com.stevesoltys.seedvault.ui.BackupActivity import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD @@ -82,14 +82,6 @@ class StorageActivity : BackupActivity() { } } - override fun onBackPressed() { - if (supportFragmentManager.backStackEntryCount > 0) { - Log.d(TAG, "Blocking back button.") - } else { - super.onBackPressed() - } - } - private fun onInvalidLocation(errorMsg: String) { if (viewModel.isRestoreOperation) { val dialog = AlertDialog.Builder(this) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt index b1df24e0..b414ff7f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt @@ -1,6 +1,7 @@ package com.stevesoltys.seedvault.ui.storage import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.View.GONE @@ -10,6 +11,7 @@ import android.view.ViewGroup import android.widget.Button import android.widget.ProgressBar import android.widget.TextView +import androidx.activity.addCallback import androidx.fragment.app.Fragment import com.stevesoltys.seedvault.R @@ -34,6 +36,14 @@ class StorageCheckFragment : Fragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requireActivity().onBackPressedDispatcher.addCallback(this) { + Log.i("StorageCheckFragment", "Not navigating back!") + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOption.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOption.kt index c7cc0bc1..7fe477e0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOption.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOption.kt @@ -1,8 +1,11 @@ package com.stevesoltys.seedvault.ui.storage +import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri import android.provider.DocumentsContract.buildTreeDocumentUri +import androidx.appcompat.content.res.AppCompatResources.getDrawable +import com.stevesoltys.seedvault.R internal sealed class StorageOption { abstract val id: String @@ -46,3 +49,14 @@ internal sealed class StorageOption { return id.hashCode() } } + +internal class WebDavOption(context: Context) : StorageOption() { + override val id: String = "webdav" + override val icon: Drawable? = getDrawable(context, R.drawable.ic_cloud_circle) + override val title: String = context.getString(R.string.storage_webdav_option_title) + override val summary: String = context.getString(R.string.storage_webdav_option_summary) + override val availableBytes: Long? = null + override val requiresNetwork: Boolean = true + override val enabled: Boolean = true + override val nonDefaultAction: (() -> Unit)? = null +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt similarity index 91% rename from app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt rename to app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt index 1357f299..afe35a00 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionFetcher.kt @@ -13,9 +13,11 @@ import android.provider.DocumentsContract.PROVIDER_INTERFACE import android.provider.DocumentsContract.buildRootsUri import android.util.Log import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.saf.SafStorageOptions +import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption -private val TAG = StorageRootFetcher::class.java.simpleName +private val TAG = StorageOptionFetcher::class.java.simpleName const val AUTHORITY_STORAGE = "com.android.externalstorage.documents" const val ROOT_ID_DEVICE = "primary" @@ -30,7 +32,7 @@ internal interface RemovableStorageListener { fun onStorageChanged() } -internal class StorageRootFetcher(private val context: Context, private val isRestore: Boolean) { +internal class StorageOptionFetcher(private val context: Context, private val isRestore: Boolean) { private val packageManager = context.packageManager private val contentResolver = context.contentResolver @@ -60,7 +62,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe internal fun getRemovableStorageListener() = listener internal fun getStorageOptions(): List { - val roots = ArrayList() + val roots = ArrayList().apply { + add(WebDavOption(context)) + } 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/StorageOptionsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt index 12c65260..fa1f7e18 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt @@ -55,7 +55,7 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false) + val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false) titleView = v.requireViewById(R.id.titleView) warningIcon = v.requireViewById(R.id.warningIcon) @@ -115,11 +115,21 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener } override fun onClick(storageOption: StorageOption) { - if (storageOption is SafOption) { - viewModel.onSafOptionChosen(storageOption) - openDocumentTree.launch(storageOption.uri) - } else { - throw IllegalArgumentException("Non-SAF storage not yet supported") + when (storageOption) { + is SafOption -> { + viewModel.onSafOptionChosen(storageOption) + openDocumentTree.launch(storageOption.uri) + } + + is WebDavOption -> { + val isRestore = requireArguments().getBoolean(INTENT_EXTRA_IS_RESTORE) + val f = WebDavConfigFragment.newInstance(isRestore) + parentFragmentManager.beginTransaction().apply { + replace(R.id.fragment, f) + addToBackStack("WebDAV") + commit() + } + } } } 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 0d0e5f11..ea3cc3ac 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 @@ -1,32 +1,33 @@ 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.settings.BackupManagerSettings -import com.stevesoltys.seedvault.settings.FlashDrive +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.plugins.saf.SafHandler +import com.stevesoltys.seedvault.plugins.saf.SafStorage +import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig +import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler +import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties +import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption - -private val TAG = StorageViewModel::class.java.simpleName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch internal abstract class StorageViewModel( private val app: Application, + protected val safHandler: SafHandler, + protected val webdavHandler: WebDavHandler, protected val settingsManager: SettingsManager, + protected val storagePluginManager: StoragePluginManager, ) : AndroidViewModel(app), RemovableStorageListener { private val mStorageOptions = MutableLiveData>() @@ -38,38 +39,29 @@ internal abstract class StorageViewModel( protected val mLocationChecked = MutableLiveEvent() internal val locationChecked: LiveEvent get() = mLocationChecked - private val storageRootFetcher by lazy { StorageRootFetcher(app, isRestoreOperation) } + private val storageOptionFetcher by lazy { StorageOptionFetcher(app, isRestoreOperation) } private var safOption: SafOption? = null internal var isSetupWizard: Boolean = false internal val hasStorageSet: Boolean - get() = settingsManager.getStorage() != null + get() = storagePluginManager.storageProperties != null abstract val isRestoreOperation: Boolean - companion object { - internal fun validLocationIsSet( - context: Context, - settingsManager: SettingsManager, - ): Boolean { - val storage = settingsManager.getStorage() ?: return false - if (storage.isUsb) return true - return permitDiskReads { - storage.getDocumentFile(context).isDirectory - } - } - } - internal fun loadStorageRoots() { - if (storageRootFetcher.getRemovableStorageListener() == null) { - storageRootFetcher.setRemovableStorageListener(this) + if (storageOptionFetcher.getRemovableStorageListener() == null) { + storageOptionFetcher.setRemovableStorageListener(this) } Thread { - mStorageOptions.postValue(storageRootFetcher.getStorageOptions()) + mStorageOptions.postValue(storageOptionFetcher.getStorageOptions()) }.start() } override fun onStorageChanged() = loadStorageRoots() + /** + * Remembers that the user chose SAF. + * Usually followed by a call of [onUriPermissionResultReceived]. + */ fun onSafOptionChosen(option: SafOption) { safOption = option } @@ -80,76 +72,40 @@ 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) - - onLocationSet(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): Storage { - 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 Storage(uri, name, root.isUsb, root.requiresNetwork) - } - - protected fun saveStorage(storage: Storage): Boolean { - settingsManager.setStorage(storage) - - if (storage.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: ${storage.uri}") - - return storage.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 onLocationSet(uri: Uri) + abstract fun onSafUriSet(safStorage: SafStorage) + abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) override fun onCleared() { - storageRootFetcher.setRemovableStorageListener(null) + storageOptionFetcher.setRemovableStorageListener(null) super.onCleared() } + val webdavConfigState get() = webdavHandler.configState + + fun onWebDavConfigReceived(url: String, user: String, pass: String) { + val config = WebDavConfig(url = url, username = user, password = pass) + viewModelScope.launch(Dispatchers.IO) { + webdavHandler.onConfigReceived(config) + } + } + + fun resetWebDavConfig() = webdavHandler.resetConfigState() + + @UiThread + fun onWebDavConfigSuccess(properties: WebDavProperties, plugin: WebDavStoragePlugin) { + mLocationSet.setEvent(true) + onWebDavConfigSet(properties, plugin) + } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt new file mode 100644 index 00000000..a302a8d5 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/WebDavConfigFragment.kt @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.ui.storage + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.widget.Button +import android.widget.ProgressBar +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.transition.TransitionManager.beginDelayedTransition +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.snackbar.Snackbar.LENGTH_LONG +import com.google.android.material.textfield.TextInputEditText +import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.plugins.webdav.WebDavConfigState +import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.getSharedViewModel + +class WebDavConfigFragment : Fragment(), View.OnClickListener { + + companion object { + fun newInstance(isRestore: Boolean): WebDavConfigFragment { + val f = WebDavConfigFragment() + f.arguments = Bundle().apply { + putBoolean(INTENT_EXTRA_IS_RESTORE, isRestore) + } + return f + } + } + + private lateinit var viewModel: StorageViewModel + + private lateinit var urlInput: TextInputEditText + private lateinit var userInput: TextInputEditText + private lateinit var passInput: TextInputEditText + private lateinit var button: Button + private lateinit var progressBar: ProgressBar + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val v: View = inflater.inflate(R.layout.fragment_webdav_config, container, false) + urlInput = v.requireViewById(R.id.webdavUrlInput) + userInput = v.requireViewById(R.id.webdavUserInput) + passInput = v.requireViewById(R.id.webDavPassInput) + button = v.requireViewById(R.id.webdavButton) + button.setOnClickListener(this) + progressBar = v.requireViewById(R.id.progressBar) + return v + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = if (requireArguments().getBoolean(INTENT_EXTRA_IS_RESTORE)) { + getSharedViewModel() + } else { + getSharedViewModel() + } + lifecycleScope.launch { + viewModel.webdavConfigState.flowWithLifecycle(lifecycle, STARTED).collect { + onConfigStateChanged(it) + } + } + } + + override fun onClick(v: View) { + if (urlInput.text.isNullOrBlank()) { + Snackbar.make( + requireView(), + R.string.storage_webdav_config_malformed_url, + LENGTH_LONG + ).setAnchorView(button).show() + } else { + viewModel.onWebDavConfigReceived( + url = urlInput.text.toString(), + user = userInput.text.toString(), + pass = passInput.text.toString(), + ) + } + } + + override fun onDestroy() { + viewModel.resetWebDavConfig() + super.onDestroy() + } + + private fun onConfigStateChanged(state: WebDavConfigState) { + when (state) { + WebDavConfigState.Empty -> { + + } + + WebDavConfigState.Checking -> { + beginDelayedTransition(requireView() as ViewGroup) + progressBar.visibility = VISIBLE + button.visibility = INVISIBLE + } + + is WebDavConfigState.Success -> { + viewModel.onWebDavConfigSuccess(state.properties, state.plugin) + } + + is WebDavConfigState.Error -> { + val s = if (state.e == null) { + getString(R.string.storage_check_fragment_backup_error) + } else { + getString(R.string.storage_check_fragment_backup_error) + + " ${state.e::class.java.simpleName} ${state.e.message}" + } + Snackbar.make(requireView(), s, LENGTH_LONG).setAnchorView(button).show() + + beginDelayedTransition(requireView() as ViewGroup) + progressBar.visibility = INVISIBLE + button.visibility = VISIBLE + } + } + } + +} 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/main/res/drawable/ic_cloud_circle.xml b/app/src/main/res/drawable/ic_cloud_circle.xml new file mode 100644 index 00000000..9bb47d6a --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_circle.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_storage_root.xml b/app/src/main/res/layout/fragment_storage_options.xml similarity index 100% rename from app/src/main/res/layout/fragment_storage_root.xml rename to app/src/main/res/layout/fragment_storage_options.xml diff --git a/app/src/main/res/layout/fragment_webdav_config.xml b/app/src/main/res/layout/fragment_webdav_config.xml new file mode 100644 index 00000000..f6272ef1 --- /dev/null +++ b/app/src/main/res/layout/fragment_webdav_config.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + +