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
This commit is contained in:
Ricki Hirner 2024-04-23 17:34:20 +02:00 committed by GitHub
parent 3fbffc4a72
commit 0b212fc6bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1106 additions and 910 deletions

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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))
}
}

View File

@ -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<WebDavMount>
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
fun getAllLive(): LiveData<List<WebDavMount>>
fun getAllFlow(): Flow<List<WebDavMount>>
@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<List<WebDavMountWithRootDocument>>
}

View File

@ -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?
)

View File

@ -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,

View File

@ -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 ->

View File

@ -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<Model>()
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<String>()
val displayNameError = MutableLiveData<String>()
val url = MutableLiveData<String>()
val urlError = MutableLiveData<String>()
val userName = MutableLiveData<String>()
val password = MutableLiveData<String>()
val error = MutableLiveData<String>()
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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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<Model>()
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<MountInfo>) {
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<List<MountInfo>>() {
var mounts: List<WebDavMount>? = null
var roots: List<WebDavDocument>? = 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<MountInfo>()
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()
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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<WebDavMountWithRootDocument>,
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()

View File

@ -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
}
}

View File

@ -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" }