From 74ca28930ba3cf2ca249c11f67fbf4f8f3a9f4b0 Mon Sep 17 00:00:00 2001 From: Reto Kaiser Date: Fri, 13 Mar 2026 21:46:22 +0100 Subject: [PATCH 1/2] WebDAV: Trailing slash for DELETE and MOVE on dirs According to WebDAV RFC when issuing a MOVE or DELETE on a folder (instead of a file), the path should have a trailing slash. For example the nginx webdav server requires this for it to work. --- .../files/provider/webdav/WebDavCopyMove.kt | 10 +++++----- .../provider/webdav/WebDavFileSystemProvider.kt | 8 +++++++- .../android/files/provider/webdav/WebDavPath.kt | 9 +++++++++ .../files/provider/webdav/client/Client.kt | 17 +++++++++++++---- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt index 9aab7a92f..9141b9aa9 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavCopyMove.kt @@ -44,7 +44,7 @@ internal object WebDavCopyMove { throw FileAlreadyExistsException(source.toString(), target.toString(), null) } try { - Client.delete(target) + Client.delete(target, targetFile.isDirectory) } catch (e: DavException) { throw e.toFileSystemException(target.toString()) } @@ -139,14 +139,14 @@ internal object WebDavCopyMove { throw FileAlreadyExistsException(source.toString(), target.toString(), null) } try { - Client.delete(target) + Client.delete(target, targetResponse.isDirectory) } catch (e: DavException) { throw e.toFileSystemException(target.toString()) } } var renameSuccessful = false try { - Client.move(source, target) + Client.move(source, target, sourceResponse.isDirectory) renameSuccessful = true } catch (e: DavException) { if (copyOptions.atomicMove) { @@ -170,11 +170,11 @@ internal object WebDavCopyMove { } copy(source, target, copyOptions) try { - Client.delete(source) + Client.delete(source, sourceResponse.isDirectory) } catch (e: DavException) { if (e.toFileSystemException(source.toString()) !is NoSuchFileException) { try { - Client.delete(target) + Client.delete(target, sourceResponse.isDirectory) } catch (e2: DavException) { e.addSuppressed(e2.toFileSystemException(target.toString())) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt index 233f0458d..f7d75cfda 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt @@ -46,6 +46,7 @@ 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.isDirectory import me.zhanghai.android.files.provider.webdav.client.isSymbolicLink import java.io.IOException import java.io.InputStream @@ -300,8 +301,13 @@ object WebDavFileSystemProvider : FileSystemProvider(), PathObservableProvider, @Throws(IOException::class) override fun delete(path: Path) { path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) + val pathResponse = try { + Client.findProperties(path, true) + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } try { - Client.delete(path) + Client.delete(path, pathResponse.isDirectory) } catch (e: DavException) { throw e.toFileSystemException(path.toString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt index f1493a3b6..7e18061ee 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavPath.kt @@ -101,6 +101,15 @@ internal class WebDavPath : ByteStringListPath, Client.Path { .addPathSegments(toString().removePrefix("/")) .build() + override val directoryUrl: HttpUrl + get() = if (toString() == "/") { + url + } else { + url.newBuilder() + .addPathSegment("") + .build() + } + private constructor(source: Parcel) : super(source) { fileSystem = source.readParcelable()!! } diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt index 682f5244e..921687851 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/client/Client.kt @@ -99,9 +99,9 @@ object Client { } @Throws(DavException::class) - fun delete(path: Path) { + fun delete(path: Path, isDirectory: Boolean = false) { try { - DavResource(getClient(path.authority), path.url).delete {} + DavResource(getClient(path.authority), path.url(isDirectory)).delete {} } catch (e: IOException) { throw e.toDavException() } @@ -110,12 +110,13 @@ object Client { } @Throws(DavException::class) - fun move(source: Path, target: Path) { + fun move(source: Path, target: Path, isDirectory: Boolean = false) { if (source.authority != target.authority) { throw IOException("Paths aren't on the same authority") } try { - DavResource(getClient(source.authority), source.url).move(target.url, false) {} + DavResource(getClient(source.authority), source.url(isDirectory)) + .move(target.url(isDirectory), false) {} } catch (e: IOException) { throw e.toDavException() } @@ -257,9 +258,17 @@ object Client { interface Path { val authority: Authority val url: HttpUrl + val directoryUrl: HttpUrl fun resolve(other: String): Path } + private fun Path.url(isDirectory: Boolean): HttpUrl = + if (isDirectory) { + directoryUrl + } else { + url + } + private class OkHttpAuthenticatorInterceptor( private val authenticator: Authenticator, private val authority: Authority From c4d1103d1d92117ff40efc024abea8a5d2d8205f Mon Sep 17 00:00:00 2001 From: Reto Kaiser Date: Sat, 14 Mar 2026 16:36:15 +0100 Subject: [PATCH 2/2] Pass 'isDirectory' to Path.delete() to avoid additional PROPFIND when deleting WebDAV files. --- .../android/files/filejob/FileJobs.kt | 15 ++++++++++----- .../files/ftpserver/ProviderFtpFile.kt | 2 +- .../files/provider/common/ForeignCopyMove.kt | 6 +++--- .../files/provider/common/PathExtensions.kt | 10 ++++++++-- .../webdav/WebDavFileSystemProvider.kt | 19 ++++++++++++++----- .../files/viewer/image/ImageViewerFragment.kt | 3 ++- 6 files changed, 38 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt index 4213b2374..4eb88de84 100644 --- a/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt +++ b/app/src/main/java/me/zhanghai/android/files/filejob/FileJobs.kt @@ -964,7 +964,7 @@ class DeleteFileJob(private val paths: List) : FileJob() { Files.walkFileTree(path, object : SimpleFileVisitor() { @Throws(IOException::class) override fun visitFile(file: Path, attributes: BasicFileAttributes): FileVisitResult { - delete(file, transferInfo, actionAllInfo) + delete(file, attributes.isDirectory, transferInfo, actionAllInfo) throwIfInterrupted() return FileVisitResult.CONTINUE } @@ -984,7 +984,7 @@ class DeleteFileJob(private val paths: List) : FileJob() { if (exception != null) { throw exception } - delete(directory, transferInfo, actionAllInfo) + delete(directory, true, transferInfo, actionAllInfo) throwIfInterrupted() return FileVisitResult.CONTINUE } @@ -993,12 +993,17 @@ class DeleteFileJob(private val paths: List) : FileJob() { } @Throws(IOException::class) -private fun FileJob.delete(path: Path, transferInfo: TransferInfo?, actionAllInfo: ActionAllInfo) { +private fun FileJob.delete( + path: Path, + isDirectory: Boolean?, + transferInfo: TransferInfo?, + actionAllInfo: ActionAllInfo +) { var retry: Boolean do { retry = false try { - path.delete() + path.delete(isDirectory) if (transferInfo != null) { transferInfo.incrementTransferredFileCount() postDeleteNotification(transferInfo, path) @@ -1152,7 +1157,7 @@ class MoveFileJob(private val sources: List, private val targetDirectory: if (exception != null) { throw exception } - delete(directory, null, actionAllInfo) + delete(directory, true, null, actionAllInfo) throwIfInterrupted() return FileVisitResult.CONTINUE } diff --git a/app/src/main/java/me/zhanghai/android/files/ftpserver/ProviderFtpFile.kt b/app/src/main/java/me/zhanghai/android/files/ftpserver/ProviderFtpFile.kt index 39503e0aa..78ea3778e 100644 --- a/app/src/main/java/me/zhanghai/android/files/ftpserver/ProviderFtpFile.kt +++ b/app/src/main/java/me/zhanghai/android/files/ftpserver/ProviderFtpFile.kt @@ -151,7 +151,7 @@ class ProviderFtpFile( false } else { try { - path.delete() + path.delete(null) true } catch (e: IOException) { e.printStackTrace() diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt index 6ecbbcd6b..7ef153b47 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/ForeignCopyMove.kt @@ -142,11 +142,11 @@ internal object ForeignCopyMove { } copy(source, target, *optionsForCopy) try { - source.delete() + source.delete(null) } catch (e: IOException) { if (e !is NoSuchFileException) { try { - target.delete() + target.delete(null) } catch (e2: IOException) { e.addSuppressed(e2) } catch (e2: UnsupportedOperationException) { @@ -156,7 +156,7 @@ internal object ForeignCopyMove { throw e } catch (e: UnsupportedOperationException) { try { - target.delete() + target.delete(null) } catch (e2: IOException) { e.addSuppressed(e2) } catch (e2: UnsupportedOperationException) { diff --git a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt index c3d56b485..75f52bb6f 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/common/PathExtensions.kt @@ -5,6 +5,8 @@ package me.zhanghai.android.files.provider.common +import me.zhanghai.android.files.provider.webdav.WebDavFileSystemProvider +import me.zhanghai.android.files.provider.webdav.WebDavPath import java8.nio.channels.SeekableByteChannel import java8.nio.file.AccessMode import java8.nio.file.CopyOption @@ -81,8 +83,12 @@ fun Path.createSymbolicLink(target: ByteString, vararg attributes: FileAttribute createSymbolicLink(ByteStringPath(target), *attributes) @Throws(IOException::class) -fun Path.delete() { - Files.delete(this) +fun Path.delete(isDirectory: Boolean?) { + if (provider == WebDavFileSystemProvider && this is WebDavPath) { + WebDavFileSystemProvider.delete(this, isDirectory) + } else { + Files.delete(this) + } } @Throws(IOException::class) diff --git a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt index f7d75cfda..ba2b5bd17 100644 --- a/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt +++ b/app/src/main/java/me/zhanghai/android/files/provider/webdav/WebDavFileSystemProvider.kt @@ -301,13 +301,22 @@ object WebDavFileSystemProvider : FileSystemProvider(), PathObservableProvider, @Throws(IOException::class) override fun delete(path: Path) { path as? WebDavPath ?: throw ProviderMismatchException(path.toString()) - val pathResponse = try { - Client.findProperties(path, true) - } catch (e: DavException) { - throw e.toFileSystemException(path.toString()) + delete(path, null) + } + + @Throws(IOException::class) + internal fun delete(path: WebDavPath, isDirectory: Boolean?) { + val resolvedIsDirectory = if (isDirectory != null) { + isDirectory + } else { + try { + Client.findProperties(path, true).isDirectory + } catch (e: DavException) { + throw e.toFileSystemException(path.toString()) + } } try { - Client.delete(path, pathResponse.isDirectory) + Client.delete(path, resolvedIsDirectory) } catch (e: DavException) { throw e.toFileSystemException(path.toString()) } diff --git a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt index cb082f449..9df07e985 100644 --- a/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt +++ b/app/src/main/java/me/zhanghai/android/files/viewer/image/ImageViewerFragment.kt @@ -162,7 +162,8 @@ class ImageViewerFragment : Fragment(), ConfirmDeleteDialogFragment.Listener { override fun delete(path: Path) { try { - path.delete() + // The image viewer only deletes files, se we pass isDirectory=false + path.delete(false) } catch (e: IOException) { e.printStackTrace() showToast(e.toString())