From b9e84715fc2953705b9aff577095378f319dadcd Mon Sep 17 00:00:00 2001 From: Hai Zhang Date: Sun, 25 Feb 2024 17:53:30 -0800 Subject: [PATCH] Feat!: Add WebDAV support Bug: #191 --- README.md | 2 +- README_zh-CN.md | 2 +- app/build.gradle | 3 + .../bitfire/dav4jvm/DavResourceAccessor.java | 28 ++ .../android/files/app/SystemServices.kt | 3 + .../android/files/compat/DateTimeCompat.kt | 5 + .../files/provider/FileSystemProviders.kt | 4 + .../provider/webdav/DavExceptionExtensions.kt | 27 ++ .../webdav/OpenOptionsWebDavExtensions.kt | 21 + .../provider/webdav/PathWebDavExtensions.kt | 12 + .../files/provider/webdav/WebDavCopyMove.kt | 185 ++++++++ .../webdav/WebDavFileAttributeView.kt | 62 +++ .../provider/webdav/WebDavFileAttributes.kt | 66 +++ .../files/provider/webdav/WebDavFileSystem.kt | 135 ++++++ .../webdav/WebDavFileSystemProvider.kt | 444 ++++++++++++++++++ .../files/provider/webdav/WebDavPath.kt | 125 +++++ .../provider/webdav/client/Authentication.kt | 79 ++++ .../provider/webdav/client/Authenticator.kt | 10 + .../files/provider/webdav/client/Authority.kt | 29 ++ .../files/provider/webdav/client/Client.kt | 293 ++++++++++++ .../provider/webdav/client/DavIOException.kt | 16 + .../webdav/client/DavResourceCompat.kt | 239 ++++++++++ .../provider/webdav/client/FileByteChannel.kt | 82 ++++ .../provider/webdav/client/MemoryCookieJar.kt | 40 ++ .../files/provider/webdav/client/Protocol.kt | 18 + .../webdav/client/ResponseExtensions.kt | 33 ++ app/src/main/res/raw/licenses.xml | 7 + .../android/en-US/full_description.txt | 2 +- .../android/zh-CN/full_description.txt | 2 +- 29 files changed, 1970 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/at/bitfire/dav4jvm/DavResourceAccessor.java create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt create mode 100644 app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt diff --git a/README.md b/README.md index bb965c82..232ca507 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ An open source Material Design file manager, for Android 5.0+. - Breadcrumbs: Navigate in the filesystem with ease. - Root support: View and manage files with root access. - Archive support: View, extract and create common compressed files. -- NAS support: View and manage files on FTP, SFTP and SMB servers. +- NAS support: View and manage files on FTP, SFTP, SMB and WebDAV servers. - Themes: Customizable UI colors, plus night mode with optional true black. - Linux-aware: Like [Nautilus](https://wiki.gnome.org/action/show/Apps/Files), knows symbolic links, file permissions and SELinux context. - Robust: Uses Linux system calls under the hood, not yet another [`ls` parser](https://news.ycombinator.com/item?id=7994720). diff --git a/README_zh-CN.md b/README_zh-CN.md index ad5ae255..864a35c2 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -20,7 +20,7 @@ - 面包屑导航栏:点击导航栏所显示路径中的任一文件夹即可快速访问。 - Root 支持:使用 root 权限查看和管理文件。 - 压缩文件支持:查看、提取和创建常见的压缩文件。 -- NAS 支持:查看和管理 FTP、SFTP 和 SMB 服务器上的文件。 +- NAS 支持:查看和管理 FTP、SFTP、SMB 和 WebDAV 服务器上的文件。 - 主题:可定制的界面颜色,以及可选纯黑的夜间模式。 - Linux 友好:类似 [Nautilus](https://wiki.gnome.org/action/show/Apps/Files),支持符号链接、文件权限和 SELinux 上下文。 - 健壮性:使用 Linux 系统调用实现,而不是另一个 [`ls` 解析器](https://news.ycombinator.com/item?id=7994720)。 diff --git a/app/build.gradle b/app/build.gradle index 7ae7d8b4..2a85510e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -104,6 +104,9 @@ repositories { } } dependencies { + // TODO: Remove DavResource.moveCompat once https://github.com/bitfireAT/dav4jvm/issues/39 is + // fixed. + implementation 'com.github.bitfireAT:dav4jvm:2.2.1' implementation 'com.github.chrisbanes:PhotoView:2.3.0' releaseImplementation 'com.github.mypplication:stetho-noop:1.1' implementation 'com.github.topjohnwu.libsu:service:5.2.2' diff --git a/app/src/main/java/at/bitfire/dav4jvm/DavResourceAccessor.java b/app/src/main/java/at/bitfire/dav4jvm/DavResourceAccessor.java new file mode 100644 index 00000000..0aa812db --- /dev/null +++ b/app/src/main/java/at/bitfire/dav4jvm/DavResourceAccessor.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package at.bitfire.dav4jvm; + +import java.io.IOException; + +import androidx.annotation.NonNull; +import at.bitfire.dav4jvm.exception.DavException; +import at.bitfire.dav4jvm.exception.HttpException; +import kotlin.jvm.functions.Function0; +import okhttp3.Response; + +public class DavResourceAccessor { + private DavResourceAccessor() {} + + public static void checkStatus(@NonNull DavResource davResource, @NonNull Response response) + throws HttpException { + davResource.checkStatus(response); + } + + public static Response followRedirects(@NonNull DavResource davResource, + @NonNull Function0 sendRequest) throws DavException, IOException { + return davResource.followRedirects$build(sendRequest); + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt b/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt index 4c1df9d2..388fdbbb 100644 --- a/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt +++ b/app/src/main/java/me/zhanghai/android/files/app/SystemServices.kt @@ -17,6 +17,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.preference.PreferenceManager import me.zhanghai.android.files.compat.getSystemServiceCompat import me.zhanghai.android.files.compat.mainExecutorCompat +import okhttp3.OkHttpClient import java.util.concurrent.Executor val appClassLoader = AppProvider::class.java.classLoader @@ -31,6 +32,8 @@ val defaultSharedPreferences: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(application) } +val okHttpClient: OkHttpClient by lazy { OkHttpClient() } + val inputMethodManager: InputMethodManager by lazy { application.getSystemServiceCompat(InputMethodManager::class.java) } diff --git a/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt b/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt index 6a39604a..a1ad5316 100644 --- a/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt +++ b/app/src/main/java/me/zhanghai/android/files/compat/DateTimeCompat.kt @@ -3,5 +3,10 @@ package me.zhanghai.android.files.compat import org.threeten.bp.DateTimeUtils import org.threeten.bp.Instant import java.util.Calendar +import java.util.Date fun Calendar.toInstantCompat(): Instant = DateTimeUtils.toInstant(this) + +fun Date.toInstantCompat(): Instant = DateTimeUtils.toInstant(this) + +fun Instant.toDateCompat(): Date = DateTimeUtils.toDate(this) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt index bde790c9..7126ca27 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/FileSystemProviders.kt @@ -19,6 +19,8 @@ import me.zhanghai.android.files.provider.linux.LinuxFileSystemProvider import me.zhanghai.android.files.provider.root.isRunningAsRoot import me.zhanghai.android.files.provider.sftp.SftpFileSystemProvider import me.zhanghai.android.files.provider.smb.SmbFileSystemProvider +import me.zhanghai.android.files.provider.webdav.WebDavFileSystemProvider +import me.zhanghai.android.files.provider.webdav.WebDavsFileSystemProvider object FileSystemProviders { /** @@ -42,6 +44,8 @@ object FileSystemProviders { FileSystemProvider.installProvider(FtpesFileSystemProvider) FileSystemProvider.installProvider(SftpFileSystemProvider) FileSystemProvider.installProvider(SmbFileSystemProvider) + FileSystemProvider.installProvider(WebDavFileSystemProvider) + FileSystemProvider.installProvider(WebDavsFileSystemProvider) } Files.installFileTypeDetector(AndroidFileTypeDetector) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt new file mode 100644 index 00000000..a74b3878 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/DavExceptionExtensions.kt @@ -0,0 +1,27 @@ +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import java8.nio.file.AccessDeniedException +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.FileSystemException +import java8.nio.file.NoSuchFileException +import me.zhanghai.android.files.provider.webdav.client.DavIOException + +fun DavException.toFileSystemException( + file: String?, + other: String? = null +): FileSystemException { + return when (this) { + is DavIOException -> + return FileSystemException(file, other, message).apply { initCause(cause) } + is UnauthorizedException, is ForbiddenException -> + AccessDeniedException(file, other, message) + is NotFoundException -> NoSuchFileException(file, other, message) + is ConflictException -> FileAlreadyExistsException(file, other, message) + else -> FileSystemException(file, other, message) + }.apply { initCause(this@toFileSystemException) } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt new file mode 100644 index 00000000..dc03fbcb --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/OpenOptionsWebDavExtensions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import java8.nio.file.StandardOpenOption +import me.zhanghai.android.files.provider.common.OpenOptions + +internal fun OpenOptions.checkForWebDav() { + if (deleteOnClose) { + throw UnsupportedOperationException(StandardOpenOption.DELETE_ON_CLOSE.toString()) + } + if (sync) { + throw UnsupportedOperationException(StandardOpenOption.SYNC.toString()) + } + if (dsync) { + throw UnsupportedOperationException(StandardOpenOption.DSYNC.toString()) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt new file mode 100644 index 00000000..5f3fe531 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/PathWebDavExtensions.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import java8.nio.file.Path +import me.zhanghai.android.files.provider.webdav.client.Authority + +fun Authority.createWebDavRootPath(): Path = + WebDavFileSystemProvider.getOrNewFileSystem(this).rootDirectory diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt new file mode 100644 index 00000000..9aab7a92 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.NoSuchFileException +import java8.nio.file.StandardCopyOption +import me.zhanghai.android.files.provider.common.CopyOptions +import me.zhanghai.android.files.provider.common.copyTo +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.provider.webdav.client.isDirectory +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import me.zhanghai.android.files.provider.webdav.client.lastModifiedTime +import me.zhanghai.android.files.provider.webdav.client.size +import java.io.IOException + +internal object WebDavCopyMove { + @Throws(IOException::class) + fun copy(source: WebDavPath, target: WebDavPath, copyOptions: CopyOptions) { + if (copyOptions.atomicMove) { + throw UnsupportedOperationException(StandardCopyOption.ATOMIC_MOVE.toString()) + } + val sourceResponse = try { + Client.findProperties(source, copyOptions.noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + val targetFile = try { + Client.findPropertiesOrNull(target, true) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + val sourceSize = sourceResponse.size + if (targetFile != null) { + if (source == target) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (!copyOptions.replaceExisting) { + throw FileAlreadyExistsException(source.toString(), target.toString(), null) + } + try { + Client.delete(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + } + when { + sourceResponse.isDirectory -> { + try { + Client.makeCollection(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + copyOptions.progressListener?.invoke(sourceSize) + } + sourceResponse.isSymbolicLink -> + throw UnsupportedOperationException("Cannot copy symbolic links") + else -> { + val sourceInputStream = try { + Client.get(source) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + try { + val targetOutputStream = try { + Client.put(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + var successful = false + try { + sourceInputStream.copyTo( + targetOutputStream, copyOptions.progressIntervalMillis, + copyOptions.progressListener + ) + successful = true + } finally { + try { + targetOutputStream.close() + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } finally { + if (!successful) { + try { + Client.delete(target) + } catch (e: DavException) { + e.printStackTrace() + } + } + } + } + } finally { + try { + sourceInputStream.close() + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + } + } + } + // We don't take error when copying attribute fatal, so errors will only be logged from now + // on. + if (!sourceResponse.isSymbolicLink) { + val lastModifiedTime = sourceResponse.lastModifiedTime + if (lastModifiedTime != null) { + try { + Client.setLastModifiedTime(target, lastModifiedTime) + } catch (e: DavException) { + e.printStackTrace() + } + } + } + } + + @Throws(IOException::class) + fun move(source: WebDavPath, target: WebDavPath, copyOptions: CopyOptions) { + val sourceResponse = try { + Client.findProperties(source, copyOptions.noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(source.toString()) + } + val targetResponse = try { + Client.findPropertiesOrNull(target, true) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + val sourceSize = sourceResponse.size + if (targetResponse != null) { + if (source == target) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (!copyOptions.replaceExisting) { + throw FileAlreadyExistsException(source.toString(), target.toString(), null) + } + try { + Client.delete(target) + } catch (e: DavException) { + throw e.toFileSystemException(target.toString()) + } + } + var renameSuccessful = false + try { + Client.move(source, target) + renameSuccessful = true + } catch (e: DavException) { + if (copyOptions.atomicMove) { + throw e.toFileSystemException(source.toString(), target.toString()) + } + // Ignored. + } + if (renameSuccessful) { + copyOptions.progressListener?.invoke(sourceSize) + return + } + if (copyOptions.atomicMove) { + throw AssertionError() + } + var copyOptions = copyOptions + if (!copyOptions.copyAttributes || !copyOptions.noFollowLinks) { + copyOptions = CopyOptions( + copyOptions.replaceExisting, true, false, true, copyOptions.progressIntervalMillis, + copyOptions.progressListener + ) + } + copy(source, target, copyOptions) + try { + Client.delete(source) + } catch (e: DavException) { + if (e.toFileSystemException(source.toString()) !is NoSuchFileException) { + try { + Client.delete(target) + } catch (e2: DavException) { + e.addSuppressed(e2.toFileSystemException(target.toString())) + } + } + throw e.toFileSystemException(source.toString()) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt new file mode 100644 index 00000000..5da2cdb8 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributeView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.file.LinkOption +import java8.nio.file.attribute.BasicFileAttributeView +import java8.nio.file.attribute.FileTime +import me.zhanghai.android.files.provider.webdav.client.Client +import java.io.IOException + +internal class WebDavFileAttributeView( + private val path: WebDavPath, + private val noFollowLinks: Boolean +) : BasicFileAttributeView { + override fun name(): String = NAME + + @Throws(IOException::class) + override fun readAttributes(): WebDavFileAttributes { + val file = try { + Client.findProperties(path, noFollowLinks) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + return WebDavFileAttributes.from(file, path) + } + + override fun setTimes( + lastModifiedTime: FileTime?, + lastAccessTime: FileTime?, + createTime: FileTime? + ) { + if (lastModifiedTime == null) { + // Only throw if caller is trying to set only last access time and/or create time, so + // that foreign copy move can still set last modified time. + if (lastAccessTime != null) { + throw UnsupportedOperationException("lastAccessTime") + } + if (createTime != null) { + throw UnsupportedOperationException("createTime") + } + return + } + if (noFollowLinks) { + throw UnsupportedOperationException(LinkOption.NOFOLLOW_LINKS.toString()) + } + try { + Client.setLastModifiedTime(path, lastModifiedTime.toInstant()) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + companion object { + private val NAME = WebDavFileSystemProvider.scheme + + val SUPPORTED_NAMES = setOf("basic", NAME) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt new file mode 100644 index 00000000..d8e5fc7c --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileAttributes.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcelable +import at.bitfire.dav4jvm.Response +import java8.nio.file.attribute.FileTime +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith +import me.zhanghai.android.files.provider.common.AbstractBasicFileAttributes +import me.zhanghai.android.files.provider.common.BasicFileType +import me.zhanghai.android.files.provider.common.EPOCH +import me.zhanghai.android.files.provider.common.FileTimeParceler +import me.zhanghai.android.files.provider.webdav.client.creationTime +import me.zhanghai.android.files.provider.webdav.client.isDirectory +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import me.zhanghai.android.files.provider.webdav.client.lastModifiedTime +import me.zhanghai.android.files.provider.webdav.client.size +import org.threeten.bp.Instant + +@Parcelize +internal data class WebDavFileAttributes( + override val lastModifiedTime: @WriteWith FileTime, + override val lastAccessTime: @WriteWith FileTime, + override val creationTime: @WriteWith FileTime, + override val type: BasicFileType, + override val size: Long, + override val fileKey: Parcelable +) : AbstractBasicFileAttributes() { + companion object { + fun from(response: Response, path: WebDavPath): WebDavFileAttributes = + when { + response.isSuccess() -> { + val lastModifiedTime = FileTime.from(response.lastModifiedTime ?: Instant.EPOCH) + val lastAccessTime = lastModifiedTime + val creationTime = + response.creationTime?.let { FileTime.from(it) } ?: lastModifiedTime + val type = if (response.isDirectory) { + BasicFileType.DIRECTORY + } else { + BasicFileType.REGULAR_FILE + } + val size = response.size + val fileKey = path + WebDavFileAttributes( + lastModifiedTime, lastAccessTime, creationTime, type, size, fileKey + ) + } + response.isSymbolicLink -> { + val lastModifiedTime = FileTime::class.EPOCH + val lastAccessTime = lastModifiedTime + val creationTime = lastModifiedTime + val type = BasicFileType.SYMBOLIC_LINK + val size = 0L + val fileKey = path + WebDavFileAttributes( + lastModifiedTime, lastAccessTime, creationTime, type, size, fileKey + ) + } + else -> error(response) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt new file mode 100644 index 00000000..dbaedb88 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystem.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.Path +import java8.nio.file.PathMatcher +import java8.nio.file.WatchService +import java8.nio.file.attribute.UserPrincipalLookupService +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringBuilder +import me.zhanghai.android.files.provider.common.ByteStringListPathCreator +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.util.readParcelable +import java.io.IOException + +internal class WebDavFileSystem( + private val provider: WebDavFileSystemProvider, + val authority: Authority +) : FileSystem(), ByteStringListPathCreator, Parcelable { + val rootDirectory = WebDavPath(this, SEPARATOR_BYTE_STRING) + + init { + if (!rootDirectory.isAbsolute) { + throw AssertionError("Root directory must be absolute") + } + if (rootDirectory.nameCount != 0) { + throw AssertionError("Root directory must contain no names") + } + } + + private val lock = Any() + + private var isOpen = true + + val defaultDirectory: WebDavPath + get() = rootDirectory + + override fun provider(): FileSystemProvider = provider + + override fun close() { + synchronized(lock) { + if (!isOpen) { + return + } + provider.removeFileSystem(this) + isOpen = false + } + } + + override fun isOpen(): Boolean = synchronized(lock) { isOpen } + + override fun isReadOnly(): Boolean = false + + override fun getSeparator(): String = SEPARATOR_STRING + + override fun getRootDirectories(): Iterable = listOf(rootDirectory) + + override fun getFileStores(): Iterable { + // TODO + throw UnsupportedOperationException() + } + + override fun supportedFileAttributeViews(): Set = + WebDavFileAttributeView.SUPPORTED_NAMES + + override fun getPath(first: String, vararg more: String): WebDavPath { + val path = ByteStringBuilder(first.toByteString()) + .apply { more.forEach { append(SEPARATOR).append(it.toByteString()) } } + .toByteString() + return WebDavPath(this, path) + } + + override fun getPath(first: ByteString, vararg more: ByteString): WebDavPath { + val path = ByteStringBuilder(first) + .apply { more.forEach { append(SEPARATOR).append(it) } } + .toByteString() + return WebDavPath(this, path) + } + + override fun getPathMatcher(syntaxAndPattern: String): PathMatcher { + throw UnsupportedOperationException() + } + + override fun getUserPrincipalLookupService(): UserPrincipalLookupService { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newWatchService(): WatchService = LocalWatchService() + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + other as WebDavFileSystem + return authority == other.authority + } + + override fun hashCode(): Int = authority.hashCode() + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeParcelable(authority, flags) + } + + companion object { + const val SEPARATOR = '/'.code.toByte() + private val SEPARATOR_BYTE_STRING = SEPARATOR.toByteString() + private const val SEPARATOR_STRING = SEPARATOR.toInt().toChar().toString() + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): WebDavFileSystem { + val authority = source.readParcelable()!! + return WebDavFileSystemProvider.getOrNewFileSystem(authority) + } + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt new file mode 100644 index 00000000..8396a586 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import at.bitfire.dav4jvm.exception.DavException +import java8.nio.channels.FileChannel +import java8.nio.channels.SeekableByteChannel +import java8.nio.file.AccessMode +import java8.nio.file.CopyOption +import java8.nio.file.DirectoryStream +import java8.nio.file.FileAlreadyExistsException +import java8.nio.file.FileStore +import java8.nio.file.FileSystem +import java8.nio.file.FileSystemAlreadyExistsException +import java8.nio.file.FileSystemException +import java8.nio.file.FileSystemNotFoundException +import java8.nio.file.LinkOption +import java8.nio.file.NoSuchFileException +import java8.nio.file.NotLinkException +import java8.nio.file.OpenOption +import java8.nio.file.Path +import java8.nio.file.ProviderMismatchException +import java8.nio.file.StandardOpenOption +import java8.nio.file.attribute.BasicFileAttributes +import java8.nio.file.attribute.FileAttribute +import java8.nio.file.attribute.FileAttributeView +import java8.nio.file.spi.FileSystemProvider +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringPath +import me.zhanghai.android.files.provider.common.DelegateSchemeFileSystemProvider +import me.zhanghai.android.files.provider.common.PathListDirectoryStream +import me.zhanghai.android.files.provider.common.PathObservable +import me.zhanghai.android.files.provider.common.PathObservableProvider +import me.zhanghai.android.files.provider.common.Searchable +import me.zhanghai.android.files.provider.common.WalkFileTreeSearchable +import me.zhanghai.android.files.provider.common.WatchServicePathObservable +import me.zhanghai.android.files.provider.common.decodedPathByteString +import me.zhanghai.android.files.provider.common.toAccessModes +import me.zhanghai.android.files.provider.common.toByteString +import me.zhanghai.android.files.provider.common.toCopyOptions +import me.zhanghai.android.files.provider.common.toLinkOptions +import me.zhanghai.android.files.provider.common.toOpenOptions +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.provider.webdav.client.Protocol +import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URI + +object WebDavFileSystemProvider : FileSystemProvider(), PathObservableProvider, Searchable { + private val HIDDEN_FILE_NAME_PREFIX = ".".toByteString() + + private val fileSystems = mutableMapOf() + + private val lock = Any() + + override fun getScheme(): String = Protocol.DAV.scheme + + override fun newFileSystem(uri: URI, env: Map): FileSystem { + uri.requireSameScheme() + val authority = uri.webDavAuthority + synchronized(lock) { + if (fileSystems[authority] != null) { + throw FileSystemAlreadyExistsException(authority.toString()) + } + return newFileSystemLocked(authority) + } + } + + internal fun getOrNewFileSystem(authority: Authority): WebDavFileSystem = + synchronized(lock) { fileSystems[authority] ?: newFileSystemLocked(authority) } + + private fun newFileSystemLocked(authority: Authority): WebDavFileSystem { + val fileSystem = WebDavFileSystem(this, authority) + fileSystems[authority] = fileSystem + return fileSystem + } + + override fun getFileSystem(uri: URI): FileSystem { + uri.requireSameScheme() + val authority = uri.webDavAuthority + return synchronized(lock) { fileSystems[authority] } + ?: throw FileSystemNotFoundException(authority.toString()) + } + + internal fun removeFileSystem(fileSystem: WebDavFileSystem) { + val authority = fileSystem.authority + synchronized(lock) { fileSystems.remove(authority) } + } + + override fun getPath(uri: URI): Path { + uri.requireSameScheme() + val authority = uri.webDavAuthority + val path = uri.decodedPathByteString + ?: throw IllegalArgumentException("URI must have a path") + return getOrNewFileSystem(authority).getPath(path) + } + + private fun URI.requireSameScheme() { + val scheme = scheme + require(scheme in Protocol.SCHEMES) { "URI scheme $scheme must be in ${Protocol.SCHEMES}" } + } + + private val URI.webDavAuthority: Authority + get() { + val protocol = Protocol.fromScheme(scheme) + val port = if (port != -1) port else protocol.defaultPort + return Authority(protocol, host, port, userInfo) + } + + @Throws(IOException::class) + override fun newInputStream(file: Path, vararg options: OpenOption): InputStream { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val openOptions = options.toOpenOptions() + openOptions.checkForWebDav() + if (openOptions.write) { + throw UnsupportedOperationException(StandardOpenOption.WRITE.toString()) + } + if (openOptions.append) { + throw UnsupportedOperationException(StandardOpenOption.APPEND.toString()) + } + if (openOptions.truncateExisting) { + throw UnsupportedOperationException(StandardOpenOption.TRUNCATE_EXISTING.toString()) + } + if (openOptions.create || openOptions.createNew || openOptions.noFollowLinks) { + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.noFollowLinks && fileResponse != null && fileResponse.isSymbolicLink) { + throw FileSystemException( + file.toString(), null, "File is a symbolic link: $fileResponse" + ) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if ((openOptions.create || openOptions.createNew) && fileResponse == null) { + try { + Client.makeFile(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + } + try { + return Client.get(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newOutputStream(file: Path, vararg options: OpenOption): OutputStream { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val optionsSet = mutableSetOf(*options) + if (optionsSet.isEmpty()) { + optionsSet += StandardOpenOption.CREATE + optionsSet += StandardOpenOption.TRUNCATE_EXISTING + } + optionsSet += StandardOpenOption.WRITE + val openOptions = optionsSet.toOpenOptions() + openOptions.checkForWebDav() + if (!openOptions.truncateExisting && !openOptions.createNew) { + throw UnsupportedOperationException("Missing ${StandardOpenOption.TRUNCATE_EXISTING}") + } + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if (!(openOptions.create || openOptions.createNew) && fileResponse == null) { + throw NoSuchFileException(file.toString()) + } + try { + return Client.put(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newFileChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): FileChannel { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + options.toOpenOptions().checkForWebDav() + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun newByteChannel( + file: Path, + options: Set, + vararg attributes: FileAttribute<*> + ): SeekableByteChannel { + file as? WebDavPath ?: throw ProviderMismatchException(file.toString()) + val openOptions = options.toOpenOptions() + openOptions.checkForWebDav() + if (openOptions.write && !openOptions.truncateExisting) { + throw UnsupportedOperationException("Missing ${StandardOpenOption.TRUNCATE_EXISTING}") + } + if (openOptions.write || openOptions.create || openOptions.createNew || + openOptions.noFollowLinks) { + val fileResponse = try { + Client.findPropertiesOrNull(file, true) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + if (openOptions.createNew && fileResponse != null) { + throw FileAlreadyExistsException(file.toString()) + } + if (openOptions.noFollowLinks && fileResponse != null && fileResponse.isSymbolicLink) { + throw FileSystemException( + file.toString(), null, "File is a symbolic link: $fileResponse" + ) + } + if (fileResponse == null) { + if (!(openOptions.create || openOptions.createNew)) { + throw NoSuchFileException(file.toString()) + } + try { + Client.makeFile(file) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + } + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + try { + return Client.openByteChannel(file, openOptions.append) + } catch (e: DavException) { + throw e.toFileSystemException(file.toString()) + } + } + + @Throws(IOException::class) + override fun newDirectoryStream( + directory: Path, + filter: DirectoryStream.Filter + ): DirectoryStream { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + val paths = try { + @Suppress("UNCHECKED_CAST") + Client.findCollectionMembers(directory) as List + } catch (e: DavException) { + throw e.toFileSystemException(directory.toString()) + } + return PathListDirectoryStream(paths, filter) + } + + @Throws(IOException::class) + override fun createDirectory(directory: Path, vararg attributes: FileAttribute<*>) { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + try { + Client.makeCollection(directory) + } catch (e: DavException) { + throw e.toFileSystemException(directory.toString()) + } + } + + override fun createSymbolicLink(link: Path, target: Path, vararg attributes: FileAttribute<*>) { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + when (target) { + is WebDavPath, is ByteStringPath -> {} + else -> throw ProviderMismatchException(target.toString()) + } + if (attributes.isNotEmpty()) { + throw UnsupportedOperationException(attributes.contentToString()) + } + throw UnsupportedOperationException() + } + + override fun createLink(link: Path, existing: Path) { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + existing as? WebDavPath ?: throw ProviderMismatchException(existing.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun delete(path: Path) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + try { + Client.delete(path) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + override fun readSymbolicLink(link: Path): Path { + link as? WebDavPath ?: throw ProviderMismatchException(link.toString()) + val linkResponse = try { + Client.findProperties(link, true) + } catch (e: DavException) { + throw e.toFileSystemException(link.toString()) + } + val target = linkResponse.newLocation?.toString() + ?: throw NotLinkException(link.toString(), null, linkResponse.toString()) + // TODO: Convert to webdav(s) scheme? + return ByteStringPath(ByteString.fromString(target)) + } + + @Throws(IOException::class) + override fun copy(source: Path, target: Path, vararg options: CopyOption) { + source as? WebDavPath ?: throw ProviderMismatchException(source.toString()) + target as? WebDavPath ?: throw ProviderMismatchException(target.toString()) + val copyOptions = options.toCopyOptions() + WebDavCopyMove.copy(source, target, copyOptions) + } + + @Throws(IOException::class) + override fun move(source: Path, target: Path, vararg options: CopyOption) { + source as? WebDavPath ?: throw ProviderMismatchException(source.toString()) + target as? WebDavPath ?: throw ProviderMismatchException(target.toString()) + val copyOptions = options.toCopyOptions() + WebDavCopyMove.move(source, target, copyOptions) + } + + override fun isSameFile(path: Path, path2: Path): Boolean { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + return path == path2 + } + + override fun isHidden(path: Path): Boolean { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val fileName = path.fileNameByteString ?: return false + return fileName.startsWith(HIDDEN_FILE_NAME_PREFIX) + } + + override fun getFileStore(path: Path): FileStore { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun checkAccess(path: Path, vararg modes: AccessMode) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val accessModes = modes.toAccessModes() + if (accessModes.write) { + throw UnsupportedOperationException(AccessMode.WRITE.toString()) + } + if (accessModes.execute) { + throw UnsupportedOperationException(AccessMode.EXECUTE.toString()) + } + // Assume the file can be read if it can be listed. + try { + Client.findProperties(path, false) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } + } + + override fun getFileAttributeView( + path: Path, + type: Class, + vararg options: LinkOption + ): V? { + if (!supportsFileAttributeView(type)) { + return null + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path, *options) as V + } + + internal fun supportsFileAttributeView(type: Class): Boolean = + type.isAssignableFrom(WebDavFileAttributeView::class.java) + + @Throws(IOException::class) + override fun readAttributes( + path: Path, + type: Class, + vararg options: LinkOption + ): A { + if (!type.isAssignableFrom(BasicFileAttributes::class.java)) { + throw UnsupportedOperationException(type.toString()) + } + @Suppress("UNCHECKED_CAST") + return getFileAttributeView(path, *options).readAttributes() as A + } + + private fun getFileAttributeView(path: Path, vararg options: LinkOption): WebDavFileAttributeView { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val linkOptions = options.toLinkOptions() + return WebDavFileAttributeView(path, linkOptions.noFollowLinks) + } + + override fun readAttributes( + path: Path, + attributes: String, + vararg options: LinkOption + ): Map { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + override fun setAttribute( + path: Path, + attribute: String, + value: Any, + vararg options: LinkOption + ) { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun observe(path: Path, intervalMillis: Long): PathObservable { + path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + return WatchServicePathObservable(path, intervalMillis) + } + + @Throws(IOException::class) + override fun search( + directory: Path, + query: String, + intervalMillis: Long, + listener: (List) -> Unit + ) { + directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString()) + WalkFileTreeSearchable.search(directory, query, intervalMillis, listener) + } +} + +val WebDavsFileSystemProvider = + DelegateSchemeFileSystemProvider(Protocol.DAVS.scheme, WebDavFileSystemProvider) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt new file mode 100644 index 00000000..f1493a3b --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav + +import android.os.Parcel +import android.os.Parcelable +import java8.nio.file.FileSystem +import java8.nio.file.LinkOption +import java8.nio.file.Path +import java8.nio.file.ProviderMismatchException +import java8.nio.file.WatchEvent +import java8.nio.file.WatchKey +import java8.nio.file.WatchService +import me.zhanghai.android.files.provider.common.ByteString +import me.zhanghai.android.files.provider.common.ByteStringListPath +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.UriAuthority +import me.zhanghai.android.files.provider.webdav.client.Authority +import me.zhanghai.android.files.provider.webdav.client.Client +import me.zhanghai.android.files.util.readParcelable +import okhttp3.HttpUrl +import java.io.File +import java.io.IOException + +internal class WebDavPath : ByteStringListPath, Client.Path { + private val fileSystem: WebDavFileSystem + + constructor( + fileSystem: WebDavFileSystem, + path: ByteString + ) : super(WebDavFileSystem.SEPARATOR, path) { + this.fileSystem = fileSystem + } + + private constructor( + fileSystem: WebDavFileSystem, + absolute: Boolean, + segments: List + ) : super(WebDavFileSystem.SEPARATOR, absolute, segments) { + this.fileSystem = fileSystem + } + + override fun isPathAbsolute(path: ByteString): Boolean = + path.isNotEmpty() && path[0] == WebDavFileSystem.SEPARATOR + + override fun createPath(path: ByteString): WebDavPath = WebDavPath(fileSystem, path) + + override fun createPath(absolute: Boolean, segments: List): WebDavPath = + WebDavPath(fileSystem, absolute, segments) + + override val uriScheme: String + get() = fileSystem.authority.protocol.scheme + + override val uriAuthority: UriAuthority + get() = fileSystem.authority.toUriAuthority() + + override val defaultDirectory: WebDavPath + get() = fileSystem.defaultDirectory + + override fun getFileSystem(): FileSystem = fileSystem + + override fun getRoot(): WebDavPath? = if (isAbsolute) fileSystem.rootDirectory else null + + @Throws(IOException::class) + override fun toRealPath(vararg options: LinkOption): WebDavPath { + throw UnsupportedOperationException() + } + + override fun toFile(): File { + throw UnsupportedOperationException() + } + + @Throws(IOException::class) + override fun register( + watcher: WatchService, + events: Array>, + vararg modifiers: WatchEvent.Modifier + ): WatchKey { + if (watcher !is LocalWatchService) { + throw ProviderMismatchException(watcher.toString()) + } + return watcher.register(this, events, *modifiers) + } + + override val authority: Authority + get() = fileSystem.authority + + override val url: HttpUrl + get() = HttpUrl.Builder() + .scheme(authority.protocol.httpScheme) + .host(authority.host) + .apply { + val port = authority.port + if (port != authority.protocol.defaultPort) { + port(port) + } + } + .addPathSegments(toString().removePrefix("/")) + .build() + + private constructor(source: Parcel) : super(source) { + fileSystem = source.readParcelable()!! + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + super.writeToParcel(dest, flags) + + dest.writeParcelable(fileSystem, flags) + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): WebDavPath = WebDavPath(source) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } +} + +val Path.isWebDavPath: Boolean + get() = this is WebDavPath diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt new file mode 100644 index 00000000..82fe8b19 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authentication.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import android.os.Parcelable +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.UrlUtils +import kotlinx.parcelize.Parcelize +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +sealed class Authentication : Parcelable { + abstract fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor +} + +interface AuthenticatorInterceptor : Authenticator, Interceptor + +@Parcelize +data object NoneAuthentication : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + override fun authenticate(route: Route?, response: Response): Request? = null + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed(chain.request()) + } +} + +@Parcelize +data class PasswordAuthentication( + val password: String +) : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + private val basicDigestAuthHandler = BasicDigestAuthHandler( + UrlUtils.hostToDomain(authority.host), authority.username!!, password + ) + + override fun authenticate(route: Route?, response: Response): Request? = + basicDigestAuthHandler.authenticate(route, response) + + override fun intercept(chain: Interceptor.Chain): Response = + basicDigestAuthHandler.intercept(chain) + } +} + +@Parcelize +data class AccessTokenAuthentication( + val accessToken: String +) : Authentication() { + override fun createAuthenticatorInterceptor(authority: Authority): AuthenticatorInterceptor = + object : AuthenticatorInterceptor { + override fun authenticate(route: Route?, response: Response): Request? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestHost = request.url.host + val domain = UrlUtils.hostToDomain(authority.host) + if (!UrlUtils.hostToDomain(requestHost).equals(domain, true)) { + Dav4jvm.log.warning( + "Not authenticating against $requestHost because it doesn't belong to " + + domain + ) + return chain.proceed(request) + } + val newRequest = request.newBuilder() + .header("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(newRequest) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt new file mode 100644 index 00000000..24ab1615 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authenticator.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +interface Authenticator { + fun getAuthentication(authority: Authority): Authentication? +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt new file mode 100644 index 00000000..dc0a15d1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Authority.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import me.zhanghai.android.files.provider.common.UriAuthority + +@Parcelize +data class Authority( + val protocol: Protocol, + val host: String, + val port: Int, + val username: String? +) : Parcelable { + init { + require(username == null || username.isNotEmpty()) { "Username cannot be empty" } + } + + fun toUriAuthority(): UriAuthority { + val uriPort = port.takeIf { it != protocol.defaultPort } + return UriAuthority(username, host, uriPort) + } + + override fun toString(): String = toUriAuthority().toString() +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt new file mode 100644 index 00000000..520c8168 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.ConflictException +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.ForbiddenException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.NotFoundException +import at.bitfire.dav4jvm.exception.PreconditionFailedException +import at.bitfire.dav4jvm.exception.ServiceUnavailableException +import at.bitfire.dav4jvm.exception.UnauthorizedException +import at.bitfire.dav4jvm.property.CreationDate +import at.bitfire.dav4jvm.property.GetContentLength +import at.bitfire.dav4jvm.property.GetLastModified +import at.bitfire.dav4jvm.property.ResourceType +import java8.nio.channels.SeekableByteChannel +import me.zhanghai.android.files.app.okHttpClient +import me.zhanghai.android.files.compat.toDateCompat +import me.zhanghai.android.files.provider.common.LocalWatchService +import me.zhanghai.android.files.provider.common.NotifyEntryModifiedOutputStream +import me.zhanghai.android.files.provider.common.NotifyEntryModifiedSeekableByteChannel +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Route +import org.threeten.bp.Instant +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.util.Collections +import java.util.WeakHashMap +import java8.nio.file.Path as Java8Path +import okhttp3.Response as OkHttpResponse + +// See also https://github.com/miquels/webdavfs/blob/master/fuse.go +object Client { + private val FILE_PROPERTIES = arrayOf( + ResourceType.NAME, + CreationDate.NAME, + GetContentLength.NAME, + GetLastModified.NAME + ) + + @Volatile + lateinit var authenticator: Authenticator + + private val clients = mutableMapOf() + + private val collectionMemberCache = Collections.synchronizedMap(WeakHashMap()) + + @Throws(IOException::class) + private fun getClient(authority: Authority): OkHttpClient { + synchronized(clients) { + var client = clients[authority] + if (client == null) { + val authenticatorInterceptor = + OkHttpAuthenticatorInterceptor(authenticator, authority) + client = okHttpClient.newBuilder() + // Turn off follow redirects for PROPFIND. + .followRedirects(false) + .cookieJar(MemoryCookieJar()) + .addNetworkInterceptor(authenticatorInterceptor) + .authenticator(authenticatorInterceptor) + .build() + clients[authority] = client + } + return client + } + } + + @Throws(DavException::class) + fun makeCollection(path: Path) { + try { + DavResource(getClient(path.authority), path.url).mkCol(null) {} + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryCreated(path as Java8Path) + } + + @Throws(DavException::class) + fun makeFile(path: Path) { + try { + put(path).close() + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryCreated(path as Java8Path) + } + + @Throws(DavException::class) + fun delete(path: Path) { + try { + DavResource(getClient(path.authority), path.url).delete {} + } catch (e: IOException) { + throw e.toDavException() + } + collectionMemberCache -= path + LocalWatchService.onEntryDeleted(path as Java8Path) + } + + @Throws(DavException::class) + fun move(source: Path, target: Path) { + if (source.authority != target.authority) { + throw IOException("Paths aren't on the same authority") + } + try { + DavResource(getClient(source.authority), source.url).moveCompat(target.url, false) {} + } catch (e: IOException) { + throw e.toDavException() + } + collectionMemberCache -= source + collectionMemberCache -= target + LocalWatchService.onEntryDeleted(source as Java8Path) + LocalWatchService.onEntryCreated(target as Java8Path) + } + + @Throws(DavException::class) + fun get(path: Path): InputStream = + try { + DavResource(getClient(path.authority), path.url).getCompat("*/*", null) + } catch (e: IOException) { + throw e.toDavException() + } + + @Throws(DavException::class) + fun findCollectionMembers(path: Path): List = + buildList { + try { + DavCollection(getClient(path.authority), path.url) + .propfind(1, *FILE_PROPERTIES) { response, relation -> + if (relation != Response.HrefRelation.MEMBER) { + return@propfind + } + this += path.resolve(response.hrefName()) + .also { + if (response.isSuccess()) { + collectionMemberCache[it] = response + } + } + } + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class) + fun findPropertiesOrNull(path: Path, noFollowLinks: Boolean): Response? = + try { + findProperties(path, noFollowLinks) + } catch (e: NotFoundException) { + null + } catch (e: IOException) { + throw e.toDavException() + } + + // TODO: Support noFollowLinks. + @Throws(DavException::class) + fun findProperties(path: Path, noFollowLinks: Boolean): Response { + synchronized(collectionMemberCache) { + collectionMemberCache.remove(path)?.let { return it } + } + try { + return findProperties( + DavResource(getClient(path.authority), path.url), *FILE_PROPERTIES + ) + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class, IOException::class) + internal fun findProperties(resource: DavResource, vararg properties: Property.Name): Response { + var responseRef: Response? = null + resource.propfind(0, *properties) { response, relation -> + if (relation != Response.HrefRelation.SELF) { + return@propfind + } + if (responseRef != null) { + throw DavException("Duplicate response for self") + } + responseRef = response + } + val response = responseRef ?: throw DavException("Couldn't find a response for self") + response.checkSuccess() + return response + } + + @Throws(DavException::class) + fun openByteChannel(path: Path, isAppend: Boolean): SeekableByteChannel { + try { + val client = getClient(path.authority) + val resource = DavResource(client, path.url) + val patchSupport = resource.getPatchSupport() + return NotifyEntryModifiedSeekableByteChannel( + FileByteChannel(resource, patchSupport, isAppend), path as Java8Path + ) + } catch (e: IOException) { + throw e.toDavException() + } + } + + @Throws(DavException::class) + fun setLastModifiedTime(path: Path, lastModifiedTime: Instant) { + if (true) { + return + } + // The following doesn't work on most servers. See also + // https://github.com/sabre-io/dav/issues/1277 + try { + DavResource(getClient(path.authority), path.url).proppatch( + mapOf( + GetLastModified.NAME to HttpUtils.formatDate(lastModifiedTime.toDateCompat()) + ), emptyList() + ) { response, _ -> response.checkSuccess() } + } catch (e: IOException) { + throw e.toDavException() + } + LocalWatchService.onEntryModified(path as Java8Path) + } + + @Throws(DavException::class) + fun put(path: Path): OutputStream = + try { + NotifyEntryModifiedOutputStream( + DavResource(getClient(path.authority), path.url).putCompat(), path as Java8Path + ) + } catch (e: IOException) { + throw e.toDavException() + } + + // @see DavResource.checkStatus + private fun Response.checkSuccess() { + if (isSuccess()) { + return + } + val status = status!! + throw when (status.code) { + HttpURLConnection.HTTP_UNAUTHORIZED -> UnauthorizedException(status.message) + HttpURLConnection.HTTP_FORBIDDEN -> ForbiddenException(status.message) + HttpURLConnection.HTTP_NOT_FOUND -> NotFoundException(status.message) + HttpURLConnection.HTTP_CONFLICT -> ConflictException(status.message) + HttpURLConnection.HTTP_PRECON_FAILED -> PreconditionFailedException(status.message) + HttpURLConnection.HTTP_UNAVAILABLE -> ServiceUnavailableException(status.message) + else -> HttpException(status.code, status.message) + } + } + + interface Path { + val authority: Authority + val url: HttpUrl + fun resolve(other: String): Path + } + + private class OkHttpAuthenticatorInterceptor( + private val authenticator: Authenticator, + private val authority: Authority + ) : AuthenticatorInterceptor { + private var authenticatorInterceptorCache: Pair? = + null + + private fun getAuthenticatorInterceptor(): AuthenticatorInterceptor { + val authentication = authenticator.getAuthentication(authority) + ?: throw IOException("No authentication found for $authority") + authenticatorInterceptorCache?.let { + (cachedAuthentication, cachedAuthenticatorInterceptor) -> + if (cachedAuthentication == authentication) { + return cachedAuthenticatorInterceptor + } + } + return authentication.createAuthenticatorInterceptor(authority).also { + authenticatorInterceptorCache = authentication to it + } + } + + override fun authenticate(route: Route?, response: OkHttpResponse): Request? = + getAuthenticatorInterceptor().authenticate(route, response) + + override fun intercept(chain: Interceptor.Chain): OkHttpResponse = + getAuthenticatorInterceptor().intercept(chain) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt new file mode 100644 index 00000000..9daa8f04 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavIOException.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.exception.DavException +import java.io.IOException + +class DavIOException(cause: IOException) : DavException(cause.message ?: "", cause) { + override val cause: Throwable + get() = super.cause!! +} + +fun IOException.toDavException(): DavIOException = DavIOException(this) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt new file mode 100644 index 00000000..58c7de03 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/DavResourceCompat.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.DavResourceAccessor +import at.bitfire.dav4jvm.QuotedStringUtils +import at.bitfire.dav4jvm.ResponseCallback +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import me.zhanghai.android.files.provider.common.DelegateOutputStream +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okio.BufferedSink +import okio.Pipe +import okio.buffer +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch + +@Throws(DavException::class, IOException::class) +fun DavResource.getCompat(accept: String, headers: Headers?): InputStream = + followRedirects { + val request = Request.Builder().get().url(location) + if (headers != null) { + request.headers(headers) + } + // always Accept header + request.header("Accept", accept) + httpClient.newCall(request.build()).execute() + }.also { checkStatus(it) }.body!!.byteStream() + +@Throws(DavException::class, IOException::class) +fun DavResource.getRangeCompat( + accept: String, + offset: Long, + size: Int, + headers: Headers? +): InputStream = + followRedirects { + val request = Request.Builder().get().url(location) + if (headers != null) { + request.headers(headers) + } + request.header("Accept", accept) + val lastIndex = offset + size - 1 + request.header("Range", "bytes=$offset-$lastIndex") + httpClient.newCall(request.build()).execute() + }.also { checkStatus(it) }.body!!.byteStream() + +// This doesn't follow redirects since the request body is one-shot anyway. +@Throws(DavException::class, IOException::class) +fun DavResource.putCompat( + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, +): OutputStream { + val pipe = Pipe(DEFAULT_BUFFER_SIZE.toLong()) + val body = object : RequestBody() { + override fun contentType(): MediaType? = null + override fun isOneShot() = true + override fun writeTo(sink: BufferedSink) { + sink.writeAll(pipe.source) + } + } + val builder = Request.Builder().put(body).url(location) + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + var exceptionRef: IOException? = null + var responseRef: Response? = null + val callbackLatch = CountDownLatch(1) + httpClient.newCall(builder.build()).enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + exceptionRef = e + callbackLatch.countDown() + } + + override fun onResponse(call: Call, response: Response) { + responseRef = response + callbackLatch.countDown() + } + } + ) + return object : DelegateOutputStream(pipe.sink.buffer().outputStream()) { + override fun close() { + super.close() + callbackLatch.await() + exceptionRef?.let { throw it } + checkStatus(responseRef!!) + } + } +} + +enum class PatchSupport { + NONE, + APACHE, + SABRE +} + +@Throws(DavException::class, IOException::class) +fun DavResource.getPatchSupport(): PatchSupport { + lateinit var patchSupport: PatchSupport + options { davCapabilities, response -> + patchSupport = when { + response.headers["Server"]?.contains("Apache") == true && + "" in davCapabilities -> + PatchSupport.APACHE + + "sabredav-partialupdate" in davCapabilities -> PatchSupport.SABRE + else -> PatchSupport.NONE + } + } + return patchSupport +} + +// https://github.com/bitfireAT/dav4jvm/issues/39 +@Throws(DavException::class, IOException::class) +fun DavResource.moveCompat( + destination: HttpUrl, + forceOverride: Boolean, + callback: ResponseCallback +) { + move(destination, !forceOverride, callback) +} + +// https://sabre.io/dav/http-patch/ +@Throws(DavException::class, IOException::class) +fun DavResource.patchCompat( + buffer: ByteBuffer, + offset: Long, + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, + callback: ResponseCallback +) { + followRedirects { + val builder = Request.Builder() + .patch(buffer.toRequestBody("application/x-sabredav-partialupdate".toMediaType())) + .url(location) + val lastIndex = offset + buffer.remaining() - 1 + builder.header("X-Update-Range", "bytes=$offset-$lastIndex") + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } +} + +@Throws(DavException::class, IOException::class) +fun DavResource.putRangeCompat( + buffer: ByteBuffer, + offset: Long, + ifETag: String? = null, + ifScheduleTag: String? = null, + ifNoneMatch: Boolean = false, + callback: ResponseCallback +) { + followRedirects { + val builder = Request.Builder() + .put(buffer.toRequestBody()) + .url(location) + val lastIndex = offset + buffer.remaining() - 1 + builder.header("Range", "bytes=$offset-$lastIndex/*") + if (ifETag != null) { + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + } + if (ifScheduleTag != null) { + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + } + if (ifNoneMatch) { + // don't overwrite anything existing + builder.header("If-None-Match", "*") + } + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } +} + +@Throws(HttpException::class) +private fun DavResource.checkStatus(response: Response) { + DavResourceAccessor.checkStatus(this, response) +} + +private fun DavResource.followRedirects(sendRequest: () -> Response): Response = + DavResourceAccessor.followRedirects(this, sendRequest) + +private fun ByteBuffer.toRequestBody(contentType: MediaType? = null): RequestBody { + val contentLength = remaining().toLong() + mark() + return object : RequestBody() { + override fun contentType() = contentType + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + reset() + sink.write(this@toRequestBody) + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt new file mode 100644 index 00000000..b9ffb65e --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/FileByteChannel.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.property.GetContentLength +import me.zhanghai.android.files.provider.common.AbstractFileByteChannel +import me.zhanghai.android.files.provider.common.readFully +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +// https://blog.sphere.chronosempire.org.uk/2012/11/21/webdav-and-the-http-patch-nightmare +class FileByteChannel( + private val resource: DavResource, + private val patchSupport: PatchSupport, + isAppend: Boolean +) : AbstractFileByteChannel(isAppend) { + private var nextSequentialWritePosition = 0L + private var sequentialWriteOutputStream: OutputStream? = null + + @Throws(IOException::class) + override fun onRead(position: Long, size: Int): ByteBuffer { + val destination = ByteBuffer.allocate(size) + val inputStream = resource.getRangeCompat("*/*", position, size, null) + val limit = inputStream.use { + it.readFully(destination.array(), destination.arrayOffset(), size) + } + destination.limit(limit) + return destination + } + + @Throws(IOException::class) + override fun onWrite(position: Long, source: ByteBuffer) { + when (patchSupport) { + PatchSupport.APACHE -> + resource.putRangeCompat(source, position) {} + PatchSupport.SABRE -> + resource.patchCompat(source, position) {} + PatchSupport.NONE -> { + if (position != nextSequentialWritePosition) { + throw IOException("Unsupported non-sequential write") + } + val outputStream = sequentialWriteOutputStream + ?: resource.putCompat().also { sequentialWriteOutputStream = it } + val remaining = source.remaining() + // I don't think we are using native or read-only ByteBuffer, so just call array() + // here. + outputStream.write( + source.array(), source.arrayOffset() + source.position(), remaining + ) + nextSequentialWritePosition += remaining + } + } + } + + @Throws(IOException::class) + override fun onTruncate(size: Long) { + if (size == 0L) { + resource.put(byteArrayOf().toRequestBody()) {} + } else { + throw IOException("Unsupported truncate to non-zero size") + } + } + + @Throws(IOException::class) + override fun onSize(): Long { + val getContentLength = + Client.findProperties(resource, GetContentLength.NAME)[GetContentLength::class.java] + ?: throw IOException("Missing GetContentLength") + return getContentLength.contentLength + } + + @Throws(IOException::class) + override fun onClose() { + sequentialWriteOutputStream?.close() + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt new file mode 100644 index 00000000..bd6fb328 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/MemoryCookieJar.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class MemoryCookieJar : CookieJar { + private val cookieMap = mutableMapOf, Cookie>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + synchronized(cookieMap) { + for (cookie in cookies) { + cookieMap[Triple(cookie.domain, cookie.path, cookie.name)] = cookie + } + } + } + + override fun loadForRequest(url: HttpUrl): List = + buildList { + synchronized(cookieMap) { + val iterator = cookieMap.values.iterator() + val currentTimeMillis = System.currentTimeMillis() + while (iterator.hasNext()) { + val cookie = iterator.next() + if (cookie.expiresAt <= currentTimeMillis) { + iterator.remove() + continue + } + if (cookie.matches(url)) { + this += cookie + } + } + } + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt new file mode 100644 index 00000000..0264f129 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Protocol.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +enum class Protocol(val scheme: String, val httpScheme: String, val defaultPort: Int) { + DAV("dav", "http", 80), + DAVS("davs", "https", 443); + + companion object { + val SCHEMES = entries.map { it.scheme } + + fun fromScheme(scheme: String): Protocol = + entries.firstOrNull { it.scheme == scheme } ?: throw IllegalArgumentException(scheme) + } +} diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt new file mode 100644 index 00000000..1d1931c1 --- /dev/null +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/ResponseExtensions.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Hai Zhang + * All Rights Reserved. + */ + +package me.zhanghai.android.files.provider.webdav.client + +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.CreationDate +import at.bitfire.dav4jvm.property.GetContentLength +import at.bitfire.dav4jvm.property.GetLastModified +import at.bitfire.dav4jvm.property.ResourceType +import me.zhanghai.android.files.compat.toInstantCompat +import org.threeten.bp.Instant + +val Response.creationTime: Instant? + get() = + this[CreationDate::class.java]?.creationDate?.let { HttpUtils.parseDate(it) } + ?.toInstantCompat() + +val Response.isDirectory: Boolean + get() = this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true + +val Response.isSymbolicLink: Boolean + get() = newLocation != null + +val Response.lastModifiedTime: Instant? + get() = + this[GetLastModified::class.java]?.lastModified?.let { Instant.ofEpochMilli(it) } + +val Response.size: Long + get() = this[GetContentLength::class.java]?.contentLength ?: 0 diff --git a/app/src/main/res/raw/licenses.xml b/app/src/main/res/raw/licenses.xml index 85d178dd..55ccf0b9 100644 --- a/app/src/main/res/raw/licenses.xml +++ b/app/src/main/res/raw/licenses.xml @@ -35,6 +35,13 @@ BSD 3-Clause License + + dav4jvm + https://github.com/bitfireAT/dav4jvm + Copyright 2015 dav4jvm contributors + Mozilla Public License 2.0 + + PhotoView https://github.com/chrisbanes/PhotoView diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index dbb41bfa..ec650e0c 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -6,7 +6,7 @@ Features: - Breadcrumbs: Navigate in the filesystem with ease. - Root support: View and manage files with root access. - Archive support: View, extract and create common compressed files. -- NAS support: View and manage files on FTP, SFTP and SMB servers. +- NAS support: View and manage files on FTP, SFTP, SMB and WebDAV servers. - Themes: Customizable UI colors, plus night mode with optional true black. - Linux-aware: Knows symbolic links, file permissions and SELinux context. - Robust: Uses Linux system calls under the hood, not yet another ls parser. diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index 505d0083..eaa26026 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -6,7 +6,7 @@ - 面包屑导航栏:点击导航栏所显示路径中的任一文件夹即可快速访问。 - Root 支持:使用 root 权限查看和管理文件。 - 压缩文件支持:查看、提取和创建常见的压缩文件。 -- NAS 支持:查看和管理 FTP、SFTP 和 SMB 服务器上的文件。 +- NAS 支持:查看和管理 FTP、SFTP、SMB 和 WebDAV 服务器上的文件。 - 主题:可定制的界面颜色,以及可选纯黑的夜间模式。 - Linux 友好:支持符号链接、文件权限和 SELinux 上下文。 - 健壮性:使用 Linux 系统调用实现,而不是另一个 ls 解析器。