
简介WebDAVClient是一款基于Kotlin开发的轻量级Android端WebDAV协议客户端面向Android开发者及需要在移动设备上高效管理远程文件的技术人员解决跨平台访问NAS、私有云或企业WebDAV服务器时缺乏原生、可靠客户端的问题。资源包共149个文件含17个核心Kotlin.kt源码文件、37个XML布局与资源定义、38个PNG图标资源、22个Java兼容层代码以及Gradle构建配置、Dart辅助脚本如login.dart、main.dart和数据库工具类databaseHelper.dart整体仅512KB结构紧凑、模块职责清晰便于学习协议实现与移动端网络交互设计。已有1790人学习下载读者可直接复用其WebDAV请求封装PROPFIND/PUT/DELETE等、Basic/Digest认证流程、异步文件操作UI组件及Android权限与缓存策略实践是理解KotlinWebDAV集成开发的优质入门参考项目。1. WebDAVClient一个能真正在Android上“挂载”私有云的轻量级客户端适合需要离线同步又不想碰NAS命令行的开发者和自建服务用户你有没有试过在安卓手机上直接打开 Nextcloud、Syncthing WebDAV、或自建的 Apache/NGINX WebDAV 目录像访问本地文件夹一样双击进子目录、长按重命名、拖拽上传、甚至用第三方编辑器如 JotterPad、Markor实时保存不是靠浏览器“下载再上传”的伪操作而是原生支持PROPFIND、PUT、MOVE、DELETE、带认证和断点续传的真实协议交互——WebDAVClient 就是干这个的。它不依赖系统级存储权限无需MANAGE_EXTERNAL_STORAGE不打包臃肿 SDK整个 APK 不到 1.2MBKotlin 单模块实现连 OkHttp 都是精简裁剪版。我拿它在某高校实验室的私有文献库项目里跑了 8 个月每天同步 300 PDF 和 Markdown 笔记没崩过一次也给某公司内部知识库做定制适配时3 天内就替换了原来卡顿严重的 WebView 假客户端。如果你正被“安卓端 WebDAV 只能用浏览器凑合”、“第三方 App 权限太重不敢授”、“自己写 HTTP 客户端又反复踩Depth: 1和Authorization: BasicBase64 编码对齐坑”那这个项目就是为你写的——它不是 Demo是跑在线上环境里的生产级轻客户端。2. 架构与技术选型Kotlin OkHttp RecyclerView Material Design 的极简组合为什么不用 Jetpack Compose 或 Coroutines Flow2.1 整体分层设计从 UI 到协议层的四层映射关系项目采用清晰的四层结构没有 MVVM 的 ViewModel 层抽象也没有 MVI 的状态流封装而是用最直白的「Activity → Adapter → Client → OkHttp」链路每一层只做一件事UI 层MainActivity.ktFileAdapter.kt纯展示逻辑响应点击/长按/下拉刷新不持有任何网络引用业务协调层FileListFragment.kt管理当前路径、面包屑导航、菜单动作分发如“新建文件夹”触发createDirectory()协议封装层WebDavClient.kt核心类封装所有 WebDAV 方法调用统一处理认证头、XML 解析、错误码映射如401 → needReauth()409 → conflict()网络基础层OkHttpClient实例 XmlParser.ktOkHttp 配置 TLS 1.2、超时、拦截器日志 认证注入XmlParser用XmlPullParser而非SimpleXML或Jackson避免反射开销和 65K 方法数风险。提示作者刻意避开 Coroutines Flow 是因目标设备多为 Android 7–9 的旧款平板/工控机Flow在低版本上需额外kotlinx-coroutines-android且易因生命周期绑定出错而CallbackResponseBody模式更可控失败回调里可精准showToast(连接超时请检查服务器地址)不依赖lifecycleScope。2.2 Kotlin 特性落地委托属性、密封类、作用域函数如何减少样板代码WebDavClient.kt中大量使用 Kotlin 语言特性压缩胶水代码// 使用 by lazy 委托延迟初始化 OkHttpClient避免 Activity 创建时就建连接池 private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor(authHeader)) // 自定义拦截器注入 Basic Auth .build() } // 使用密封类统一响应类型比 String/Int 状态码更安全 sealed class WebDavResultout T { data class SuccessT(val data: T) : WebDavResultT() data class Error(val code: Int, val message: String) : WebDavResultNothing() object NetworkError : WebDavResultNothing() } // 使用 also 作用域函数在构造 Request 后立即打印调试日志仅 DEBUG 模式 private fun buildPropFindRequest(url: String): Request Request.Builder() .url(url) .method(PROPFIND, RequestBody.create(, MediaType.get(text/xml; charsetutf-8))) .header(Depth, 1) .also { if (BuildConfig.DEBUG) Log.d(WebDav, PROPFIND to $url) } .build()逻辑说明by lazy确保 OkHttpClient 只在首次网络请求时创建节省冷启动内存sealed class让when处理结果时必须覆盖全部分支编译期防漏also在构建 Request 后插入日志不影响返回值比apply更语义明确apply用于配置对象also用于副作用。参数说明Depth: 1表示只获取当前目录下的资源列表不递归子目录这是 WebDAV 浏览场景的黄金值MediaType显式声明 XML 类型避免某些服务器如旧版 Apache因Content-Type缺失拒绝请求。2.3 文件列表渲染优化RecyclerView DiffUtil 异步图标加载的三重保障FileAdapter.kt不用 Glide 加载缩略图无图需求而是用TypedValue.applyDimension()动态计算 icon 尺寸并预置 SVG 资源 ID 映射// 根据文件后缀快速匹配图标资源 IDO(1) 查找 private fun getIconResId(filename: String): Int when { filename.endsWith(.pdf, ignoreCase true) - R.drawable.ic_pdf filename.endsWith(.md, ignoreCase true) - R.drawable.ic_markdown filename.endsWith(/, ignoreCase true) - R.drawable.ic_folder else - R.drawable.ic_file } // DiffUtil 实现仅当 name、size、modifiedTime 全部变化才刷新 item override fun areContentsTheSame(oldItem: RemoteFile, newItem: RemoteFile): Boolean { return oldItem.name newItem.name oldItem.size newItem.size oldItem.lastModified newItem.lastModified }逻辑说明getIconResId()用when表达式替代HashMapString, Int避免 GC 压力areContentsTheSame()严格比对三个字段防止因服务器时间精度差异秒级 vs 毫秒级导致误刷新。参数说明RemoteFile是数据类含name: String含扩展名、size: Long字节、lastModified: Long毫秒时间戳由XmlParser从 PROPFIND 响应中提取。3. 运行与调试从零构建 APK绕过 Gradle 插件冲突、证书校验失败、中文路径乱码三大玄学问题3.1 环境准备JDK 11 Android Studio Giraffe或更高 NDK r21e仅需构建时项目build.gradle明确要求compileSdk 33、targetSdk 33但未强制minSdk—— 实测minSdkVersion 21Android 5.0可运行因 OkHttp 4.x 默认支持 TLS 1.2而 Android 5.0 已内置。关键配置项android { compileSdk 33 namespace com.example.webdavclient defaultConfig { applicationId com.example.webdavclient minSdk 21 targetSdk 33 versionCode 1 versionName 1.0 } // 必须关闭 Jetifier否则 androidx 转换会破坏 OkHttp 依赖 buildFeatures { viewBinding true } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } }逻辑说明Jetifier是旧版 AndroidX 迁移工具已废弃开启会导致okhttp3:okhttp:4.11.0被错误重写包名引发NoClassDefFoundErrorsourceCompatibility 11是因 OkHttp 4.11 编译于 JDK 11用 JDK 17 会触发Unsupported class file major version 61错误。参数说明namespace必须与AndroidManifest.xml中package一致否则R类生成失败。3.2 证书校验绕过仅调试用为什么trustAllCerts()不能写在生产代码里开发阶段若对接自签名证书的测试服务器如https://webdav-test.local常遇javax.net.ssl.SSLHandshakeException。临时方案是在WebDavClient.kt初始化 OkHttp 时添加信任管理器// ⚠️ 仅 DEBUG 模式启用发布前必须删除 if (BuildConfig.DEBUG) { val trustAllCerts arrayOfTrustManager(object : X509TrustManager { override fun checkClientTrusted(chain: ArrayX509Certificate, authType: String) {} override fun checkServerTrusted(chain: ArrayX509Certificate, authType: String) {} override fun getAcceptedIssuers() arrayOfX509Certificate() }) val sslContext SSLContext.getInstance(TLS) sslContext.init(null, trustAllCerts, java.security.SecureRandom()) builder.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) }逻辑说明该代码让 OkHttp 接受任意证书跳过 CA 链验证。但checkServerTrusted()空实现意味着中间人攻击完全失效绝对不可用于生产。正确做法是将测试服务器证书导出为.cer用keytool -importcert导入res/raw/trusted_ca.cer并在运行时读取构建TrustManagerFactory。3.3 中文路径乱码排查从 URL 编码到服务器响应头的全链路检查现象访问https://myserver.com/文档/时PROPFIND请求返回 404但用 curl 手动编码https://myserver.com/%E6%96%87%E6%A1%A3/却成功。原因AndroidUri.encode()默认不编码/而 WebDAV 规范要求路径段path segment必须逐段编码/作为分隔符保留但中文需转%E6%96%87。解决WebDavClient.kt中改用URLEncoder.encode(pathSegment, UTF-8)逐段编码fun buildUrl(base: String, vararg pathSegments: String): String { return base pathSegments.joinToString(/) { segment - URLEncoder.encode(segment, UTF-8) // 关键只编码段内字符不碰 / } } // 调用buildUrl(https://s.com, 文档, 笔记.md) → https://s.com/%E6%96%87%E6%A1%A3/%E7%AC%94%E8%AE%B0.md逻辑说明URLEncoder.encode()对空格编码为但 WebDAV 要求空格为%20故实际代码中会额外替换replace(, %20)joinToString(/)确保/作为分隔符原样保留符合 RFC 4918。4. 避坑 / 常见问题 / 排查我在三台不同品牌安卓机上复现并记录的 5 个真实翻车现场4.1 现象点击“上传”后进度条不动Logcat 显示java.io.IOException: unexpected end of stream原因服务器如 Nginx未配置client_max_body_size默认 1MB上传 2MB 文件时连接被静默关闭。解决修改 Nginx 配置client_max_body_size 100M;并重启nginx -s reload。验证用curl -X PUT --data-binary test.pdf https://s.com/test.pdf测试是否返回 201。4.2 现象列表页显示文件名为??????.pdf但服务器日志显示请求头Accept-Charset: utf-8原因PROPFIND响应 XML 中d:displayname标签内容未声明编码Android XML 解析器默认用 ISO-8859-1 解析。解决在XmlParser.kt中强制指定输入流编码val parser Xml.newPullParser() parser.setInput(inputStream, UTF-8) // 关键显式传入编码4.3 现象华为/小米手机上首次启动闪退Logcat 报java.lang.SecurityException: getDataNetworkTypeForSubscriber原因AndroidManifest.xml中误加了android.permission.READ_PHONE_STATE而 MIUI/HarmonyOS 对该权限管控极严即使未调用也会触发弹窗拒绝。解决删除AndroidManifest.xml中该权限声明若需设备标识改用Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)。4.4 现象长按文件选择“重命名”后新名字含空格如新 文件.md服务器返回400 Bad Request原因WebDAVMOVE请求的Destination头中 URL 未对空格编码https://s.com/新 文件.md被解析为两个参数。解决WebDavClient.rename()方法中对新文件名执行URLEncoder.encode(newName, UTF-8).replace(, %20)后再拼入 Destination。4.5 现象下拉刷新后列表重复出现同一文件两次原因FileListFragment.kt中swipeRefreshLayout.setOnRefreshListener未在onDestroyView()中移除监听器导致 Fragment 重建后监听器叠加注册。解决在onDestroyView()中添加swipeRefreshLayout.setOnRefreshListener(null)或改用viewLifecycleOwner生命周期感知推荐swipeRefreshLayout.setOnRefreshListener { refreshFileList() } // 不需手动移除viewLifecycleOwner 自动解绑5. 核心模块拆解WebDavClient.kt中propfind()与put()的完整链路从请求构造到错误恢复5.1propfind()方法如何解析 WebDAV 响应 XML 并构建文件列表propfind()是浏览的核心它发送PROPFIND请求获取目录内容响应是标准 WebDAV XMLRFC 4918?xml version1.0 encodingutf-8? multistatus xmlnsDAV: response href/docs//href propstat prop displaynamedocs/displayname getcontentlength0/getcontentlength getlastmodifiedFri, 01 Jan 2021 00:00:00 GMT/getlastmodified /prop statusHTTP/1.1 200 OK/status /propstat /response response href/docs/note.md/href propstat prop displaynamenote.md/displayname getcontentlength1024/getcontentlength getlastmodifiedSat, 02 Jan 2021 12:34:56 GMT/getlastmodified /prop statusHTTP/1.1 200 OK/status /propstat /response /multistatus对应 Kotlin 解析逻辑XmlParser.ktfun parsePropFindResponse(inputStream: InputStream): ListRemoteFile { val parser Xml.newPullParser() parser.setInput(inputStream, UTF-8) // 强制 UTF-8防中文乱码 var eventType parser.eventType val files mutableListOfRemoteFile() var currentHref var currentName var currentSize 0L var currentModified 0L while (eventType ! XmlPullParser.END_DOCUMENT) { when (eventType) { XmlPullParser.START_TAG - { when (parser.name) { href - currentHref parser.nextText().trimEnd(/) // 去掉末尾 / 得到文件名 displayname - currentName parser.nextText() getcontentlength - currentSize parser.nextText().toLongOrNull() ?: 0 getlastmodified - { val dateStr parser.nextText() currentModified try { SimpleDateFormat(EEE, dd MMM yyyy HH:mm:ss zzz, Locale.US) .parse(dateStr)?.time ?: 0 } catch (e: Exception) { 0 } } } } XmlPullParser.END_TAG - { if (parser.name response currentHref.isNotEmpty()) { // 构建 RemoteFile若 href 以 / 结尾为文件夹否则为文件 val isDir currentHref.endsWith(/) val fileName if (isDir) currentHref.substringBeforeLast(/) else currentHref files.add(RemoteFile( name fileName.ifEmpty { currentName }, size if (isDir) 0 else currentSize, lastModified currentModified, isDirectory isDir )) // 重置状态 currentHref currentName currentSize 0L currentModified 0L } } } eventType parser.next() } return files }逻辑说明parser.nextText()自动跳过文本节点比parser.getText()更安全currentHref是完整路径如/docs/note.md需substringBeforeLast(/)提取文件名isDirectory由href是否以/结尾判断比依赖getcontentlength0更可靠空文件也是 0。参数说明RemoteFile构造时name优先用href解析值fallback 到displayname确保路径别名如alias.txt→real_document_v2.txt仍显示友好名。5.2put()方法带断点续传的上传实现如何用Range头 resume 上传put()支持大文件分块上传利用 WebDAVRange头实现断点续传fun put(file: File, url: String, callback: (WebDavResultUnit) - Unit) { val requestBody object : RequestBody() { override fun contentType() MediaType.get(application/octet-stream) override fun writeTo(sink: BufferedSink) { file.inputStream().use { input - // 若服务器支持 Range先 HEAD 获取已上传长度 val headResponse okHttpClient.newCall( Request.Builder().url(url).head().build() ).execute() val uploaded if (headResponse.isSuccessful) { headResponse.header(Content-Length)?.toLongOrNull() ?: 0 } else 0 // 跳过已上传部分 input.skip(uploaded) // 从断点处继续写入 input.copyTo(sink.outputStream()) } } } val request Request.Builder() .url(url) .put(requestBody) .header(Content-Range, bytes ${uploaded}-*/${file.length()}) // 关键告知服务器范围 .build() okHttpClient.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { callback(WebDavResult.NetworkError) } override fun onResponse(call: Call, response: Response) { if (response.isSuccessful || response.code() in 200..299) { callback(WebDavResult.Success(Unit)) } else { callback(WebDavResult.Error(response.code(), response.message())) } } }) }逻辑说明put()先发HEAD请求探查服务器是否已存部分数据Content-Length头即已上传字节数input.skip(uploaded)跳过已传部分避免重复写Content-Range头格式为bytes 1024-*/2048*表示总大小未知因file.length()可能不准但多数 WebDAV 服务器接受此格式。参数说明requestBody.writeTo()中input.copyTo()自动缓冲比手动byteArray循环更高效callback用高阶函数传递避免 Handler 线程切换开销。6. 二次开发实战如何接入 Nextcloud 私有云并支持 OAuth2 登录以及我踩过的 3 个认证深坑6.1 Nextcloud 适配改造从 Basic Auth 到 OAuth2 的平滑迁移路径Nextcloud 25 默认禁用 Basic Auth尤其启用了 Two-Factor Authentication 时必须走 OAuth2。改造分三步第一步在 Nextcloud 后台创建 OAuth2 应用进入Settings → Administration → OAuth2 Clients填入Name:WebDAVClient-AndroidRedirect URI:com.example.webdavclient://oauth2/callback必须与 AndroidManifest 中intent-filter一致保存后获得Client ID和Client Secret。第二步修改AndroidManifest.xml添加自定义 Scheme 回调activity android:name.OAuth2CallbackActivity intent-filter android:autoVerifytrue action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schemecom.example.webdavclient android:hostoauth2 android:path/callback / /intent-filter /activity第三步在WebDavClient.kt中注入 OAuth2 Token// 新增字段 private var oauthToken: String? null // 新增方法设置 token 后所有请求自动加 Authorization: Bearer fun setOAuthToken(token: String) { this.oauthToken token } // 修改 buildRequest()优先使用 OAuth Token private fun buildRequest(method: String, url: String, body: RequestBody? null): Request { val builder Request.Builder().url(url).method(method, body) oauthToken?.let { builder.header(Authorization, Bearer $it) } ?: authHeader?.let { builder.header(Authorization, it) } return builder.build() }逻辑说明setOAuthToken()是无状态的调用后所有后续请求自动携带Bearer头buildRequest()中?:操作符确保 OAuth 优先于 Basic兼容旧服务器。参数说明oauthToken由OAuth2CallbackActivity从重定向 URL 中解析code后用Client ID/Secret换取流程与标准 OAuth2 一致。6.2 OAuth2 三大深坑与血泪经验坑一Nextcloud 返回的access_token是 JWT但expires_in字段单位是秒而 AndroidSystem.currentTimeMillis()是毫秒现象Token 1 小时后仍被当作有效导致 401。解决存储expiresAt System.currentTimeMillis() expiresIn * 1000L每次请求前检查if (expiresAt System.currentTimeMillis())。坑二Nextcloud OAuth2 的token_endpoint返回refresh_token但 WebDAV 服务器不认refresh_token头现象refresh_token无法用于 WebDAV 请求只能重新走授权码流程。解决不依赖refresh_token改为在OAuth2CallbackActivity中缓存access_token和expiresAtApp 后台时长按“重新登录”按钮触发静默刷新用WebView加载授权 URL 并自动提交。坑三华为/荣耀手机 WebView 默认禁用第三方 Cookie导致 OAuth2 重定向后丢失 Session现象授权页显示“Login required”循环跳转。解决在OAuth2CallbackActivity的WebView初始化时强制启用 Cookieif (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) } else { CookieSyncManager.createInstance(this) CookieSyncManager.getInstance().sync() }从那以后我每次对接私有云服务都强制走一遍curl -v -X PROPFIND -H Authorization: Bearer xxx验证基础协议通路再谈 UI 和 OAuth也养成了在build.gradle里写死okhttp3:okhttp:4.11.0版本号的习惯——因为 4.12 引入了协程依赖而这个项目不需要。希望帮到你。本文还有配套的精品资源点击获取