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..4e4d9a999 --- /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 + } + } + null to accessToken?.let { AccessTokenAuthentication(it) } + } + AuthenticationType.NONE -> null 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +