diff --git a/ROADMAP.md b/ROADMAP.md
index f0a7f7a7..59f2c4ea 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -241,7 +241,7 @@ Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), local sync folder via SAF/WorkManager
-- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events
+- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events
- **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized)
diff --git a/android/README.md b/android/README.md
index 804ed214..f446fad2 100644
--- a/android/README.md
+++ b/android/README.md
@@ -38,8 +38,11 @@ CI runs `assembleDebug` on every PR.
`filesDir/offline/` and registers the entry (DataStore JSON);
star icon in the top bar opens the offline list — open via
FileProvider, re-download to refresh, remove to delete
+- DocumentsProvider: the whole tree appears in the system Files app /
+ SAF pickers — browse, open (cached download), thumbnails, rename,
+ delete, and search all proxy to `api/v4`
## Planned next
-- DocumentsProvider
+- Quick-share tile, task/share notifications, local sync folder
- No iOS. Ever.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7ac9fbc7..23b3e9ac 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -47,5 +47,16 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
+
+
+
+
+
+
diff --git a/android/app/src/main/java/org/cloudreve/android/provider/CloudreveDocumentsProvider.kt b/android/app/src/main/java/org/cloudreve/android/provider/CloudreveDocumentsProvider.kt
new file mode 100644
index 00000000..bfad6c8f
--- /dev/null
+++ b/android/app/src/main/java/org/cloudreve/android/provider/CloudreveDocumentsProvider.kt
@@ -0,0 +1,247 @@
+package org.cloudreve.android.provider
+
+import android.content.Context
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.graphics.Point
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document
+import android.provider.DocumentsContract.Root
+import android.provider.DocumentsProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.cloudreve.android.CloudreveApp
+import org.cloudreve.android.R
+import org.cloudreve.android.api.FileObject
+import org.cloudreve.android.data.FileRepository
+import org.cloudreve.android.util.CrUri
+import java.io.File
+import java.io.FileNotFoundException
+import java.security.MessageDigest
+
+/**
+ * Exposes the Cloudreve filesystem to the system file picker / Files app.
+ * Document IDs are the entries' `cloudreve://` URIs, matching the desktop
+ * client's addressing scheme.
+ *
+ * Provider methods run on binder threads, so the suspend repository calls are
+ * bridged with runBlocking. All network errors surface as
+ * FileNotFoundException, which the Documents UI renders as "unavailable".
+ */
+class CloudreveDocumentsProvider : DocumentsProvider() {
+
+ private val repo: FileRepository
+ get() = (context!!.applicationContext as CloudreveApp).fileRepository
+
+ private val ioScope = Dispatchers.IO
+
+ override fun onCreate(): Boolean = true
+
+ override fun queryRoots(projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS)
+ cursor.newRow().apply {
+ add(Root.COLUMN_ROOT_ID, ROOT_ID)
+ add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
+ add(Root.COLUMN_TITLE, "Cloudreve")
+ add(Root.COLUMN_SUMMARY, rootSummary())
+ add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_SEARCH or Root.FLAG_SUPPORTS_IS_CHILD)
+ add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
+ add(Root.COLUMN_MIME_TYPES, "*/*")
+ }
+ return cursor
+ }
+
+ private fun rootSummary(): String = runCatching {
+ val base = runBlocking(ioScope) {
+ (context!!.applicationContext as CloudreveApp).apiClient.serverBase()
+ }
+ java.net.URI(base).host
+ }.getOrNull() ?: ""
+
+ override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
+ documentId != parentDocumentId && documentId.startsWith("${parentDocumentId.trimEnd('/')}/")
+
+ override fun queryDocument(documentId: String, projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_DOC_COLUMNS)
+ if (documentId == ROOT_ID) {
+ cursor.newRow().apply {
+ add(Document.COLUMN_DOCUMENT_ID, ROOT_ID)
+ add(Document.COLUMN_DISPLAY_NAME, "My files")
+ add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR)
+ add(Document.COLUMN_FLAGS, 0)
+ }
+ return cursor
+ }
+ val obj = findObject(documentId)
+ cursor.addFileRow(obj)
+ return cursor
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String,
+ projection: Array?,
+ sortOrder: String?,
+ ): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_DOC_COLUMNS)
+ runBlocking(ioScope) {
+ var token: String? = null
+ do {
+ val page = repo.list(parentDocumentId, token)
+ page.files.forEach { cursor.addFileRow(it) }
+ token = page.pagination.nextToken
+ } while (token != null)
+ }
+ return cursor
+ }
+
+ override fun querySearchDocuments(
+ rootId: String,
+ query: String,
+ projection: Array?,
+ ): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_DOC_COLUMNS)
+ runBlocking(ioScope) {
+ repo.search(query).hits.forEach { cursor.addFileRow(it.file) }
+ }
+ return cursor
+ }
+
+ override fun openDocument(
+ documentId: String,
+ mode: String,
+ signal: CancellationSignal?,
+ ): ParcelFileDescriptor {
+ if (mode.contains('w')) {
+ throw FileNotFoundException("Cloudreve documents are read-only here")
+ }
+ val local = fetchToCache(documentId)
+ return ParcelFileDescriptor.open(local, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ override fun openDocumentThumbnail(
+ documentId: String,
+ sizeHint: Point?,
+ signal: CancellationSignal?,
+ ): android.content.res.AssetFileDescriptor {
+ val local = runBlocking(ioScope) {
+ val url = repo.thumbUrl(documentId)
+ ?: throw FileNotFoundException("No thumbnail")
+ downloadTo(url, cacheFile("thumb", documentId))
+ }
+ val pfd = ParcelFileDescriptor.open(local, ParcelFileDescriptor.MODE_READ_ONLY)
+ return android.content.res.AssetFileDescriptor(pfd, 0, local.length())
+ }
+
+ override fun deleteDocument(documentId: String) {
+ runBlocking(ioScope) { repo.delete(listOf(documentId)) }
+ notifyChange(documentId)
+ }
+
+ override fun renameDocument(documentId: String, displayName: String): String {
+ runBlocking(ioScope) { repo.rename(documentId, displayName) }
+ val newId = CrUri.join(CrUri.parent(documentId), displayName)
+ notifyChange(documentId)
+ return newId
+ }
+
+ private fun notifyChange(documentId: String) {
+ context!!.contentResolver.notifyChange(
+ DocumentsContract.buildDocumentUri(authority(), documentId),
+ null,
+ )
+ }
+
+ private fun authority(): String = "${context!!.packageName}.documents"
+
+ /** Looks up a file by listing its parent directory. */
+ private fun findObject(documentId: String): FileObject = runBlocking(ioScope) {
+ val parent = CrUri.parent(documentId)
+ var token: String? = null
+ do {
+ val page = repo.list(parent, token)
+ page.files.firstOrNull { it.path == documentId }?.let { return@runBlocking it }
+ token = page.pagination.nextToken
+ } while (token != null)
+ throw FileNotFoundException("Not found: $documentId")
+ }
+
+ private fun fetchToCache(documentId: String): File = runBlocking(ioScope) {
+ val url = repo.downloadUrl(documentId)
+ downloadTo(url, cacheFile("doc", documentId))
+ }
+
+ private suspend fun downloadTo(url: String, dest: File): File {
+ val resp = repo.download(url)
+ if (!resp.isSuccessful) {
+ resp.body()?.close()
+ throw FileNotFoundException("Download failed: HTTP ${resp.code()}")
+ }
+ dest.parentFile?.mkdirs()
+ resp.body()!!.byteStream().use { input ->
+ dest.outputStream().use { output -> input.copyTo(output) }
+ }
+ return dest
+ }
+
+ private fun cacheFile(kind: String, documentId: String): File {
+ val hash = MessageDigest.getInstance("SHA-256")
+ .digest(documentId.toByteArray())
+ .joinToString("") { "%02x".format(it) }
+ .take(16)
+ val ext = CrUri.fileName(documentId).substringAfterLast('.', "")
+ val suffix = if (ext.isEmpty()) "" else ".$ext"
+ return File(context!!.cacheDir, "docprovider/$kind-$hash$suffix")
+ }
+
+ private fun MatrixCursor.addFileRow(file: FileObject) {
+ val isDir = file.isFolder
+ val flags = Document.FLAG_SUPPORTS_DELETE or
+ Document.FLAG_SUPPORTS_RENAME or
+ (if (!isDir && file.thumbnail == true) Document.FLAG_SUPPORTS_THUMBNAIL else 0)
+ newRow().apply {
+ add(Document.COLUMN_DOCUMENT_ID, file.path)
+ add(Document.COLUMN_DISPLAY_NAME, file.name)
+ add(
+ Document.COLUMN_MIME_TYPE,
+ if (isDir) Document.MIME_TYPE_DIR
+ else java.net.URLConnection.guessContentTypeFromName(file.name)
+ ?: "application/octet-stream",
+ )
+ add(Document.COLUMN_SIZE, if (isDir) null else file.size)
+ add(Document.COLUMN_LAST_MODIFIED, parseInstant(file.updatedAt))
+ add(Document.COLUMN_FLAGS, flags)
+ }
+ }
+
+ private fun parseInstant(value: String): Long? {
+ if (value.isBlank()) return null
+ return runCatching { java.time.Instant.parse(value).toEpochMilli() }
+ .recoverCatching { java.time.OffsetDateTime.parse(value).toInstant().toEpochMilli() }
+ .getOrNull()
+ }
+
+ companion object {
+ private const val ROOT_ID = CrUri.MY_PREFIX
+
+ private val DEFAULT_ROOT_COLUMNS = arrayOf(
+ Root.COLUMN_ROOT_ID,
+ Root.COLUMN_DOCUMENT_ID,
+ Root.COLUMN_TITLE,
+ Root.COLUMN_SUMMARY,
+ Root.COLUMN_FLAGS,
+ Root.COLUMN_ICON,
+ Root.COLUMN_MIME_TYPES,
+ )
+
+ private val DEFAULT_DOC_COLUMNS = arrayOf(
+ Document.COLUMN_DOCUMENT_ID,
+ Document.COLUMN_DISPLAY_NAME,
+ Document.COLUMN_MIME_TYPE,
+ Document.COLUMN_SIZE,
+ Document.COLUMN_LAST_MODIFIED,
+ Document.COLUMN_FLAGS,
+ )
+ }
+}