Add the UI for the WebDAV plugin

including ViewModel and StoragePluginManager logic
This commit is contained in:
Torsten Grote 2024-04-12 17:54:55 -03:00
parent 7e612cb8e0
commit b1c87a8a9e
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
18 changed files with 526 additions and 36 deletions

View file

@ -100,7 +100,8 @@
<activity <activity
android:name=".ui.storage.StorageActivity" android:name=".ui.storage.StorageActivity"
android:theme="@style/AppTheme.NoActionBar" /> android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".ui.storage.PermissionGrantActivity" android:name=".ui.storage.PermissionGrantActivity"

View file

@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.AppListRetriever import com.stevesoltys.seedvault.settings.AppListRetriever
@ -55,7 +56,7 @@ open class App : Application() {
private val appModule = module { private val appModule = module {
single { SettingsManager(this@App) } single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) } single { BackupNotificationManager(this@App) }
single { StoragePluginManager(this@App, get(), get()) } single { StoragePluginManager(this@App, get(), get(), get()) }
single { Clock() } single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
factory { AppListRetriever(this@App, get(), get(), get()) } factory { AppListRetriever(this@App, get(), get(), get()) }
@ -74,9 +75,6 @@ open class App : Application() {
) )
} }
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
viewModel { viewModel {
BackupStorageViewModel( BackupStorageViewModel(
app = this@App, app = this@App,
@ -84,11 +82,12 @@ open class App : Application() {
backupInitializer = get(), backupInitializer = get(),
storageBackup = get(), storageBackup = get(),
safHandler = get(), safHandler = get(),
webDavHandler = get(),
settingsManager = get(), settingsManager = get(),
storagePluginManager = get(), storagePluginManager = get(),
) )
} }
viewModel { RestoreStorageViewModel(this@App, get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
viewModel { FileSelectionViewModel(this@App, get()) } viewModel { FileSelectionViewModel(this@App, get()) }
} }
@ -127,7 +126,8 @@ open class App : Application() {
cryptoModule, cryptoModule,
headerModule, headerModule,
metadataModule, metadataModule,
storagePluginModuleSaf, // storage plugin storagePluginModuleSaf,
storagePluginModuleWebDav,
backupModule, backupModule,
restoreModule, restoreModule,
installModule, installModule,

View file

@ -14,6 +14,7 @@ import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.SafFactory 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.SettingsManager
import com.stevesoltys.seedvault.settings.StoragePluginEnum import com.stevesoltys.seedvault.settings.StoragePluginEnum
@ -47,6 +48,7 @@ class StoragePluginManager(
private val context: Context, private val context: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
safFactory: SafFactory, safFactory: SafFactory,
webDavFactory: WebDavFactory,
) { ) {
private var _appPlugin: StoragePlugin<*>? private var _appPlugin: StoragePlugin<*>?
@ -81,6 +83,14 @@ class StoragePluginManager(
_storageProperties = safStorage _storageProperties = safStorage
} }
StoragePluginEnum.WEB_DAV -> {
val webDavProperties =
settingsManager.webDavProperties ?: error("No WebDAV config saved")
_appPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config)
_filesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config)
_storageProperties = webDavProperties
}
null -> { null -> {
_appPlugin = null _appPlugin = null
_filesPlugin = null _filesPlugin = null

View file

@ -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<WebDavConfig> {
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,
)
}
}

View file

