diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ac67576d3..35e8b9b2a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -217,6 +217,11 @@
android:label="@string/storage_edit_smb_server_title_edit"
android:theme="@style/Theme.MaterialFiles" />
+
+
+ * All Rights Reserved.
+ */
+
+package me.zhanghai.android.files.storage
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.commit
+import me.zhanghai.android.files.app.AppActivity
+import me.zhanghai.android.files.util.args
+import me.zhanghai.android.files.util.putArgs
+
+class EditWebDavServerActivity : AppActivity() {
+ private val args by args()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Calls ensureSubDecor().
+ findViewById(android.R.id.content)
+ if (savedInstanceState == null) {
+ val fragment = EditWebDavServerFragment().putArgs(args)
+ supportFragmentManager.commit { add(android.R.id.content, fragment) }
+ }
+ }
+}
diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt
new file mode 100644
index 000000000..abf813b95
--- /dev/null
+++ b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerFragment.kt
@@ -0,0 +1,344 @@
+/*
+ * Copyright (c) 2024 Hai Zhang
+ * All Rights Reserved.
+ */
+
+package me.zhanghai.android.files.storage
+
+import android.app.Activity
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.isVisible
+import androidx.core.widget.doAfterTextChanged
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.textfield.TextInputEditText
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+import me.zhanghai.android.files.R
+import me.zhanghai.android.files.databinding.EditWebdavServerFragmentBinding
+import me.zhanghai.android.files.provider.webdav.client.AccessTokenAuthentication
+import me.zhanghai.android.files.provider.webdav.client.Authority
+import me.zhanghai.android.files.provider.webdav.client.NoneAuthentication
+import me.zhanghai.android.files.provider.webdav.client.PasswordAuthentication
+import me.zhanghai.android.files.provider.webdav.client.Protocol
+import me.zhanghai.android.files.ui.UnfilteredArrayAdapter
+import me.zhanghai.android.files.util.ActionState
+import me.zhanghai.android.files.util.ParcelableArgs
+import me.zhanghai.android.files.util.args
+import me.zhanghai.android.files.util.fadeToVisibilityUnsafe
+import me.zhanghai.android.files.util.finish
+import me.zhanghai.android.files.util.getTextArray
+import me.zhanghai.android.files.util.hideTextInputLayoutErrorOnTextChange
+import me.zhanghai.android.files.util.isReady
+import me.zhanghai.android.files.util.setResult
+import me.zhanghai.android.files.util.showToast
+import me.zhanghai.android.files.util.takeIfNotEmpty
+import me.zhanghai.android.files.util.viewModels
+import java.net.URI
+
+class EditWebDavServerFragment : Fragment() {
+ private val args by args()
+
+ private val viewModel by viewModels { { EditWebDavServerViewModel() } }
+
+ private lateinit var binding: EditWebdavServerFragmentBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ lifecycleScope.launchWhenStarted {
+ launch { viewModel.connectState.collect { onConnectStateChanged(it) } }
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View =
+ EditWebdavServerFragmentBinding.inflate(inflater, container, false)
+ .also { binding = it }
+ .root
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val activity = requireActivity() as AppCompatActivity
+ activity.lifecycleScope.launchWhenCreated {
+ activity.setSupportActionBar(binding.toolbar)
+ activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+ activity.setTitle(
+ if (args.server != null) {
+ R.string.storage_edit_webdav_server_title_edit
+ } else {
+ R.string.storage_edit_webdav_server_title_add
+ }
+ )
+ }
+
+ binding.hostEdit.hideTextInputLayoutErrorOnTextChange(binding.hostLayout)
+ binding.hostEdit.doAfterTextChanged { updateNamePlaceholder() }
+ binding.portEdit.hideTextInputLayoutErrorOnTextChange(binding.portLayout)
+ binding.portEdit.doAfterTextChanged { updateNamePlaceholder() }
+ binding.pathEdit.doAfterTextChanged { updateNamePlaceholder() }
+ binding.protocolEdit.setAdapter(
+ UnfilteredArrayAdapter(
+ binding.protocolEdit.context, R.layout.dropdown_item,
+ objects = getTextArray(R.array.storage_edit_webdav_server_protocol_entries)
+ )
+ )
+ protocol = Protocol.DAVS
+ binding.protocolEdit.doAfterTextChanged {
+ updateNamePlaceholder()
+ updatePortPlaceholder()
+ }
+ binding.authenticationTypeEdit.setAdapter(
+ UnfilteredArrayAdapter(
+ binding.authenticationTypeEdit.context, R.layout.dropdown_item,
+ objects =
+ getTextArray(R.array.storage_edit_webdav_server_authentication_type_entries)
+ )
+ )
+ authenticationType = AuthenticationType.PASSWORD
+ binding.authenticationTypeEdit.doAfterTextChanged {
+ onAuthenticationTypeChanged(authenticationType)
+ updateNamePlaceholder()
+ }
+ binding.usernameEdit.hideTextInputLayoutErrorOnTextChange(binding.usernameLayout)
+ binding.usernameEdit.doAfterTextChanged { updateNamePlaceholder() }
+ binding.saveOrConnectAndAddButton.setText(
+ if (args.server != null) {
+ R.string.save
+ } else {
+ R.string.storage_edit_webdav_server_connect_and_add
+ }
+ )
+ binding.saveOrConnectAndAddButton.setOnClickListener {
+ if (args.server != null) {
+ saveOrAdd()
+ } else {
+ connectAndAdd()
+ }
+ }
+ binding.cancelButton.setOnClickListener { finish() }
+ binding.removeOrAddButton.setText(
+ if (args.server != null) R.string.remove else R.string.storage_edit_webdav_server_add
+ )
+ binding.removeOrAddButton.setOnClickListener {
+ if (args.server != null) {
+ remove()
+ } else {
+ saveOrAdd()
+ }
+ }
+
+ if (savedInstanceState == null) {
+ val server = args.server
+ if (server != null) {
+ val authority = server.authority
+ binding.hostEdit.setText(authority.host)
+ protocol = authority.protocol
+ if (authority.port != protocol.defaultPort) {
+ binding.portEdit.setText(authority.port.toString())
+ }
+ when (val authentication = server.authentication) {
+ is PasswordAuthentication -> {
+ authenticationType = AuthenticationType.PASSWORD
+ binding.passwordEdit.setText(authentication.password)
+ }
+ is AccessTokenAuthentication -> {
+ authenticationType = AuthenticationType.ACCESS_TOKEN
+ binding.accessTokenEdit.setText(authentication.accessToken)
+ }
+ is NoneAuthentication -> authenticationType = AuthenticationType.NONE
+ }
+ binding.pathEdit.setText(server.relativePath)
+ binding.nameEdit.setText(server.customName)
+ } else {
+ val host = args.host
+ if (host != null) {
+ binding.hostEdit.setText(host)
+ }
+ }
+ }
+ }
+
+ private fun updateNamePlaceholder() {
+ val host = binding.hostEdit.text.toString().takeIfNotEmpty()
+ val port = binding.portEdit.text.toString().takeIfNotEmpty()?.toIntOrNull()
+ ?: protocol.defaultPort
+ val path = binding.pathEdit.text.toString().trim()
+ val username = if (authenticationType == AuthenticationType.PASSWORD) {
+ binding.usernameEdit.text.toString()
+ } else {
+ ""
+ }
+ binding.nameLayout.placeholderText = if (host != null) {
+ val authority = Authority(protocol, host, port, username)
+ if (path.isNotEmpty()) "$authority/$path" else authority.toString()
+ } else {
+ getString(R.string.storage_edit_webdav_server_name_placeholder)
+ }
+ }
+
+ private fun updatePortPlaceholder() {
+ binding.portLayout.placeholderText = protocol.defaultPort.toString()
+ }
+
+ private var protocol: Protocol
+ get() {
+ val adapter = binding.protocolEdit.adapter
+ val items = List(adapter.count) { adapter.getItem(it) as CharSequence }
+ val selectedItem = binding.protocolEdit.text
+ val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) }
+ return Protocol.entries[selectedIndex]
+ }
+ set(value) {
+ val adapter = binding.protocolEdit.adapter
+ val item = adapter.getItem(value.ordinal) as CharSequence
+ binding.protocolEdit.setText(item, false)
+ }
+
+ private var authenticationType: AuthenticationType
+ get() {
+ val adapter = binding.authenticationTypeEdit.adapter
+ val items = List(adapter.count) { adapter.getItem(it) as CharSequence }
+ val selectedItem = binding.authenticationTypeEdit.text
+ val selectedIndex = items.indexOfFirst { TextUtils.equals(it, selectedItem) }
+ return AuthenticationType.entries[selectedIndex]
+ }
+ set(value) {
+ val adapter = binding.authenticationTypeEdit.adapter
+ val item = adapter.getItem(value.ordinal) as CharSequence
+ binding.authenticationTypeEdit.setText(item, false)
+ onAuthenticationTypeChanged(value)
+ }
+
+ private fun onAuthenticationTypeChanged(authenticationType: AuthenticationType) {
+ binding.passwordAuthenticationLayout.isVisible =
+ authenticationType == AuthenticationType.PASSWORD
+ binding.accessTokenLayout.isVisible = authenticationType == AuthenticationType.ACCESS_TOKEN
+ }
+
+ private fun saveOrAdd() {
+ val server = getServerOrSetError() ?: return
+ Storages.addOrReplace(server)
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+
+ private fun connectAndAdd() {
+ if (!viewModel.connectState.value.isReady) {
+ return
+ }
+ val server = getServerOrSetError() ?: return
+ viewModel.connect(server)
+ }
+
+ private fun onConnectStateChanged(state: ActionState) {
+ when (state) {
+ is ActionState.Ready, is ActionState.Running -> {
+ val isConnecting = state is ActionState.Running
+ binding.progress.fadeToVisibilityUnsafe(isConnecting)
+ binding.scrollView.fadeToVisibilityUnsafe(!isConnecting)
+ binding.saveOrConnectAndAddButton.isEnabled = !isConnecting
+ binding.removeOrAddButton.isEnabled = !isConnecting
+ }
+ is ActionState.Success -> {
+ Storages.addOrReplace(state.argument)
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+ is ActionState.Error -> {
+ val throwable = state.throwable
+ throwable.printStackTrace()
+ showToast(throwable.toString())
+ viewModel.finishConnecting()
+ }
+ }
+ }
+
+ private fun remove() {
+ Storages.remove(args.server!!)
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+
+ private fun getServerOrSetError(): WebDavServer? {
+ var errorEdit: TextInputEditText? = null
+ val host = binding.hostEdit.text.toString().takeIfNotEmpty()
+ ?.let { URI::class.canonicalizeHost(it) }
+ if (host == null) {
+ binding.hostLayout.error = getString(R.string.storage_edit_webdav_server_host_error_empty)
+ if (errorEdit == null) {
+ errorEdit = binding.hostEdit
+ }
+ } else if (!URI::class.isValidHost(host)) {
+ binding.hostLayout.error =
+ getString(R.string.storage_edit_webdav_server_host_error_invalid)
+ if (errorEdit == null) {
+ errorEdit = binding.hostEdit
+ }
+ }
+ val port = binding.portEdit.text.toString().takeIfNotEmpty()
+ .let { if (it != null) it.toIntOrNull() else protocol.defaultPort }
+ if (port == null) {
+ binding.portLayout.error =
+ getString(R.string.storage_edit_webdav_server_port_error_invalid)
+ if (errorEdit == null) {
+ errorEdit = binding.portEdit
+ }
+ }
+ val path = binding.pathEdit.text.toString().trim()
+ val name = binding.nameEdit.text.toString().takeIfNotEmpty()
+ val (username, authentication) = when (authenticationType) {
+ AuthenticationType.PASSWORD -> {
+ val username = binding.usernameEdit.text.toString().takeIfNotEmpty()
+ if (username == null) {
+ binding.usernameLayout.error =
+ getString(R.string.storage_edit_webdav_server_username_error_empty)
+ if (errorEdit == null) {
+ errorEdit = binding.usernameEdit
+ }
+ }
+ val password = binding.passwordEdit.text.toString()
+ username to PasswordAuthentication(password)
+ }
+ AuthenticationType.ACCESS_TOKEN -> {
+ val accessToken = binding.accessTokenEdit.text.toString().takeIfNotEmpty()
+ if (accessToken == null) {
+ binding.accessTokenLayout.error =
+ getString(R.string.storage_edit_webdav_server_access_token_error_empty)
+ if (errorEdit == null) {
+ errorEdit = binding.accessTokenEdit
+ }
+ }
+ "" to accessToken?.let { AccessTokenAuthentication(it) }
+ }
+ AuthenticationType.NONE -> "" to NoneAuthentication
+ }
+ if (errorEdit != null) {
+ errorEdit.requestFocus()
+ return null
+ }
+ val authority = Authority(protocol, host!!, port!!, username!!)
+ return WebDavServer(args.server?.id, name, authority, authentication!!, path)
+ }
+
+ @Parcelize
+ class Args(
+ val server: WebDavServer? = null,
+ val host: String? = null
+ ) : ParcelableArgs
+
+ private enum class AuthenticationType {
+ PASSWORD,
+ ACCESS_TOKEN,
+ NONE
+ }
+}
diff --git a/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt
new file mode 100644
index 000000000..54924576d
--- /dev/null
+++ b/app/src/main/java/me/zhanghai/android/files/storage/EditWebDavServerViewModel.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024 Hai Zhang
+ * All Rights Reserved.
+ */
+
+package me.zhanghai.android.files.storage
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
+import me.zhanghai.android.files.provider.common.newDirectoryStream
+import me.zhanghai.android.files.util.ActionState
+import me.zhanghai.android.files.util.isFinished
+import me.zhanghai.android.files.util.isReady
+
+class EditWebDavServerViewModel : ViewModel() {
+ private val _connectState =
+ MutableStateFlow>(ActionState.Ready())
+ val connectState = _connectState.asStateFlow()
+
+ fun connect(server: WebDavServer) {
+ viewModelScope.launch {
+ check(_connectState.value.isReady)
+ _connectState.value = ActionState.Running(server)
+ _connectState.value = try {
+ runInterruptible(Dispatchers.IO) {
+ WebDavServerAuthenticator.addTransientServer(server)
+ try {
+ val path = server.path
+ path.fileSystem.use {
+ path.newDirectoryStream().toList()
+ }
+ } finally {
+ WebDavServerAuthenticator.removeTransientServer(server)
+ }
+ }
+ ActionState.Success(server, Unit)
+ } catch (e: Exception) {
+ ActionState.Error(server, e)
+ }
+ }
+ }
+
+ fun finishConnecting() {
+ viewModelScope.launch {
+ check(_connectState.value.isFinished)
+ _connectState.value = ActionState.Ready()
+ }
+ }
+}
diff --git a/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt
new file mode 100644
index 000000000..f15719bac
--- /dev/null
+++ b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServer.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2024 Hai Zhang
+ * All Rights Reserved.
+ */
+
+package me.zhanghai.android.files.storage
+
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.DrawableRes
+import java8.nio.file.Path
+import kotlinx.parcelize.Parcelize
+import me.zhanghai.android.files.R
+import me.zhanghai.android.files.provider.webdav.client.Authentication
+import me.zhanghai.android.files.provider.webdav.client.Authority
+import me.zhanghai.android.files.provider.webdav.createWebDavRootPath
+import me.zhanghai.android.files.util.createIntent
+import me.zhanghai.android.files.util.putArgs
+import kotlin.random.Random
+
+@Parcelize
+class WebDavServer(
+ override val id: Long,
+ override val customName: String?,
+ val authority: Authority,
+ val authentication: Authentication,
+ val relativePath: String
+) : Storage() {
+ constructor(
+ id: Long?,
+ customName: String?,
+ authority: Authority,
+ authentication: Authentication,
+ relativePath: String
+ ) : this(id ?: Random.nextLong(), customName, authority, authentication, relativePath)
+
+ override val iconRes: Int
+ @DrawableRes
+ get() = R.drawable.computer_icon_white_24dp
+
+ override fun getDefaultName(context: Context): String =
+ if (relativePath.isNotEmpty()) "$authority/$relativePath" else authority.toString()
+
+ override val description: String
+ get() = authority.toString()
+
+ override val path: Path
+ get() = authority.createWebDavRootPath().resolve(relativePath)
+
+ override fun createEditIntent(): Intent =
+ EditWebDavServerActivity::class.createIntent().putArgs(EditWebDavServerFragment.Args(this))
+}
diff --git a/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt
new file mode 100644
index 000000000..93c59199d
--- /dev/null
+++ b/app/src/main/java/me/zhanghai/android/files/storage/WebDavServerAuthenticator.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2024 Hai Zhang
+ * All Rights Reserved.
+ */
+
+package me.zhanghai.android.files.storage
+
+import me.zhanghai.android.files.provider.webdav.client.Authentication
+import me.zhanghai.android.files.provider.webdav.client.Authenticator
+import me.zhanghai.android.files.provider.webdav.client.Authority
+import me.zhanghai.android.files.settings.Settings
+import me.zhanghai.android.files.util.valueCompat
+
+object WebDavServerAuthenticator : Authenticator {
+ private val transientServers = mutableSetOf()
+
+ override fun getAuthentication(authority: Authority): Authentication? {
+ val server = synchronized(transientServers) {
+ transientServers.find { it.authority == authority }
+ } ?: Settings.STORAGES.valueCompat.find {
+ it is WebDavServer && it.authority == authority
+ } as WebDavServer?
+ return server?.authentication
+ }
+
+ fun addTransientServer(server: WebDavServer) {
+ synchronized(transientServers) { transientServers += server }
+ }
+
+ fun removeTransientServer(server: WebDavServer) {
+ synchronized(transientServers) { transientServers -= server }
+ }
+}
diff --git a/app/src/main/res/layout/edit_webdav_server_fragment.xml b/app/src/main/res/layout/edit_webdav_server_fragment.xml
new file mode 100644
index 000000000..55c57eadf
--- /dev/null
+++ b/app/src/main/res/layout/edit_webdav_server_fragment.xml
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 44d8225cf..709b3499c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -399,6 +399,7 @@
FTP 服务器
SFTP 服务器
SMB 服务器
+ WebDAV 服务器
编辑设备存储
名称
路径
@@ -492,6 +493,31 @@
域
连接并添加
添加
+ 编辑 WebDAV 服务器
+ 添加 WebDAV 服务器
+ 主机名
+ 输入主机名
+ 无效主机名
+ 端口
+ 无效端口
+ 路径
+ 可留空
+ 名称
+ 使用主机名
+ 协议
+ 验证
+
+ - 密码
+ - 访问令牌
+ - 无
+
+ 用户名
+ 输入用户名
+ 密码
+ 访问令牌
+ 输入访问令牌
+ 连接并添加
+ 添加
共 %2$s,剩余 %1$s
添加存储…
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 223befefa..25d210fd8 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -399,6 +399,7 @@
FTP 伺服器
SFTP 伺服器
SMB 伺服器
+ WebDAV 伺服器
編輯裝置儲存空間
名稱
路徑
@@ -492,6 +493,31 @@
網域
連線並新增
新增
+ 編輯 WebDAV 伺服器
+ 新增 WebDAV 伺服器
+ 主機名稱
+ 輸入主機名稱
+ 無效的主機名稱
+ 連接埠
+ 無效的連接埠
+ 路徑
+ 可留空
+ 名稱
+ 使用主機名稱
+ 通訊協定
+ 驗證
+
+ - 密碼
+ - 存取權杖
+ - 無
+
+ 使用者名稱
+ 輸入使用者名稱
+ 密碼
+ 存取權杖
+ 輸入存取權杖
+ 連線並新增
+ 新增
共 %2$s,剩餘 %1$s
新增儲存…
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 713fb8d9b..b4dc1900b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -521,6 +521,7 @@
FTP server
SFTP server
SMB server
+ WebDAV server
Edit device storage
Name
Path
@@ -623,6 +624,36 @@
Domain
Connect and add
Add
+ Edit WebDAV server
+ Add WebDAV server
+ Hostname
+ Enter a hostname
+ Invalid hostname
+ Port
+ 443
+ Invalid port
+ Path
+ Can be left empty
+ Name
+ Use hostname
+ Protocol
+
+ - HTTP
+ - HTTPS
+
+ Authentication
+
+ - Password
+ - Access token
+ - None
+
+ Username
+ Enter a username
+ Password
+ Access token
+ Enter an access token
+ Connect and add
+ Add
%1$s free of %2$s
Add storage…