mirror of
https://github.com/zhanghai/MaterialFiles
synced 2024-07-03 06:48:36 +00:00
parent
ce4421550d
commit
b9e84715fc
|
@ -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).
|
||||
|
|
|
@ -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)。
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
- 面包屑导航栏:点击导航栏所显示路径中的任一文件夹即可快速访问。
|
||||
- Root 支持:使用 root 权限查看和管理文件。
|
||||
- 压缩文件支持:查看、提取和创建常见的压缩文件。
|
||||
- NAS 支持:查看和管理 FTP、SFTP 和 SMB 服务器上的文件。
|
||||
- NAS 支持:查看和管理 FTP、SFTP、SMB 和 WebDAV 服务器上的文件。
|
||||
- 主题:可定制的界面颜色,以及可选纯黑的夜间模式。
|
||||
- Linux 友好:支持符号链接、文件权限和 SELinux 上下文。
|
||||
- 健壮性:使用 Linux 系统调用实现,而不是另一个 ls 解析器。
|
||||
|
|
Loading…
Reference in New Issue
Block a user