@ -0,0 +1,91 @@
/*
* 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 _configState = MutableStateFlow<WebDavConfigState>(WebDavConfigState.Empty)
val configState = _configState.asStateFlow()
suspend fun onConfigReceived(config: WebDavConfig) {
_configState.value = WebDavConfigState.Checking
val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin
try {
if (plugin.test()) {
val properties = createWebDavProperties(context, config)
_configState.value = WebDavConfigState.Success(properties, plugin)
} else {
_configState.value = WebDavConfigState.Error(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error testing WebDAV config at ${config.url}", e)
_configState.value = WebDavConfigState.Error(e)
}
}
/**
* 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),
)
}
}

View file

@ -1,10 +1,9 @@
package com.stevesoltys.seedvault.plugins.webdav package com.stevesoltys.seedvault.plugins.webdav
import com.stevesoltys.seedvault.plugins.StoragePlugin
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val webDavModule = module { val storagePluginModuleWebDav = module {
// TODO PluginManager should create the plugin on demand single { WebDavFactory(androidContext(), get()) }
single<StoragePlugin<*>> { WebDavStoragePlugin(androidContext(), WebDavConfig("", "", "")) } single { WebDavHandler(androidContext(), get(), get(), get()) }
} }

View file

@ -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<WebDavConfig>() {
override val isUsb: Boolean = false
override val requiresNetwork: Boolean = true
override fun isUnavailableUsb(context: Context): Boolean = false
}

View file

@ -5,13 +5,15 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.net.Uri import android.net.Uri
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.SafStorage 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 com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
@ -26,6 +28,7 @@ private const val PREF_KEY_STORAGE_PLUGIN = "storagePlugin"
internal enum class StoragePluginEnum { // don't rename, will break existing installs internal enum class StoragePluginEnum { // don't rename, will break existing installs
SAF, SAF,
WEB_DAV,
} }
private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_URI = "storageUri"
@ -38,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_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId" 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_APP_BLACKLIST = "backupAppBlacklist"
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage" private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
@ -94,7 +101,6 @@ class SettingsManager(private val context: Context) {
token = newToken token = newToken
} }
// FIXME SafStorage is currently plugin specific and not generic
internal val storagePluginType: StoragePluginEnum? internal val storagePluginType: StoragePluginEnum?
get() = prefs.getString(PREF_KEY_STORAGE_PLUGIN, StoragePluginEnum.SAF.name)?.let { get() = prefs.getString(PREF_KEY_STORAGE_PLUGIN, StoragePluginEnum.SAF.name)?.let {
try { try {
@ -107,6 +113,7 @@ class SettingsManager(private val context: Context) {
fun setStoragePlugin(plugin: StoragePlugin<*>) { fun setStoragePlugin(plugin: StoragePlugin<*>) {
val value = when (plugin) { val value = when (plugin) {
is DocumentsProviderStoragePlugin -> StoragePluginEnum.SAF is DocumentsProviderStoragePlugin -> StoragePluginEnum.SAF
is WebDavStoragePlugin -> StoragePluginEnum.WEB_DAV
else -> error("Unsupported plugin: ${plugin::class.java.simpleName}") else -> error("Unsupported plugin: ${plugin::class.java.simpleName}")
}.name }.name
prefs.edit() prefs.edit()
@ -159,20 +166,22 @@ class SettingsManager(private val context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId) return FlashDrive(name, serialNumber, vendorId, productId)
} }
/** val webDavProperties: WebDavProperties?
* Check if we are able to do backups now by examining possible pre-conditions get() {
* such as plugged-in flash drive or internet access. val config = WebDavConfig(
* url = prefs.getString(PREF_KEY_WEBDAV_URL, null) ?: return null,
* Should be run off the UI thread (ideally I/O) because of disk access. username = prefs.getString(PREF_KEY_WEBDAV_USER, null) ?: return null,
* password = prefs.getString(PREF_KEY_WEBDAV_PASS, null) ?: return null,
* @return true if a backup is possible, false if not. )
*/ return createWebDavProperties(context, config)
@WorkerThread }
fun canDoBackupNow(): Boolean {
val storage = getSafStorage() ?: return false fun saveWebDavConfig(config: WebDavConfig) {
val systemContext = context.getStorageContext { storage.isUsb } prefs.edit()
return !storage.isUnavailableUsb(systemContext) && .putString(PREF_KEY_WEBDAV_URL, config.url)
!storage.isUnavailableNetwork(context, useMeteredNetwork) .putString(PREF_KEY_WEBDAV_USER, config.username)
.putString(PREF_KEY_WEBDAV_PASS, config.password)
.apply()
} }
fun backupApks(): Boolean { fun backupApks(): Boolean {

View file

@ -10,6 +10,9 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage 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.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.backup.BackupInitializer import com.stevesoltys.seedvault.transport.backup.BackupInitializer
@ -29,9 +32,10 @@ internal class BackupStorageViewModel(
private val backupInitializer: BackupInitializer, private val backupInitializer: BackupInitializer,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
safHandler: SafHandler, safHandler: SafHandler,
webDavHandler: WebDavHandler,
settingsManager: SettingsManager, settingsManager: SettingsManager,
storagePluginManager: StoragePluginManager, storagePluginManager: StoragePluginManager,
) : StorageViewModel(app, safHandler, settingsManager, storagePluginManager) { ) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
override val isRestoreOperation = false override val isRestoreOperation = false
@ -49,6 +53,13 @@ internal class BackupStorageViewModel(
onStorageLocationSet(safStorage.isUsb) 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) { private fun onStorageLocationSet(isUsb: Boolean) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {

View file

@ -8,6 +8,9 @@ import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage 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.settings.SettingsManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,9 +21,10 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName
internal class RestoreStorageViewModel( internal class RestoreStorageViewModel(
private val app: Application, private val app: Application,
safHandler: SafHandler, safHandler: SafHandler,
webDavHandler: WebDavHandler,
settingsManager: SettingsManager, settingsManager: SettingsManager,
storagePluginManager: StoragePluginManager, storagePluginManager: StoragePluginManager,
) : StorageViewModel(app, safHandler, settingsManager, storagePluginManager) { ) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
override val isRestoreOperation = true override val isRestoreOperation = true
@ -46,4 +50,27 @@ 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))
}
}
}
} }

View file

