mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-21 02:23: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.paging.compose)
|
||||||
implementation(libs.androidx.preference)
|
implementation(libs.androidx.preference)
|
||||||
implementation(libs.androidx.security)
|
implementation(libs.androidx.security)
|
||||||
implementation(libs.androidx.swiperefreshlayout)
|
|
||||||
implementation(libs.androidx.work.base)
|
implementation(libs.androidx.work.base)
|
||||||
implementation(libs.android.flexbox)
|
implementation(libs.android.flexbox)
|
||||||
implementation(libs.android.material)
|
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
|
package at.bitfire.davdroid.db
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface WebDavMountDao {
|
interface WebDavMountDao {
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
fun delete(mount: WebDavMount)
|
suspend fun deleteAsync(mount: WebDavMount)
|
||||||
|
|
||||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||||
fun getAll(): List<WebDavMount>
|
fun getAll(): List<WebDavMount>
|
||||||
|
|
||||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
@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")
|
@Query("SELECT * FROM webdav_mount WHERE id=:id")
|
||||||
fun getById(id: Long): WebDavMount
|
fun getById(id: Long): WebDavMount
|
||||||
|
@ -28,4 +28,10 @@ interface WebDavMountDao {
|
||||||
@Insert
|
@Insert
|
||||||
fun insert(mount: WebDavMount): Long
|
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.focusGroup
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
@ -37,6 +37,7 @@ fun PasswordTextField(
|
||||||
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
isError: Boolean = false
|
isError: Boolean = false
|
||||||
) {
|
) {
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
@ -49,6 +50,7 @@ fun PasswordTextField(
|
||||||
isError = isError,
|
isError = isError,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
modifier = modifier.focusGroup(),
|
modifier = modifier.focusGroup(),
|
||||||
keyboardOptions = keyboardOptions,
|
keyboardOptions = keyboardOptions,
|
||||||
keyboardActions = keyboardActions,
|
keyboardActions = keyboardActions,
|
||||||
|
|
|
@ -17,7 +17,6 @@ import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarResult
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
@ -31,11 +30,13 @@ import kotlinx.coroutines.launch
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectClientCertificateCard(
|
fun SelectClientCertificateCard(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
suggestedAlias: String?,
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
suggestedAlias: String? = null,
|
||||||
chosenAlias: String?,
|
chosenAlias: String?,
|
||||||
onAliasChosen: (String) -> Unit = {}
|
onAliasChosen: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = modifier) {
|
||||||
Column(Modifier.padding(8.dp)) {
|
Column(Modifier.padding(8.dp)) {
|
||||||
Text(
|
Text(
|
||||||
if (!chosenAlias.isNullOrEmpty())
|
if (!chosenAlias.isNullOrEmpty())
|
||||||
|
@ -49,6 +50,7 @@ fun SelectClientCertificateCard(
|
||||||
val activity = LocalContext.current as? Activity
|
val activity = LocalContext.current as? Activity
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
enabled = enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (activity != null)
|
if (activity != null)
|
||||||
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
||||||
|
|
|
@ -4,426 +4,23 @@
|
||||||
|
|
||||||
package at.bitfire.davdroid.ui.webdav
|
package at.bitfire.davdroid.ui.webdav
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.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
|
@AndroidEntryPoint
|
||||||
class AddWebdavMountActivity : AppCompatActivity() {
|
class AddWebdavMountActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val model by viewModels<Model>()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val isLoading by model.isLoading.observeAsState(initial = false)
|
AddWebdavMountScreen(
|
||||||
val error by model.error.observeAsState(initial = null)
|
onNavUp = { onSupportNavigateUp() },
|
||||||
val displayName by model.displayName.observeAsState(initial = "")
|
onFinish = { finish() }
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
package at.bitfire.davdroid.ui.webdav
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.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
|
@AndroidEntryPoint
|
||||||
class WebdavMountsActivity: AppCompatActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
M2Theme {
|
WebdavMountsScreen(
|
||||||
val mountInfos by model.mountInfos.observeAsState(emptyList())
|
onAddWebdavMount = {
|
||||||
WebdavMountsContent(mountInfos)
|
startActivity(Intent(this, AddWebdavMountActivity::class.java))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
onNavUp = { onSupportNavigateUp() }
|
||||||
TextButton(
|
|
||||||
onClick = { showingDialog = false }
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.dialog_deny))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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-paging = "3.2.1"
|
||||||
androidx-preference = "1.2.1"
|
androidx-preference = "1.2.1"
|
||||||
androidx-security = "1.1.0-alpha06"
|
androidx-security = "1.1.0-alpha06"
|
||||||
androidx-swiperefreshlayout = "1.1.0"
|
|
||||||
androidx-test-core = "1.5.0"
|
androidx-test-core = "1.5.0"
|
||||||
androidx-test-runner = "1.5.2"
|
androidx-test-runner = "1.5.2"
|
||||||
androidx-test-rules = "1.5.0"
|
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-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
|
||||||
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
|
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
|
||||||
androidx-security = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
|
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-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-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
|
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" }
|
||||||
|
|
Loading…
Reference in a new issue