
WebDAV client implementing PROPFIND/GET/PUT/LOCK with streaming uploads/downloads, locking with auto-release, resumable ranges, conditional requests, capability probing, and robust XML parsing.
A WebDAV (RFC 4918) client library written in Kotlin Multiplatform, sharing 100% of its protocol logic between Android, iOS, JVM and the browser (Kotlin/JS and Kotlin/Wasm). It is a library (no UI) meant to be embedded in other apps and projects.
PROPFIND), download (GET), upload (PUT),
delete (DELETE), create collections (MKCOL), move/copy (MOVE/COPY), read and
modify properties (PROPPATCH).LOCK/UNLOCK) with lock refresh,
lock discovery (getLocks), withLock auto-release, and lock tokens submitted via
the If header on every write operation (RFC 4918 §7.3).If-Match / If-None-Match (ETag) on uploads, downloads,
moves, copies and deletes for optimistic concurrency and cache validation.capabilities() parses both the Allow and the DAV
compliance headers; execute() exposes the underlying request path for WebDAV
extension methods (REPORT, SEARCH, ...).(bytesTransferred, totalBytes).| Target | Engine | Artifact |
|---|---|---|
| Android (minSdk 24) | OkHttp |
com.phylaris.webdav.client AAR |
| iOS (arm64, simulator) | Darwin (NSURLSession) |
WebDavClient XCFramework |
| JVM | CIO | JVM jar |
| Browser JS / Wasm | Js (browser fetch) | JS library / Wasm library |
The JVM target also serves as the test host: ./gradlew :jvmTest runs the shared
suite (XML parsing golden cases + protocol behavior via Ktor's MockEngine). The
same suite runs in the browser: ./gradlew :jsBrowserTest :wasmJsBrowserTest
(Chrome headless).
io.ktor:ktor-client-*) — note that Ktor 3.5 moved
bodyAsText/bodyAsChannel to io.ktor.client.statement, removed the WebDAV
HttpMethod constants and buildHeaders, and switched ByteReadPacket to
kotlinx.io.Source
io.github.pdvrieze.xmlutil) for multi-status XML parsingval client = WebDavClient.create(
baseUrl = "https://example.com/remote.php/dav/files/alice/",
auth = Auth.Basic(username = "alice", password = "secret"),
)let client = WebDavClientKt.WebDavClient.create(
baseUrl: "https://example.com/remote.php/dav/files/alice/",
auth: Auth.Basic(username: "alice", password: "secret")
)val dav = WebDavClient.create(baseUrl, auth = Auth.Basic("u", "p"))
// List a directory
val files: List<WebDavFile> = dav.list("docs")
// Download (streamed)
dav.downloadToChannel("docs/big.bin", sink) { transferred, total ->
println("$transferred / $total")
}
// Upload (streamed)
dav.uploadFromChannel("docs/backup.bin", channel, contentLength = size) { transferred, total ->
println("$transferred / $total")
}
// Mutations
dav.mkdir("new folder")
dav.move("a.txt", "b/a.txt")
dav.copy("docs", "docs-copy")
dav.delete("old.txt")
dav.setProperties("f.txt", set = mapOf(PropertyName.DISPLAYNAME to "renamed"))
// Locking (exclusive or shared), with automatic release
val lock = dav.lock("f.txt", owner = "my-app", timeout = 5.minutes)
dav.uploadBytes("f.txt", bytes, lockToken = lock.token) // writes carry the token
// dav.unlock("f.txt", lock.token)
// Or: lock for the duration of a block, unlock guaranteed in a finally block
val uploaded = dav.withLock("f.txt") { lock ->
dav.uploadBytes("f.txt", bytes, lockToken = lock.token)
}
// Refresh a lock, inspect active locks
val refreshed = dav.refreshLock("f.txt", lock.token, timeout = 30.minutes)
val locks = dav.getLocks("f.txt")
// Resume a download
dav.downloadToChannel("big.bin", sink, range = downloadedBytes..Long.MAX_VALUE)
// Probe server capabilities (Allow + DAV compliance classes)
val caps = dav.capabilities()
if (caps.supportsLocking) { /* ... */ }Read arbitrary (dead) properties returned by the server:
val favorite = file.properties[PropertyName("http://owncloud.org/ns", "favorite")]?.textWhen embedding the library in a web app, keep in mind:
OPTIONS) requests and allow the custom methods (PROPFIND, MKCOL,
LOCK, ...) and headers (Depth, Destination, Lock-Token, ...) the client sends.User-Agent is a forbidden header in browsers; setting it is
silently ignored, but the browser always sends its own UA string, so servers that
reject UA-less requests (e.g. fnos) keep working.ReadChannelContent uploads may
be buffered fully in memory, so very large uploads use more memory than on JVM.All path arguments are logical paths relative to the client base URL, without a
leading slash, e.g. "docs/report 2026.txt". The library handles URL encoding,
decoding of server href values, and normalizes base URLs (trailing slash).
The library disables Ktor's automatic redirect handling and follows 3xx responses
itself: methods and request bodies are preserved on redirect (301/302/307/308), and
303 responses switch to GET. If you supply your own HttpClient, keep
followRedirects = false.
All failures derive from DavException:
HttpStatusException and subclasses: UnauthorizedException (401),
ForbiddenException (403), NotFoundException (404), ConflictException (409),
PreconditionFailedException (412), LockedException (423), ...DavProtocolException: unparseable server responses (malformed XML, missing
lock token, partial PROPPATCH failures)../gradlew :jvmTest # run shared tests on the JVM host
./gradlew :jsBrowserTest # run shared tests in the browser (Kotlin/JS)
./gradlew :wasmJsBrowserTest # run shared tests in the browser (Kotlin/Wasm)
./gradlew :assembleDebug # build the Android AAR
./gradlew :linkDebugFrameworkIosArm64 # build the iOS framework (macOS only)The shared test suite covers XML parsing golden cases and protocol behavior using
Ktor's MockEngine (no live server needed).
WebDavClient.lock() now returns LockInfo (with scope, type and depth);
LockToken is deprecated but retained. lock.token / lock.timeoutSeconds keep
their names, so most call sites migrate without changes.execute() and ensureSuccess() are public extension points; WebDAV methods
without a dedicated wrapper (REPORT, SEARCH, ...) can be issued through them.Licensed under the Apache License, Version 2.0.
A WebDAV (RFC 4918) client library written in Kotlin Multiplatform, sharing 100% of its protocol logic between Android, iOS, JVM and the browser (Kotlin/JS and Kotlin/Wasm). It is a library (no UI) meant to be embedded in other apps and projects.
PROPFIND), download (GET), upload (PUT),
delete (DELETE), create collections (MKCOL), move/copy (MOVE/COPY), read and
modify properties (PROPPATCH).LOCK/UNLOCK) with lock refresh,
lock discovery (getLocks), withLock auto-release, and lock tokens submitted via
the If header on every write operation (RFC 4918 §7.3).If-Match / If-None-Match (ETag) on uploads, downloads,
moves, copies and deletes for optimistic concurrency and cache validation.capabilities() parses both the Allow and the DAV
compliance headers; execute() exposes the underlying request path for WebDAV
extension methods (REPORT, SEARCH, ...).(bytesTransferred, totalBytes).| Target | Engine | Artifact |
|---|---|---|
| Android (minSdk 24) | OkHttp |
com.phylaris.webdav.client AAR |
| iOS (arm64, simulator) | Darwin (NSURLSession) |
WebDavClient XCFramework |
| JVM | CIO | JVM jar |
| Browser JS / Wasm | Js (browser fetch) | JS library / Wasm library |
The JVM target also serves as the test host: ./gradlew :jvmTest runs the shared
suite (XML parsing golden cases + protocol behavior via Ktor's MockEngine). The
same suite runs in the browser: ./gradlew :jsBrowserTest :wasmJsBrowserTest
(Chrome headless).
io.ktor:ktor-client-*) — note that Ktor 3.5 moved
bodyAsText/bodyAsChannel to io.ktor.client.statement, removed the WebDAV
HttpMethod constants and buildHeaders, and switched ByteReadPacket to
kotlinx.io.Source
io.github.pdvrieze.xmlutil) for multi-status XML parsingval client = WebDavClient.create(
baseUrl = "https://example.com/remote.php/dav/files/alice/",
auth = Auth.Basic(username = "alice", password = "secret"),
)let client = WebDavClientKt.WebDavClient.create(
baseUrl: "https://example.com/remote.php/dav/files/alice/",
auth: Auth.Basic(username: "alice", password: "secret")
)val dav = WebDavClient.create(baseUrl, auth = Auth.Basic("u", "p"))
// List a directory
val files: List<WebDavFile> = dav.list("docs")
// Download (streamed)
dav.downloadToChannel("docs/big.bin", sink) { transferred, total ->
println("$transferred / $total")
}
// Upload (streamed)
dav.uploadFromChannel("docs/backup.bin", channel, contentLength = size) { transferred, total ->
println("$transferred / $total")
}
// Mutations
dav.mkdir("new folder")
dav.move("a.txt", "b/a.txt")
dav.copy("docs", "docs-copy")
dav.delete("old.txt")
dav.setProperties("f.txt", set = mapOf(PropertyName.DISPLAYNAME to "renamed"))
// Locking (exclusive or shared), with automatic release
val lock = dav.lock("f.txt", owner = "my-app", timeout = 5.minutes)
dav.uploadBytes("f.txt", bytes, lockToken = lock.token) // writes carry the token
// dav.unlock("f.txt", lock.token)
// Or: lock for the duration of a block, unlock guaranteed in a finally block
val uploaded = dav.withLock("f.txt") { lock ->
dav.uploadBytes("f.txt", bytes, lockToken = lock.token)
}
// Refresh a lock, inspect active locks
val refreshed = dav.refreshLock("f.txt", lock.token, timeout = 30.minutes)
val locks = dav.getLocks("f.txt")
// Resume a download
dav.downloadToChannel("big.bin", sink, range = downloadedBytes..Long.MAX_VALUE)
// Probe server capabilities (Allow + DAV compliance classes)
val caps = dav.capabilities()
if (caps.supportsLocking) { /* ... */ }Read arbitrary (dead) properties returned by the server:
val favorite = file.properties[PropertyName("http://owncloud.org/ns", "favorite")]?.textWhen embedding the library in a web app, keep in mind:
OPTIONS) requests and allow the custom methods (PROPFIND, MKCOL,
LOCK, ...) and headers (Depth, Destination, Lock-Token, ...) the client sends.User-Agent is a forbidden header in browsers; setting it is
silently ignored, but the browser always sends its own UA string, so servers that
reject UA-less requests (e.g. fnos) keep working.ReadChannelContent uploads may
be buffered fully in memory, so very large uploads use more memory than on JVM.All path arguments are logical paths relative to the client base URL, without a
leading slash, e.g. "docs/report 2026.txt". The library handles URL encoding,
decoding of server href values, and normalizes base URLs (trailing slash).
The library disables Ktor's automatic redirect handling and follows 3xx responses
itself: methods and request bodies are preserved on redirect (301/302/307/308), and
303 responses switch to GET. If you supply your own HttpClient, keep
followRedirects = false.
All failures derive from DavException:
HttpStatusException and subclasses: UnauthorizedException (401),
ForbiddenException (403), NotFoundException (404), ConflictException (409),
PreconditionFailedException (412), LockedException (423), ...DavProtocolException: unparseable server responses (malformed XML, missing
lock token, partial PROPPATCH failures)../gradlew :jvmTest # run shared tests on the JVM host
./gradlew :jsBrowserTest # run shared tests in the browser (Kotlin/JS)
./gradlew :wasmJsBrowserTest # run shared tests in the browser (Kotlin/Wasm)
./gradlew :assembleDebug # build the Android AAR
./gradlew :linkDebugFrameworkIosArm64 # build the iOS framework (macOS only)The shared test suite covers XML parsing golden cases and protocol behavior using
Ktor's MockEngine (no live server needed).
WebDavClient.lock() now returns LockInfo (with scope, type and depth);
LockToken is deprecated but retained. lock.token / lock.timeoutSeconds keep
their names, so most call sites migrate without changes.execute() and ensureSuccess() are public extension points; WebDAV methods
without a dedicated wrapper (REPORT, SEARCH, ...) can be issued through them.Licensed under the Apache License, Version 2.0.