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..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 @@ -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 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), + 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..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 @@ -14,7 +14,9 @@ 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.Executors @@ -81,12 +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 { 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) } - } + fetchDocumentRow(api, decoded?.kind, uuid) } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") null @@ -96,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?, @@ -200,6 +208,131 @@ class InternxtDocumentsProvider : DocumentsProvider() { row.forEach { (column, value) -> builder.add(column, value) } } + override fun renameDocument(documentId: String, displayName: String): String? = + mutate("renameDocument", documentId) { api, kind, uuid -> + val parent = parentUuidOf(api, kind, uuid) + when (kind) { + DocumentId.Kind.FILE -> api.renameFile(uuid, displayName) + DocumentId.Kind.FOLDER -> api.renameFolder(uuid, displayName) + } + notifyEncodedParent(parent) { encoded -> + patchRowDisplayName(encoded, documentId, displayName) + } + null + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + 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 { + removeRow(it, sourceDocumentId) + notifyChildren(it) + } + invalidateChildren(targetParentDocumentId) + null + } + + override fun deleteDocument(documentId: String) { + 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) + } + } + } + + 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, "$op $documentId failed: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException(e.message) + } + } + + 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 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, 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?, 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());