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..bb87dd8f9 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 cfc42d68e..835b34631 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 @@ -12,11 +12,17 @@ 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 class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager + private val itemKinds = ConcurrentHashMap() + + private enum class ItemKind { FILE, FOLDER } override fun onCreate(): Boolean { authManager = InternxtAuthManager.create(context!!.applicationContext) @@ -57,8 +63,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { 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) } + api.getFolder(id)?.let { + itemKinds[id] = ItemKind.FOLDER + DocumentRowBuilder.folderRow(it) + } ?: api.getFile(id)?.let { + itemKinds[id] = ItemKind.FILE + DocumentRowBuilder.fileRow(it) + } } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") null @@ -86,9 +97,11 @@ class InternxtDocumentsProvider : DocumentsProvider() { try { paginate({ offset, size -> api.listFolderFolders(parent, offset, size) }) { + itemKinds[it.uuid] = ItemKind.FOLDER cursor.addDocumentRow(DocumentRowBuilder.folderRow(it)) } paginate({ offset, size -> api.listFolderFiles(parent, offset, size) }) { + itemKinds[it.uuid] = ItemKind.FILE cursor.addDocumentRow(DocumentRowBuilder.fileRow(it)) } Log.d(TAG, "queryChildDocuments parent=$parent rows=${cursor.count}") @@ -125,6 +138,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 5bf38fe13..8011463e1 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 0d74bd9b3..f64456bf0 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 2f8cdf2bd..52f88979d 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -228,7 +228,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());