@ -1,8 +1,11 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract.buildTreeDocumentUri import android.provider.DocumentsContract.buildTreeDocumentUri
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import com.stevesoltys.seedvault.R
internal sealed class StorageOption { internal sealed class StorageOption {
abstract val id: String abstract val id: String
@ -46,3 +49,14 @@ internal sealed class StorageOption {
return id.hashCode() 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
}

View file

@ -62,7 +62,9 @@ internal class StorageOptionFetcher(private val context: Context, private val is
internal fun getRemovableStorageListener() = listener internal fun getRemovableStorageListener() = listener
internal fun getStorageOptions(): List<StorageOption> { internal fun getStorageOptions(): List<StorageOption> {
val roots = ArrayList<StorageOption>() val roots = ArrayList<StorageOption>().apply {
add(WebDavOption(context))
}
val intent = Intent(PROVIDER_INTERFACE) val intent = Intent(PROVIDER_INTERFACE)
val providers = packageManager.queryIntentContentProviders(intent, 0) val providers = packageManager.queryIntentContentProviders(intent, 0)
for (info in providers) { for (info in providers) {

View file

@ -115,11 +115,21 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
} }
override fun onClick(storageOption: StorageOption) { override fun onClick(storageOption: StorageOption) {
if (storageOption is SafOption) { when (storageOption) {
viewModel.onSafOptionChosen(storageOption) is SafOption -> {
openDocumentTree.launch(storageOption.uri) viewModel.onSafOptionChosen(storageOption)
} else { openDocumentTree.launch(storageOption.uri)
throw IllegalArgumentException("Non-SAF storage not yet supported") }
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()
}
}
} }
} }

View file

@ -11,6 +11,10 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafHandler import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage 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.SettingsManager
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
@ -21,6 +25,7 @@ import kotlinx.coroutines.launch
internal abstract class StorageViewModel( internal abstract class StorageViewModel(
private val app: Application, private val app: Application,
protected val safHandler: SafHandler, protected val safHandler: SafHandler,
protected val webdavHandler: WebDavHandler,
protected val settingsManager: SettingsManager, protected val settingsManager: SettingsManager,
protected val storagePluginManager: StoragePluginManager, protected val storagePluginManager: StoragePluginManager,
) : AndroidViewModel(app), RemovableStorageListener { ) : AndroidViewModel(app), RemovableStorageListener {
@ -79,11 +84,26 @@ internal abstract class StorageViewModel(
} }
abstract fun onSafUriSet(safStorage: SafStorage) abstract fun onSafUriSet(safStorage: SafStorage)
abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin)
override fun onCleared() { override fun onCleared() {
storageOptionFetcher.setRemovableStorageListener(null) storageOptionFetcher.setRemovableStorageListener(null)
super.onCleared() 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)
}
}
@UiThread
fun onWebDavConfigSuccess(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
mLocationSet.setEvent(true)
onWebDavConfigSet(properties, plugin)
}
} }

View file

@ -0,0 +1,128 @@
/*
* 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<RestoreStorageViewModel>()
} else {
getSharedViewModel<BackupStorageViewModel>()
}
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(),
)
}
}
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
}
}
}
}

View file

@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM16.5,16L8,16c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3l0.14,0.01C8.58,8.28 10.13,7 12,7c2.21,0 4,1.79 4,4h0.5c1.38,0 2.5,1.12 2.5,2.5S17.88,16 16.5,16z" />
</vector>

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavUrlLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webdavUrlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_url"
android:inputType="text|textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavUserLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintBottom_toTopOf="@+id/webdavPassLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavUrlLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webdavUserInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_user"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavPassLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavUserLayout"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webDavPassInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_pass"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/webdavButton"
style="@style/SudPrimaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/storage_webdav_config_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavPassLayout"
app:layout_constraintVertical_bias="1.0" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/webdavButton"
app:layout_constraintEnd_toEndOf="@+id/webdavButton"
app:layout_constraintStart_toStartOf="@+id/webdavButton"
app:layout_constraintTop_toTopOf="@+id/webdavButton"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -97,6 +97,15 @@
<string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string> <string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string>
<string name="storage_check_fragment_error_button">Back</string> <string name="storage_check_fragment_error_button">Back</string>
<string name="storage_webdav_option_title">WebDAV Cloud (beta)</string>
<string name="storage_webdav_option_summary">Integrated direct WebDAV access</string>
<string name="storage_webdav_config_url">WebDAV URL</string>
<string name="storage_webdav_config_user">User name</string>
<string name="storage_webdav_config_pass">Password</string>
<string name="storage_webdav_config_button">Use WebDAV cloud</string>
<string name="storage_webdav_config_malformed_url">Invalid WebDAV URL</string>
<string name="storage_webdav_name">WebDAV %s</string>
<!-- Recovery Code --> <!-- Recovery Code -->
<string name="recovery_code_title">Recovery code</string> <string name="recovery_code_title">Recovery code</string>
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string> <string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>