From 0b212fc6bd2ca69fd8c46b152b76139a929aab54 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 23 Apr 2024 17:34:20 +0200 Subject: [PATCH] WebDAV Mounts UI: M3, refactoring (#736) * WebDavMountsScreen: M3, refactoring * [WIP] AddWebDavMount * Use M3 pull-to-refresh * AddWebDavMount, move logic to WebDavMountRepository * Show loading state in LinearProgressBar * Add WebDAV mount: IME action and focus requester * Move "test WebDAV" logic from model to repository * Move "refresh quota" logic to repository * Move querying and deleting mounts to repository; IME navigation * KDoc * Move hasWebDav tests to repository --- app/build.gradle.kts | 1 - .../ui/webdav/AddWebdavMountActivityTest.kt | 79 ---- .../webdav/WebDavMountRepositoryTest.kt | 66 +++ .../at/bitfire/davdroid/db/WebDavMountDao.kt | 12 +- .../db/WebDavMountWithRootDocument.kt | 22 + .../ui/composable/PasswordTextField.kt | 8 +- .../composable/SelectClientCertificateCard.kt | 8 +- .../ui/webdav/AddWebdavMountActivity.kt | 409 +---------------- .../davdroid/ui/webdav/AddWebdavMountModel.kt | 104 +++++ .../ui/webdav/AddWebdavMountScreen.kt | 261 +++++++++++ .../ui/webdav/WebdavMountsActivity.kt | 417 +---------------- .../davdroid/ui/webdav/WebdavMountsModel.kt | 62 +++ .../davdroid/ui/webdav/WebdavMountsScreen.kt | 433 ++++++++++++++++++ .../davdroid/webdav/WebDavMountRepository.kt | 132 ++++++ gradle/libs.versions.toml | 2 - 15 files changed, 1106 insertions(+), 910 deletions(-) delete mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt create mode 100644 app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e852e0a4..26c5ffe0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,7 +156,6 @@ dependencies { implementation(libs.androidx.paging.compose) implementation(libs.androidx.preference) implementation(libs.androidx.security) - implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.work.base) implementation(libs.android.flexbox) implementation(libs.android.material) diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt deleted file mode 100644 index f9aa553f..00000000 --- a/app/src/androidTest/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivityTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.webdav - -import android.security.NetworkSecurityPolicy -import androidx.test.core.app.ApplicationProvider -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.WebDavMount -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.junit4.MockKRule -import io.mockk.spyk -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assume -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import javax.inject.Inject - -@HiltAndroidTest -class AddWebdavMountActivityTest { - - @get:Rule - val hiltRule = HiltAndroidRule(this) - @get:Rule - val mockkRule = MockKRule(this) - - @Inject - lateinit var db: AppDatabase - - @Before - fun setUp() { - hiltRule.inject() - - model = spyk(AddWebdavMountActivity.Model(ApplicationProvider.getApplicationContext(), db)) - - Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted) - } - - - lateinit var model: AddWebdavMountActivity.Model - val web = MockWebServer() - - @Test - fun testHasWebDav_NoDavHeader() { - web.enqueue(MockResponse().setResponseCode(200)) - assertFalse(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) - } - - @Test - fun testHasWebDav_DavClass_1() { - web.enqueue(MockResponse() - .setResponseCode(200) - .addHeader("DAV", "1")) - assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) - } - - @Test - fun testHasWebDav_DavClass_1and2() { - web.enqueue(MockResponse() - .setResponseCode(200) - .addHeader("DAV", "1,2")) - assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) - } - - @Test - fun testHasWebDav_DavClass_2() { - web.enqueue(MockResponse() - .setResponseCode(200) - .addHeader("DAV", "2")) - assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null)) - } - -} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt new file mode 100644 index 00000000..1987209d --- /dev/null +++ b/app/src/androidTest/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepositoryTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class WebDavMountRepositoryTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: WebDavMountRepository + + @Before + fun setUp() { + hiltRule.inject() + } + + val web = MockWebServer() + val url = web.url("/") + + @Test + fun testHasWebDav_NoDavHeader() = runBlocking { + web.enqueue(MockResponse().setResponseCode(200)) + assertFalse(repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass1() = runBlocking { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1")) + assertTrue(repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass2() = runBlocking { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1, 2")) + assertTrue(repository.hasWebDav(url, null)) + } + + @Test + fun testHasWebDav_DavClass3() = runBlocking { + web.enqueue(MockResponse() + .setResponseCode(200) + .addHeader("DAV: 1, 3")) + assertTrue(repository.hasWebDav(url, null)) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt index a1de8304..34f33cb5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountDao.kt @@ -4,23 +4,23 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface WebDavMountDao { @Delete - fun delete(mount: WebDavMount) + suspend fun deleteAsync(mount: WebDavMount) @Query("SELECT * FROM webdav_mount ORDER BY name, url") fun getAll(): List @Query("SELECT * FROM webdav_mount ORDER BY name, url") - fun getAllLive(): LiveData> + fun getAllFlow(): Flow> @Query("SELECT * FROM webdav_mount WHERE id=:id") fun getById(id: Long): WebDavMount @@ -28,4 +28,10 @@ interface WebDavMountDao { @Insert fun insert(mount: WebDavMount): Long + + // complex queries + + @Query("SELECT * FROM webdav_mount ORDER BY name, url") + fun getAllWithRootDocumentFlow(): Flow> + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt new file mode 100644 index 00000000..4ed632cc --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/WebDavMountWithRootDocument.kt @@ -0,0 +1,22 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.db + +import androidx.room.Embedded +import androidx.room.Relation + +/** + * A [WebDavMount] with an optional root document (that contains information like quota). + */ +data class WebDavMountWithRootDocument( + @Embedded + val mount: WebDavMount, + + @Relation( + parentColumn = "id", + entityColumn = "mountId" + ) + val rootDocument: WebDavDocument? +) \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt index 0e93e493..d6aa4acd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/PasswordTextField.kt @@ -7,13 +7,13 @@ package at.bitfire.davdroid.ui.composable import androidx.compose.foundation.focusGroup import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +37,7 @@ fun PasswordTextField( keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardActions: KeyboardActions = KeyboardActions.Default, enabled: Boolean = true, + readOnly: Boolean = false, isError: Boolean = false ) { var passwordVisible by remember { mutableStateOf(false) } @@ -49,6 +50,7 @@ fun PasswordTextField( isError = isError, singleLine = true, enabled = enabled, + readOnly = readOnly, modifier = modifier.focusGroup(), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt index e5f30c45..3ccbdc48 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -31,11 +30,13 @@ import kotlinx.coroutines.launch @Composable fun SelectClientCertificateCard( snackbarHostState: SnackbarHostState, - suggestedAlias: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + suggestedAlias: String? = null, chosenAlias: String?, onAliasChosen: (String) -> Unit = {} ) { - Card(modifier = Modifier.fillMaxWidth()) { + Card(modifier = modifier) { Column(Modifier.padding(8.dp)) { Text( if (!chosenAlias.isNullOrEmpty()) @@ -49,6 +50,7 @@ fun SelectClientCertificateCard( val activity = LocalContext.current as? Activity val scope = rememberCoroutineScope() OutlinedButton( + enabled = enabled, onClick = { if (activity != null) KeyChain.choosePrivateKeyAlias(activity, { alias -> diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt index c9493a8c..4f3d84dd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountActivity.kt @@ -4,426 +4,23 @@ package at.bitfire.davdroid.ui.webdav -import android.app.Application import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.annotation.StringRes -import androidx.annotation.WorkerThread import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.lifecycleScope -import at.bitfire.dav4jvm.DavResource -import at.bitfire.dav4jvm.UrlUtils -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.Constants.withStatParams -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Credentials -import at.bitfire.davdroid.db.WebDavMount -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.ui.M2Theme -import at.bitfire.davdroid.ui.composable.PasswordTextField -import at.bitfire.davdroid.webdav.CredentialsStore -import at.bitfire.davdroid.webdav.DavDocumentsProvider import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.apache.commons.collections4.CollectionUtils -import java.net.URI -import java.net.URISyntaxException -import java.util.logging.Level -import javax.inject.Inject @AndroidEntryPoint class AddWebdavMountActivity : AppCompatActivity() { - val model by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - val isLoading by model.isLoading.observeAsState(initial = false) - val error by model.error.observeAsState(initial = null) - val displayName by model.displayName.observeAsState(initial = "") - val displayNameError by model.displayNameError.observeAsState(initial = null) - val url by model.url.observeAsState(initial = "") - val urlError by model.urlError.observeAsState(initial = null) - val username by model.userName.observeAsState(initial = "") - val password by model.password.observeAsState(initial = "") - - M2Theme { - Layout( - isLoading = isLoading, - error = error, - onErrorClearRequested = { model.error.value = null }, - displayName = displayName, - onDisplayNameChange = model.displayName::setValue, - displayNameError = displayNameError, - url = url, - onUrlChange = model.url::setValue, - urlError = urlError, - username = username, - onUsernameChange = model.userName::setValue, - password = password, - onPasswordChange = model.password::setValue - ) - } - } - } - - - @Composable - fun Layout( - isLoading: Boolean = false, - error: String? = null, - onErrorClearRequested: () -> Unit = {}, - displayName: String = "", - onDisplayNameChange: (String) -> Unit = {}, - displayNameError: String? = null, - url: String = "", - onUrlChange: (String) -> Unit = {}, - urlError: String? = null, - username: String = "", - onUsernameChange: (String) -> Unit = {}, - password: String = "", - onPasswordChange: (String) -> Unit = {} - ) { - val uriHandler = LocalUriHandler.current - - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(error) { - if (error != null) { - snackbarHostState.showSnackbar( - message = error - ) - onErrorClearRequested() - } - } - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = { onSupportNavigateUp() }) { - Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) - } - }, - title = { Text(stringResource(R.string.webdav_add_mount_title)) }, - actions = { - IconButton( - onClick = { - uriHandler.openUri( - Constants.HOMEPAGE_URL.buildUpon() - .appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES) - .withStatParams("AddWebdavMountActivity") - .build().toString() - ) - } - ) { - Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help)) - } - } - ) - }, - bottomBar = { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = 4.dp - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton( - enabled = !isLoading, - onClick = ::validate - ) { - Text( - text = stringResource(R.string.webdav_add_mount_add).uppercase() - ) - } - } - } - }, - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - ) { - if (isLoading) - LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - modifier = Modifier.fillMaxWidth() - ) - - Form( - displayName, - onDisplayNameChange, - displayNameError, - url, - onUrlChange, - urlError, - username, - onUsernameChange, - password, - onPasswordChange - ) - } - } - } - - @Composable - fun Form( - displayName: String, - onDisplayNameChange: (String) -> Unit, - displayNameError: String?, - url: String, - onUrlChange: (String) -> Unit, - urlError: String?, - username: String, - onUsernameChange: (String) -> Unit, - password: String, - onPasswordChange: (String) -> Unit - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - FormField( - displayName, - onDisplayNameChange, - displayNameError, - R.string.webdav_add_mount_display_name - ) - FormField( - url, - onUrlChange, - urlError, - R.string.webdav_add_mount_url - ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = stringResource(R.string.webdav_add_mount_authentication), - style = MaterialTheme.typography.body1, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) - FormField( - username, - onUsernameChange, - null, - R.string.webdav_add_mount_username - ) - - PasswordTextField( - password = password, - onPasswordChange = onPasswordChange, - labelText = stringResource(R.string.webdav_add_mount_password) + AddWebdavMountScreen( + onNavUp = { onSupportNavigateUp() }, + onFinish = { finish() } ) } } - @Composable - fun FormField( - value: String, - onValueChange: (String) -> Unit, - error: String?, - @StringRes label: Int - ) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - label = { Text(stringResource(label)) }, - singleLine = true, - isError = error != null - ) - if (error != null) { - Text( - text = error, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.caption.copy( - color = MaterialTheme.colors.error - ) - ) - } - } - - @Preview - @Composable - fun Layout_Preview() { - M2Theme { - Layout() - } - } - - - private fun validate() { - var ok = true - - val displayName = model.displayName.value - model.displayNameError.value = null - if (displayName.isNullOrBlank()) { - ok = false - model.displayNameError.value = getString(R.string.field_required) - } - - var url: HttpUrl? = null - model.urlError.value = null - val rawUrl = model.url.value - if (rawUrl.isNullOrBlank()) { - ok = false - model.urlError.value = getString(R.string.field_required) - } else { - try { - var uri = URI(rawUrl) - if (uri.scheme == null) - uri = URI("https", uri.schemeSpecificPart, null) - url = uri.toHttpUrlOrNull() - if (url == null) { - // should never happen - ok = false - model.urlError.value = getString(R.string.webdav_add_mount_url_invalid) - } - } catch (e: URISyntaxException) { - ok = false - model.urlError.value = e.localizedMessage - } - } - - val userName = model.userName.value - val password = model.password.value - val credentials = - if (userName != null && password != null) - Credentials(userName, password) - else - null - - if (ok && url != null) { - model.isLoading.postValue(true) - - val mount = WebDavMount( - name = model.displayName.value ?: return, - url = UrlUtils.withTrailingSlash(url) - ) - lifecycleScope.launch(Dispatchers.IO) { - if (model.addMount(mount, credentials)) - finish() - - model.isLoading.postValue(false) - } - } - } - - - @HiltViewModel - class Model @Inject constructor( - val context: Application, - val db: AppDatabase - ): ViewModel() { - - val displayName = MutableLiveData() - val displayNameError = MutableLiveData() - val url = MutableLiveData() - val urlError = MutableLiveData() - val userName = MutableLiveData() - val password = MutableLiveData() - - val error = MutableLiveData() - val isLoading = MutableLiveData(false) - - - @WorkerThread - fun addMount(mount: WebDavMount, credentials: Credentials?): Boolean { - val supportsDav = try { - hasWebDav(mount, credentials) - } catch (e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't query WebDAV support", e) - error.postValue(e.localizedMessage) - return false - } - if (!supportsDav) { - error.postValue(context.getString(R.string.webdav_add_mount_no_support)) - return false - } - - val id = db.webDavMountDao().insert(mount) - - val credentialsStore = CredentialsStore(context) - credentialsStore.setCredentials(id, credentials) - - // notify content URI listeners - DavDocumentsProvider.notifyMountsChanged(context) - - return true - } - - fun hasWebDav(mount: WebDavMount, credentials: Credentials?): Boolean { - var supported = false - HttpClient.Builder(context, null, credentials) - .setForeground(true) - .build() - .use { client -> - val dav = DavResource(client.okHttpClient, mount.url) - dav.options { davCapabilities, _ -> - if (CollectionUtils.containsAny(davCapabilities, "1", "2", "3")) - supported = true - } - } - return supported - } - - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt new file mode 100644 index 00000000..3b3ecc49 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountModel.kt @@ -0,0 +1,104 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.webdav.WebDavMountRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import javax.inject.Inject + +@HiltViewModel +class AddWebdavMountModel @Inject constructor( + val context: Application, + val db: AppDatabase, + private val mountRepository: WebDavMountRepository +): ViewModel() { + + data class UiState( + val isLoading: Boolean = false, + val success: Boolean = false, + val error: String? = null, + val displayName: String = "", + val url: String = "", + val username: String = "", + val password: String = "", + val certificateAlias: String? = null + ) { + val urlWithPrefix = + if (url.startsWith("http://", true) || url.startsWith("https://", true)) + url + else + "https://$url" + val httpUrl = urlWithPrefix.toHttpUrlOrNull() + val canContinue = displayName.isNotBlank() && httpUrl != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setUrl(url: String) { + uiState = uiState.copy(url = url) + } + + fun setUsername(username: String) { + uiState = uiState.copy(username = username) + } + + fun setPassword(password: String) { + uiState = uiState.copy(password = password) + } + + fun setCertificateAlias(certAlias: String) { + uiState = uiState.copy(certificateAlias = certAlias) + } + + + fun addMount() { + if (uiState.isLoading) + return + val url = uiState.httpUrl ?: return + uiState = uiState.copy(isLoading = true) + + val displayName = uiState.displayName + val credentials = Credentials( + username = uiState.username, + password = uiState.password, + certificateAlias = uiState.certificateAlias + ) + + viewModelScope.launch { + var error: String? = null + try { + if (!mountRepository.addMount(url, displayName, credentials)) + error = context.getString(R.string.webdav_add_mount_no_support) + else + uiState = uiState.copy(success = true) + } catch (e: Exception) { + error = e.localizedMessage + } + + uiState = uiState.copy(isLoading = false, error = error) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt new file mode 100644 index 00000000..8fa03148 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/AddWebdavMountScreen.kt @@ -0,0 +1,261 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.PasswordTextField +import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard + +@Composable +fun AddWebdavMountScreen( + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {}, + model: AddWebdavMountModel = viewModel() +) { + val uiState = model.uiState + + if (uiState.success) { + onFinish() + return + } + + AppTheme { + AddWebDavMountScreen( + isLoading = uiState.isLoading, + error = uiState.error, + onResetError = model::resetError, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + url = uiState.url, + onSetUrl = model::setUrl, + username = uiState.username, + onSetUsername = model::setUsername, + password = uiState.password, + onSetPassword = model::setPassword, + certificateAlias = uiState.certificateAlias, + onSetCertificateAlias = model::setCertificateAlias, + canContinue = uiState.canContinue, + onAddMount = { model.addMount() }, + onNavUp = onNavUp + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun AddWebDavMountScreen( + isLoading: Boolean, + error: String?, + onResetError: () -> Unit = {}, + displayName: String, + onSetDisplayName: (String) -> Unit = {}, + url: String, + onSetUrl: (String) -> Unit = {}, + username: String, + onSetUsername: (String) -> Unit = {}, + password: String, + onSetPassword: (String) -> Unit = {}, + certificateAlias: String?, + onSetCertificateAlias: (String) -> Unit = {}, + canContinue: Boolean, + onAddMount: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(error) { + if (error != null) { + snackbarHostState.showSnackbar(error) + onResetError() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + title = { Text(stringResource(R.string.webdav_add_mount_title)) }, + actions = { + val uriHandler = LocalUriHandler.current + IconButton( + onClick = { + uriHandler.openUri(webdavMountsHelpUrl().toString()) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.help) + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + if (isLoading) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + label = { Text(stringResource(R.string.webdav_add_mount_url)) }, + leadingIcon = { Icon(Icons.Default.Cloud, contentDescription = null) }, + placeholder = { Text("dav.example.com") }, + value = url, + onValueChange = onSetUrl, + singleLine = true, + readOnly = isLoading, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Uri + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + label = { Text(stringResource(R.string.webdav_add_mount_display_name)) }, + value = displayName, + onValueChange = onSetDisplayName, + singleLine = true, + readOnly = isLoading, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.webdav_add_mount_authentication), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + OutlinedTextField( + label = { Text(stringResource(R.string.login_user_name)) }, + value = username, + onValueChange = onSetUsername, + singleLine = true, + readOnly = isLoading, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + PasswordTextField( + password = password, + onPasswordChange = onSetPassword, + labelText = stringResource(R.string.login_password), + readOnly = isLoading, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onAddMount() } + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + SelectClientCertificateCard( + snackbarHostState = snackbarHostState, + enabled = !isLoading, + chosenAlias = certificateAlias, + onAliasChosen = onSetCertificateAlias, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Button( + enabled = canContinue && !isLoading, + onClick = { onAddMount() } + ) { + Text( + text = stringResource(R.string.webdav_add_mount_add) + ) + } + } + } + } +} + +@Composable +@Preview +fun AddWebDavMountScreen_Preview() { + AppTheme { + AddWebDavMountScreen( + isLoading = true, + error = null, + displayName = "Test", + url = "https://example.com", + username = "user", + password = "password", + certificateAlias = null, + canContinue = true + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt index 90b156fe..4c47088b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsActivity.kt @@ -4,435 +4,26 @@ package at.bitfire.davdroid.ui.webdav -import android.app.Application -import android.content.Context import android.content.Intent -import android.net.Uri -import android.os.Build import android.os.Bundle -import android.provider.DocumentsContract import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.AlertDialog -import androidx.compose.material.Card -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Help -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.app.ShareCompat -import androidx.core.text.HtmlCompat -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.viewModelScope -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.WebDavDocument -import at.bitfire.davdroid.db.WebDavMount -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.M2Theme -import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString -import at.bitfire.davdroid.ui.widget.ClickableTextWithLink -import at.bitfire.davdroid.util.DavUtils -import at.bitfire.davdroid.webdav.CredentialsStore -import at.bitfire.davdroid.webdav.DavDocumentsProvider import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import okhttp3.HttpUrl -import org.apache.commons.io.FileUtils -import java.util.logging.Level -import javax.inject.Inject @AndroidEntryPoint class WebdavMountsActivity: AppCompatActivity() { - companion object { - val helpUrl: Uri = Constants.MANUAL_URL.buildUpon() - .appendPath(Constants.MANUAL_PATH_WEBDAV_MOUNTS) - .build() - } - - private val model by viewModels() - - private val browser = registerForActivityResult(StartActivityForResult()) { result -> - result.data?.data?.let { uri -> - ShareCompat.IntentBuilder(this) - .setType(DavUtils.MIME_TYPE_ACCEPT_ALL) - .addStream(uri) - .startChooser() - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - M2Theme { - val mountInfos by model.mountInfos.observeAsState(emptyList()) - WebdavMountsContent(mountInfos) - } - } - } - - - @Composable - fun WebdavMountsContent(mountInfos: List) { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton( - onClick = ::finish - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null - ) - } - }, - title = { Text(stringResource(R.string.webdav_mounts_title)) }, - actions = { - IconButton( - onClick = { - uriHandler.openUri(helpUrl.toString()) - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Help, - contentDescription = stringResource(R.string.help) - ) - } - } - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { startActivity(Intent(this, AddWebdavMountActivity::class.java)) } - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = stringResource(R.string.webdav_add_mount_add) - ) - } - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentAlignment = Alignment.Center - ) { - if (mountInfos.isEmpty()) { - HintText() - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .padding(paddingValues) - ) { - items(mountInfos, key = { it.mount.id }, contentType = { "mount" }) { - WebdavMountsItem(it) - } - } - } - } - } - } - - @Composable - @OptIn(ExperimentalTextApi::class) - fun HintText() { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.webdav_mounts_empty), - style = MaterialTheme.typography.h6, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) - - val text = HtmlCompat.fromHtml( - stringResource( - R.string.webdav_add_mount_empty_more_info, - helpUrl.toString() - ), - 0 - ).toAnnotatedString() - ClickableTextWithLink( - text = text, - style = MaterialTheme.typography.body1, - modifier = Modifier.fillMaxWidth() - ) - } - } - - @Composable - fun WebdavMountsItem(info: MountInfo) { - var showingDialog by remember { mutableStateOf(false) } - if (showingDialog) { - AlertDialog( - onDismissRequest = { showingDialog = false }, - title = { Text(stringResource(R.string.webdav_remove_mount_title)) }, - text = { Text(stringResource(R.string.webdav_remove_mount_text)) }, - confirmButton = { - TextButton( - onClick = { - Logger.log.log(Level.INFO, "User removes mount point", info.mount) - model.remove(info.mount) - } - ) { - Text(stringResource(R.string.dialog_remove)) - } + WebdavMountsScreen( + onAddWebdavMount = { + startActivity(Intent(this, AddWebdavMountActivity::class.java)) }, - dismissButton = { - TextButton( - onClick = { showingDialog = false } - ) { - Text(stringResource(R.string.dialog_deny)) - } - } + onNavUp = { onSupportNavigateUp() } ) } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - backgroundColor = MaterialTheme.colors.onSecondary - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = info.mount.name, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = MaterialTheme.typography.body1 - ) - Text( - text = info.mount.url.toString(), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = MaterialTheme.typography.caption - ) - - val quotaUsed = info.rootDocument?.quotaUsed - val quotaAvailable = info.rootDocument?.quotaAvailable - if (quotaUsed != null && quotaAvailable != null) { - val quotaTotal = quotaUsed + quotaAvailable - val progress = quotaUsed.toFloat() / quotaTotal - LinearProgressIndicator( - progress = progress, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) - Text( - text = stringResource( - R.string.webdav_mounts_quota_used_available, - FileUtils.byteCountToDisplaySize(quotaUsed), - FileUtils.byteCountToDisplaySize(quotaAvailable) - ), - modifier = Modifier.fillMaxWidth() - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - TextButton( - onClick = { - val authority = getString(R.string.webdav_authority) - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - val uri = DocumentsContract.buildRootUri(authority, info.mount.id.toString()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) - } - browser.launch(intent) - } - ) { - Text( - text = stringResource(R.string.webdav_mounts_share_content).uppercase() - ) - } - Spacer(Modifier.weight(1f)) - IconButton( - onClick = { showingDialog = true } - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.webdav_mounts_unmount) - ) - } - } - } - } - } - - @Preview(showBackground = true, showSystemUi = true) - @Composable - fun WebdavMountsContent_Preview() { - M2Theme { - WebdavMountsContent(emptyList()) - } - } - - @Preview(showBackground = true) - @Composable - fun WebdavMountsItem_Preview() { - M2Theme { - WebdavMountsItem( - info = MountInfo( - mount = WebDavMount( - id = 0, - name = "Preview Webdav Mount", - url = HttpUrl.Builder() - .scheme("https") - .host("example.com") - .build() - ), - rootDocument = WebDavDocument( - mountId = 0, - parentId = null, - name = "Root", - quotaAvailable = 1024 * 1024 * 1024, - quotaUsed = 512 * 1024 * 1024 - ) - ) - ) - } - } - - - data class MountInfo( - val mount: WebDavMount, - val rootDocument: WebDavDocument? - ) - - - @HiltViewModel - class Model @Inject constructor( - application: Application, - val db: AppDatabase - ): AndroidViewModel(application) { - - val context: Context get() = getApplication() - - val authority = context.getString(R.string.webdav_authority) - - val mountInfos = object: MediatorLiveData>() { - var mounts: List? = null - var roots: List? = null - init { - addSource(db.webDavMountDao().getAllLive()) { newMounts -> - mounts = newMounts - - viewModelScope.launch(Dispatchers.IO) { - // query children of root document for every mount to show quota - for (mount in newMounts) - queryChildrenOfRoot(mount) - - merge() - } - } - addSource(db.webDavDocumentDao().getRootsLive()) { newRoots -> - roots = newRoots - merge() - } - } - @Synchronized - fun merge() { - val result = mutableListOf() - mounts?.forEach { mount -> - result += MountInfo( - mount = mount, - rootDocument = roots?.firstOrNull { it.mountId == mount.id } - ) - } - postValue(result) - } - } - - /** - * Removes the mountpoint (deleting connection information) - */ - fun remove(mount: WebDavMount) { - viewModelScope.launch(Dispatchers.IO) { - // remove mount from database - db.webDavMountDao().delete(mount) - - // remove credentials, too - CredentialsStore(context).setCredentials(mount.id, null) - - // notify content URI listeners - DavDocumentsProvider.notifyMountsChanged(context) - } - } - - - private fun queryChildrenOfRoot(mount: WebDavMount) { - val resolver = context.contentResolver - db.webDavDocumentDao().getOrCreateRoot(mount).let { root -> - resolver.query(DocumentsContract.buildChildDocumentsUri(authority, root.id.toString()), null, null, null, null)?.close() - } - } - } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt new file mode 100644 index 00000000..4908279e --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsModel.kt @@ -0,0 +1,62 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.webdav.WebDavMountRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WebdavMountsModel @Inject constructor( + private val mountRepository: WebDavMountRepository +): ViewModel() { + + private val mounts = mountRepository.getAllFlow() + + // UI state + val mountInfos = mountRepository.getAllWithRootFlow() + var refreshingQuota by mutableStateOf(false) + private set + + init { + // refresh quota as soon as (new) mounts are available + viewModelScope.launch { + mounts.collect { + refreshQuota() + } + } + } + + /** + * Refreshes quota of all mounts (causes progress bar to be shown during refresh). + */ + fun refreshQuota() { + if (refreshingQuota) + return + refreshingQuota = true + + viewModelScope.launch { + mountRepository.refreshAllQuota() + refreshingQuota = false + } + } + + /** + * Removes the mountpoint locally (= deletes connection information). + */ + fun remove(mount: WebDavMount) { + viewModelScope.launch { + mountRepository.delete(mount) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt new file mode 100644 index 00000000..09bab6d5 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/webdav/WebdavMountsScreen.kt @@ -0,0 +1,433 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.webdav + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.app.ShareCompat +import androidx.core.text.HtmlCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.Constants +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.WebDavDocument +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.db.WebDavMountWithRootDocument +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString +import at.bitfire.davdroid.ui.widget.ClickableTextWithLink +import at.bitfire.davdroid.util.DavUtils +import okhttp3.HttpUrl +import org.apache.commons.io.FileUtils + +@Composable +fun WebdavMountsScreen( + onAddWebdavMount: () -> Unit, + onNavUp: () -> Unit, + model: WebdavMountsModel = viewModel() +) { + val mountInfos by model.mountInfos.collectAsStateWithLifecycle(emptyList()) + + AppTheme { + WebdavMountsScreen( + mountInfos = mountInfos, + refreshingQuota = model.refreshingQuota, + onRefreshQuota = { + model.refreshQuota() + }, + onAddMount = onAddWebdavMount, + onRemoveMount = { mount -> + model.remove(mount) + }, + onNavUp = onNavUp + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun WebdavMountsScreen( + mountInfos: List, + refreshingQuota: Boolean = false, + onRefreshQuota: () -> Unit = {}, + onAddMount: () -> Unit = {}, + onRemoveMount: (WebDavMount) -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val uriHandler = LocalUriHandler.current + + val refreshState = rememberPullToRefreshState() + LaunchedEffect(refreshState.isRefreshing) { + if (refreshState.isRefreshing) { + onRefreshQuota() + refreshState.endRefresh() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton( + onClick = onNavUp + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + title = { Text(stringResource(R.string.webdav_mounts_title)) }, + actions = { + IconButton( + onClick = { + uriHandler.openUri(webdavMountsHelpUrl().toString()) + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.help) + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddMount + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.webdav_add_mount_add) + ) + } + }, + modifier = Modifier.nestedScroll(refreshState.nestedScrollConnection) + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (mountInfos.isEmpty()) + HintText() + else { + Column { + if (refreshingQuota) + LinearProgressIndicator(Modifier + .fillMaxWidth() + .height(4.dp)) + else + Spacer(Modifier.height(4.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + ) { + items(mountInfos, key = { it.mount.id }, contentType = { "mount" }) { + WebdavMountsItem( + info = it, + onRemoveMount = onRemoveMount + ) + } + } + } + } + + PullToRefreshContainer( + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } +} + +@Composable +fun HintText() { + Column( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(align = Alignment.Center) + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.webdav_mounts_empty), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + + val text = HtmlCompat.fromHtml( + stringResource( + R.string.webdav_add_mount_empty_more_info, + webdavMountsHelpUrl().toString() + ), + 0 + ).toAnnotatedString() + ClickableTextWithLink( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +fun WebdavMountsItem( + info: WebDavMountWithRootDocument, + onRemoveMount: (WebDavMount) -> Unit = {}, +) { + var showingDialog by remember { mutableStateOf(false) } + if (showingDialog) { + AlertDialog( + onDismissRequest = { showingDialog = false }, + title = { Text(stringResource(R.string.webdav_remove_mount_title)) }, + text = { Text(stringResource(R.string.webdav_remove_mount_text)) }, + confirmButton = { + TextButton( + onClick = { + onRemoveMount(info.mount) + } + ) { + Text(stringResource(R.string.dialog_remove)) + } + }, + dismissButton = { + TextButton( + onClick = { showingDialog = false } + ) { + Text(stringResource(R.string.dialog_deny)) + } + } + ) + } + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = info.mount.name, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = info.mount.url.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + ) + + val quotaUsed = info.rootDocument?.quotaUsed + val quotaAvailable = info.rootDocument?.quotaAvailable + if (quotaUsed != null && quotaAvailable != null) { + val quotaTotal = quotaUsed + quotaAvailable + val progress = quotaUsed.toFloat() / quotaTotal + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + Text( + text = stringResource( + R.string.webdav_mounts_quota_used_available, + FileUtils.byteCountToDisplaySize(quotaUsed), + FileUtils.byteCountToDisplaySize(quotaAvailable) + ), + modifier = Modifier.fillMaxWidth() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val context = LocalContext.current + + val browser = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + result.data?.data?.let { uri -> + ShareCompat.IntentBuilder(context) + .setType(DavUtils.MIME_TYPE_ACCEPT_ALL) + .addStream(uri) + .startChooser() + } + } + + Button( + onClick = { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + val uri = DocumentsContract.buildRootUri(context.getString(R.string.webdav_authority), info.mount.id.toString()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) + } + browser.launch(intent) + } + ) { + Text( + text = stringResource(R.string.webdav_mounts_share_content) + ) + } + Spacer(Modifier.weight(1f)) + IconButton( + onClick = { showingDialog = true } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.webdav_mounts_unmount) + ) + } + } + } + } +} + +@Composable +@Preview +fun WebdavMountsScreen_Preview_Empty() { + WebdavMountsScreen( + mountInfos = emptyList(), + refreshingQuota = false + ) +} + +@Composable +@Preview +fun WebdavMountsScreen_Preview_TwoMounts() { + WebdavMountsScreen( + mountInfos = listOf( + WebDavMountWithRootDocument( + mount = WebDavMount( + id = 0, + name = "Preview Webdav Mount 1", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + rootDocument = WebDavDocument( + mountId = 0, + parentId = null, + name = "Root", + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ), + WebDavMountWithRootDocument( + mount = WebDavMount( + id = 1, + name = "Preview Webdav Mount 2", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + rootDocument = WebDavDocument( + mountId = 1, + parentId = null, + name = "Root", + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ) + ), + refreshingQuota = true + ) +} + +@Composable +@Preview +fun WebdavMountsItem_Preview() { + WebdavMountsItem( + info = WebDavMountWithRootDocument( + mount = WebDavMount( + id = 0, + name = "Preview Webdav Mount", + url = HttpUrl.Builder() + .scheme("https") + .host("example.com") + .build() + ), + rootDocument = WebDavDocument( + mountId = 0, + parentId = null, + name = "Root", + quotaAvailable = 1024 * 1024 * 1024, + quotaUsed = 512 * 1024 * 1024 + ) + ) + ) +} + + +fun webdavMountsHelpUrl(): Uri = Constants.MANUAL_URL.buildUpon() + .appendPath(Constants.MANUAL_PATH_WEBDAV_MOUNTS) + .build() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt new file mode 100644 index 00000000..aa2026fe --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/webdav/WebDavMountRepository.kt @@ -0,0 +1,132 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.webdav + +import android.app.Application +import android.provider.DocumentsContract +import androidx.annotation.VisibleForTesting +import at.bitfire.dav4jvm.DavResource +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.WebDavMount +import at.bitfire.davdroid.network.HttpClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import org.apache.commons.collections4.CollectionUtils +import javax.inject.Inject + +class WebDavMountRepository @Inject constructor( + val context: Application, + val db: AppDatabase +) { + + private val mountDao = db.webDavMountDao() + private val documentDao = db.webDavDocumentDao() + + /** authority of our WebDAV document provider ([DavDocumentsProvider]) */ + private val authority = context.getString(R.string.webdav_authority) + + /** + * Checks whether an HTTP endpoint supports WebDAV and if it does, adds it as a new WebDAV mount. + * + * @param url URL of the HTTP endpoint + * @param displayName display name of the mount + * @param credentials credentials to use for the mount + * + * @return `true` if the mount was added successfully, `false` if the endpoint doesn't support WebDAV + */ + suspend fun addMount( + url: HttpUrl, + displayName: String, + credentials: Credentials? + ): Boolean = withContext(Dispatchers.IO) { + if (!hasWebDav(url, credentials)) + return@withContext false + + // create in database + val mount = WebDavMount( + url = url, + name = displayName + ) + val id = db.webDavMountDao().insert(mount) + + // store credentials + val credentialsStore = CredentialsStore(context) + credentialsStore.setCredentials(id, credentials) + + // notify content URI listeners + DavDocumentsProvider.notifyMountsChanged(context) + + true + } + + suspend fun delete(mount: WebDavMount) { + // remove mount from database + mountDao.deleteAsync(mount) + + // remove credentials, too + CredentialsStore(context).setCredentials(mount.id, null) + + // notify content URI listeners + DavDocumentsProvider.notifyMountsChanged(context) + } + + fun getAllFlow() = mountDao.getAllFlow() + + fun getAllWithRootFlow() = mountDao.getAllWithRootDocumentFlow() + + suspend fun refreshAllQuota() { + val resolver = context.contentResolver + + withContext(Dispatchers.Default) { + // query root document of each mount to refresh quota + mountDao.getAll().forEach { mount -> + documentDao.getOrCreateRoot(mount).let { root -> + var loading = true + while (loading) { + val rootDocumentUri = DocumentsContract.buildChildDocumentsUri(authority, root.id.toString()) + resolver.query(rootDocumentUri, null, null, null, null)?.use { cursor -> + loading = cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING) + } + + if (loading) // still loading, wait a bit + delay(100) + } + } + } + } + } + + + // helpers + + @VisibleForTesting + internal suspend fun hasWebDav( + url: HttpUrl, + credentials: Credentials? + ): Boolean = withContext(Dispatchers.IO) { + var supported = false + + HttpClient.Builder(context, null, credentials) + .setForeground(true) + .build() + .use { client -> + val dav = DavResource(client.okHttpClient, url) + runInterruptible { + dav.options { davCapabilities, _ -> + if (CollectionUtils.containsAny(davCapabilities, "1", "2", "3")) + supported = true + } + } + } + + supported + } + +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 016e204d..8d25d368 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,6 @@ androidx-lifecycle = "2.7.0" androidx-paging = "3.2.1" androidx-preference = "1.2.1" androidx-security = "1.1.0-alpha06" -androidx-swiperefreshlayout = "1.1.0" androidx-test-core = "1.5.0" androidx-test-runner = "1.5.2" androidx-test-rules = "1.5.0" @@ -79,7 +78,6 @@ androidx-paging = { module = "androidx.paging:paging-runtime-ktx", version.ref = androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } androidx-security = { module = "androidx.security:security-crypto", version.ref = "androidx-security" } -androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }