mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-08 20:16:24 +00:00
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:
parent
3fbffc4a72
commit
0b212fc6bd
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
|
@ -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>>
|
||||
|
||||
}
|
|
@ -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?
|
||||
)
|
|
@ -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,
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in New Issue
Block a user