From 600ee1da567c6a557aaccf7de70ff6aecec1e44b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 28 Apr 2026 22:55:12 -0400 Subject: [PATCH 1/2] feat(android): list folder contents in SAF documents provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement queryChildDocuments to paginate folders + files for a parent UUID - Implement queryDocument to resolve real metadata via folders/{uuid}/meta and files/{uuid}/meta, with a UUID-named placeholder fallback for offline / transient failures - Add InternxtApiClient.getFolder / getFile (404 → null) plus tests - Introduce DocumentRowBuilder (column-keyed rows) and MimeTypes (hand-rolled table + Android MimeTypeMap fallback) with tests - Surface InternxtApiException + null auth-config via Log.w so silent failures are no longer invisible - Fix auth slice to sync newToken (not token) to native credentials so the documents provider authenticates against drive endpoints --- .../cloud/documents/DocumentRowBuilder.kt | 46 ++++++++ .../documents/InternxtDocumentsProvider.kt | 85 +++++++++++++- .../com/internxt/cloud/documents/MimeTypes.kt | 45 ++++++++ .../cloud/documents/api/InternxtApiClient.kt | 10 ++ .../cloud/documents/DocumentRowBuilderTest.kt | 107 ++++++++++++++++++ .../internxt/cloud/documents/MimeTypesTest.kt | 47 ++++++++ .../documents/api/InternxtApiClientTest.kt | 43 +++++++ src/store/slices/auth/index.ts | 4 +- 8 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt new file mode 100644 index 000000000..a26aad6af --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt @@ -0,0 +1,46 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import java.time.Instant +import java.time.format.DateTimeParseException + +object DocumentRowBuilder { + + private const val FOLDER_FLAGS = Document.FLAG_DIR_SUPPORTS_CREATE + private const val FILE_FLAGS = 0 + + fun folderRow(folder: DriveFolder): Map = folderRow( + uuid = folder.uuid, + displayName = folder.plainName, + lastModified = parseIsoToMillis(folder.updatedAt), + ) + + fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to uuid, + Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, + Document.COLUMN_DISPLAY_NAME to displayName, + Document.COLUMN_LAST_MODIFIED to lastModified, + Document.COLUMN_FLAGS to FOLDER_FLAGS, + Document.COLUMN_SIZE to null, + ) + + fun fileRow(file: DriveFile): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to file.uuid, + Document.COLUMN_MIME_TYPE to MimeTypes.fromExtension(file.type), + Document.COLUMN_DISPLAY_NAME to file.plainName, + Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(file.updatedAt), + Document.COLUMN_FLAGS to FILE_FLAGS, + Document.COLUMN_SIZE to file.size, + ) + + internal fun parseIsoToMillis(iso: String?): Long? { + if (iso.isNullOrBlank()) return null + return try { + Instant.parse(iso).toEpochMilli() + } catch (_: DateTimeParseException) { + null + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt index cd337c892..642ae39c7 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -8,7 +8,10 @@ import android.provider.DocumentsContract import android.provider.DocumentsContract.Document import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider +import android.util.Log import com.internxt.cloud.R +import com.internxt.cloud.documents.api.InternxtApiClient +import com.internxt.cloud.documents.api.InternxtApiException import com.internxt.cloud.documents.auth.InternxtAuthManager class InternxtDocumentsProvider : DocumentsProvider() { @@ -39,14 +42,89 @@ class InternxtDocumentsProvider : DocumentsProvider() { return cursor } - override fun queryDocument(documentId: String?, projection: Array?): Cursor = - MatrixCursor(resolveDocumentProjection(projection)) + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val id = documentId ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildDocumentUri(AUTHORITY, id)) + Log.d(TAG, "queryDocument id=$id") + + if (id == authManager.authenticatedRootUuid()) { + cursor.addDocumentRow( + DocumentRowBuilder.folderRow(id, ctx.getString(R.string.documents_provider_label)) + ) + return cursor + } + + val api = apiClient(op = "queryDocument") + val row = if (api == null) null else try { + api.getFolder(id)?.let { DocumentRowBuilder.folderRow(it) } + ?: api.getFile(id)?.let { DocumentRowBuilder.fileRow(it) } + } catch (e: InternxtApiException) { + Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + + cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = id, displayName = id)) + return cursor + } override fun queryChildDocuments( parentDocumentId: String?, projection: Array?, sortOrder: String? - ): Cursor = MatrixCursor(resolveDocumentProjection(projection)) + ): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val parent = parentDocumentId ?: return cursor + cursor.setNotificationUri( + ctx.contentResolver, + DocumentsContract.buildChildDocumentsUri(AUTHORITY, parent) + ) + Log.d(TAG, "queryChildDocuments parent=$parent") + + val api = apiClient(op = "queryChildDocuments") ?: return cursor + + try { + paginate({ offset, size -> api.listFolderFolders(parent, offset, size) }) { + cursor.addDocumentRow(DocumentRowBuilder.folderRow(it)) + } + paginate({ offset, size -> api.listFolderFiles(parent, offset, size) }) { + cursor.addDocumentRow(DocumentRowBuilder.fileRow(it)) + } + Log.d(TAG, "queryChildDocuments parent=$parent rows=${cursor.count}") + } catch (e: InternxtApiException) { + Log.w(TAG, "queryChildDocuments parent=$parent failed: ${e.javaClass.simpleName}: ${e.message}") + return cursor + } + + return cursor + } + + private fun apiClient(op: String): InternxtApiClient? { + val cfg = authManager.loadAuthConfig() + if (cfg == null) { + Log.w(TAG, "$op: loadAuthConfig() returned null") + return null + } + return InternxtApiClient(cfg) + } + + private inline fun paginate(fetch: (offset: Int, size: Int) -> List, onItem: (T) -> Unit) { + val pageSize = InternxtApiClient.DEFAULT_PAGE_SIZE + var offset = 0 + while (true) { + val page = fetch(offset, pageSize) + page.forEach(onItem) + if (page.size < pageSize) break + offset += pageSize + } + } + + private fun MatrixCursor.addDocumentRow(row: Map) { + val builder = newRow() + row.forEach { (column, value) -> builder.add(column, value) } + } override fun openDocument( documentId: String?, @@ -65,6 +143,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { companion object { const val AUTHORITY = "com.internxt.cloud.documents" private const val ROOT_ID = "internxt-root" + private const val TAG = "InternxtDocsProvider" private val DEFAULT_ROOT_PROJECTION = arrayOf( Root.COLUMN_ROOT_ID, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt new file mode 100644 index 000000000..7fe17a7c8 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt @@ -0,0 +1,45 @@ +package com.internxt.cloud.documents + +import android.webkit.MimeTypeMap + +object MimeTypes { + + const val DEFAULT = "application/octet-stream" + + private val TABLE = mapOf( + "pdf" to "application/pdf", + "png" to "image/png", + "jpg" to "image/jpeg", + "jpeg" to "image/jpeg", + "gif" to "image/gif", + "webp" to "image/webp", + "mp4" to "video/mp4", + "mov" to "video/quicktime", + "mp3" to "audio/mpeg", + "wav" to "audio/wav", + "txt" to "text/plain", + "csv" to "text/csv", + "json" to "application/json", + "xml" to "application/xml", + "html" to "text/html", + "zip" to "application/zip", + "doc" to "application/msword", + "docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" to "application/vnd.ms-excel", + "xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" to "application/vnd.ms-powerpoint", + "pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + fun fromExtension(type: String?): String { + val key = type?.trim()?.lowercase().orEmpty() + if (key.isEmpty()) return DEFAULT + return TABLE[key] ?: lookupSystem(key) ?: DEFAULT + } + + private fun lookupSystem(extension: String): String? = try { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } catch (_: Throwable) { + null + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt index d97aedf52..5bf38fe13 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -29,6 +29,16 @@ class InternxtApiClient( fun listFolderFiles(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = listChildren(parentUuid, kind = "files", jsonKey = "files", offset, limit, ::parseFile) + fun getFolder(uuid: String): DriveFolder? = getMetaOrNull("folders/$uuid/meta", ::parseFolder) + + fun getFile(uuid: String): DriveFile? = getMetaOrNull("files/$uuid/meta", ::parseFile) + + private fun getMetaOrNull(path: String, parse: (JSONObject) -> T): T? = try { + parse(executeApiRequest(driveRequest(driveUrl(path)).get().build())) + } catch (_: InternxtApiException.NotFoundException) { + null + } + private fun listChildren( parentUuid: String, kind: String, diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt new file mode 100644 index 000000000..0d74bd9b3 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt @@ -0,0 +1,107 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DocumentRowBuilderTest { + + @Test + fun folderRowFields() { + val folder = DriveFolder( + uuid = "folder-uuid", + plainName = "Documents", + parentUuid = "parent-uuid", + bucket = null, + createdAt = null, + updatedAt = "2026-01-11T00:00:00.000Z", + ) + + val row = DocumentRowBuilder.folderRow(folder) + + assertEquals("folder-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Documents", row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + } + + @Test + fun fileRowFields() { + val file = DriveFile( + uuid = "file-uuid", + plainName = "report.pdf", + type = "pdf", + size = 102400L, + bucket = "bucket-id", + folderUuid = "parent-uuid", + createdAt = null, + updatedAt = "2026-01-11T00:00:00.000Z", + fileId = "file-id-1", + ) + + val row = DocumentRowBuilder.fileRow(file) + + assertEquals("file-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals("application/pdf", row[Document.COLUMN_MIME_TYPE]) + assertEquals("report.pdf", row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(0, row[Document.COLUMN_FLAGS]) + assertEquals(102400L, row[Document.COLUMN_SIZE]) + } + + @Test + fun fileRowUnknownExtensionFallsBackToOctetStream() { + val file = DriveFile( + uuid = "file-uuid", + plainName = "weird.xyz", + type = "xyz", + size = 0L, + bucket = null, + folderUuid = null, + createdAt = null, + updatedAt = null, + fileId = null, + ) + + val row = DocumentRowBuilder.fileRow(file) + + assertEquals("application/octet-stream", row[Document.COLUMN_MIME_TYPE]) + assertEquals("weird.xyz", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + } + + @Test + fun folderRowOverloadFields() { + val row = DocumentRowBuilder.folderRow("root-uuid", "Internxt Drive") + + assertEquals("root-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Internxt Drive", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + } + + @Test + fun parseIsoToMillisHandlesValidInput() { + assertEquals(1768089600000L, DocumentRowBuilder.parseIsoToMillis("2026-01-11T00:00:00.000Z")) + } + + @Test + fun parseIsoToMillisHandlesNullAndBlank() { + assertNull(DocumentRowBuilder.parseIsoToMillis(null)) + assertNull(DocumentRowBuilder.parseIsoToMillis("")) + assertNull(DocumentRowBuilder.parseIsoToMillis(" ")) + } + + @Test + fun parseIsoToMillisHandlesMalformed() { + assertNull(DocumentRowBuilder.parseIsoToMillis("not-a-date")) + assertNull(DocumentRowBuilder.parseIsoToMillis("2026-99-99")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt new file mode 100644 index 000000000..78b01faa2 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt @@ -0,0 +1,47 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MimeTypesTest { + + @Test + fun mapsKnownExtensions() { + assertEquals("application/pdf", MimeTypes.fromExtension("pdf")) + assertEquals("image/jpeg", MimeTypes.fromExtension("jpg")) + assertEquals("image/jpeg", MimeTypes.fromExtension("jpeg")) + assertEquals("image/png", MimeTypes.fromExtension("png")) + assertEquals("video/mp4", MimeTypes.fromExtension("mp4")) + assertEquals("audio/mpeg", MimeTypes.fromExtension("mp3")) + assertEquals("application/zip", MimeTypes.fromExtension("zip")) + assertEquals( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + MimeTypes.fromExtension("xlsx") + ) + } + + @Test + fun isCaseInsensitive() { + assertEquals("application/pdf", MimeTypes.fromExtension("PDF")) + assertEquals("image/jpeg", MimeTypes.fromExtension("JPG")) + assertEquals("image/jpeg", MimeTypes.fromExtension("Jpeg")) + } + + @Test + fun trimsWhitespace() { + assertEquals("application/pdf", MimeTypes.fromExtension(" pdf ")) + } + + @Test + fun unknownExtensionFallsBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("xyz")) + assertEquals("application/octet-stream", MimeTypes.fromExtension("xyz")) + } + + @Test + fun nullAndBlankFallBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(null)) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("")) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(" ")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt index b13d5947b..31a4f3681 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt @@ -263,6 +263,49 @@ class InternxtApiClientTest { assertEquals(9999999999L, file.size) } + @Test + fun getFolderHitsMetaEndpointAndReturnsParsedFolder() { + enqueueJson("""{"uuid":"folder-uuid-1","plainName":"Documents"}""") + + val folder = client.getFolder("folder-uuid-1") + + assertEquals("folder-uuid-1", folder?.uuid) + assertEquals("Documents", folder?.plainName) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/folders/folder-uuid-1/meta", recorded.path) + } + + @Test + fun getFolderReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFolder("missing-uuid")) + } + + @Test + fun getFileHitsMetaEndpointAndReturnsParsedFile() { + enqueueJson("""{"uuid":"file-uuid-1","plainName":"report.pdf","type":"pdf","size":102400}""") + + val file = client.getFile("file-uuid-1") + + assertEquals("file-uuid-1", file?.uuid) + assertEquals("report.pdf", file?.plainName) + assertEquals(102400L, file?.size) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/files/file-uuid-1/meta", recorded.path) + } + + @Test + fun getFileReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFile("missing-uuid")) + } + @Test fun serverErrorSurfacesAsApiError() { enqueueJson("""{"error":"boom"}""", code = 500) diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 853435d7a..7085e3d6b 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -147,7 +147,7 @@ export const signInThunk = createAsyncThunk< // Reset this, in case we logged out during the pull process await asyncStorageService.deleteItem(AsyncStorageKey.LastPhotoPulledDate); - await syncNativeCredentials(payload.token, userToSave); + await syncNativeCredentials(payload.newToken, userToSave); dispatch( authActions.setSignInData({ @@ -188,7 +188,7 @@ export const refreshTokensThunk = createAsyncThunk Date: Wed, 13 May 2026 17:49:24 -0400 Subject: [PATCH 2/2] feat(documents): implement encoding and decoding for folder and file IDs --- .../internxt/cloud/documents/DocumentId.kt | 20 +++ .../cloud/documents/DocumentRowBuilder.kt | 4 +- .../documents/InternxtDocumentsProvider.kt | 122 ++++++++++++++---- .../cloud/documents/api/InternxtApiClient.kt | 6 +- .../cloud/documents/DocumentRowBuilderTest.kt | 6 +- 5 files changed, 126 insertions(+), 32 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt new file mode 100644 index 000000000..1076c58be --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt @@ -0,0 +1,20 @@ +package com.internxt.cloud.documents + +object DocumentId { + + enum class Kind { FOLDER, FILE } + + data class Decoded(val kind: Kind, val uuid: String) + + private const val FOLDER_PREFIX = "f:" + private const val FILE_PREFIX = "d:" + + fun encodeFolder(uuid: String): String = FOLDER_PREFIX + uuid + fun encodeFile(uuid: String): String = FILE_PREFIX + uuid + + fun decode(id: String): Decoded? = when { + id.startsWith(FOLDER_PREFIX) -> Decoded(Kind.FOLDER, id.removePrefix(FOLDER_PREFIX)) + id.startsWith(FILE_PREFIX) -> Decoded(Kind.FILE, id.removePrefix(FILE_PREFIX)) + else -> null + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt index a26aad6af..f5eff52d8 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt @@ -18,7 +18,7 @@ object DocumentRowBuilder { ) fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf( - Document.COLUMN_DOCUMENT_ID to uuid, + Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFolder(uuid), Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, Document.COLUMN_DISPLAY_NAME to displayName, Document.COLUMN_LAST_MODIFIED to lastModified, @@ -27,7 +27,7 @@ object DocumentRowBuilder { ) fun fileRow(file: DriveFile): Map = mapOf( - Document.COLUMN_DOCUMENT_ID to file.uuid, + Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFile(file.uuid), Document.COLUMN_MIME_TYPE to MimeTypes.fromExtension(file.type), Document.COLUMN_DISPLAY_NAME to file.plainName, Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(file.updatedAt), diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt index 642ae39c7..41e2f8d55 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -2,6 +2,8 @@ package com.internxt.cloud.documents import android.database.Cursor import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle import android.os.CancellationSignal import android.os.ParcelFileDescriptor import android.provider.DocumentsContract @@ -13,11 +15,27 @@ import com.internxt.cloud.R import com.internxt.cloud.documents.api.InternxtApiClient import com.internxt.cloud.documents.api.InternxtApiException import com.internxt.cloud.documents.auth.InternxtAuthManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager + private val loaderExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } + } + + private val folderLoads = ConcurrentHashMap() + + private enum class LoadState { LOADING, DONE, ERROR } + + private class FolderLoad { + @Volatile var state: LoadState = LoadState.LOADING + @Volatile var errorMessage: String? = null + val rows = mutableListOf>() + } + override fun onCreate(): Boolean { val ctx = context ?: return false authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false @@ -29,11 +47,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { val ctx = context ?: return cursor cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) - val rootUuid = authManager.authenticatedRootUuid() ?: return cursor + val rootUuid = authManager.authenticatedRootUuid() + Log.d(TAG, "queryRoots: isLoggedIn=${authManager.isLoggedIn()} rootUuid=$rootUuid") + if (rootUuid == null) return cursor cursor.newRow().apply { add(Root.COLUMN_ROOT_ID, ROOT_ID) - add(Root.COLUMN_DOCUMENT_ID, rootUuid) + add(Root.COLUMN_DOCUMENT_ID, DocumentId.encodeFolder(rootUuid)) add(Root.COLUMN_TITLE, ctx.getString(R.string.documents_provider_label)) authManager.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) @@ -49,23 +69,30 @@ class InternxtDocumentsProvider : DocumentsProvider() { cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildDocumentUri(AUTHORITY, id)) Log.d(TAG, "queryDocument id=$id") - if (id == authManager.authenticatedRootUuid()) { + val decoded = DocumentId.decode(id) + val uuid = decoded?.uuid ?: id + + if (decoded?.kind == DocumentId.Kind.FOLDER && uuid == authManager.authenticatedRootUuid()) { cursor.addDocumentRow( - DocumentRowBuilder.folderRow(id, ctx.getString(R.string.documents_provider_label)) + DocumentRowBuilder.folderRow(uuid, ctx.getString(R.string.documents_provider_label)) ) return cursor } val api = apiClient(op = "queryDocument") val row = if (api == null) null else try { - api.getFolder(id)?.let { DocumentRowBuilder.folderRow(it) } - ?: api.getFile(id)?.let { DocumentRowBuilder.fileRow(it) } + when (decoded?.kind) { + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let { DocumentRowBuilder.folderRow(it) } + DocumentId.Kind.FILE -> api.getFile(uuid)?.let { DocumentRowBuilder.fileRow(it) } + null -> api.getFolder(uuid)?.let { DocumentRowBuilder.folderRow(it) } + ?: api.getFile(uuid)?.let { DocumentRowBuilder.fileRow(it) } + } } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") null } - cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = id, displayName = id)) + cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) return cursor } @@ -77,28 +104,75 @@ class InternxtDocumentsProvider : DocumentsProvider() { val cursor = MatrixCursor(resolveDocumentProjection(projection)) val ctx = context ?: return cursor val parent = parentDocumentId ?: return cursor - cursor.setNotificationUri( - ctx.contentResolver, - DocumentsContract.buildChildDocumentsUri(AUTHORITY, parent) - ) + val notifyUri = DocumentsContract.buildChildDocumentsUri(AUTHORITY, parent) + cursor.setNotificationUri(ctx.contentResolver, notifyUri) Log.d(TAG, "queryChildDocuments parent=$parent") - val api = apiClient(op = "queryChildDocuments") ?: return cursor + val decoded = DocumentId.decode(parent) + if (decoded?.kind == DocumentId.Kind.FILE) { + Log.w(TAG, "queryChildDocuments called with file id=$parent") + return cursor + } + val parentUuid = decoded?.uuid ?: parent - try { - paginate({ offset, size -> api.listFolderFolders(parent, offset, size) }) { - cursor.addDocumentRow(DocumentRowBuilder.folderRow(it)) + val load = folderLoads.computeIfAbsent(parent) { + FolderLoad().also { startBackgroundLoad(parentUuid, it, notifyUri) } + } + + val snapshot: List> + val state: LoadState + val errorMessage: String? + synchronized(load) { + snapshot = load.rows.toList() + state = load.state + errorMessage = load.errorMessage + } + snapshot.forEach { cursor.addDocumentRow(it) } + + cursor.extras = Bundle().apply { + putBoolean(DocumentsContract.EXTRA_LOADING, state == LoadState.LOADING) + if (state == LoadState.ERROR && errorMessage != null) { + putString(DocumentsContract.EXTRA_ERROR, errorMessage) } - paginate({ offset, size -> api.listFolderFiles(parent, offset, size) }) { - cursor.addDocumentRow(DocumentRowBuilder.fileRow(it)) + } + return cursor + } + + private fun startBackgroundLoad(parent: String, load: FolderLoad, notifyUri: Uri) { + loaderExecutor.execute { + val api = apiClient(op = "queryChildDocuments[bg]") + if (api == null) { + finishLoad(load, notifyUri, LoadState.ERROR, "Not authenticated") + return@execute + } + try { + streamPages({ offset, size -> api.listFolderFolders(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.folderRow(it) }) + } + streamPages({ offset, size -> api.listFolderFiles(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.fileRow(it) }) + } + finishLoad(load, notifyUri, LoadState.DONE, null) + Log.d(TAG, "queryChildDocuments parent=$parent loaded rows=${load.rows.size}") + } catch (e: InternxtApiException) { + Log.w(TAG, "queryChildDocuments parent=$parent failed: ${e.javaClass.simpleName}: ${e.message}") + finishLoad(load, notifyUri, LoadState.ERROR, e.message) } - Log.d(TAG, "queryChildDocuments parent=$parent rows=${cursor.count}") - } catch (e: InternxtApiException) { - Log.w(TAG, "queryChildDocuments parent=$parent failed: ${e.javaClass.simpleName}: ${e.message}") - return cursor } + } - return cursor + private fun appendRows(load: FolderLoad, notifyUri: Uri, rows: List>) { + if (rows.isEmpty()) return + synchronized(load) { load.rows.addAll(rows) } + context?.contentResolver?.notifyChange(notifyUri, null) + } + + private fun finishLoad(load: FolderLoad, notifyUri: Uri, state: LoadState, errorMessage: String?) { + synchronized(load) { + load.state = state + load.errorMessage = errorMessage + } + context?.contentResolver?.notifyChange(notifyUri, null) } private fun apiClient(op: String): InternxtApiClient? { @@ -110,12 +184,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { return InternxtApiClient(cfg) } - private inline fun paginate(fetch: (offset: Int, size: Int) -> List, onItem: (T) -> Unit) { + private inline fun streamPages(fetch: (offset: Int, size: Int) -> List, onPage: (List) -> Unit) { val pageSize = InternxtApiClient.DEFAULT_PAGE_SIZE var offset = 0 while (true) { val page = fetch(offset, pageSize) - page.forEach(onItem) + onPage(page) if (page.size < pageSize) break offset += pageSize } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt index 5bf38fe13..e6d864fe1 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -29,11 +29,11 @@ class InternxtApiClient( fun listFolderFiles(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = listChildren(parentUuid, kind = "files", jsonKey = "files", offset, limit, ::parseFile) - fun getFolder(uuid: String): DriveFolder? = getMetaOrNull("folders/$uuid/meta", ::parseFolder) + fun getFolder(uuid: String): DriveFolder? = getMeta("folders/$uuid/meta", ::parseFolder) - fun getFile(uuid: String): DriveFile? = getMetaOrNull("files/$uuid/meta", ::parseFile) + fun getFile(uuid: String): DriveFile? = getMeta("files/$uuid/meta", ::parseFile) - private fun getMetaOrNull(path: String, parse: (JSONObject) -> T): T? = try { + private fun getMeta(path: String, parse: (JSONObject) -> T): T? = try { parse(executeApiRequest(driveRequest(driveUrl(path)).get().build())) } catch (_: InternxtApiException.NotFoundException) { null diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt index 0d74bd9b3..97495ec8b 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt @@ -22,7 +22,7 @@ class DocumentRowBuilderTest { val row = DocumentRowBuilder.folderRow(folder) - assertEquals("folder-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals("f:folder-uuid", row[Document.COLUMN_DOCUMENT_ID]) assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) assertEquals("Documents", row[Document.COLUMN_DISPLAY_NAME]) assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) @@ -46,7 +46,7 @@ class DocumentRowBuilderTest { val row = DocumentRowBuilder.fileRow(file) - assertEquals("file-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals("d:file-uuid", row[Document.COLUMN_DOCUMENT_ID]) assertEquals("application/pdf", row[Document.COLUMN_MIME_TYPE]) assertEquals("report.pdf", row[Document.COLUMN_DISPLAY_NAME]) assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) @@ -79,7 +79,7 @@ class DocumentRowBuilderTest { fun folderRowOverloadFields() { val row = DocumentRowBuilder.folderRow("root-uuid", "Internxt Drive") - assertEquals("root-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals("f:root-uuid", row[Document.COLUMN_DOCUMENT_ID]) assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) assertEquals("Internxt Drive", row[Document.COLUMN_DISPLAY_NAME]) assertNull(row[Document.COLUMN_LAST_MODIFIED])