From d722a748e616d41fbf816bccc72f266dc34d4ad1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 30 Apr 2026 10:41:38 -0400 Subject: [PATCH 1/2] feat(android): support rename, move, and trash in SAF picker - Override renameDocument, moveDocument, and deleteDocument in InternxtDocumentsProvider so SAF surfaces the corresponding row actions. - Surface FLAG_SUPPORTS_RENAME | FLAG_SUPPORTS_DELETE | FLAG_SUPPORTS_MOVE on file and folder rows; keep root and placeholder rows mutation-free. - Cache item kind (file vs folder) per UUID so mutations resolve their endpoint without an extra round-trip on the happy path. - Notify affected child-document URIs after each mutation so the picker refreshes without manual reload. - Fix InternxtApiClient.renameFile / renameFolder to match the SDK: PUT /{files|folders}/{uuid}/meta with { plainName }, void response. - Await clearCredentials in signOutThunk so the SAF root is hidden immediately after logout instead of lingering until documentsui refreshes. --- .../cloud/documents/DocumentRowBuilder.kt | 23 +++-- .../documents/InternxtDocumentsProvider.kt | 90 ++++++++++++++++++- .../cloud/documents/api/InternxtApiClient.kt | 18 ++-- .../cloud/documents/DocumentRowBuilderTest.kt | 11 ++- .../documents/api/InternxtApiClientTest.kt | 71 +++++++++++++++ src/store/slices/auth/index.ts | 2 +- 6 files changed, 194 insertions(+), 21 deletions(-) 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 f5eff52d8..532891397 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 @@ -8,13 +8,22 @@ import java.time.format.DateTimeParseException object DocumentRowBuilder { - private const val FOLDER_FLAGS = Document.FLAG_DIR_SUPPORTS_CREATE - private const val FILE_FLAGS = 0 + private const val MUTATION_FLAGS = + Document.FLAG_SUPPORTS_RENAME or + Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE - fun folderRow(folder: DriveFolder): Map = folderRow( - uuid = folder.uuid, - displayName = folder.plainName, - lastModified = parseIsoToMillis(folder.updatedAt), + private const val FOLDER_FLAGS_BASIC = Document.FLAG_DIR_SUPPORTS_CREATE + private const val FOLDER_FLAGS = FOLDER_FLAGS_BASIC or MUTATION_FLAGS + private const val FILE_FLAGS = MUTATION_FLAGS + + fun folderRow(folder: DriveFolder): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to folder.uuid, + Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, + Document.COLUMN_DISPLAY_NAME to folder.plainName, + Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(folder.updatedAt), + Document.COLUMN_FLAGS to FOLDER_FLAGS, + Document.COLUMN_SIZE to null, ) fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf( @@ -22,7 +31,7 @@ object DocumentRowBuilder { 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_FLAGS to FOLDER_FLAGS_BASIC, Document.COLUMN_SIZE to 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 41e2f8d55..3f35871c3 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 @@ -14,13 +14,19 @@ 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.api.model.TrashItem import com.internxt.cloud.documents.auth.InternxtAuthManager +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager + private val itemKinds = ConcurrentHashMap() + + private enum class ItemKind { FILE, FOLDER } private val loaderExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } @@ -82,10 +88,15 @@ class InternxtDocumentsProvider : DocumentsProvider() { val api = apiClient(op = "queryDocument") val row = if (api == null) null else try { when (decoded?.kind) { - DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let { DocumentRowBuilder.folderRow(it) } + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let { + itemKinds[id] = ItemKind.FOLDER + 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) } + } ?: api.getFile(uuid)?.let { + itemKinds[id] = ItemKind.FILE + DocumentRowBuilder.fileRow(it) + } } } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") @@ -200,6 +211,81 @@ class InternxtDocumentsProvider : DocumentsProvider() { row.forEach { (column, value) -> builder.add(column, value) } } + override fun renameDocument(documentId: String, displayName: String): String? { + val api = apiClient(op = "renameDocument") ?: throw FileNotFoundException("No auth") + val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") + val parentUuid: String? = try { + when (kind) { + ItemKind.FILE -> { + val parent = api.getFile(documentId)?.folderUuid + api.renameFile(documentId, displayName) + parent + } + ItemKind.FOLDER -> { + val parent = api.getFolder(documentId)?.parentUuid + api.renameFolder(documentId, displayName) + parent + } + } + } catch (e: InternxtApiException) { + Log.w(TAG, "renameDocument $documentId failed: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException(e.message) + } + parentUuid?.let { notifyChildren(it) } + return null + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + targetParentDocumentId: String + ): String? { + val api = apiClient(op = "moveDocument") ?: throw FileNotFoundException("No auth") + val kind = resolveKind(api, sourceDocumentId) ?: throw FileNotFoundException("Not found: $sourceDocumentId") + try { + when (kind) { + ItemKind.FILE -> api.moveFile(sourceDocumentId, targetParentDocumentId) + ItemKind.FOLDER -> api.moveFolder(sourceDocumentId, targetParentDocumentId) + } + } catch (e: InternxtApiException) { + Log.w(TAG, "moveDocument $sourceDocumentId failed: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException(e.message) + } + sourceParentDocumentId?.let { notifyChildren(it) } + notifyChildren(targetParentDocumentId) + return null + } + + override fun deleteDocument(documentId: String) { + val api = apiClient(op = "deleteDocument") ?: throw FileNotFoundException("No auth") + val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") + val parentUuid: String? = when (kind) { + ItemKind.FILE -> api.getFile(documentId)?.folderUuid + ItemKind.FOLDER -> api.getFolder(documentId)?.parentUuid + } + val trashType = if (kind == ItemKind.FILE) TrashItem.Type.FILE else TrashItem.Type.FOLDER + try { + api.sendToTrash(listOf(TrashItem(documentId, trashType))) + } catch (e: InternxtApiException) { + Log.w(TAG, "deleteDocument $documentId failed: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException(e.message) + } + itemKinds.remove(documentId) + parentUuid?.let { notifyChildren(it) } + } + + private fun resolveKind(api: InternxtApiClient, uuid: String): ItemKind? = + itemKinds[uuid] + ?: api.getFolder(uuid)?.let { itemKinds[uuid] = ItemKind.FOLDER; ItemKind.FOLDER } + ?: api.getFile(uuid)?.let { itemKinds[uuid] = ItemKind.FILE; ItemKind.FILE } + + private fun notifyChildren(parentUuid: String) { + context?.contentResolver?.notifyChange( + DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentUuid), + null + ) + } + override fun openDocument( documentId: String?, mode: String?, 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 e6d864fe1..bcdb2b41e 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 @@ -68,20 +68,20 @@ class InternxtApiClient( return parseFolder(executeApiRequest(req)) } - fun renameFile(fileUuid: String, newName: String): DriveFile { - val payload = JSONObject().put("name", newName) - val req = driveRequest(driveUrl("files/$fileUuid")) - .patch(payload.toString().toRequestBody(JSON)) + fun renameFile(fileUuid: String, newName: String) { + val payload = JSONObject().put("plainName", newName) + val req = driveRequest(driveUrl("files/$fileUuid/meta")) + .put(payload.toString().toRequestBody(JSON)) .build() - return parseFile(executeApiRequest(req)) + executeApiRequest(req) } - fun renameFolder(folderUuid: String, newName: String): DriveFolder { - val payload = JSONObject().put("name", newName) - val req = driveRequest(driveUrl("folders/$folderUuid")) + fun renameFolder(folderUuid: String, newName: String) { + val payload = JSONObject().put("plainName", newName) + val req = driveRequest(driveUrl("folders/$folderUuid/meta")) .put(payload.toString().toRequestBody(JSON)) .build() - return parseFolder(executeApiRequest(req)) + executeApiRequest(req) } fun moveFile(fileUuid: String, destinationFolderUuid: String): DriveFile { 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 97495ec8b..a38124c07 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 @@ -26,7 +26,11 @@ class DocumentRowBuilderTest { 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]) + val expectedFolderFlags = Document.FLAG_DIR_SUPPORTS_CREATE or + Document.FLAG_SUPPORTS_RENAME or + Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE + assertEquals(expectedFolderFlags, row[Document.COLUMN_FLAGS]) assertNull(row[Document.COLUMN_SIZE]) } @@ -50,7 +54,10 @@ class DocumentRowBuilderTest { 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]) + val expectedFileFlags = Document.FLAG_SUPPORTS_RENAME or + Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE + assertEquals(expectedFileFlags, row[Document.COLUMN_FLAGS]) assertEquals(102400L, row[Document.COLUMN_SIZE]) } 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 31a4f3681..a9fd59070 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 @@ -1,5 +1,6 @@ package com.internxt.cloud.documents.api +import com.internxt.cloud.documents.api.model.TrashItem import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy @@ -306,6 +307,76 @@ class InternxtApiClientTest { assertNull(client.getFile("missing-uuid")) } + @Test + fun renameFilePutsPlainNameToMetaEndpoint() { + enqueueJson("") + + client.renameFile("file-uuid-1", "renamed.pdf") + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/files/file-uuid-1/meta", recorded.path) + assertEquals("renamed.pdf", JSONObject(recorded.body.readUtf8()).getString("plainName")) + } + + @Test + fun renameFolderPutsPlainNameToMetaEndpoint() { + enqueueJson("") + + client.renameFolder("folder-uuid-1", "Renamed") + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/folders/folder-uuid-1/meta", recorded.path) + assertEquals("Renamed", JSONObject(recorded.body.readUtf8()).getString("plainName")) + } + + @Test + fun moveFilePatchesDestinationPayload() { + enqueueJson("""{"uuid":"file-uuid-1","folderUuid":"$PARENT_UUID"}""") + + client.moveFile("file-uuid-1", PARENT_UUID) + + val recorded = server.takeRequest() + assertEquals("PATCH", recorded.method) + assertEquals("/files/file-uuid-1", recorded.path) + assertEquals(PARENT_UUID, JSONObject(recorded.body.readUtf8()).getString("destinationFolder")) + } + + @Test + fun moveFolderPatchesDestinationPayload() { + enqueueJson("""{"uuid":"folder-uuid-1","parentUuid":"$PARENT_UUID"}""") + + client.moveFolder("folder-uuid-1", PARENT_UUID) + + val recorded = server.takeRequest() + assertEquals("PATCH", recorded.method) + assertEquals("/folders/folder-uuid-1", recorded.path) + assertEquals(PARENT_UUID, JSONObject(recorded.body.readUtf8()).getString("destinationFolder")) + } + + @Test + fun sendToTrashPostsItemsPayload() { + enqueueJson("") + + client.sendToTrash( + listOf( + TrashItem("file-uuid-1", TrashItem.Type.FILE), + TrashItem("folder-uuid-1", TrashItem.Type.FOLDER), + ) + ) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/storage/trash/add", recorded.path) + val items = JSONObject(recorded.body.readUtf8()).getJSONArray("items") + assertEquals(2, items.length()) + assertEquals("file-uuid-1", items.getJSONObject(0).getString("uuid")) + assertEquals("file", items.getJSONObject(0).getString("type")) + assertEquals("folder-uuid-1", items.getJSONObject(1).getString("uuid")) + assertEquals("folder", items.getJSONObject(1).getString("type")) + } + @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 7085e3d6b..0ea4f0a9e 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -240,7 +240,7 @@ export const signOutThunk = createAsyncThunk< const reason = payload.reason; authService.signout(reason).catch(errorService.reportError); drive.clear().catch(errorService.reportError); - clearCredentials().catch(errorService.reportError); + await clearCredentials().catch(errorService.reportError); dispatch(uiActions.resetState()); dispatch(authActions.resetState()); dispatch(driveActions.resetState()); From bb867880ba3747eaebb58439fca0e870a988cf29 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 13 May 2026 20:00:27 -0400 Subject: [PATCH 2/2] feat(documents): update folderRow to encode folder UUID and refactor document operations --- .../cloud/documents/DocumentRowBuilder.kt | 2 +- .../documents/InternxtDocumentsProvider.kt | 183 +++++++++++------- 2 files changed, 116 insertions(+), 69 deletions(-) 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 532891397..b8c3e4463 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 { private const val FILE_FLAGS = MUTATION_FLAGS fun folderRow(folder: DriveFolder): Map = mapOf( - Document.COLUMN_DOCUMENT_ID to folder.uuid, + Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFolder(folder.uuid), Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, Document.COLUMN_DISPLAY_NAME to folder.plainName, Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(folder.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 3f35871c3..b381f2258 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 @@ -18,15 +18,11 @@ import com.internxt.cloud.documents.api.model.TrashItem import com.internxt.cloud.documents.auth.InternxtAuthManager import java.io.FileNotFoundException import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager - private val itemKinds = ConcurrentHashMap() - - private enum class ItemKind { FILE, FOLDER } private val loaderExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } @@ -87,17 +83,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { val api = apiClient(op = "queryDocument") val row = if (api == null) null else try { - when (decoded?.kind) { - DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let { - itemKinds[id] = ItemKind.FOLDER - 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 { - itemKinds[id] = ItemKind.FILE - DocumentRowBuilder.fileRow(it) - } - } + fetchDocumentRow(api, decoded?.kind, uuid) } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") null @@ -107,6 +93,17 @@ class InternxtDocumentsProvider : DocumentsProvider() { return cursor } + private fun fetchDocumentRow( + api: InternxtApiClient, + kind: DocumentId.Kind?, + uuid: String, + ): Map? = when (kind) { + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let(DocumentRowBuilder::folderRow) + DocumentId.Kind.FILE -> api.getFile(uuid)?.let(DocumentRowBuilder::fileRow) + null -> api.getFolder(uuid)?.let(DocumentRowBuilder::folderRow) + ?: api.getFile(uuid)?.let(DocumentRowBuilder::fileRow) + } + override fun queryChildDocuments( parentDocumentId: String?, projection: Array?, @@ -211,81 +208,131 @@ class InternxtDocumentsProvider : DocumentsProvider() { row.forEach { (column, value) -> builder.add(column, value) } } - override fun renameDocument(documentId: String, displayName: String): String? { - val api = apiClient(op = "renameDocument") ?: throw FileNotFoundException("No auth") - val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") - val parentUuid: String? = try { + override fun renameDocument(documentId: String, displayName: String): String? = + mutate("renameDocument", documentId) { api, kind, uuid -> + val parent = parentUuidOf(api, kind, uuid) when (kind) { - ItemKind.FILE -> { - val parent = api.getFile(documentId)?.folderUuid - api.renameFile(documentId, displayName) - parent - } - ItemKind.FOLDER -> { - val parent = api.getFolder(documentId)?.parentUuid - api.renameFolder(documentId, displayName) - parent - } + DocumentId.Kind.FILE -> api.renameFile(uuid, displayName) + DocumentId.Kind.FOLDER -> api.renameFolder(uuid, displayName) } - } catch (e: InternxtApiException) { - Log.w(TAG, "renameDocument $documentId failed: ${e.javaClass.simpleName}: ${e.message}") - throw FileNotFoundException(e.message) + notifyEncodedParent(parent) { encoded -> + patchRowDisplayName(encoded, documentId, displayName) + } + null } - parentUuid?.let { notifyChildren(it) } - return null - } override fun moveDocument( sourceDocumentId: String, sourceParentDocumentId: String?, - targetParentDocumentId: String - ): String? { - val api = apiClient(op = "moveDocument") ?: throw FileNotFoundException("No auth") - val kind = resolveKind(api, sourceDocumentId) ?: throw FileNotFoundException("Not found: $sourceDocumentId") - try { - when (kind) { - ItemKind.FILE -> api.moveFile(sourceDocumentId, targetParentDocumentId) - ItemKind.FOLDER -> api.moveFolder(sourceDocumentId, targetParentDocumentId) - } - } catch (e: InternxtApiException) { - Log.w(TAG, "moveDocument $sourceDocumentId failed: ${e.javaClass.simpleName}: ${e.message}") - throw FileNotFoundException(e.message) + targetParentDocumentId: String, + ): String? = mutate("moveDocument", sourceDocumentId) { api, kind, uuid -> + val targetUuid = rawUuid(targetParentDocumentId) + when (kind) { + DocumentId.Kind.FILE -> api.moveFile(uuid, targetUuid) + DocumentId.Kind.FOLDER -> api.moveFolder(uuid, targetUuid) } - sourceParentDocumentId?.let { notifyChildren(it) } - notifyChildren(targetParentDocumentId) - return null + sourceParentDocumentId?.let { + removeRow(it, sourceDocumentId) + notifyChildren(it) + } + invalidateChildren(targetParentDocumentId) + null } override fun deleteDocument(documentId: String) { - val api = apiClient(op = "deleteDocument") ?: throw FileNotFoundException("No auth") - val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") - val parentUuid: String? = when (kind) { - ItemKind.FILE -> api.getFile(documentId)?.folderUuid - ItemKind.FOLDER -> api.getFolder(documentId)?.parentUuid + mutate("deleteDocument", documentId) { api, kind, uuid -> + val parent = parentUuidOf(api, kind, uuid) + api.sendToTrash(listOf(TrashItem(uuid, trashTypeOf(kind)))) + notifyEncodedParent(parent) { encoded -> + removeRow(encoded, documentId) + } } - val trashType = if (kind == ItemKind.FILE) TrashItem.Type.FILE else TrashItem.Type.FOLDER - try { - api.sendToTrash(listOf(TrashItem(documentId, trashType))) + } + + private inline fun mutate( + op: String, + documentId: String, + block: (api: InternxtApiClient, kind: DocumentId.Kind, uuid: String) -> R, + ): R { + val api = apiClient(op) ?: throw FileNotFoundException("No auth") + val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") + return try { + block(api, kind, rawUuid(documentId)) } catch (e: InternxtApiException) { - Log.w(TAG, "deleteDocument $documentId failed: ${e.javaClass.simpleName}: ${e.message}") + Log.w(TAG, "$op $documentId failed: ${e.javaClass.simpleName}: ${e.message}") throw FileNotFoundException(e.message) } - itemKinds.remove(documentId) - parentUuid?.let { notifyChildren(it) } } - private fun resolveKind(api: InternxtApiClient, uuid: String): ItemKind? = - itemKinds[uuid] - ?: api.getFolder(uuid)?.let { itemKinds[uuid] = ItemKind.FOLDER; ItemKind.FOLDER } - ?: api.getFile(uuid)?.let { itemKinds[uuid] = ItemKind.FILE; ItemKind.FILE } + private fun parentUuidOf(api: InternxtApiClient, kind: DocumentId.Kind, uuid: String): String? = + when (kind) { + DocumentId.Kind.FILE -> api.getFile(uuid)?.folderUuid + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.parentUuid + } + + private fun trashTypeOf(kind: DocumentId.Kind): TrashItem.Type = when (kind) { + DocumentId.Kind.FILE -> TrashItem.Type.FILE + DocumentId.Kind.FOLDER -> TrashItem.Type.FOLDER + } + + private inline fun notifyEncodedParent(rawParentUuid: String?, mutateCache: (encodedParent: String) -> Unit) { + rawParentUuid?.let { + val encoded = DocumentId.encodeFolder(it) + mutateCache(encoded) + notifyChildren(encoded) + } + } - private fun notifyChildren(parentUuid: String) { + private fun rawUuid(documentId: String): String = + DocumentId.decode(documentId)?.uuid ?: documentId + + private fun resolveKind(api: InternxtApiClient, documentId: String): DocumentId.Kind? { + DocumentId.decode(documentId)?.kind?.let { return it } + return api.getFolder(documentId)?.let { DocumentId.Kind.FOLDER } + ?: api.getFile(documentId)?.let { DocumentId.Kind.FILE } + } + + private fun notifyChildren(parentDocumentId: String) { context?.contentResolver?.notifyChange( - DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentUuid), - null + DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId), + null, ) } + private fun invalidateChildren(parentDocumentId: String) { + folderLoads.remove(parentDocumentId) + notifyChildren(parentDocumentId) + } + + private fun patchRowDisplayName(parentDocumentId: String, documentId: String, displayName: String) { + updateRows(parentDocumentId) { rows -> + val idx = rows.indexOfFirst { it[Document.COLUMN_DOCUMENT_ID] == documentId } + if (idx >= 0) rows[idx] = rows[idx] + (Document.COLUMN_DISPLAY_NAME to displayName) + } + } + + private fun removeRow(parentDocumentId: String, documentId: String) { + updateRows(parentDocumentId) { rows -> + rows.removeAll { it[Document.COLUMN_DOCUMENT_ID] == documentId } + } + } + + private inline fun updateRows( + parentDocumentId: String, + action: (MutableList>) -> Unit, + ) { + val load = folderLoads[parentDocumentId] ?: return + synchronized(load) { action(load.rows) } + } + + override fun refresh(uri: Uri, args: Bundle?, cancellationSignal: CancellationSignal?): Boolean { + val documentId = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { null } + Log.d(TAG, "refresh uri=$uri documentId=$documentId") + if (documentId == null) return false + invalidateChildren(documentId) + return true + } + override fun openDocument( documentId: String?, mode: String?,