Feat!: Add WebDAV support

Bug: #191
This commit is contained in:
Hai Zhang 2024-02-25 17:53:30 -08:00
parent ce4421550d
commit b9e84715fc
29 changed files with 1970 additions and 4 deletions

View File

@ -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).

View File

@ -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)。

View File

@ -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'

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<Response> sendRequest) throws DavException, IOException {
return davResource.followRedirects$build(sendRequest);
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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) }
}

View File

@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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())
}
}

View File

@ -0,0 +1,12 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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

View File

@ -0,0 +1,185 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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())
}
}
}

View File

@ -0,0 +1,62 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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)
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<FileTimeParceler> FileTime,
override val lastAccessTime: @WriteWith<FileTimeParceler> FileTime,
override val creationTime: @WriteWith<FileTimeParceler> 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)
}
}
}

View File

@ -0,0 +1,135 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<Path> = listOf(rootDirectory)
override fun getFileStores(): Iterable<FileStore> {
// TODO
throw UnsupportedOperationException()
}
override fun supportedFileAttributeViews(): Set<String> =
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<WebDavFileSystem> {
override fun createFromParcel(source: Parcel): WebDavFileSystem {
val authority = source.readParcelable<Authority>()!!
return WebDavFileSystemProvider.getOrNewFileSystem(authority)
}
override fun newArray(size: Int): Array<WebDavFileSystem?> = arrayOfNulls(size)
}
}
}

View File

@ -0,0 +1,444 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<Authority, WebDavFileSystem>()
private val lock = Any()
override fun getScheme(): String = Protocol.DAV.scheme
override fun newFileSystem(uri: URI, env: Map<String, *>): 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<OpenOption>,
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<OpenOption>,
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<in Path>
): DirectoryStream<Path> {
directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString())
val paths = try {
@Suppress("UNCHECKED_CAST")
Client.findCollectionMembers(directory) as List<Path>
} 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 <V : FileAttributeView> getFileAttributeView(
path: Path,
type: Class<V>,
vararg options: LinkOption
): V? {
if (!supportsFileAttributeView(type)) {
return null
}
@Suppress("UNCHECKED_CAST")
return getFileAttributeView(path, *options) as V
}
internal fun supportsFileAttributeView(type: Class<out FileAttributeView>): Boolean =
type.isAssignableFrom(WebDavFileAttributeView::class.java)
@Throws(IOException::class)
override fun <A : BasicFileAttributes> readAttributes(
path: Path,
type: Class<A>,
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<String, Any> {
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<Path>) -> Unit
) {
directory as? WebDavPath ?: throw ProviderMismatchException(directory.toString())
WalkFileTreeSearchable.search(directory, query, intervalMillis, listener)
}
}
val WebDavsFileSystemProvider =
DelegateSchemeFileSystemProvider(Protocol.DAVS.scheme, WebDavFileSystemProvider)

View File

@ -0,0 +1,125 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<WebDavPath>, 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<ByteString>
) : 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<ByteString>): 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<WatchEvent.Kind<*>>,
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<WebDavPath> {
override fun createFromParcel(source: Parcel): WebDavPath = WebDavPath(source)
override fun newArray(size: Int): Array<WebDavPath?> = arrayOfNulls(size)
}
}
}
val Path.isWebDavPath: Boolean
get() = this is WebDavPath

View File

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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)
}
}
}

View File

@ -0,0 +1,10 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package me.zhanghai.android.files.provider.webdav.client
interface Authenticator {
fun getAuthentication(authority: Authority): Authentication?
}

View File

@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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()
}

View File

@ -0,0 +1,293 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<Authority, OkHttpClient>()
private val collectionMemberCache = Collections.synchronizedMap(WeakHashMap<Path, Response>())
@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<Path> =
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<Authentication, AuthenticatorInterceptor>? =
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)
}
}

View File

@ -0,0 +1,16 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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)

View File

@ -0,0 +1,239 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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 &&
"<http://apache.org/dav/propset/fs/1>" 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)
}
}
}

View File

@ -0,0 +1,82 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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()
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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<Triple<String, String, String>, Cookie>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
synchronized(cookieMap) {
for (cookie in cookies) {
cookieMap[Triple(cookie.domain, cookie.path, cookie.name)] = cookie
}
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
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
}
}
}
}
}

View File

@ -0,0 +1,18 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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)
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 Hai Zhang <dreaming.in.code.zh@gmail.com>
* 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

View File

@ -35,6 +35,13 @@
<license>BSD 3-Clause License</license>
</notice>
<notice>
<name>dav4jvm</name>
<url>https://github.com/bitfireAT/dav4jvm</url>
<copyright>Copyright 2015 dav4jvm contributors</copyright>
<license>Mozilla Public License 2.0</license>
</notice>
<notice>
<name>PhotoView</name>
<url>https://github.com/chrisbanes/PhotoView</url>

View File

@ -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.

View File

@ -6,7 +6,7 @@
- 面包屑导航栏:点击导航栏所显示路径中的任一文件夹即可快速访问。
- Root 支持:使用 root 权限查看和管理文件。
- 压缩文件支持:查看、提取和创建常见的压缩文件。
- NAS 支持:查看和管理 FTP、SFTP 和 SMB 服务器上的文件。
- NAS 支持:查看和管理 FTP、SFTP、SMB 和 WebDAV 服务器上的文件。
- 主题:可定制的界面颜色,以及可选纯黑的夜间模式。
- Linux 友好:支持符号链接、文件权限和 SELinux 上下文。
- 健壮性:使用 Linux 系统调用实现,而不是另一个 ls 解析器。