feat(android): DocumentsProvider exposing the tree to system Files app

Registers a DocumentsProvider so the Cloudreve filesystem shows up in
the system Files app and any SAF picker. Document IDs are the entries'
cloudreve:// URIs, matching the desktop client's scheme.

- queryRoots: single root labeled with the server host
- queryChildDocuments: paginated api/v4 file listing
- openDocument: streams a fresh download into the cache, read-only PFD
- openDocumentThumbnail: proxies /file/thumb when available
- querySearchDocuments: wired to /file/search
- renameDocument/deleteDocument via existing repo calls

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent 627c8928cb
commit 0e2d861959

@ -240,7 +240,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 - **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 - **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 - **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures - **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized) - Non-goals: iOS, tablet-first layouts (works, not optimized)

@ -38,8 +38,11 @@ CI runs `assembleDebug` on every PR.
`filesDir/offline/` and registers the entry (DataStore JSON); `filesDir/offline/` and registers the entry (DataStore JSON);
star icon in the top bar opens the offline list — open via star icon in the top bar opens the offline list — open via
FileProvider, re-download to refresh, remove to delete 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 ## Planned next
- DocumentsProvider - Quick-share tile, task/share notifications, local sync folder
- No iOS. Ever. - No iOS. Ever.

@ -47,5 +47,16 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<provider
android:name=".provider.CloudreveDocumentsProvider"
android:authorities="${applicationId}.documents"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application> </application>
</manifest> </manifest>

@ -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<out String>?): 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<out String>?): 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<out String>?,
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<out String>?,
): 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,
)
}
}
Loading…
Cancel
Save