diff --git a/ROADMAP.md b/ROADMAP.md index 82c71a48..541e4a83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 - **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, + ) + } +}