From 837ab2e4f0d1bc1948699407e7a2f22f6789bdec Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 16 Apr 2026 23:06:15 -0400 Subject: [PATCH 01/80] feat(android): scaffold DocumentsProvider for SAF file picker integration --- android/app/build.gradle | 3 + android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 12 +++ .../documents/InternxtDocumentsProvider.kt | 75 +++++++++++++++++++ android/app/src/main/res/values/strings.xml | 1 + 5 files changed, 92 insertions(+) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index c20f0e5dd..d0341fd50 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -192,4 +192,7 @@ dependencies { } else { implementation jscFlavor } + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("androidx.security:security-crypto:1.1.0-alpha06") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 551eb41da..e1ca91eda 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -12,3 +12,4 @@ -keep class com.facebook.react.turbomodule.** { *; } # Add any project specific keep options here: +-keep class com.internxt.cloud.documents.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 411d12ece..8ecfbf5bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,5 +59,17 @@ + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..668493ac6 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -0,0 +1,75 @@ +package com.internxt.cloud.documents + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract.Document +import android.provider.DocumentsContract.Root +import android.provider.DocumentsProvider +import com.internxt.cloud.R + +class InternxtDocumentsProvider : DocumentsProvider() { + + override fun onCreate(): Boolean = true + + override fun queryRoots(projection: Array?): Cursor { + val cursor = MatrixCursor(resolveRootProjection(projection)) + cursor.newRow().apply { + add(Root.COLUMN_ROOT_ID, ROOT_ID) + add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) + add(Root.COLUMN_TITLE, context?.getString(R.string.documents_provider_label)) + add(Root.COLUMN_FLAGS, 0) + add(Root.COLUMN_ICON, R.mipmap.ic_launcher) + } + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor = + MatrixCursor(resolveDocumentProjection(projection)) + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor = MatrixCursor(resolveDocumentProjection(projection)) + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + throw UnsupportedOperationException("Not implemented yet") + } + + private fun resolveRootProjection(projection: Array?): Array = + projection ?: DEFAULT_ROOT_PROJECTION + + private fun resolveDocumentProjection(projection: Array?): Array = + projection ?: DEFAULT_DOCUMENT_PROJECTION + + companion object { + const val AUTHORITY = "com.internxt.cloud.documents" + private const val ROOT_ID = "root" + private const val ROOT_DOCUMENT_ID = "root" + + private val DEFAULT_ROOT_PROJECTION = arrayOf( + Root.COLUMN_ROOT_ID, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE + ) + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f0d4ea285..ae178cb3c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ Internxt + Internxt Drive automatic 1.10.2 contain From f625885614cfe70f3b6b3caddac79e09b7ebb244 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 17 Apr 2026 10:00:47 -0400 Subject: [PATCH 02/80] chore(android): bump okhttp to 5.3.2 and security-crypto to 1.1.0 --- android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d0341fd50..e01487233 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -193,6 +193,6 @@ dependencies { implementation jscFlavor } - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("androidx.security:security-crypto:1.1.0") } From e202e2795373acccbf91a5dfc5b71100e01e01c6 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:42:34 -0400 Subject: [PATCH 03/80] feat(android): add native InternxtApiClient for DocumentsProvider Adds a pure-Kotlin HTTP client that talks directly to the Drive and Bridge APIs so the SAF DocumentsProvider can fetch folder contents and download links without the React Native bridge. Covers list/create/rename/move/trash on Drive (Bearer auth) and getDownloadLinks on Bridge (Basic auth with sha256(userId).hex derivation). Typed errors for 401/404/other/network; MockWebServer tests for parsing, auth headers, gateway-required headers, and error mapping. --- android/app/build.gradle | 4 + .../cloud/documents/api/AuthConfig.kt | 12 + .../cloud/documents/api/InternxtApiClient.kt | 216 ++++++++++++++++++ .../documents/api/InternxtApiException.kt | 10 + .../documents/api/model/DownloadLinks.kt | 9 + .../cloud/documents/api/model/DriveFile.kt | 13 ++ .../cloud/documents/api/model/DriveFolder.kt | 10 + .../cloud/documents/api/model/Shard.kt | 8 + .../cloud/documents/api/model/TrashItem.kt | 11 + .../cloud/documents/crypto/HashUtil.kt | 21 ++ .../documents/api/InternxtApiClientTest.kt | 183 +++++++++++++++ .../cloud/documents/crypto/HashUtilTest.kt | 30 +++ 12 files changed, 527 insertions(+) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/DownloadLinks.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFile.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFolder.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/Shard.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/TrashItem.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/crypto/HashUtil.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/crypto/HashUtilTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index e01487233..7b47b8286 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -195,4 +195,8 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("androidx.security:security-crypto:1.1.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") + testImplementation("org.json:json:20240303") } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt new file mode 100644 index 000000000..15c9a3de0 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt @@ -0,0 +1,12 @@ +package com.internxt.cloud.documents.api + +data class AuthConfig( + val driveBaseUrl: String, + val bridgeBaseUrl: String, + val bearerToken: String, + val bridgeUser: String, + val userId: String, + val clientName: String = "drive-mobile", + val clientVersion: String = "v1.9.0", + val desktopToken: String? = 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 new file mode 100644 index 000000000..61ee3272c --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -0,0 +1,216 @@ +package com.internxt.cloud.documents.api + +import com.internxt.cloud.documents.api.model.DownloadLinks +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import com.internxt.cloud.documents.api.model.Shard +import com.internxt.cloud.documents.api.model.TrashItem +import com.internxt.cloud.documents.crypto.HashUtil +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException +import java.util.Base64 +import java.util.concurrent.TimeUnit + +class InternxtApiClient( + private val config: AuthConfig, + private val client: OkHttpClient = defaultClient() +) { + + fun listFolderFolders(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = + listChildren(parentUuid, kind = "folders", jsonKey = "folders", offset, limit, ::parseFolder) + + fun listFolderFiles(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = + listChildren(parentUuid, kind = "files", jsonKey = "files", offset, limit, ::parseFile) + + private fun listChildren( + parentUuid: String, + kind: String, + jsonKey: String, + offset: Int, + limit: Int, + parse: (JSONObject) -> T + ): List { + val url = driveUrl("folders/content/$parentUuid/$kind") + .newBuilder() + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("limit", limit.toString()) + .addQueryParameter("sort", "plainName") + .addQueryParameter("order", "ASC") + .build() + val body = execute(driveRequest(url).get().build()) + return body.optJSONArray(jsonKey).orEmpty().map(parse) + } + + fun createFolder(parentUuid: String, plainName: String): DriveFolder { + val payload = JSONObject() + .put("plainName", plainName) + .put("parentFolderUuid", parentUuid) + val req = driveRequest(driveUrl("folders")) + .post(payload.toString().toRequestBody(JSON)) + .build() + return parseFolder(execute(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)) + .build() + return parseFile(execute(req)) + } + + fun renameFolder(folderUuid: String, newName: String): DriveFolder { + val payload = JSONObject().put("name", newName) + val req = driveRequest(driveUrl("folders/$folderUuid")) + .put(payload.toString().toRequestBody(JSON)) + .build() + return parseFolder(execute(req)) + } + + fun moveFile(fileUuid: String, destinationFolderUuid: String): DriveFile { + val payload = JSONObject().put("destinationFolder", destinationFolderUuid) + val req = driveRequest(driveUrl("files/$fileUuid")) + .patch(payload.toString().toRequestBody(JSON)) + .build() + return parseFile(execute(req)) + } + + fun moveFolder(folderUuid: String, destinationFolderUuid: String): DriveFolder { + val payload = JSONObject().put("destinationFolder", destinationFolderUuid) + val req = driveRequest(driveUrl("folders/$folderUuid")) + .patch(payload.toString().toRequestBody(JSON)) + .build() + return parseFolder(execute(req)) + } + + fun sendToTrash(items: List) { + val jsonItems = JSONArray() + for (item in items) { + jsonItems.put(JSONObject().put("uuid", item.uuid).put("type", item.type.wire)) + } + val payload = JSONObject().put("items", jsonItems) + val req = driveRequest(driveUrl("storage/trash/add")) + .post(payload.toString().toRequestBody(JSON)) + .build() + execute(req) + } + + fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { + val url = bridgeUrl("buckets/$bucketId/files/$fileId/mirrors") + val body = execute(bridgeRequest(url).get().build()) + return parseDownloadLinks(body) + } + + private fun driveRequest(url: okhttp3.HttpUrl): Request.Builder = + baseRequest(url).header("Authorization", "Bearer ${config.bearerToken}") + + private fun bridgeRequest(url: okhttp3.HttpUrl): Request.Builder { + val pass = HashUtil.deriveBridgePass(config.userId) + val basic = Base64.getEncoder().encodeToString("${config.bridgeUser}:$pass".toByteArray(Charsets.UTF_8)) + return baseRequest(url).header("Authorization", "Basic $basic") + } + + private fun baseRequest(url: okhttp3.HttpUrl): Request.Builder { + val builder = Request.Builder() + .url(url) + .header("internxt-client", config.clientName) + .header("internxt-version", config.clientVersion) + config.desktopToken?.let { builder.header("x-internxt-desktop-header", it) } + return builder + } + + private fun driveUrl(path: String) = "${config.driveBaseUrl.trimEnd('/')}/$path".toHttpUrl() + private fun bridgeUrl(path: String) = "${config.bridgeBaseUrl.trimEnd('/')}/$path".toHttpUrl() + + private fun execute(request: Request): JSONObject { + val response: Response = try { + client.newCall(request).execute() + } catch (e: IOException) { + throw InternxtApiException.NetworkException(e) + } + response.use { resp -> + val bodyStr = resp.body?.string().orEmpty() + when (resp.code) { + in 200..299 -> return if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) + 401 -> throw InternxtApiException.UnauthorizedException() + 404 -> throw InternxtApiException.NotFoundException() + else -> throw InternxtApiException.ApiError(resp.code, bodyStr) + } + } + } + + private fun parseFolder(obj: JSONObject): DriveFolder = DriveFolder( + uuid = obj.getString("uuid"), + plainName = obj.optString("plainName"), + parentUuid = obj.optStringOrNull("parentUuid"), + bucket = obj.optStringOrNull("bucket"), + createdAt = obj.optStringOrNull("createdAt"), + updatedAt = obj.optStringOrNull("updatedAt") + ) + + private fun parseFile(obj: JSONObject): DriveFile = DriveFile( + uuid = obj.getString("uuid"), + plainName = obj.optString("plainName"), + type = obj.optStringOrNull("type"), + size = obj.optLongFlexible("size"), + bucket = obj.optStringOrNull("bucket"), + folderUuid = obj.optStringOrNull("folderUuid"), + createdAt = obj.optStringOrNull("createdAt"), + updatedAt = obj.optStringOrNull("updatedAt"), + fileId = obj.optStringOrNull("fileId") + ) + + private fun parseDownloadLinks(obj: JSONObject): DownloadLinks { + val shardsJson = obj.optJSONArray("shards") ?: JSONArray() + val shards = shardsJson.map { + Shard( + index = it.optInt("index"), + size = it.optLongFlexible("size"), + hash = it.optString("hash"), + url = it.optString("url") + ) + } + return DownloadLinks( + bucket = obj.optString("bucket"), + index = obj.optString("index"), + size = obj.optLongFlexible("size"), + version = obj.optInt("version", 1), + shards = shards + ) + } + + companion object { + const val DEFAULT_PAGE_SIZE = 50 + + private val JSON = "application/json; charset=utf-8".toMediaType() + + private fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } +} + +private fun JSONArray?.orEmpty(): JSONArray = this ?: JSONArray() + +private inline fun JSONArray.map(transform: (JSONObject) -> T): List { + val out = ArrayList(length()) + for (i in 0 until length()) out.add(transform(getJSONObject(i))) + return out +} + +private fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + +private fun JSONObject.optLongFlexible(key: String): Long = when (val v = opt(key)) { + is Number -> v.toLong() + is String -> v.toLongOrNull() ?: 0L + else -> 0L +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt new file mode 100644 index 000000000..2b4a39965 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt @@ -0,0 +1,10 @@ +package com.internxt.cloud.documents.api + +import java.io.IOException + +sealed class InternxtApiException(message: String, cause: Throwable? = null) : IOException(message, cause) { + class UnauthorizedException(message: String = "401 Unauthorized") : InternxtApiException(message) + class NotFoundException(message: String = "404 Not Found") : InternxtApiException(message) + class ApiError(val code: Int, val body: String?) : InternxtApiException("HTTP $code: ${body ?: ""}") + class NetworkException(cause: Throwable) : InternxtApiException("Network error", cause) +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/DownloadLinks.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DownloadLinks.kt new file mode 100644 index 000000000..2f600012f --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DownloadLinks.kt @@ -0,0 +1,9 @@ +package com.internxt.cloud.documents.api.model + +data class DownloadLinks( + val bucket: String, + val index: String, + val size: Long, + val version: Int, + val shards: List +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFile.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFile.kt new file mode 100644 index 000000000..88f83cc49 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFile.kt @@ -0,0 +1,13 @@ +package com.internxt.cloud.documents.api.model + +data class DriveFile( + val uuid: String, + val plainName: String, + val type: String?, + val size: Long, + val bucket: String?, + val folderUuid: String?, + val createdAt: String?, + val updatedAt: String?, + val fileId: String? +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFolder.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFolder.kt new file mode 100644 index 000000000..53f6c7298 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/DriveFolder.kt @@ -0,0 +1,10 @@ +package com.internxt.cloud.documents.api.model + +data class DriveFolder( + val uuid: String, + val plainName: String, + val parentUuid: String?, + val bucket: String?, + val createdAt: String?, + val updatedAt: String? +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/Shard.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/Shard.kt new file mode 100644 index 000000000..f98354bbf --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/Shard.kt @@ -0,0 +1,8 @@ +package com.internxt.cloud.documents.api.model + +data class Shard( + val index: Int, + val size: Long, + val hash: String, + val url: String +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/TrashItem.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/TrashItem.kt new file mode 100644 index 000000000..7c5e9f284 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/TrashItem.kt @@ -0,0 +1,11 @@ +package com.internxt.cloud.documents.api.model + +data class TrashItem( + val uuid: String, + val type: Type +) { + enum class Type(val wire: String) { + FILE("file"), + FOLDER("folder"); + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/HashUtil.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/HashUtil.kt new file mode 100644 index 000000000..dbf3018c4 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/HashUtil.kt @@ -0,0 +1,21 @@ +package com.internxt.cloud.documents.crypto + +import java.security.MessageDigest + +object HashUtil { + + fun deriveBridgePass(userId: String): String = sha256Hex(userId.toByteArray(Charsets.UTF_8)) + + fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + val sb = StringBuilder(digest.size * 2) + for (b in digest) { + val v = b.toInt() and 0xff + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0f]) + } + return sb.toString() + } + + private val HEX = "0123456789abcdef".toCharArray() +} 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 new file mode 100644 index 000000000..391d41aca --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt @@ -0,0 +1,183 @@ +package com.internxt.cloud.documents.api + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class InternxtApiClientTest { + + private lateinit var server: MockWebServer + private lateinit var client: InternxtApiClient + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + val base = server.url("/").toString().trimEnd('/') + client = InternxtApiClient( + AuthConfig( + driveBaseUrl = base, + bridgeBaseUrl = base, + bearerToken = "test-token", + bridgeUser = "user@example.com", + userId = "1234567890" + ) + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun listFolderFilesParsesResponseAndSendsBearerAndQuery() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "files": [ + { + "uuid": "file-uuid-1", + "plainName": "report.pdf", + "type": "pdf", + "size": 102400, + "bucket": "bucket-id", + "folderUuid": "parent-uuid", + "createdAt": "2026-01-10T00:00:00.000Z", + "updatedAt": "2026-01-11T00:00:00.000Z", + "fileId": "file-id-1" + }, + { + "uuid": "file-uuid-2", + "plainName": "photo.jpg", + "type": "jpg", + "size": 2048, + "bucket": "bucket-id", + "folderUuid": "parent-uuid", + "createdAt": "2026-01-12T00:00:00.000Z", + "updatedAt": "2026-01-12T00:00:00.000Z", + "fileId": "file-id-2" + } + ] + } + """.trimIndent() + ) + ) + + val files = client.listFolderFiles("parent-uuid") + + assertEquals(2, files.size) + val first = files[0] + assertEquals("file-uuid-1", first.uuid) + assertEquals("report.pdf", first.plainName) + assertEquals("pdf", first.type) + assertEquals(102400L, first.size) + assertEquals("bucket-id", first.bucket) + assertEquals("parent-uuid", first.folderUuid) + assertEquals("2026-01-10T00:00:00.000Z", first.createdAt) + assertEquals("file-id-1", first.fileId) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("Bearer test-token", recorded.getHeader("Authorization")) + assertEquals( + "/folders/content/parent-uuid/files?offset=0&limit=50&sort=plainName&order=ASC", + recorded.path + ) + } + + @Test + fun unauthorizedResponseSurfacesAsUnauthorizedException() { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"Invalid token"}""")) + + val ex = assertThrows(InternxtApiException.UnauthorizedException::class.java) { + client.listFolderFiles("parent-uuid") + } + assertNotNull(ex.message) + } + + @Test + fun notFoundResponseSurfacesAsNotFoundException() { + server.enqueue(MockResponse().setResponseCode(404).setBody("")) + + assertThrows(InternxtApiException.NotFoundException::class.java) { + client.listFolderFiles("missing-uuid") + } + } + + @Test + fun socketDisconnectSurfacesAsNetworkException() { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + assertThrows(InternxtApiException.NetworkException::class.java) { + client.listFolderFiles("parent-uuid") + } + } + + @Test + fun driveRequestsIncludeGatewayHeaders() { + val base = server.url("/").toString().trimEnd('/') + val clientWithHeaders = InternxtApiClient( + AuthConfig( + driveBaseUrl = base, + bridgeBaseUrl = base, + bearerToken = "test-token", + bridgeUser = "user@example.com", + userId = "1234567890", + clientName = "drive-mobile", + clientVersion = "v1.9.0", + desktopToken = "desktop-token-xyz" + ) + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"files":[]}""")) + + clientWithHeaders.listFolderFiles("parent-uuid") + + val recorded = server.takeRequest() + assertEquals("drive-mobile", recorded.getHeader("internxt-client")) + assertEquals("v1.9.0", recorded.getHeader("internxt-version")) + assertEquals("desktop-token-xyz", recorded.getHeader("x-internxt-desktop-header")) + } + + @Test + fun getDownloadLinksUsesBasicAuthWithDerivedBridgePass() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "bucket": "bucket-id", + "index": "idx", + "size": 1024, + "version": 2, + "shards": [ + {"index":0,"size":512,"hash":"aa","url":"https://shard/0"}, + {"index":1,"size":512,"hash":"bb","url":"https://shard/1"} + ] + } + """.trimIndent() + ) + ) + + val links = client.getDownloadLinks("bucket-id", "file-id-1") + + assertEquals(2, links.shards.size) + assertEquals("https://shard/0", links.shards[0].url) + assertEquals(512L, links.shards[0].size) + + val recorded = server.takeRequest() + val auth = recorded.getHeader("Authorization") ?: error("missing auth") + assertEquals( + "Basic " + java.util.Base64.getEncoder().encodeToString( + "user@example.com:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" + .toByteArray(Charsets.UTF_8) + ), + auth + ) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/HashUtilTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/HashUtilTest.kt new file mode 100644 index 000000000..c54b4e317 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/HashUtilTest.kt @@ -0,0 +1,30 @@ +package com.internxt.cloud.documents.crypto + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HashUtilTest { + + @Test + fun derivesSha256HexMatchingJsReference() { + assertEquals( + "c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646", + HashUtil.deriveBridgePass("1234567890") + ) + } + + @Test + fun derivesSha256HexForUuidShapedUserId() { + assertEquals( + "70f333dce10c05a12f6b6f372aa31a182d1e6a8d38d2041c94c9814606a653fe", + HashUtil.deriveBridgePass("79a88429-b45a-4ae7-90f1-c351b6882670") + ) + } + + @Test + fun producesLowercaseHex() { + val hex = HashUtil.deriveBridgePass("abc") + assertEquals(hex.lowercase(), hex) + assertEquals(64, hex.length) + } +} From d2019709af0f3f8d7d0e2f8914c12aa040153434 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:49:18 -0400 Subject: [PATCH 04/80] test(android): streamline InternxtApiClient tests --- .../documents/api/InternxtApiClientTest.kt | 98 +++++++------------ 1 file changed, 33 insertions(+), 65 deletions(-) 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 391d41aca..27cf3b6c9 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 @@ -5,7 +5,6 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test @@ -25,7 +24,8 @@ class InternxtApiClientTest { bridgeBaseUrl = base, bearerToken = "test-token", bridgeUser = "user@example.com", - userId = "1234567890" + userId = "1234567890", + desktopToken = "desktop-token-xyz" ) ) } @@ -36,7 +36,7 @@ class InternxtApiClientTest { } @Test - fun listFolderFilesParsesResponseAndSendsBearerAndQuery() { + fun listFolderFilesParsesResponseFields() { server.enqueue( MockResponse().setResponseCode(200).setBody( """ @@ -52,17 +52,6 @@ class InternxtApiClientTest { "createdAt": "2026-01-10T00:00:00.000Z", "updatedAt": "2026-01-11T00:00:00.000Z", "fileId": "file-id-1" - }, - { - "uuid": "file-uuid-2", - "plainName": "photo.jpg", - "type": "jpg", - "size": 2048, - "bucket": "bucket-id", - "folderUuid": "parent-uuid", - "createdAt": "2026-01-12T00:00:00.000Z", - "updatedAt": "2026-01-12T00:00:00.000Z", - "fileId": "file-id-2" } ] } @@ -72,39 +61,48 @@ class InternxtApiClientTest { val files = client.listFolderFiles("parent-uuid") - assertEquals(2, files.size) - val first = files[0] - assertEquals("file-uuid-1", first.uuid) - assertEquals("report.pdf", first.plainName) - assertEquals("pdf", first.type) - assertEquals(102400L, first.size) - assertEquals("bucket-id", first.bucket) - assertEquals("parent-uuid", first.folderUuid) - assertEquals("2026-01-10T00:00:00.000Z", first.createdAt) - assertEquals("file-id-1", first.fileId) + assertEquals(1, files.size) + val file = files[0] + assertEquals("file-uuid-1", file.uuid) + assertEquals("report.pdf", file.plainName) + assertEquals("pdf", file.type) + assertEquals(102400L, file.size) + assertEquals("bucket-id", file.bucket) + assertEquals("parent-uuid", file.folderUuid) + assertEquals("2026-01-10T00:00:00.000Z", file.createdAt) + assertEquals("file-id-1", file.fileId) + } + + @Test + fun listFolderFilesBuildsAuthenticatedDriveRequest() { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"files":[]}""")) + + client.listFolderFiles("parent-uuid") val recorded = server.takeRequest() assertEquals("GET", recorded.method) - assertEquals("Bearer test-token", recorded.getHeader("Authorization")) assertEquals( "/folders/content/parent-uuid/files?offset=0&limit=50&sort=plainName&order=ASC", recorded.path ) + assertEquals("Bearer test-token", recorded.getHeader("Authorization")) + assertEquals("drive-mobile", recorded.getHeader("internxt-client")) + assertEquals("v1.9.0", recorded.getHeader("internxt-version")) + assertEquals("desktop-token-xyz", recorded.getHeader("x-internxt-desktop-header")) } @Test fun unauthorizedResponseSurfacesAsUnauthorizedException() { - server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"Invalid token"}""")) + server.enqueue(MockResponse().setResponseCode(401)) - val ex = assertThrows(InternxtApiException.UnauthorizedException::class.java) { + assertThrows(InternxtApiException.UnauthorizedException::class.java) { client.listFolderFiles("parent-uuid") } - assertNotNull(ex.message) } @Test fun notFoundResponseSurfacesAsNotFoundException() { - server.enqueue(MockResponse().setResponseCode(404).setBody("")) + server.enqueue(MockResponse().setResponseCode(404)) assertThrows(InternxtApiException.NotFoundException::class.java) { client.listFolderFiles("missing-uuid") @@ -120,31 +118,6 @@ class InternxtApiClientTest { } } - @Test - fun driveRequestsIncludeGatewayHeaders() { - val base = server.url("/").toString().trimEnd('/') - val clientWithHeaders = InternxtApiClient( - AuthConfig( - driveBaseUrl = base, - bridgeBaseUrl = base, - bearerToken = "test-token", - bridgeUser = "user@example.com", - userId = "1234567890", - clientName = "drive-mobile", - clientVersion = "v1.9.0", - desktopToken = "desktop-token-xyz" - ) - ) - server.enqueue(MockResponse().setResponseCode(200).setBody("""{"files":[]}""")) - - clientWithHeaders.listFolderFiles("parent-uuid") - - val recorded = server.takeRequest() - assertEquals("drive-mobile", recorded.getHeader("internxt-client")) - assertEquals("v1.9.0", recorded.getHeader("internxt-version")) - assertEquals("desktop-token-xyz", recorded.getHeader("x-internxt-desktop-header")) - } - @Test fun getDownloadLinksUsesBasicAuthWithDerivedBridgePass() { server.enqueue( @@ -156,8 +129,7 @@ class InternxtApiClientTest { "size": 1024, "version": 2, "shards": [ - {"index":0,"size":512,"hash":"aa","url":"https://shard/0"}, - {"index":1,"size":512,"hash":"bb","url":"https://shard/1"} + {"index":0,"size":512,"hash":"aa","url":"https://shard/0"} ] } """.trimIndent() @@ -166,18 +138,14 @@ class InternxtApiClientTest { val links = client.getDownloadLinks("bucket-id", "file-id-1") - assertEquals(2, links.shards.size) + assertEquals(1, links.shards.size) assertEquals("https://shard/0", links.shards[0].url) assertEquals(512L, links.shards[0].size) val recorded = server.takeRequest() - val auth = recorded.getHeader("Authorization") ?: error("missing auth") - assertEquals( - "Basic " + java.util.Base64.getEncoder().encodeToString( - "user@example.com:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" - .toByteArray(Charsets.UTF_8) - ), - auth - ) + val expectedPass = "c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" + val expectedAuth = "Basic " + java.util.Base64.getEncoder() + .encodeToString("user@example.com:$expectedPass".toByteArray(Charsets.UTF_8)) + assertEquals(expectedAuth, recorded.getHeader("Authorization")) } } From 660a1a85ac7423d0b06218ac3ba74b1218a7e018 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:53:01 -0400 Subject: [PATCH 05/80] refactor(android): extract PARENT_UUID constant in InternxtApiClientTest --- .../documents/api/InternxtApiClientTest.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 27cf3b6c9..2aee71aa0 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 @@ -14,6 +14,10 @@ class InternxtApiClientTest { private lateinit var server: MockWebServer private lateinit var client: InternxtApiClient + companion object { + private const val PARENT_UUID = "parent-uuid" + } + @Before fun setUp() { server = MockWebServer().apply { start() } @@ -48,7 +52,7 @@ class InternxtApiClientTest { "type": "pdf", "size": 102400, "bucket": "bucket-id", - "folderUuid": "parent-uuid", + "folderUuid": "$PARENT_UUID", "createdAt": "2026-01-10T00:00:00.000Z", "updatedAt": "2026-01-11T00:00:00.000Z", "fileId": "file-id-1" @@ -59,7 +63,7 @@ class InternxtApiClientTest { ) ) - val files = client.listFolderFiles("parent-uuid") + val files = client.listFolderFiles(PARENT_UUID) assertEquals(1, files.size) val file = files[0] @@ -68,7 +72,7 @@ class InternxtApiClientTest { assertEquals("pdf", file.type) assertEquals(102400L, file.size) assertEquals("bucket-id", file.bucket) - assertEquals("parent-uuid", file.folderUuid) + assertEquals(PARENT_UUID, file.folderUuid) assertEquals("2026-01-10T00:00:00.000Z", file.createdAt) assertEquals("file-id-1", file.fileId) } @@ -77,12 +81,12 @@ class InternxtApiClientTest { fun listFolderFilesBuildsAuthenticatedDriveRequest() { server.enqueue(MockResponse().setResponseCode(200).setBody("""{"files":[]}""")) - client.listFolderFiles("parent-uuid") + client.listFolderFiles(PARENT_UUID) val recorded = server.takeRequest() assertEquals("GET", recorded.method) assertEquals( - "/folders/content/parent-uuid/files?offset=0&limit=50&sort=plainName&order=ASC", + "/folders/content/$PARENT_UUID/files?offset=0&limit=50&sort=plainName&order=ASC", recorded.path ) assertEquals("Bearer test-token", recorded.getHeader("Authorization")) @@ -96,7 +100,7 @@ class InternxtApiClientTest { server.enqueue(MockResponse().setResponseCode(401)) assertThrows(InternxtApiException.UnauthorizedException::class.java) { - client.listFolderFiles("parent-uuid") + client.listFolderFiles(PARENT_UUID) } } @@ -114,7 +118,7 @@ class InternxtApiClientTest { server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) assertThrows(InternxtApiException.NetworkException::class.java) { - client.listFolderFiles("parent-uuid") + client.listFolderFiles(PARENT_UUID) } } From 7a753021a7ebe7f4c5fe35b003d259003d31c4c0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 23 Apr 2026 23:50:26 -0400 Subject: [PATCH 06/80] refactor(android): source API client identity from package.json - Wire AuthConfig.clientName/clientVersion through BuildConfig.INTERNXT_CLIENT_NAME and BuildConfig.INTERNXT_CLIENT_VERSION, populated by app/build.gradle from package.json. package.json is now the single source of truth for the internxt-client/internxt-version headers. - Extract JSON helpers (orEmpty, map, optStringOrNull, optLongFlexible) from InternxtApiClient into JsonExtensions.kt; cover each branch in a new JsonExtensionsTest. - Rename InternxtApiClient.execute -> executeApiRequest. - Expand InternxtApiClientTest: listFolderFolders, createFolder, null optional fields, size given as a string, and 5xx -> ApiError. Add an enqueueJson helper to cut response-stub boilerplate. - Add @TamaraFinogina as CODEOWNER for the new documents/crypto/ directory. --- .github/CODEOWNERS | 1 + android/app/build.gradle | 4 + .../cloud/documents/api/AuthConfig.kt | 4 +- .../cloud/documents/api/InternxtApiClient.kt | 35 +--- .../cloud/documents/api/JsonExtensions.kt | 21 ++ .../documents/api/InternxtApiClientTest.kt | 195 ++++++++++++++---- .../cloud/documents/api/JsonExtensionsTest.kt | 90 ++++++++ 7 files changed, 284 insertions(+), 66 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/api/JsonExtensionsTest.kt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8110ce626..ae3a1d184 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,3 +4,4 @@ src/network/crypto.spec.ts @TamaraFinogina src/shareExtension/services/shareEncryptionService.ts @TamaraFinogina src/shareExtension/services/shareUploadService.ts @TamaraFinogina src/network/NetworkFacade.ts @TamaraFinogina +android/app/src/main/java/com/internxt/cloud/documents/crypto/ @TamaraFinogina diff --git a/android/app/build.gradle b/android/app/build.gradle index 7b47b8286..4f59a10bc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -4,6 +4,8 @@ apply plugin: "com.facebook.react" def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() +def packageJson = new groovy.json.JsonSlurper().parse(file("${projectRoot}/package.json")) + /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. @@ -97,6 +99,8 @@ android { versionName "1.10.2" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + buildConfigField "String", "INTERNXT_CLIENT_NAME", "\"${packageJson.name}\"" + buildConfigField "String", "INTERNXT_CLIENT_VERSION", "\"${packageJson.version}\"" } flavorDimensions "react-native-capture-protection" productFlavors { diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt index 15c9a3de0..239ccdb50 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt @@ -6,7 +6,7 @@ data class AuthConfig( val bearerToken: String, val bridgeUser: String, val userId: String, - val clientName: String = "drive-mobile", - val clientVersion: String = "v1.9.0", + val clientName: String, + val clientVersion: String, val desktopToken: String? = 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 61ee3272c..d97aedf52 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 @@ -44,7 +44,7 @@ class InternxtApiClient( .addQueryParameter("sort", "plainName") .addQueryParameter("order", "ASC") .build() - val body = execute(driveRequest(url).get().build()) + val body = executeApiRequest(driveRequest(url).get().build()) return body.optJSONArray(jsonKey).orEmpty().map(parse) } @@ -55,7 +55,7 @@ class InternxtApiClient( val req = driveRequest(driveUrl("folders")) .post(payload.toString().toRequestBody(JSON)) .build() - return parseFolder(execute(req)) + return parseFolder(executeApiRequest(req)) } fun renameFile(fileUuid: String, newName: String): DriveFile { @@ -63,7 +63,7 @@ class InternxtApiClient( val req = driveRequest(driveUrl("files/$fileUuid")) .patch(payload.toString().toRequestBody(JSON)) .build() - return parseFile(execute(req)) + return parseFile(executeApiRequest(req)) } fun renameFolder(folderUuid: String, newName: String): DriveFolder { @@ -71,7 +71,7 @@ class InternxtApiClient( val req = driveRequest(driveUrl("folders/$folderUuid")) .put(payload.toString().toRequestBody(JSON)) .build() - return parseFolder(execute(req)) + return parseFolder(executeApiRequest(req)) } fun moveFile(fileUuid: String, destinationFolderUuid: String): DriveFile { @@ -79,7 +79,7 @@ class InternxtApiClient( val req = driveRequest(driveUrl("files/$fileUuid")) .patch(payload.toString().toRequestBody(JSON)) .build() - return parseFile(execute(req)) + return parseFile(executeApiRequest(req)) } fun moveFolder(folderUuid: String, destinationFolderUuid: String): DriveFolder { @@ -87,7 +87,7 @@ class InternxtApiClient( val req = driveRequest(driveUrl("folders/$folderUuid")) .patch(payload.toString().toRequestBody(JSON)) .build() - return parseFolder(execute(req)) + return parseFolder(executeApiRequest(req)) } fun sendToTrash(items: List) { @@ -99,12 +99,12 @@ class InternxtApiClient( val req = driveRequest(driveUrl("storage/trash/add")) .post(payload.toString().toRequestBody(JSON)) .build() - execute(req) + executeApiRequest(req) } fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { val url = bridgeUrl("buckets/$bucketId/files/$fileId/mirrors") - val body = execute(bridgeRequest(url).get().build()) + val body = executeApiRequest(bridgeRequest(url).get().build()) return parseDownloadLinks(body) } @@ -129,7 +129,7 @@ class InternxtApiClient( private fun driveUrl(path: String) = "${config.driveBaseUrl.trimEnd('/')}/$path".toHttpUrl() private fun bridgeUrl(path: String) = "${config.bridgeBaseUrl.trimEnd('/')}/$path".toHttpUrl() - private fun execute(request: Request): JSONObject { + private fun executeApiRequest(request: Request): JSONObject { val response: Response = try { client.newCall(request).execute() } catch (e: IOException) { @@ -197,20 +197,3 @@ class InternxtApiClient( .build() } } - -private fun JSONArray?.orEmpty(): JSONArray = this ?: JSONArray() - -private inline fun JSONArray.map(transform: (JSONObject) -> T): List { - val out = ArrayList(length()) - for (i in 0 until length()) out.add(transform(getJSONObject(i))) - return out -} - -private fun JSONObject.optStringOrNull(key: String): String? = - if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } - -private fun JSONObject.optLongFlexible(key: String): Long = when (val v = opt(key)) { - is Number -> v.toLong() - is String -> v.toLongOrNull() ?: 0L - else -> 0L -} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt new file mode 100644 index 000000000..7c7760cd8 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt @@ -0,0 +1,21 @@ +package com.internxt.cloud.documents.api + +import org.json.JSONArray +import org.json.JSONObject + +internal fun JSONArray?.orEmpty(): JSONArray = this ?: JSONArray() + +internal inline fun JSONArray.map(transform: (JSONObject) -> T): List { + val out = ArrayList(length()) + for (i in 0 until length()) out.add(transform(getJSONObject(i))) + return out +} + +internal fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + +internal fun JSONObject.optLongFlexible(key: String): Long = when (val v = opt(key)) { + is Number -> v.toLong() + is String -> v.toLongOrNull() ?: 0L + else -> 0L +} 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 2aee71aa0..d675c004c 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 @@ -5,9 +5,11 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test +import org.json.JSONObject class InternxtApiClientTest { @@ -29,6 +31,8 @@ class InternxtApiClientTest { bearerToken = "test-token", bridgeUser = "user@example.com", userId = "1234567890", + clientName = "drive-mobile", + clientVersion = "v1.9.0", desktopToken = "desktop-token-xyz" ) ) @@ -39,28 +43,29 @@ class InternxtApiClientTest { server.shutdown() } + private fun enqueueJson(body: String, code: Int = 200) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + @Test fun listFolderFilesParsesResponseFields() { - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ + enqueueJson( + """ + { + "files": [ { - "files": [ - { - "uuid": "file-uuid-1", - "plainName": "report.pdf", - "type": "pdf", - "size": 102400, - "bucket": "bucket-id", - "folderUuid": "$PARENT_UUID", - "createdAt": "2026-01-10T00:00:00.000Z", - "updatedAt": "2026-01-11T00:00:00.000Z", - "fileId": "file-id-1" - } - ] + "uuid": "file-uuid-1", + "plainName": "report.pdf", + "type": "pdf", + "size": 102400, + "bucket": "bucket-id", + "folderUuid": "$PARENT_UUID", + "createdAt": "2026-01-10T00:00:00.000Z", + "fileId": "file-id-1" } - """.trimIndent() - ) + ] + } + """.trimIndent() ) val files = client.listFolderFiles(PARENT_UUID) @@ -79,7 +84,7 @@ class InternxtApiClientTest { @Test fun listFolderFilesBuildsAuthenticatedDriveRequest() { - server.enqueue(MockResponse().setResponseCode(200).setBody("""{"files":[]}""")) + enqueueJson("""{"files":[]}""") client.listFolderFiles(PARENT_UUID) @@ -96,8 +101,8 @@ class InternxtApiClientTest { } @Test - fun unauthorizedResponseSurfacesAsUnauthorizedException() { - server.enqueue(MockResponse().setResponseCode(401)) + fun unauthorizedResponseSurfacesAsUnauthorized() { + enqueueJson("", code = 401) assertThrows(InternxtApiException.UnauthorizedException::class.java) { client.listFolderFiles(PARENT_UUID) @@ -105,8 +110,8 @@ class InternxtApiClientTest { } @Test - fun notFoundResponseSurfacesAsNotFoundException() { - server.enqueue(MockResponse().setResponseCode(404)) + fun notFoundResponseSurfacesAsNotFound() { + enqueueJson("", code = 404) assertThrows(InternxtApiException.NotFoundException::class.java) { client.listFolderFiles("missing-uuid") @@ -114,7 +119,7 @@ class InternxtApiClientTest { } @Test - fun socketDisconnectSurfacesAsNetworkException() { + fun socketDisconnectSurfacesAsNetworkError() { server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) assertThrows(InternxtApiException.NetworkException::class.java) { @@ -124,20 +129,18 @@ class InternxtApiClientTest { @Test fun getDownloadLinksUsesBasicAuthWithDerivedBridgePass() { - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ - { - "bucket": "bucket-id", - "index": "idx", - "size": 1024, - "version": 2, - "shards": [ - {"index":0,"size":512,"hash":"aa","url":"https://shard/0"} - ] - } - """.trimIndent() - ) + enqueueJson( + """ + { + "bucket": "bucket-id", + "index": "idx", + "size": 1024, + "version": 2, + "shards": [ + {"index":0,"size":512,"hash":"aa","url":"https://shard/0"} + ] + } + """.trimIndent() ) val links = client.getDownloadLinks("bucket-id", "file-id-1") @@ -152,4 +155,120 @@ class InternxtApiClientTest { .encodeToString("user@example.com:$expectedPass".toByteArray(Charsets.UTF_8)) assertEquals(expectedAuth, recorded.getHeader("Authorization")) } + + @Test + fun listFolderFoldersParsesResponseFields() { + enqueueJson( + """ + { + "folders": [ + { + "uuid": "folder-uuid-1", + "plainName": "Documents", + "parentUuid": "$PARENT_UUID", + "bucket": "bucket-id", + "createdAt": "2026-01-10T00:00:00.000Z", + "updatedAt": "2026-01-11T00:00:00.000Z" + } + ] + } + """.trimIndent() + ) + + val folders = client.listFolderFolders(PARENT_UUID) + + assertEquals(1, folders.size) + val folder = folders[0] + assertEquals("folder-uuid-1", folder.uuid) + assertEquals("Documents", folder.plainName) + assertEquals(PARENT_UUID, folder.parentUuid) + assertEquals("bucket-id", folder.bucket) + + val recorded = server.takeRequest() + assertEquals( + "/folders/content/$PARENT_UUID/folders?offset=0&limit=50&sort=plainName&order=ASC", + recorded.path + ) + } + + @Test + fun createFolderPostsPayloadAndReturnsFolder() { + enqueueJson("""{"uuid":"new-folder-uuid","plainName":"New Folder","parentUuid":"$PARENT_UUID"}""") + + val created = client.createFolder(PARENT_UUID, "New Folder") + + assertEquals("new-folder-uuid", created.uuid) + assertEquals("New Folder", created.plainName) + assertEquals(PARENT_UUID, created.parentUuid) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/folders", recorded.path) + val sentBody = JSONObject(recorded.body.readUtf8()) + assertEquals("New Folder", sentBody.getString("plainName")) + assertEquals(PARENT_UUID, sentBody.getString("parentFolderUuid")) + } + + @Test + fun listFolderFilesMapsNullOptionalFieldsToNull() { + enqueueJson( + """ + { + "files": [ + { + "uuid": "file-uuid-1", + "plainName": "report.pdf", + "type": null, + "bucket": null, + "folderUuid": null, + "createdAt": null, + "updatedAt": null, + "fileId": null + } + ] + } + """.trimIndent() + ) + + val file = client.listFolderFiles(PARENT_UUID).single() + + assertNull(file.type) + assertNull(file.bucket) + assertNull(file.folderUuid) + assertNull(file.createdAt) + assertNull(file.updatedAt) + assertNull(file.fileId) + } + + @Test + fun listFolderFilesParsesSizeGivenAsString() { + enqueueJson( + """ + { + "files": [ + { + "uuid": "file-uuid-1", + "plainName": "big.bin", + "size": "9999999999" + } + ] + } + """.trimIndent() + ) + + val file = client.listFolderFiles(PARENT_UUID).single() + + assertEquals(9999999999L, file.size) + } + + @Test + fun serverErrorSurfacesAsApiError() { + enqueueJson("""{"error":"boom"}""", code = 500) + + val thrown = assertThrows(InternxtApiException.ApiError::class.java) { + client.listFolderFiles(PARENT_UUID) + } + assertEquals(500, thrown.code) + assertEquals("""{"error":"boom"}""", thrown.body) + } } diff --git a/android/app/src/test/java/com/internxt/cloud/documents/api/JsonExtensionsTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/JsonExtensionsTest.kt new file mode 100644 index 000000000..73f5c7c78 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/JsonExtensionsTest.kt @@ -0,0 +1,90 @@ +package com.internxt.cloud.documents.api + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class JsonExtensionsTest { + + @Test + fun orEmptyReturnsEmptyArrayWhenNull() { + val result = (null as JSONArray?).orEmpty() + assertNotNull(result) + assertEquals(0, result.length()) + } + + @Test + fun orEmptyReturnsSameInstanceWhenNotNull() { + val array = JSONArray().put("a") + assertSame(array, array.orEmpty()) + } + + @Test + fun mapTransformsEachElementPreservingOrder() { + val array = JSONArray() + .put(JSONObject().put("n", 1)) + .put(JSONObject().put("n", 2)) + .put(JSONObject().put("n", 3)) + + val result = array.map { it.getInt("n") } + + assertEquals(listOf(1, 2, 3), result) + } + + @Test + fun mapReturnsEmptyListForEmptyArray() { + val result = JSONArray().map { it.toString() } + assertTrue(result.isEmpty()) + } + + @Test + fun optStringOrNullReturnsNullForMissingKey() { + assertNull(JSONObject().optStringOrNull("missing")) + } + + @Test + fun optStringOrNullReturnsNullForJsonNullValue() { + val obj = JSONObject().put("key", JSONObject.NULL) + assertNull(obj.optStringOrNull("key")) + } + + @Test + fun optStringOrNullReturnsNullForEmptyString() { + val obj = JSONObject().put("key", "") + assertNull(obj.optStringOrNull("key")) + } + + @Test + fun optStringOrNullReturnsValueForNonEmptyString() { + val obj = JSONObject().put("key", "hello") + assertEquals("hello", obj.optStringOrNull("key")) + } + + @Test + fun optLongFlexibleConvertsNumericValueToLong() { + val obj = JSONObject().put("key", 42) + assertEquals(42L, obj.optLongFlexible("key")) + } + + @Test + fun optLongFlexibleParsesNumericString() { + val obj = JSONObject().put("key", "1024") + assertEquals(1024L, obj.optLongFlexible("key")) + } + + @Test + fun optLongFlexibleReturnsZeroForNonNumericString() { + val obj = JSONObject().put("key", "not-a-number") + assertEquals(0L, obj.optLongFlexible("key")) + } + + @Test + fun optLongFlexibleReturnsZeroForMissingKey() { + assertEquals(0L, JSONObject().optLongFlexible("missing")) + } +} From fb889bec9d12b634fc9eb4381d4d493ba9ee3f4a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 23 Apr 2026 23:58:10 -0400 Subject: [PATCH 07/80] refactor(android): replace hardcoded bucket ID and folder name with constants in InternxtApiClientTest --- .../documents/api/InternxtApiClientTest.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 d675c004c..b13d5947b 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 @@ -18,6 +18,8 @@ class InternxtApiClientTest { companion object { private const val PARENT_UUID = "parent-uuid" + private const val BUCKET_ID = "bucket-id" + private const val NEW_FOLDER_NAME = "New Folder" } @Before @@ -58,7 +60,7 @@ class InternxtApiClientTest { "plainName": "report.pdf", "type": "pdf", "size": 102400, - "bucket": "bucket-id", + "bucket": "$BUCKET_ID", "folderUuid": "$PARENT_UUID", "createdAt": "2026-01-10T00:00:00.000Z", "fileId": "file-id-1" @@ -76,7 +78,7 @@ class InternxtApiClientTest { assertEquals("report.pdf", file.plainName) assertEquals("pdf", file.type) assertEquals(102400L, file.size) - assertEquals("bucket-id", file.bucket) + assertEquals(BUCKET_ID, file.bucket) assertEquals(PARENT_UUID, file.folderUuid) assertEquals("2026-01-10T00:00:00.000Z", file.createdAt) assertEquals("file-id-1", file.fileId) @@ -132,7 +134,7 @@ class InternxtApiClientTest { enqueueJson( """ { - "bucket": "bucket-id", + "bucket": "$BUCKET_ID", "index": "idx", "size": 1024, "version": 2, @@ -143,7 +145,7 @@ class InternxtApiClientTest { """.trimIndent() ) - val links = client.getDownloadLinks("bucket-id", "file-id-1") + val links = client.getDownloadLinks(BUCKET_ID, "file-id-1") assertEquals(1, links.shards.size) assertEquals("https://shard/0", links.shards[0].url) @@ -166,7 +168,7 @@ class InternxtApiClientTest { "uuid": "folder-uuid-1", "plainName": "Documents", "parentUuid": "$PARENT_UUID", - "bucket": "bucket-id", + "bucket": "$BUCKET_ID", "createdAt": "2026-01-10T00:00:00.000Z", "updatedAt": "2026-01-11T00:00:00.000Z" } @@ -182,7 +184,7 @@ class InternxtApiClientTest { assertEquals("folder-uuid-1", folder.uuid) assertEquals("Documents", folder.plainName) assertEquals(PARENT_UUID, folder.parentUuid) - assertEquals("bucket-id", folder.bucket) + assertEquals(BUCKET_ID, folder.bucket) val recorded = server.takeRequest() assertEquals( @@ -193,19 +195,19 @@ class InternxtApiClientTest { @Test fun createFolderPostsPayloadAndReturnsFolder() { - enqueueJson("""{"uuid":"new-folder-uuid","plainName":"New Folder","parentUuid":"$PARENT_UUID"}""") + enqueueJson("""{"uuid":"new-folder-uuid","plainName":"$NEW_FOLDER_NAME","parentUuid":"$PARENT_UUID"}""") - val created = client.createFolder(PARENT_UUID, "New Folder") + val created = client.createFolder(PARENT_UUID, NEW_FOLDER_NAME) assertEquals("new-folder-uuid", created.uuid) - assertEquals("New Folder", created.plainName) + assertEquals(NEW_FOLDER_NAME, created.plainName) assertEquals(PARENT_UUID, created.parentUuid) val recorded = server.takeRequest() assertEquals("POST", recorded.method) assertEquals("/folders", recorded.path) val sentBody = JSONObject(recorded.body.readUtf8()) - assertEquals("New Folder", sentBody.getString("plainName")) + assertEquals(NEW_FOLDER_NAME, sentBody.getString("plainName")) assertEquals(PARENT_UUID, sentBody.getString("parentFolderUuid")) } From 6dcdb3235f95dbaef54e65c659cb4d23db7deef1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 1 May 2026 00:50:36 -0400 Subject: [PATCH 08/80] refactor(android): replace readTimeout with callTimeout in default OkHttpClient configuration --- .../java/com/internxt/cloud/documents/api/InternxtApiClient.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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..6d3f17758 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 @@ -192,8 +192,7 @@ class InternxtApiClient( private val JSON = "application/json; charset=utf-8".toMediaType() private fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) .build() } } From 63f4a7a2aaefede39f8bbb6a31bff628b82ca236 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 23 Apr 2026 00:41:11 -0400 Subject: [PATCH 09/80] feat(android): show Internxt Drive in SAF picker per session state InternxtDocumentsProvider.queryRoots() now reads credentials from EncryptedSharedPreferences via a new InternxtAuthManager and returns an empty cursor when no user is signed in, so "Internxt Drive" only appears in the system file picker while the app has an active session; credentials are written by a new RN bridge (InternxtAuthCredentialsModule) from signInThunk and refreshTokensThunk and cleared from signOutThunk, with both paths firing contentResolver.notifyChange(rootsUri) so the picker refreshes without restarting the app, while queryRoots() itself stays strictly synchronous and local-only to avoid freezing the system picker. --- .../com/internxt/cloud/MainApplication.kt | 3 + .../auth/InternxtAuthCredentialsModule.kt | 71 ++++++++ .../auth/InternxtAuthCredentialsPackage.kt | 14 ++ .../documents/InternxtDocumentsProvider.kt | 24 ++- .../documents/auth/InternxtAuthManager.kt | 98 ++++++++++ .../documents/auth/InternxtAuthManagerTest.kt | 167 ++++++++++++++++++ .../native/InternxtAuthCredentialsModule.ts | 33 ++++ src/store/slices/auth/index.ts | 24 +++ 8 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt create mode 100644 src/services/native/InternxtAuthCredentialsModule.ts diff --git a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt index 522fcce5a..9a3426c08 100644 --- a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt +++ b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt @@ -13,6 +13,8 @@ import com.facebook.react.common.ReleaseLevel import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost +import com.internxt.cloud.auth.InternxtAuthCredentialsPackage + import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper @@ -24,6 +26,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { add(ShareIntentPackage()) + add(InternxtAuthCredentialsPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt new file mode 100644 index 000000000..a183dee4b --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt @@ -0,0 +1,71 @@ +package com.internxt.cloud.auth + +import android.provider.DocumentsContract +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.internxt.cloud.documents.InternxtDocumentsProvider +import com.internxt.cloud.documents.auth.InternxtAuthManager + +class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : + ReactContextBaseJavaModule(ctx) { + + private val authManager by lazy { InternxtAuthManager.create(ctx.applicationContext) } + + override fun getName() = MODULE_NAME + + @ReactMethod + fun setCredentials(map: ReadableMap, promise: Promise) { + try { + val creds = InternxtAuthManager.Credentials( + bearerToken = map.requireString("bearerToken"), + userId = map.requireString("userId"), + bridgeUser = map.requireString("bridgeUser"), + rootFolderUuid = map.requireString("rootFolderUuid"), + email = map.optString("email"), + driveBaseUrl = map.requireString("driveBaseUrl"), + bridgeBaseUrl = map.requireString("bridgeBaseUrl"), + desktopToken = map.optString("desktopToken"), + ) + authManager.saveCredentials(creds) + notifyRootsChanged() + promise.resolve(null) + } catch (e: IllegalArgumentException) { + promise.reject("E_MISSING_FIELD", e.message, e) + } catch (e: Exception) { + promise.reject("E_SAVE_CREDENTIALS", e.message, e) + } + } + + @ReactMethod + fun clearCredentials(promise: Promise) { + try { + authManager.clear() + notifyRootsChanged() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_CLEAR_CREDENTIALS", e.message, e) + } + } + + private fun notifyRootsChanged() { + ctx.contentResolver.notifyChange( + DocumentsContract.buildRootsUri(InternxtDocumentsProvider.AUTHORITY), + null, + ) + } + + private fun ReadableMap.nonBlankString(key: String): String? = + if (hasKey(key) && !isNull(key)) getString(key)?.takeIf { it.isNotBlank() } else null + + private fun ReadableMap.requireString(key: String): String = + nonBlankString(key) ?: throw IllegalArgumentException("Missing or blank credential field: $key") + + private fun ReadableMap.optString(key: String): String? = nonBlankString(key) + + companion object { + const val MODULE_NAME = "InternxtAuthCredentialsModule" + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt new file mode 100644 index 000000000..c7bc6e68a --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsPackage.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.auth + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class InternxtAuthCredentialsPackage : ReactPackage { + override fun createNativeModules(context: ReactApplicationContext): List = + listOf(InternxtAuthCredentialsModule(context)) + + override fun createViewManagers(context: ReactApplicationContext): List> = + emptyList() +} 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 668493ac6..4019448a4 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 @@ -4,22 +4,35 @@ import android.database.Cursor import android.database.MatrixCursor 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 com.internxt.cloud.R +import com.internxt.cloud.documents.auth.InternxtAuthManager class InternxtDocumentsProvider : DocumentsProvider() { - override fun onCreate(): Boolean = true + private lateinit var authManager: InternxtAuthManager + + override fun onCreate(): Boolean { + authManager = InternxtAuthManager.create(context!!.applicationContext) + return true + } override fun queryRoots(projection: Array?): Cursor { val cursor = MatrixCursor(resolveRootProjection(projection)) + val ctx = context ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) + + val rootUuid = authManager.authenticatedRootUuid() ?: return cursor + cursor.newRow().apply { add(Root.COLUMN_ROOT_ID, ROOT_ID) - add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID) - add(Root.COLUMN_TITLE, context?.getString(R.string.documents_provider_label)) - add(Root.COLUMN_FLAGS, 0) + add(Root.COLUMN_DOCUMENT_ID, 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) add(Root.COLUMN_ICON, R.mipmap.ic_launcher) } return cursor @@ -50,8 +63,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { companion object { const val AUTHORITY = "com.internxt.cloud.documents" - private const val ROOT_ID = "root" - private const val ROOT_DOCUMENT_ID = "root" + private const val ROOT_ID = "internxt-root" private val DEFAULT_ROOT_PROJECTION = arrayOf( Root.COLUMN_ROOT_ID, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt new file mode 100644 index 000000000..73bf8c851 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -0,0 +1,98 @@ +package com.internxt.cloud.documents.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.internxt.cloud.documents.api.AuthConfig + +class InternxtAuthManager(private val prefs: SharedPreferences) { + + data class Credentials( + val bearerToken: String, + val userId: String, + val bridgeUser: String, + val rootFolderUuid: String, + val email: String?, + val driveBaseUrl: String, + val bridgeBaseUrl: String, + val desktopToken: String?, + ) + + fun isLoggedIn(): Boolean = + REQUIRED_KEYS.all { !prefs.getString(it, null).isNullOrBlank() } + + fun rootFolderUuid(): String? = prefs.getString(KEY_ROOT_FOLDER_UUID, null)?.takeIf { it.isNotBlank() } + + fun authenticatedRootUuid(): String? = if (isLoggedIn()) rootFolderUuid() else null + + fun userEmail(): String? = prefs.getString(KEY_EMAIL, null)?.takeIf { it.isNotBlank() } + + fun loadAuthConfig(): AuthConfig? { + if (!isLoggedIn()) return null + return AuthConfig( + driveBaseUrl = required(KEY_DRIVE_BASE_URL), + bridgeBaseUrl = required(KEY_BRIDGE_BASE_URL), + bearerToken = required(KEY_BEARER_TOKEN), + bridgeUser = required(KEY_BRIDGE_USER), + userId = required(KEY_USER_ID), + desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, + ) + } + + private fun required(key: String): String = + prefs.getString(key, null) ?: error("$key missing after isLoggedIn() returned true") + + fun saveCredentials(creds: Credentials) { + prefs.edit() + .putString(KEY_BEARER_TOKEN, creds.bearerToken) + .putString(KEY_USER_ID, creds.userId) + .putString(KEY_BRIDGE_USER, creds.bridgeUser) + .putString(KEY_ROOT_FOLDER_UUID, creds.rootFolderUuid) + .putString(KEY_EMAIL, creds.email) + .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) + .putString(KEY_BRIDGE_BASE_URL, creds.bridgeBaseUrl) + .putString(KEY_DESKTOP_TOKEN, creds.desktopToken) + .apply() + } + + fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS_FILE = "internxt_documents_auth" + + private const val KEY_BEARER_TOKEN = "bearerToken" + private const val KEY_USER_ID = "userId" + private const val KEY_BRIDGE_USER = "bridgeUser" + private const val KEY_ROOT_FOLDER_UUID = "rootFolderUuid" + private const val KEY_EMAIL = "email" + private const val KEY_DRIVE_BASE_URL = "driveBaseUrl" + private const val KEY_BRIDGE_BASE_URL = "bridgeBaseUrl" + private const val KEY_DESKTOP_TOKEN = "desktopToken" + + private val REQUIRED_KEYS = listOf( + KEY_BEARER_TOKEN, + KEY_USER_ID, + KEY_BRIDGE_USER, + KEY_ROOT_FOLDER_UUID, + KEY_DRIVE_BASE_URL, + KEY_BRIDGE_BASE_URL, + ) + + fun create(context: Context): InternxtAuthManager { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + return InternxtAuthManager(prefs) + } + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt new file mode 100644 index 000000000..fda3561de --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt @@ -0,0 +1,167 @@ +package com.internxt.cloud.documents.auth + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InternxtAuthManagerTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var manager: InternxtAuthManager + + companion object { + private val FULL_CREDS = InternxtAuthManager.Credentials( + bearerToken = "bearer-xyz", + userId = "user-1", + bridgeUser = "user@example.com", + rootFolderUuid = "root-uuid", + email = "user@example.com", + driveBaseUrl = "https://drive.test/api", + bridgeBaseUrl = "https://bridge.test", + desktopToken = "desktop-tok", + ) + } + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + } + + @Test + fun isLoggedInFalseWhenPrefsEmpty() { + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun isLoggedInTrueAfterSavingFullCredentials() { + manager.saveCredentials(FULL_CREDS) + + assertTrue(manager.isLoggedIn()) + assertEquals("root-uuid", manager.rootFolderUuid()) + assertEquals("user@example.com", manager.userEmail()) + } + + @Test + fun loadAuthConfigReturnsExpectedFields() { + manager.saveCredentials(FULL_CREDS) + + val config = manager.loadAuthConfig()!! + assertEquals("https://drive.test/api", config.driveBaseUrl) + assertEquals("https://bridge.test", config.bridgeBaseUrl) + assertEquals("bearer-xyz", config.bearerToken) + assertEquals("user@example.com", config.bridgeUser) + assertEquals("user-1", config.userId) + assertEquals("desktop-tok", config.desktopToken) + } + + @Test + fun loadAuthConfigOmitsDesktopTokenWhenBlank() { + manager.saveCredentials(FULL_CREDS.copy(desktopToken = null)) + + val config = manager.loadAuthConfig()!! + assertNull(config.desktopToken) + } + + @Test + fun isLoggedInFalseWhenAnyRequiredFieldMissing() { + val requiredMissing = listOf( + FULL_CREDS.copy(bearerToken = ""), + FULL_CREDS.copy(userId = ""), + FULL_CREDS.copy(bridgeUser = ""), + FULL_CREDS.copy(rootFolderUuid = ""), + FULL_CREDS.copy(driveBaseUrl = ""), + FULL_CREDS.copy(bridgeBaseUrl = ""), + ) + for (creds in requiredMissing) { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + manager.saveCredentials(creds) + + assertFalse("should be logged out when field blank: $creds", manager.isLoggedIn()) + assertNull(manager.loadAuthConfig()) + } + } + + @Test + fun clearRemovesAllCredentials() { + manager.saveCredentials(FULL_CREDS) + assertTrue(manager.isLoggedIn()) + + manager.clear() + + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun savingOverwritesPreviousCredentials() { + manager.saveCredentials(FULL_CREDS) + manager.saveCredentials(FULL_CREDS.copy(bearerToken = "new-token", rootFolderUuid = "new-root")) + + assertEquals("new-token", manager.loadAuthConfig()!!.bearerToken) + assertEquals("new-root", manager.rootFolderUuid()) + } +} + +private class FakeSharedPreferences : SharedPreferences { + private val store = HashMap() + + override fun getAll(): MutableMap = HashMap(store) + override fun getString(key: String?, defValue: String?): String? = + store[key] as? String ?: defValue + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + @Suppress("UNCHECKED_CAST") (store[key] as? MutableSet) ?: defValues + + override fun getInt(key: String?, defValue: Int): Int = (store[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (store[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (store[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (store[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = store.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun edit(): SharedPreferences.Editor = FakeEditor(store) + + private class FakeEditor(private val store: HashMap) : SharedPreferences.Editor { + private val pending = HashMap() + private val removed = HashSet() + private var clearPending = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, values: MutableSet?) = apply { pending[key] = values } + override fun putInt(key: String, value: Int) = apply { pending[key] = value } + override fun putLong(key: String, value: Long) = apply { pending[key] = value } + override fun putFloat(key: String, value: Float) = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value } + override fun remove(key: String) = apply { removed.add(key) } + override fun clear() = apply { clearPending = true } + + override fun commit(): Boolean { + applyChanges() + return true + } + + override fun apply() { + applyChanges() + } + + private fun applyChanges() { + if (clearPending) store.clear() + for (k in removed) store.remove(k) + for ((k, v) in pending) { + if (v == null) store.remove(k) else store[k] = v + } + } + } +} diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts new file mode 100644 index 000000000..77bd7f166 --- /dev/null +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -0,0 +1,33 @@ +import { NativeModules, Platform } from 'react-native'; + +export interface InternxtAuthCredentials { + bearerToken: string; + userId: string; + bridgeUser: string; + rootFolderUuid: string; + email?: string | null; + driveBaseUrl: string; + bridgeBaseUrl: string; + desktopToken?: string | null; +} + +interface NativeBridge { + setCredentials(creds: InternxtAuthCredentials): Promise; + clearCredentials(): Promise; +} + +const bridge: NativeBridge | undefined = + Platform.OS === 'android' ? NativeModules.InternxtAuthCredentialsModule : undefined; + +const InternxtAuthCredentialsModule = { + async setCredentials(creds: InternxtAuthCredentials): Promise { + if (!bridge) return; + await bridge.setCredentials(creds); + }, + async clearCredentials(): Promise { + if (!bridge) return; + await bridge.clearCredentials(); + }, +}; + +export default InternxtAuthCredentialsModule; diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index ed35f395c..3be860968 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -8,8 +8,10 @@ import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import errorService from 'src/services/ErrorService'; import { RootState } from '../..'; import strings from '../../../../assets/lang/strings'; +import appService from '../../../services/AppService'; import asyncStorageService from '../../../services/AsyncStorageService'; import authService from '../../../services/AuthService'; +import InternxtAuthCredentialsModule from '../../../services/native/InternxtAuthCredentialsModule'; import notificationsService from '../../../services/NotificationsService'; import { default as userService } from '../../../services/UserService'; import { AsyncStorageKey, NotificationType } from '../../../types'; @@ -34,6 +36,22 @@ const initialState: AuthState = { sessionPassword: undefined, }; +async function syncNativeCredentials(token: string, user: UserSettings): Promise { + try { + await InternxtAuthCredentialsModule.setCredentials({ + bearerToken: token, + userId: user.userId, + bridgeUser: user.bridgeUser, + rootFolderUuid: user.rootFolderUuid || user.rootFolderId, + email: user.email, + driveBaseUrl: appService.constants.DRIVE_NEW_API_URL, + bridgeBaseUrl: appService.constants.BRIDGE_URL, + }); + } catch (err) { + errorService.reportError(err); + } +} + export const initializeThunk = createAsyncThunk( 'auth/initialize', async (_, { dispatch }) => { @@ -117,6 +135,9 @@ export const signInThunk = createAsyncThunk< await asyncStorageService.saveItem(AsyncStorageKey.User, JSON.stringify(userToSave)); // Reset this, in case we logged out during the pull process await asyncStorageService.deleteItem(AsyncStorageKey.LastPhotoPulledDate); + + await syncNativeCredentials(payload.token, userToSave); + dispatch( authActions.setSignInData({ token: payload.token, @@ -156,6 +177,8 @@ export const refreshTokensThunk = createAsyncThunk Date: Thu, 23 Apr 2026 00:52:01 -0400 Subject: [PATCH 10/80] refactor(auth): replace InternxtAuthCredentialsModule with direct function imports --- .../native/InternxtAuthCredentialsModule.ts | 19 ++++++++----------- src/store/slices/auth/index.ts | 6 +++--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts index 77bd7f166..ae296f24d 100644 --- a/src/services/native/InternxtAuthCredentialsModule.ts +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -19,15 +19,12 @@ interface NativeBridge { const bridge: NativeBridge | undefined = Platform.OS === 'android' ? NativeModules.InternxtAuthCredentialsModule : undefined; -const InternxtAuthCredentialsModule = { - async setCredentials(creds: InternxtAuthCredentials): Promise { - if (!bridge) return; - await bridge.setCredentials(creds); - }, - async clearCredentials(): Promise { - if (!bridge) return; - await bridge.clearCredentials(); - }, -}; +export async function setCredentials(creds: InternxtAuthCredentials): Promise { + if (!bridge) return; + await bridge.setCredentials(creds); +} -export default InternxtAuthCredentialsModule; +export async function clearCredentials(): Promise { + if (!bridge) return; + await bridge.clearCredentials(); +} diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 3be860968..73cb072f1 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -11,7 +11,7 @@ import strings from '../../../../assets/lang/strings'; import appService from '../../../services/AppService'; import asyncStorageService from '../../../services/AsyncStorageService'; import authService from '../../../services/AuthService'; -import InternxtAuthCredentialsModule from '../../../services/native/InternxtAuthCredentialsModule'; +import { clearCredentials, setCredentials } from '../../../services/native/InternxtAuthCredentialsModule'; import notificationsService from '../../../services/NotificationsService'; import { default as userService } from '../../../services/UserService'; import { AsyncStorageKey, NotificationType } from '../../../types'; @@ -38,7 +38,7 @@ const initialState: AuthState = { async function syncNativeCredentials(token: string, user: UserSettings): Promise { try { - await InternxtAuthCredentialsModule.setCredentials({ + await setCredentials({ bearerToken: token, userId: user.userId, bridgeUser: user.bridgeUser, @@ -236,7 +236,7 @@ export const signOutThunk = createAsyncThunk< await dispatch(photosSignOutThunk()); authService.signout(payload.reason).catch(errorService.reportError); drive.clear().catch(errorService.reportError); - InternxtAuthCredentialsModule.clearCredentials().catch(errorService.reportError); + clearCredentials().catch(errorService.reportError); dispatch(uiActions.resetState()); dispatch(authActions.resetState()); dispatch(driveActions.resetState()); From b7dc7d26799457fb1b7154567cb3014da25915f5 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 24 Apr 2026 00:41:22 -0400 Subject: [PATCH 11/80] feat(auth): include client name and version in authentication credentials --- .../internxt/cloud/documents/auth/InternxtAuthManager.kt | 3 +++ .../cloud/documents/auth/InternxtAuthManagerTest.kt | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt index 73bf8c851..9d4856c35 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import com.internxt.cloud.BuildConfig import com.internxt.cloud.documents.api.AuthConfig class InternxtAuthManager(private val prefs: SharedPreferences) { @@ -36,6 +37,8 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { bearerToken = required(KEY_BEARER_TOKEN), bridgeUser = required(KEY_BRIDGE_USER), userId = required(KEY_USER_ID), + clientName = BuildConfig.INTERNXT_CLIENT_NAME, + clientVersion = BuildConfig.INTERNXT_CLIENT_VERSION, desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, ) } diff --git a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt index fda3561de..a64a68cb5 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt @@ -14,12 +14,13 @@ class InternxtAuthManagerTest { private lateinit var manager: InternxtAuthManager companion object { + private const val USER_EMAIL = "user@example.com" private val FULL_CREDS = InternxtAuthManager.Credentials( bearerToken = "bearer-xyz", userId = "user-1", - bridgeUser = "user@example.com", + bridgeUser = USER_EMAIL, rootFolderUuid = "root-uuid", - email = "user@example.com", + email = USER_EMAIL, driveBaseUrl = "https://drive.test/api", bridgeBaseUrl = "https://bridge.test", desktopToken = "desktop-tok", @@ -46,7 +47,7 @@ class InternxtAuthManagerTest { assertTrue(manager.isLoggedIn()) assertEquals("root-uuid", manager.rootFolderUuid()) - assertEquals("user@example.com", manager.userEmail()) + assertEquals(USER_EMAIL, manager.userEmail()) } @Test @@ -57,7 +58,7 @@ class InternxtAuthManagerTest { assertEquals("https://drive.test/api", config.driveBaseUrl) assertEquals("https://bridge.test", config.bridgeBaseUrl) assertEquals("bearer-xyz", config.bearerToken) - assertEquals("user@example.com", config.bridgeUser) + assertEquals(USER_EMAIL, config.bridgeUser) assertEquals("user-1", config.userId) assertEquals("desktop-tok", config.desktopToken) } From 33a713fb29161bdfcae72a5977654877a0c4ffe2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 13 May 2026 00:24:32 -0400 Subject: [PATCH 12/80] feat(auth): enhance credential management with error handling and root folder UUID synchronization --- .../auth/InternxtAuthCredentialsModule.kt | 18 +++++++- .../documents/InternxtDocumentsProvider.kt | 3 +- .../documents/auth/InternxtAuthManager.kt | 45 +++++++++++-------- src/store/slices/auth/index.ts | 16 ++++++- 4 files changed, 59 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt index a183dee4b..8c425793c 100644 --- a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt @@ -18,6 +18,10 @@ class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : @ReactMethod fun setCredentials(map: ReadableMap, promise: Promise) { + val manager = authManager ?: run { + promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable") + return + } try { val creds = InternxtAuthManager.Credentials( bearerToken = map.requireString("bearerToken"), @@ -29,7 +33,10 @@ class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : bridgeBaseUrl = map.requireString("bridgeBaseUrl"), desktopToken = map.optString("desktopToken"), ) - authManager.saveCredentials(creds) + if (!manager.saveCredentials(creds)) { + promise.reject("E_SAVE_CREDENTIALS", "Failed to persist credentials") + return + } notifyRootsChanged() promise.resolve(null) } catch (e: IllegalArgumentException) { @@ -41,8 +48,15 @@ class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : @ReactMethod fun clearCredentials(promise: Promise) { + val manager = authManager ?: run { + promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable") + return + } try { - authManager.clear() + if (!manager.clear()) { + promise.reject("E_CLEAR_CREDENTIALS", "Failed to clear credentials") + return + } notifyRootsChanged() promise.resolve(null) } catch (e: Exception) { 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 4019448a4..cd337c892 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 @@ -16,7 +16,8 @@ class InternxtDocumentsProvider : DocumentsProvider() { private lateinit var authManager: InternxtAuthManager override fun onCreate(): Boolean { - authManager = InternxtAuthManager.create(context!!.applicationContext) + val ctx = context ?: return false + authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false return true } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt index 9d4856c35..b8676738b 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -2,10 +2,13 @@ package com.internxt.cloud.documents.auth import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.internxt.cloud.BuildConfig import com.internxt.cloud.documents.api.AuthConfig +import java.io.IOException +import java.security.GeneralSecurityException class InternxtAuthManager(private val prefs: SharedPreferences) { @@ -46,7 +49,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { private fun required(key: String): String = prefs.getString(key, null) ?: error("$key missing after isLoggedIn() returned true") - fun saveCredentials(creds: Credentials) { + fun saveCredentials(creds: Credentials): Boolean = prefs.edit() .putString(KEY_BEARER_TOKEN, creds.bearerToken) .putString(KEY_USER_ID, creds.userId) @@ -56,14 +59,12 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) .putString(KEY_BRIDGE_BASE_URL, creds.bridgeBaseUrl) .putString(KEY_DESKTOP_TOKEN, creds.desktopToken) - .apply() - } + .commit() - fun clear() { - prefs.edit().clear().apply() - } + fun clear(): Boolean = prefs.edit().clear().commit() companion object { + private const val TAG = "InternxtAuthManager" private const val PREFS_FILE = "internxt_documents_auth" private const val KEY_BEARER_TOKEN = "bearerToken" @@ -84,18 +85,26 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { KEY_BRIDGE_BASE_URL, ) - fun create(context: Context): InternxtAuthManager { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - val prefs = EncryptedSharedPreferences.create( - context, - PREFS_FILE, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - return InternxtAuthManager(prefs) + fun create(context: Context): InternxtAuthManager? { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + InternxtAuthManager(prefs) + } catch (e: GeneralSecurityException) { + Log.e(TAG, "Keystore unavailable, SAF auth disabled", e) + null + } catch (e: IOException) { + Log.e(TAG, "Could not open encrypted prefs, SAF auth disabled", e) + null + } } } } diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 73cb072f1..822b19fc6 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -36,16 +36,27 @@ const initialState: AuthState = { sessionPassword: undefined, }; +async function ensureRootFolderUuid(user: UserSettings): Promise { + if (user.rootFolderUuid || !user.root_folder_id) return user; + const meta = await SdkManager.getInstance().storageV2.getFolderMetaById(user.root_folder_id); + return { ...user, rootFolderUuid: meta.uuid }; +} + async function syncNativeCredentials(token: string, user: UserSettings): Promise { + if (!user.rootFolderUuid) { + errorService.reportError(new Error('syncNativeCredentials: missing rootFolderUuid')); + return; + } try { await setCredentials({ bearerToken: token, userId: user.userId, bridgeUser: user.bridgeUser, - rootFolderUuid: user.rootFolderUuid || user.rootFolderId, + rootFolderUuid: user.rootFolderUuid, email: user.email, driveBaseUrl: appService.constants.DRIVE_NEW_API_URL, bridgeBaseUrl: appService.constants.BRIDGE_URL, + desktopToken: appService.constants.CLOUDFLARE_TOKEN, }); } catch (err) { errorService.reportError(err); @@ -118,7 +129,6 @@ export const signInThunk = createAsyncThunk< { user: UserSettings; token: string; newToken: string }, { state: RootState } >('auth/signIn', async (payload, { dispatch }) => { - const userToSave = payload.user; SdkManager.init({ token: payload.token, newToken: payload.newToken, @@ -130,6 +140,8 @@ export const signInThunk = createAsyncThunk< newToken: payload.newToken, }); + const userToSave = await ensureRootFolderUuid(payload.user); + await asyncStorageService.saveItem(AsyncStorageKey.Token, payload.token); await asyncStorageService.saveItem(AsyncStorageKey.PhotosToken, payload.newToken); // Photos access token await asyncStorageService.saveItem(AsyncStorageKey.User, JSON.stringify(userToSave)); From 31602662132ea5439428d075f928f06d68478680 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 16 May 2026 16:26:39 -0400 Subject: [PATCH 13/80] refactor(documents): update authManager to nullable type for safer access --- .../internxt/cloud/documents/InternxtDocumentsProvider.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..86a09510d 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 @@ -13,7 +13,7 @@ import com.internxt.cloud.documents.auth.InternxtAuthManager class InternxtDocumentsProvider : DocumentsProvider() { - private lateinit var authManager: InternxtAuthManager + private var authManager: InternxtAuthManager? = null override fun onCreate(): Boolean { val ctx = context ?: return false @@ -26,13 +26,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() ?: return cursor cursor.newRow().apply { add(Root.COLUMN_ROOT_ID, ROOT_ID) add(Root.COLUMN_DOCUMENT_ID, rootUuid) add(Root.COLUMN_TITLE, ctx.getString(R.string.documents_provider_label)) - authManager.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } + authManager?.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) add(Root.COLUMN_ICON, R.mipmap.ic_launcher) } From 07aa7c435d1dfd1a9a5ac0bbc657c24e21b62726 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 28 Apr 2026 22:55:12 -0400 Subject: [PATCH 14/80] 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 86a09510d..826f922eb 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 6d3f17758..4b75937cb 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 822b19fc6..5482f04aa 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -148,7 +148,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({ @@ -189,7 +189,7 @@ export const refreshTokensThunk = createAsyncThunk Date: Wed, 13 May 2026 17:49:24 -0400 Subject: [PATCH 15/80] 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 826f922eb..84d05fae3 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 var authManager: InternxtAuthManager? = null + 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 4b75937cb..c399e2c24 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]) From c0abba02344399836fd326545d2647a70a2be6e4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 13 May 2026 17:49:24 -0400 Subject: [PATCH 16/80] feat(documents): implement encoding and decoding for folder and file IDs --- .../cloud/documents/InternxtDocumentsProvider.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 84d05fae3..51f09ba1f 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 @@ -36,6 +36,20 @@ class InternxtDocumentsProvider : DocumentsProvider() { val rows = mutableListOf>() } + 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 From e65484feebba223e018336c0fab5d71f08b05e9d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 30 Apr 2026 10:41:38 -0400 Subject: [PATCH 17/80] 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 51f09ba1f..f9554e6a1 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,10 @@ 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 @@ -35,6 +38,9 @@ class InternxtDocumentsProvider : DocumentsProvider() { @Volatile var errorMessage: String? = null val rows = mutableListOf>() } + private val itemKinds = ConcurrentHashMap() + + private enum class ItemKind { FILE, FOLDER } private val loaderExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } @@ -96,10 +102,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}") @@ -214,6 +225,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 c399e2c24..b8338a5ef 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 5482f04aa..81234b3aa 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -248,7 +248,7 @@ export const signOutThunk = createAsyncThunk< await dispatch(photosSignOutThunk()); authService.signout(payload.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 5e3a4f49ae8cad65d68673c787a5c86bbf10ecc1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 13 May 2026 20:00:27 -0400 Subject: [PATCH 18/80] 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 f9554e6a1..2f42dbf86 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,7 +18,6 @@ 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() { @@ -38,9 +37,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { @Volatile var errorMessage: String? = null val rows = mutableListOf>() } - private val itemKinds = ConcurrentHashMap() - - private enum class ItemKind { FILE, FOLDER } private val loaderExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } @@ -101,17 +97,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 @@ -121,6 +107,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?, @@ -225,81 +222,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?, From 908e48fb784e8f7f9bc2b5ca196dacd111b77f1f Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 19 May 2026 23:27:04 -0400 Subject: [PATCH 19/80] chore(android): reset docs provider + tests to PB-5927 base for split --- .../documents/InternxtDocumentsProvider.kt | 159 +----------------- .../documents/api/InternxtApiClientTest.kt | 71 -------- 2 files changed, 6 insertions(+), 224 deletions(-) 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 2f42dbf86..84d05fae3 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,9 +14,7 @@ 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 @@ -38,20 +36,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { val rows = mutableListOf>() } - 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 @@ -97,7 +81,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { val api = apiClient(op = "queryDocument") val row = if (api == null) null else try { - fetchDocumentRow(api, decoded?.kind, uuid) + 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 @@ -107,17 +96,6 @@ 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?, @@ -222,131 +200,6 @@ 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/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt index a9fd59070..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 @@ -1,6 +1,5 @@ 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 @@ -307,76 +306,6 @@ 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) From a353c4a82a30f54ea4479f4c1dce00303b8061d8 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 19 May 2026 23:35:47 -0400 Subject: [PATCH 20/80] feat(documents): implement document rename, move, and delete functionalities --- .../documents/InternxtDocumentsProvider.kt | 151 ++++++++++++++++-- 1 file changed, 142 insertions(+), 9 deletions(-) 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 84d05fae3..ee3d7ab38 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 @@ -48,7 +50,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) val rootUuid = authManager?.authenticatedRootUuid() - Log.d(TAG, "queryRoots: isLoggedIn=${authManager.isLoggedIn()} rootUuid=$rootUuid") + Log.d(TAG, "queryRoots: isLoggedIn=${authManager?.isLoggedIn()} rootUuid=$rootUuid") if (rootUuid == null) return cursor cursor.newRow().apply { @@ -72,7 +74,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { val decoded = DocumentId.decode(id) val uuid = decoded?.uuid ?: id - if (decoded?.kind == DocumentId.Kind.FOLDER && uuid == authManager.authenticatedRootUuid()) { + if (decoded?.kind == DocumentId.Kind.FOLDER && uuid == authManager?.authenticatedRootUuid()) { cursor.addDocumentRow( DocumentRowBuilder.folderRow(uuid, ctx.getString(R.string.documents_provider_label)) ) @@ -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?, @@ -176,7 +184,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private fun apiClient(op: String): InternxtApiClient? { - val cfg = authManager.loadAuthConfig() + val cfg = authManager?.loadAuthConfig() if (cfg == null) { Log.w(TAG, "$op: loadAuthConfig() returned null") return null @@ -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?, From 26dec09fb065871cb72a15e9f7623546342204a7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 19 May 2026 23:36:35 -0400 Subject: [PATCH 21/80] feat(tests): add unit tests for file and folder rename, move, and trash functionalities --- .../documents/api/InternxtApiClientTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) 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) From f421224121793eccf8dc914f3f4c4abad3079422 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 30 Apr 2026 10:41:38 -0400 Subject: [PATCH 22/80] 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. --- .../com/internxt/cloud/documents/InternxtDocumentsProvider.kt | 3 +++ 1 file changed, 3 insertions(+) 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 ee3d7ab38..098d07b0c 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 @@ -37,6 +37,9 @@ class InternxtDocumentsProvider : DocumentsProvider() { @Volatile var errorMessage: String? = null val rows = mutableListOf>() } + private val itemKinds = ConcurrentHashMap() + + private enum class ItemKind { FILE, FOLDER } override fun onCreate(): Boolean { val ctx = context ?: return false From 13bbd2ff1e7ac1683cc7d8ae1466ef9fe4441fc8 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 12 May 2026 00:05:47 -0400 Subject: [PATCH 23/80] feat(auth): add mnemonic to authentication credentials and related modules --- .../auth/InternxtAuthCredentialsModule.kt | 1 + .../documents/InternxtDocumentsProvider.kt | 95 ++++++++++++++++++- .../cloud/documents/api/AuthConfig.kt | 1 + .../documents/auth/InternxtAuthManager.kt | 5 + .../cloud/documents/cache/DocumentCache.kt | 51 ++++++++++ .../cloud/documents/crypto/FileKeyDeriver.kt | 58 +++++++++++ .../download/EncryptedFileDownloader.kt | 67 +++++++++++++ .../native/InternxtAuthCredentialsModule.ts | 1 + src/store/slices/auth/index.ts | 1 + 9 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt diff --git a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt index 8c425793c..ced6fbe84 100644 --- a/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt +++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt @@ -27,6 +27,7 @@ class InternxtAuthCredentialsModule(private val ctx: ReactApplicationContext) : bearerToken = map.requireString("bearerToken"), userId = map.requireString("userId"), bridgeUser = map.requireString("bridgeUser"), + mnemonic = map.requireString("mnemonic"), rootFolderUuid = map.requireString("rootFolderUuid"), email = map.optString("email"), driveBaseUrl = map.requireString("driveBaseUrl"), 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 098d07b0c..c03e3487b 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 @@ -5,6 +5,9 @@ import android.database.MatrixCursor import android.net.Uri import android.os.Bundle import android.os.CancellationSignal +import android.os.Handler +import android.os.HandlerThread +import android.os.OperationCanceledException import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import android.provider.DocumentsContract.Document @@ -16,8 +19,16 @@ 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 com.internxt.cloud.documents.cache.DocumentCache +import com.internxt.cloud.documents.crypto.FileKeyDeriver +import com.internxt.cloud.documents.download.EncryptedFileDownloader +import com.rncrypto.util.CryptoService +import okhttp3.OkHttpClient +import java.io.File import java.io.FileNotFoundException import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { @@ -38,12 +49,22 @@ class InternxtDocumentsProvider : DocumentsProvider() { val rows = mutableListOf>() } private val itemKinds = ConcurrentHashMap() + private lateinit var closeHandler: Handler + private val downloadClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.MINUTES) + .writeTimeout(2, TimeUnit.MINUTES) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + } private enum class ItemKind { FILE, FOLDER } override fun onCreate(): Boolean { val ctx = context ?: return false authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false + closeHandler = Handler(HandlerThread("InternxtDocsClose").apply { start() }.looper) return true } @@ -341,7 +362,79 @@ class InternxtDocumentsProvider : DocumentsProvider() { mode: String?, signal: CancellationSignal? ): ParcelFileDescriptor { - throw UnsupportedOperationException("Not implemented yet") + val ctx = context ?: throw FileNotFoundException("No context") + val id = documentId ?: throw FileNotFoundException("No document id") + if (mode != null && mode != "r") { + throw UnsupportedOperationException("Only read-only access is supported (mode=$mode)") + } + + val cfg = authManager.loadAuthConfig() ?: throw FileNotFoundException("Not authenticated") + val api = InternxtApiClient(cfg) + + val file = try { + api.getFile(id) ?: throw FileNotFoundException("File not found: $id") + } catch (e: InternxtApiException) { + throw FileNotFoundException("getFile $id failed: ${e.message}") + } + val bucket = file.bucket ?: throw FileNotFoundException("File $id has no bucket") + val fileId = file.fileId ?: throw FileNotFoundException("File $id has no fileId") + val updatedAt = file.updatedAt ?: throw FileNotFoundException("File $id has no updatedAt") + + val cacheFile = DocumentCache.cacheFileFor(ctx, id, updatedAt) + if (cacheFile.exists() && cacheFile.length() > 0) { + Log.d(TAG, "openDocument cache hit id=$id") + return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + } + + val (tempEnc, tempDec) = DocumentCache.tempPaths(ctx, id) + try { + signal?.throwIfCanceled() + val links = api.getDownloadLinks(bucket, fileId) + EncryptedFileDownloader.download(downloadClient, links.shards, tempEnc, signal) + + signal?.throwIfCanceled() + val key = FileKeyDeriver.deriveFileKey(cfg.mnemonic, bucket, links.index) + val iv = FileKeyDeriver.deriveIv(links.index) + decryptBlocking(tempEnc, tempDec, FileKeyDeriver.toHex(key), FileKeyDeriver.toHex(iv)) + + if (!tempDec.renameTo(cacheFile)) { + throw FileNotFoundException("Failed to promote temp file to cache for $id") + } + tempEnc.delete() + DocumentCache.pruneSiblings(ctx, id, cacheFile) + + Log.d(TAG, "openDocument cached id=$id size=${cacheFile.length()}") + return ParcelFileDescriptor.open( + cacheFile, + ParcelFileDescriptor.MODE_READ_ONLY, + closeHandler, + ) { DocumentCache.deleteTempsFor(ctx, id) } + } catch (e: OperationCanceledException) { + tempEnc.delete(); tempDec.delete() + throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + } catch (e: Exception) { + tempEnc.delete(); tempDec.delete() + Log.w(TAG, "openDocument $id failed: ${e.javaClass.simpleName}: ${e.message}") + if (e is FileNotFoundException) throw e + throw FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } + } + } + + private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { + val latch = CountDownLatch(1) + val errorRef = arrayOfNulls(1) + CryptoService.getInstance().decryptFile( + src.absolutePath, + dst.absolutePath, + hexKey, + hexIv, + /* runInBackground = */ true, + ) { err -> + errorRef[0] = err + latch.countDown() + } + latch.await() + errorRef[0]?.let { throw it } } private fun resolveRootProjection(projection: Array?): Array = diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt index 239ccdb50..52755fce9 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt @@ -6,6 +6,7 @@ data class AuthConfig( val bearerToken: String, val bridgeUser: String, val userId: String, + val mnemonic: String, val clientName: String, val clientVersion: String, val desktopToken: String? = null diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt index b8676738b..2ca048e72 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -16,6 +16,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { val bearerToken: String, val userId: String, val bridgeUser: String, + val mnemonic: String, val rootFolderUuid: String, val email: String?, val driveBaseUrl: String, @@ -40,6 +41,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { bearerToken = required(KEY_BEARER_TOKEN), bridgeUser = required(KEY_BRIDGE_USER), userId = required(KEY_USER_ID), + mnemonic = required(KEY_MNEMONIC), clientName = BuildConfig.INTERNXT_CLIENT_NAME, clientVersion = BuildConfig.INTERNXT_CLIENT_VERSION, desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, @@ -54,6 +56,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { .putString(KEY_BEARER_TOKEN, creds.bearerToken) .putString(KEY_USER_ID, creds.userId) .putString(KEY_BRIDGE_USER, creds.bridgeUser) + .putString(KEY_MNEMONIC, creds.mnemonic) .putString(KEY_ROOT_FOLDER_UUID, creds.rootFolderUuid) .putString(KEY_EMAIL, creds.email) .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) @@ -70,6 +73,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { private const val KEY_BEARER_TOKEN = "bearerToken" private const val KEY_USER_ID = "userId" private const val KEY_BRIDGE_USER = "bridgeUser" + private const val KEY_MNEMONIC = "mnemonic" private const val KEY_ROOT_FOLDER_UUID = "rootFolderUuid" private const val KEY_EMAIL = "email" private const val KEY_DRIVE_BASE_URL = "driveBaseUrl" @@ -80,6 +84,7 @@ class InternxtAuthManager(private val prefs: SharedPreferences) { KEY_BEARER_TOKEN, KEY_USER_ID, KEY_BRIDGE_USER, + KEY_MNEMONIC, KEY_ROOT_FOLDER_UUID, KEY_DRIVE_BASE_URL, KEY_BRIDGE_BASE_URL, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt new file mode 100644 index 000000000..fcf8c2f78 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt @@ -0,0 +1,51 @@ +package com.internxt.cloud.documents.cache + +import android.content.Context +import java.io.File + +object DocumentCache { + + private const val ROOT_DIR = "internxt_documents" + private const val CACHE_DIR = "cache" + private const val TMP_DIR = "tmp" + private const val DEC_SUFFIX = ".dec" + private const val ENC_SUFFIX = ".enc" + + fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File { + val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR").apply { mkdirs() } + return File(dir, "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") + } + + fun tempPaths(context: Context, uuid: String): Pair { + val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR").apply { mkdirs() } + val token = "${uuid}_${System.nanoTime()}" + return File(dir, "$token$ENC_SUFFIX") to File(dir, "$token$DEC_SUFFIX") + } + + fun pruneSiblings(context: Context, uuid: String, keep: File) { + val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR") + val children = dir.listFiles() ?: return + for (file in children) { + if (file == keep) continue + if (file.name.startsWith("${uuid}_") && file.name.endsWith(DEC_SUFFIX)) { + file.delete() + } + } + } + + fun deleteTempsFor(context: Context, uuid: String) { + val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR") + val children = dir.listFiles() ?: return + for (file in children) { + if (file.name.startsWith("${uuid}_")) file.delete() + } + } + + fun slugFromUpdatedAt(updatedAt: String): String { + val sb = StringBuilder(updatedAt.length) + for (c in updatedAt) { + if (c.isLetterOrDigit()) sb.append(c) + } + return if (sb.isEmpty()) "0" else sb.toString() + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt new file mode 100644 index 000000000..6b1439634 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt @@ -0,0 +1,58 @@ +package com.internxt.cloud.documents.crypto + +import com.rncrypto.util.CryptoService +import java.security.MessageDigest + +object FileKeyDeriver { + + private const val PBKDF2_SALT = "mnemonic" + private const val PBKDF2_ROUNDS = 2048 + private const val SEED_LENGTH = 64 + private const val FILE_KEY_LENGTH = 32 + private const val IV_LENGTH = 16 + + fun deriveFileKey(mnemonic: String, bucketIdHex: String, indexHex: String): ByteArray { + val seed = pbkdf2Seed(mnemonic) + val bucketKey = sha512(seed, hexDecode(bucketIdHex)) + val fileKey = sha512(bucketKey.copyOf(FILE_KEY_LENGTH), hexDecode(indexHex)) + return fileKey.copyOf(FILE_KEY_LENGTH) + } + + fun deriveIv(indexHex: String): ByteArray = hexDecode(indexHex).copyOf(IV_LENGTH) + + fun toHex(bytes: ByteArray): String { + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) { + val v = b.toInt() and 0xff + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0f]) + } + return sb.toString() + } + + private fun pbkdf2Seed(mnemonic: String): ByteArray = + CryptoService.getInstance().pbkdf2(mnemonic, PBKDF2_SALT.toByteArray(Charsets.UTF_8), PBKDF2_ROUNDS, SEED_LENGTH) + + private fun sha512(key: ByteArray, data: ByteArray): ByteArray { + val md = MessageDigest.getInstance("SHA-512") + md.update(key) + md.update(data) + return md.digest() + } + + private fun hexDecode(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Hex string must have even length" } + val out = ByteArray(hex.length / 2) + var i = 0 + while (i < hex.length) { + val hi = Character.digit(hex[i], 16) + val lo = Character.digit(hex[i + 1], 16) + require(hi >= 0 && lo >= 0) { "Invalid hex character at position $i" } + out[i / 2] = ((hi shl 4) or lo).toByte() + i += 2 + } + return out + } + + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt new file mode 100644 index 000000000..c57e27b98 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -0,0 +1,67 @@ +package com.internxt.cloud.documents.download + +import android.os.CancellationSignal +import android.os.OperationCanceledException +import com.internxt.cloud.documents.api.model.Shard +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +object EncryptedFileDownloader { + + private const val COPY_BUFFER_SIZE = 16 * 1024 + + @Throws(IOException::class, OperationCanceledException::class) + fun download( + client: OkHttpClient, + shards: List, + target: File, + signal: CancellationSignal? + ) { + require(shards.isNotEmpty()) { "No shards to download" } + target.parentFile?.mkdirs() + if (target.exists()) target.delete() + + val activeCall = AtomicReference(null) + signal?.setOnCancelListener { activeCall.get()?.cancel() } + + FileOutputStream(target, /* append = */ true).use { out -> + for (shard in shards.sortedBy { it.index }) { + signal?.throwIfCanceled() + + val request = Request.Builder().url(shard.url).get().build() + val call = client.newCall(request) + activeCall.set(call) + + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw IOException("Shard ${shard.index} HTTP ${response.code}") + } + val body = response.body ?: throw IOException("Shard ${shard.index} empty body") + val buffer = ByteArray(COPY_BUFFER_SIZE) + body.byteStream().use { input -> + while (true) { + val read = input.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + } + } + } + } catch (e: IOException) { + if (signal?.isCanceled == true) { + throw OperationCanceledException("Download cancelled").apply { initCause(e) } + } + throw e + } finally { + activeCall.set(null) + } + } + out.flush() + } + } +} diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts index ae296f24d..e3b64a9f6 100644 --- a/src/services/native/InternxtAuthCredentialsModule.ts +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -4,6 +4,7 @@ export interface InternxtAuthCredentials { bearerToken: string; userId: string; bridgeUser: string; + mnemonic: string; rootFolderUuid: string; email?: string | null; driveBaseUrl: string; diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index 81234b3aa..777daab0c 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -52,6 +52,7 @@ async function syncNativeCredentials(token: string, user: UserSettings): Promise bearerToken: token, userId: user.userId, bridgeUser: user.bridgeUser, + mnemonic: user.mnemonic, rootFolderUuid: user.rootFolderUuid, email: user.email, driveBaseUrl: appService.constants.DRIVE_NEW_API_URL, From ee5665d7d018194ccdb51f2ca350bcfe0bb0dfe5 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 21 May 2026 01:26:09 -0400 Subject: [PATCH 24/80] feat(api): update download links retrieval to use new /info endpoint and enhance error handling --- .../documents/InternxtDocumentsProvider.kt | 51 +++++++++--- .../cloud/documents/api/InternxtApiClient.kt | 77 ++++++++++++------- .../documents/api/InternxtApiClientTest.kt | 12 ++- .../documents/auth/InternxtAuthManagerTest.kt | 1 + 4 files changed, 104 insertions(+), 37 deletions(-) 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 c03e3487b..1a1292468 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 @@ -39,6 +39,10 @@ class InternxtDocumentsProvider : DocumentsProvider() { Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } } + private val openExecutor = Executors.newCachedThreadPool { r -> + Thread(r, "InternxtDocsProvider-open").apply { isDaemon = true } + } + private val folderLoads = ConcurrentHashMap() private enum class LoadState { LOADING, DONE, ERROR } @@ -368,21 +372,52 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw UnsupportedOperationException("Only read-only access is supported (mode=$mode)") } - val cfg = authManager.loadAuthConfig() ?: throw FileNotFoundException("Not authenticated") + val decoded = DocumentId.decode(id) + if (decoded?.kind != DocumentId.Kind.FILE) { + throw FileNotFoundException("openDocument requires a file id (got=$id)") + } + val fileUuid = decoded.uuid + + val future = openExecutor.submit { + openDocumentBlocking(ctx, id, mode, signal, fileUuid) + } + return try { + future.get() + } catch (e: java.util.concurrent.ExecutionException) { + val cause = e.cause + when (cause) { + is FileNotFoundException -> throw cause + is UnsupportedOperationException -> throw cause + null -> throw FileNotFoundException("openDocument failed: ${e.message}") + else -> throw FileNotFoundException("openDocument failed: ${cause.javaClass.simpleName}: ${cause.message}").apply { initCause(cause) } + } + } + } + + private fun openDocumentBlocking( + ctx: android.content.Context, + id: String, + mode: String?, + signal: CancellationSignal?, + fileUuid: String, + ): ParcelFileDescriptor { + val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException("Not authenticated") + if (cfg.mnemonic.isBlank()) { + throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") + } val api = InternxtApiClient(cfg) val file = try { - api.getFile(id) ?: throw FileNotFoundException("File not found: $id") + api.getFile(fileUuid) ?: throw FileNotFoundException("File not found: $fileUuid") } catch (e: InternxtApiException) { - throw FileNotFoundException("getFile $id failed: ${e.message}") + throw FileNotFoundException("getFile $fileUuid failed: ${e.message}") } - val bucket = file.bucket ?: throw FileNotFoundException("File $id has no bucket") - val fileId = file.fileId ?: throw FileNotFoundException("File $id has no fileId") - val updatedAt = file.updatedAt ?: throw FileNotFoundException("File $id has no updatedAt") + val bucket = file.bucket ?: throw FileNotFoundException("File $fileUuid has no bucket") + val fileId = file.fileId ?: throw FileNotFoundException("File $fileUuid has no fileId") + val updatedAt = file.updatedAt ?: throw FileNotFoundException("File $fileUuid has no updatedAt") val cacheFile = DocumentCache.cacheFileFor(ctx, id, updatedAt) if (cacheFile.exists() && cacheFile.length() > 0) { - Log.d(TAG, "openDocument cache hit id=$id") return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) } @@ -403,7 +438,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { tempEnc.delete() DocumentCache.pruneSiblings(ctx, id, cacheFile) - Log.d(TAG, "openDocument cached id=$id size=${cacheFile.length()}") return ParcelFileDescriptor.open( cacheFile, ParcelFileDescriptor.MODE_READ_ONLY, @@ -414,7 +448,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } } catch (e: Exception) { tempEnc.delete(); tempDec.delete() - Log.w(TAG, "openDocument $id failed: ${e.javaClass.simpleName}: ${e.message}") if (e is FileNotFoundException) throw e throw FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } } 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 b8338a5ef..bf218ac17 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 @@ -113,9 +113,37 @@ class InternxtApiClient( } fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { - val url = bridgeUrl("buckets/$bucketId/files/$fileId/mirrors") - val body = executeApiRequest(bridgeRequest(url).get().build()) - return parseDownloadLinks(body) + val url = bridgeUrl("buckets/$bucketId/files/$fileId/info") + val request = bridgeRequest(url).header("x-api-version", "2").get().build() + val body = executeApiRequest(request) + + val index = body.optStringOrNull("index") + ?: throw InternxtApiException.ApiError(500, "Bridge /info response missing 'index'") + val version = body.optInt("version", 1) + if (version != 2) { + throw InternxtApiException.ApiError(409, "File version=$version is not supported (V2 only)") + } + + val shardsJson = body.optJSONArray("shards") ?: JSONArray() + if (shardsJson.length() == 0) { + throw InternxtApiException.ApiError(404, "No shards returned for file $fileId") + } + val shards = shardsJson.map { obj -> + Shard( + index = obj.optInt("index"), + size = obj.optLongFlexible("size"), + hash = obj.optString("hash"), + url = obj.optString("url") + ) + } + + return DownloadLinks( + bucket = bucketId, + index = index, + size = body.optLongFlexible("size"), + version = version, + shards = shards + ) } private fun driveRequest(url: okhttp3.HttpUrl): Request.Builder = @@ -140,6 +168,16 @@ class InternxtApiClient( private fun bridgeUrl(path: String) = "${config.bridgeBaseUrl.trimEnd('/')}/$path".toHttpUrl() private fun executeApiRequest(request: Request): JSONObject { + val bodyStr = executeRaw(request) + return if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) + } + + private fun executeApiRequestArray(request: Request): JSONArray { + val bodyStr = executeRaw(request) + return if (bodyStr.isBlank()) JSONArray() else JSONArray(bodyStr) + } + + private fun executeRaw(request: Request): String { val response: Response = try { client.newCall(request).execute() } catch (e: IOException) { @@ -148,7 +186,7 @@ class InternxtApiClient( response.use { resp -> val bodyStr = resp.body?.string().orEmpty() when (resp.code) { - in 200..299 -> return if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) + in 200..299 -> return bodyStr 401 -> throw InternxtApiException.UnauthorizedException() 404 -> throw InternxtApiException.NotFoundException() else -> throw InternxtApiException.ApiError(resp.code, bodyStr) @@ -177,32 +215,19 @@ class InternxtApiClient( fileId = obj.optStringOrNull("fileId") ) - private fun parseDownloadLinks(obj: JSONObject): DownloadLinks { - val shardsJson = obj.optJSONArray("shards") ?: JSONArray() - val shards = shardsJson.map { - Shard( - index = it.optInt("index"), - size = it.optLongFlexible("size"), - hash = it.optString("hash"), - url = it.optString("url") - ) - } - return DownloadLinks( - bucket = obj.optString("bucket"), - index = obj.optString("index"), - size = obj.optLongFlexible("size"), - version = obj.optInt("version", 1), - shards = shards - ) - } - companion object { const val DEFAULT_PAGE_SIZE = 50 private val JSON = "application/json; charset=utf-8".toMediaType() - private fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .callTimeout(30, TimeUnit.SECONDS) - .build() + private val sharedClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .build() + } + + private fun defaultClient(): OkHttpClient = sharedClient } } 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 a9fd59070..f02ae1a7a 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 @@ -34,6 +34,7 @@ class InternxtApiClientTest { bearerToken = "test-token", bridgeUser = "user@example.com", userId = "1234567890", + mnemonic = "test mnemonic phrase", clientName = "drive-mobile", clientVersion = "v1.9.0", desktopToken = "desktop-token-xyz" @@ -148,15 +149,22 @@ class InternxtApiClientTest { val links = client.getDownloadLinks(BUCKET_ID, "file-id-1") + assertEquals(BUCKET_ID, links.bucket) + assertEquals("idx", links.index) + assertEquals(1024L, links.size) + assertEquals(2, links.version) assertEquals(1, links.shards.size) assertEquals("https://shard/0", links.shards[0].url) assertEquals(512L, links.shards[0].size) - val recorded = server.takeRequest() + val infoRequest = server.takeRequest() + assertEquals("/buckets/$BUCKET_ID/files/file-id-1/info", infoRequest.path) + assertEquals("2", infoRequest.getHeader("x-api-version")) + val expectedPass = "c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" val expectedAuth = "Basic " + java.util.Base64.getEncoder() .encodeToString("user@example.com:$expectedPass".toByteArray(Charsets.UTF_8)) - assertEquals(expectedAuth, recorded.getHeader("Authorization")) + assertEquals(expectedAuth, infoRequest.getHeader("Authorization")) } @Test diff --git a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt index a64a68cb5..d698d6f3b 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt @@ -19,6 +19,7 @@ class InternxtAuthManagerTest { bearerToken = "bearer-xyz", userId = "user-1", bridgeUser = USER_EMAIL, + mnemonic = "test mnemonic phrase", rootFolderUuid = "root-uuid", email = USER_EMAIL, driveBaseUrl = "https://drive.test/api", From 2e66bd27def1ceffbaff7a16128f35ab3a70ad10 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 21 May 2026 01:29:43 -0400 Subject: [PATCH 25/80] feat(crypto): refactor FileKeyDeriver to use CryptoService for key derivation and simplify methods --- .../documents/InternxtDocumentsProvider.kt | 11 ++-- .../cloud/documents/api/InternxtApiClient.kt | 20 ++----- .../cloud/documents/crypto/FileKeyDeriver.kt | 56 ++++++------------- 3 files changed, 24 insertions(+), 63 deletions(-) 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 1a1292468..0b7278b37 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 @@ -379,17 +379,15 @@ class InternxtDocumentsProvider : DocumentsProvider() { val fileUuid = decoded.uuid val future = openExecutor.submit { - openDocumentBlocking(ctx, id, mode, signal, fileUuid) + openDocumentBlocking(ctx, id, signal, fileUuid) } return try { future.get() } catch (e: java.util.concurrent.ExecutionException) { val cause = e.cause - when (cause) { - is FileNotFoundException -> throw cause - is UnsupportedOperationException -> throw cause - null -> throw FileNotFoundException("openDocument failed: ${e.message}") - else -> throw FileNotFoundException("openDocument failed: ${cause.javaClass.simpleName}: ${cause.message}").apply { initCause(cause) } + if (cause is FileNotFoundException) throw cause + throw FileNotFoundException("openDocument failed: ${cause?.message ?: e.message}").apply { + if (cause != null) initCause(cause) } } } @@ -397,7 +395,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { private fun openDocumentBlocking( ctx: android.content.Context, id: String, - mode: String?, signal: CancellationSignal?, fileUuid: String, ): ParcelFileDescriptor { 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 bf218ac17..1ae3db677 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 @@ -20,7 +20,7 @@ import java.util.concurrent.TimeUnit class InternxtApiClient( private val config: AuthConfig, - private val client: OkHttpClient = defaultClient() + private val client: OkHttpClient = sharedClient ) { fun listFolderFolders(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = @@ -168,16 +168,6 @@ class InternxtApiClient( private fun bridgeUrl(path: String) = "${config.bridgeBaseUrl.trimEnd('/')}/$path".toHttpUrl() private fun executeApiRequest(request: Request): JSONObject { - val bodyStr = executeRaw(request) - return if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) - } - - private fun executeApiRequestArray(request: Request): JSONArray { - val bodyStr = executeRaw(request) - return if (bodyStr.isBlank()) JSONArray() else JSONArray(bodyStr) - } - - private fun executeRaw(request: Request): String { val response: Response = try { client.newCall(request).execute() } catch (e: IOException) { @@ -185,8 +175,8 @@ class InternxtApiClient( } response.use { resp -> val bodyStr = resp.body?.string().orEmpty() - when (resp.code) { - in 200..299 -> return bodyStr + return when (resp.code) { + in 200..299 -> if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) 401 -> throw InternxtApiException.UnauthorizedException() 404 -> throw InternxtApiException.NotFoundException() else -> throw InternxtApiException.ApiError(resp.code, bodyStr) @@ -220,14 +210,12 @@ class InternxtApiClient( private val JSON = "application/json; charset=utf-8".toMediaType() - private val sharedClient: OkHttpClient by lazy { + internal val sharedClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .callTimeout(30, TimeUnit.SECONDS) .build() } - - private fun defaultClient(): OkHttpClient = sharedClient } } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt index 6b1439634..50d8ab1cc 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt @@ -1,8 +1,17 @@ package com.internxt.cloud.documents.crypto +import com.facebook.common.util.Hex import com.rncrypto.util.CryptoService -import java.security.MessageDigest +/** + * Derives the AES-CTR key/IV used to decrypt files from Internxt's network shards. + * Matches src/network/crypto.ts: generateFileKey. + * + * seed = PBKDF2(mnemonic, "mnemonic", 2048, 64) + * bucketKey = SHA-512( seed || bucketId ) + * fileKey = SHA-512( bucketKey[0..32] || index )[0..32] + * iv = index[0..16] + */ object FileKeyDeriver { private const val PBKDF2_SALT = "mnemonic" @@ -12,47 +21,14 @@ object FileKeyDeriver { private const val IV_LENGTH = 16 fun deriveFileKey(mnemonic: String, bucketIdHex: String, indexHex: String): ByteArray { - val seed = pbkdf2Seed(mnemonic) - val bucketKey = sha512(seed, hexDecode(bucketIdHex)) - val fileKey = sha512(bucketKey.copyOf(FILE_KEY_LENGTH), hexDecode(indexHex)) + val crypto = CryptoService.getInstance() + val seed = crypto.pbkdf2(mnemonic, PBKDF2_SALT.toByteArray(Charsets.UTF_8), PBKDF2_ROUNDS, SEED_LENGTH) + val bucketKey = crypto.sha512(listOf(seed, Hex.decodeHex(bucketIdHex))) + val fileKey = crypto.sha512(listOf(bucketKey.copyOf(FILE_KEY_LENGTH), Hex.decodeHex(indexHex))) return fileKey.copyOf(FILE_KEY_LENGTH) } - fun deriveIv(indexHex: String): ByteArray = hexDecode(indexHex).copyOf(IV_LENGTH) + fun deriveIv(indexHex: String): ByteArray = Hex.decodeHex(indexHex).copyOf(IV_LENGTH) - fun toHex(bytes: ByteArray): String { - val sb = StringBuilder(bytes.size * 2) - for (b in bytes) { - val v = b.toInt() and 0xff - sb.append(HEX[v ushr 4]) - sb.append(HEX[v and 0x0f]) - } - return sb.toString() - } - - private fun pbkdf2Seed(mnemonic: String): ByteArray = - CryptoService.getInstance().pbkdf2(mnemonic, PBKDF2_SALT.toByteArray(Charsets.UTF_8), PBKDF2_ROUNDS, SEED_LENGTH) - - private fun sha512(key: ByteArray, data: ByteArray): ByteArray { - val md = MessageDigest.getInstance("SHA-512") - md.update(key) - md.update(data) - return md.digest() - } - - private fun hexDecode(hex: String): ByteArray { - require(hex.length % 2 == 0) { "Hex string must have even length" } - val out = ByteArray(hex.length / 2) - var i = 0 - while (i < hex.length) { - val hi = Character.digit(hex[i], 16) - val lo = Character.digit(hex[i + 1], 16) - require(hi >= 0 && lo >= 0) { "Invalid hex character at position $i" } - out[i / 2] = ((hi shl 4) or lo).toByte() - i += 2 - } - return out - } - - private val HEX = "0123456789abcdef".toCharArray() + fun toHex(bytes: ByteArray): String = Hex.encodeHex(bytes, /* truncateAtFirstZero = */ false) } From adb341132e6f6c957a9401d4fc5607f2dc687385 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 21 May 2026 10:58:28 -0400 Subject: [PATCH 26/80] feat(download): refactor download logic to improve readability and error handling --- .../download/EncryptedFileDownloader.kt | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt index c57e27b98..c3aac66fa 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -23,45 +23,62 @@ object EncryptedFileDownloader { signal: CancellationSignal? ) { require(shards.isNotEmpty()) { "No shards to download" } - target.parentFile?.mkdirs() - if (target.exists()) target.delete() + prepareTarget(target) val activeCall = AtomicReference(null) signal?.setOnCancelListener { activeCall.get()?.cancel() } FileOutputStream(target, /* append = */ true).use { out -> - for (shard in shards.sortedBy { it.index }) { + shards.sortedBy { it.index }.forEach { shard -> signal?.throwIfCanceled() + downloadShard(client, shard, out, activeCall, signal) + } + out.flush() + } + } + + private fun prepareTarget(target: File) { + target.parentFile?.mkdirs() + if (target.exists() && !target.delete()) { + throw IOException("Failed to delete existing target file: ${target.absolutePath}") + } + } - val request = Request.Builder().url(shard.url).get().build() - val call = client.newCall(request) - activeCall.set(call) + private fun downloadShard( + client: OkHttpClient, + shard: Shard, + out: FileOutputStream, + activeCall: AtomicReference, + signal: CancellationSignal? + ) { + val request = Request.Builder().url(shard.url).get().build() + val call = client.newCall(request) + activeCall.set(call) - try { - call.execute().use { response -> - if (!response.isSuccessful) { - throw IOException("Shard ${shard.index} HTTP ${response.code}") - } - val body = response.body ?: throw IOException("Shard ${shard.index} empty body") - val buffer = ByteArray(COPY_BUFFER_SIZE) - body.byteStream().use { input -> - while (true) { - val read = input.read(buffer) - if (read == -1) break - out.write(buffer, 0, read) - } - } - } - } catch (e: IOException) { - if (signal?.isCanceled == true) { - throw OperationCanceledException("Download cancelled").apply { initCause(e) } - } - throw e - } finally { - activeCall.set(null) - } + try { + call.execute().use { response -> writeShardResponse(shard, response, out) } + } catch (e: IOException) { + if (signal?.isCanceled == true) { + throw OperationCanceledException("Download cancelled").apply { initCause(e) } + } + throw e + } finally { + activeCall.set(null) + } + } + + private fun writeShardResponse(shard: Shard, response: okhttp3.Response, out: FileOutputStream) { + if (!response.isSuccessful) { + throw IOException("Shard ${shard.index} HTTP ${response.code}") + } + val body = response.body ?: throw IOException("Shard ${shard.index} empty body") + val buffer = ByteArray(COPY_BUFFER_SIZE) + body.byteStream().use { input -> + var read = input.read(buffer) + while (read != -1) { + out.write(buffer, 0, read) + read = input.read(buffer) } - out.flush() } } } From f120a1eb88c5f86ada5bc887b514adf81a9bd77a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 21 May 2026 11:39:28 -0400 Subject: [PATCH 27/80] feat(download): refactor download process and enhance error handling with new HttpClients --- .../documents/InternxtDocumentsProvider.kt | 103 +++++++++++------- .../cloud/documents/api/InternxtApiClient.kt | 28 ++--- .../documents/api/InternxtApiException.kt | 1 + .../cloud/documents/cache/DocumentCache.kt | 40 +++---- .../cloud/documents/crypto/FileKeyDeriver.kt | 4 +- .../download/EncryptedFileDownloader.kt | 2 +- .../cloud/documents/http/HttpClients.kt | 24 ++++ 7 files changed, 117 insertions(+), 85 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt 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 0b7278b37..63fa200c0 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 @@ -21,14 +21,14 @@ import com.internxt.cloud.documents.api.model.TrashItem import com.internxt.cloud.documents.auth.InternxtAuthManager import com.internxt.cloud.documents.cache.DocumentCache import com.internxt.cloud.documents.crypto.FileKeyDeriver +import com.internxt.cloud.documents.crypto.toHex import com.internxt.cloud.documents.download.EncryptedFileDownloader +import com.internxt.cloud.documents.http.HttpClients import com.rncrypto.util.CryptoService -import okhttp3.OkHttpClient import java.io.File import java.io.FileNotFoundException +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.Executors class InternxtDocumentsProvider : DocumentsProvider() { @@ -54,20 +54,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private val itemKinds = ConcurrentHashMap() private lateinit var closeHandler: Handler - private val downloadClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(2, TimeUnit.MINUTES) - .writeTimeout(2, TimeUnit.MINUTES) - .callTimeout(0, TimeUnit.MILLISECONDS) - .build() - } private enum class ItemKind { FILE, FOLDER } override fun onCreate(): Boolean { val ctx = context ?: return false authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false + // Process-scoped: ContentProvider has no shutdown hook, so the thread lives until the process dies. closeHandler = Handler(HandlerThread("InternxtDocsClose").apply { start() }.looper) return true } @@ -403,56 +396,82 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") } val api = InternxtApiClient(cfg) + val file = requireFileMetadata(api, fileUuid) + + val cacheFile = DocumentCache.cacheFileFor(ctx, id, file.updatedAt) + if (cacheFile.exists() && cacheFile.length() > 0) { + return openCached(ctx, id, cacheFile) + } + materializeIntoCache(ctx, id, api, cfg.mnemonic, file, cacheFile, signal) + return openCached(ctx, id, cacheFile) + } + + private data class FileMetadata( + val bucket: String, + val fileId: String, + val updatedAt: String, + ) + private fun requireFileMetadata(api: InternxtApiClient, fileUuid: String): FileMetadata { val file = try { api.getFile(fileUuid) ?: throw FileNotFoundException("File not found: $fileUuid") } catch (e: InternxtApiException) { throw FileNotFoundException("getFile $fileUuid failed: ${e.message}") } - val bucket = file.bucket ?: throw FileNotFoundException("File $fileUuid has no bucket") - val fileId = file.fileId ?: throw FileNotFoundException("File $fileUuid has no fileId") - val updatedAt = file.updatedAt ?: throw FileNotFoundException("File $fileUuid has no updatedAt") - - val cacheFile = DocumentCache.cacheFileFor(ctx, id, updatedAt) - if (cacheFile.exists() && cacheFile.length() > 0) { - return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) - } + return FileMetadata( + bucket = file.bucket ?: throw FileNotFoundException("File $fileUuid has no bucket"), + fileId = file.fileId ?: throw FileNotFoundException("File $fileUuid has no fileId"), + updatedAt = file.updatedAt ?: throw FileNotFoundException("File $fileUuid has no updatedAt"), + ) + } + private fun materializeIntoCache( + ctx: android.content.Context, + id: String, + api: InternxtApiClient, + mnemonic: String, + file: FileMetadata, + cacheFile: File, + signal: CancellationSignal?, + ) { val (tempEnc, tempDec) = DocumentCache.tempPaths(ctx, id) try { signal?.throwIfCanceled() - val links = api.getDownloadLinks(bucket, fileId) - EncryptedFileDownloader.download(downloadClient, links.shards, tempEnc, signal) + val links = api.getDownloadLinks(file.bucket, file.fileId) + EncryptedFileDownloader.download(HttpClients.download, links.shards, tempEnc, signal) signal?.throwIfCanceled() - val key = FileKeyDeriver.deriveFileKey(cfg.mnemonic, bucket, links.index) + val key = FileKeyDeriver.deriveFileKey(mnemonic, file.bucket, links.index) val iv = FileKeyDeriver.deriveIv(links.index) - decryptBlocking(tempEnc, tempDec, FileKeyDeriver.toHex(key), FileKeyDeriver.toHex(iv)) + decryptBlocking(tempEnc, tempDec, key.toHex(), iv.toHex()) if (!tempDec.renameTo(cacheFile)) { throw FileNotFoundException("Failed to promote temp file to cache for $id") } tempEnc.delete() DocumentCache.pruneSiblings(ctx, id, cacheFile) - - return ParcelFileDescriptor.open( - cacheFile, - ParcelFileDescriptor.MODE_READ_ONLY, - closeHandler, - ) { DocumentCache.deleteTempsFor(ctx, id) } - } catch (e: OperationCanceledException) { - tempEnc.delete(); tempDec.delete() - throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } } catch (e: Exception) { - tempEnc.delete(); tempDec.delete() - if (e is FileNotFoundException) throw e - throw FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } + tempEnc.delete() + tempDec.delete() + throw when (e) { + is FileNotFoundException -> e + is OperationCanceledException -> + FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + else -> + FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } + } } } + private fun openCached(ctx: android.content.Context, id: String, cacheFile: File): ParcelFileDescriptor = + ParcelFileDescriptor.open( + cacheFile, + ParcelFileDescriptor.MODE_READ_ONLY, + closeHandler, + ) { DocumentCache.deleteTempsFor(ctx, id) } + private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { - val latch = CountDownLatch(1) - val errorRef = arrayOfNulls(1) + val done = CompletableFuture() CryptoService.getInstance().decryptFile( src.absolutePath, dst.absolutePath, @@ -460,11 +479,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { hexIv, /* runInBackground = */ true, ) { err -> - errorRef[0] = err - latch.countDown() + if (err != null) done.completeExceptionally(err) else done.complete(Unit) + } + try { + done.get() + } catch (e: java.util.concurrent.ExecutionException) { + throw e.cause ?: e } - latch.await() - errorRef[0]?.let { throw it } } private fun resolveRootProjection(projection: Array?): Array = 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 1ae3db677..61ff56d81 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 @@ -6,6 +6,7 @@ import com.internxt.cloud.documents.api.model.DriveFolder import com.internxt.cloud.documents.api.model.Shard import com.internxt.cloud.documents.api.model.TrashItem import com.internxt.cloud.documents.crypto.HashUtil +import com.internxt.cloud.documents.http.HttpClients import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -16,11 +17,10 @@ import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.util.Base64 -import java.util.concurrent.TimeUnit class InternxtApiClient( private val config: AuthConfig, - private val client: OkHttpClient = sharedClient + private val client: OkHttpClient = HttpClients.api ) { fun listFolderFolders(parentUuid: String, offset: Int = 0, limit: Int = DEFAULT_PAGE_SIZE): List = @@ -115,34 +115,34 @@ class InternxtApiClient( fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { val url = bridgeUrl("buckets/$bucketId/files/$fileId/info") val request = bridgeRequest(url).header("x-api-version", "2").get().build() - val body = executeApiRequest(request) + return parseDownloadLinks(executeApiRequest(request), bucketId, fileId) + } + private fun parseDownloadLinks(body: JSONObject, bucketId: String, fileId: String): DownloadLinks { val index = body.optStringOrNull("index") - ?: throw InternxtApiException.ApiError(500, "Bridge /info response missing 'index'") + ?: throw InternxtApiException.MalformedResponse("Bridge /info missing 'index'") val version = body.optInt("version", 1) if (version != 2) { - throw InternxtApiException.ApiError(409, "File version=$version is not supported (V2 only)") + throw InternxtApiException.MalformedResponse("File version=$version is not supported (V2 only)") } - val shardsJson = body.optJSONArray("shards") ?: JSONArray() if (shardsJson.length() == 0) { - throw InternxtApiException.ApiError(404, "No shards returned for file $fileId") + throw InternxtApiException.MalformedResponse("No shards returned for file $fileId") } val shards = shardsJson.map { obj -> Shard( index = obj.optInt("index"), size = obj.optLongFlexible("size"), hash = obj.optString("hash"), - url = obj.optString("url") + url = obj.optString("url"), ) } - return DownloadLinks( bucket = bucketId, index = index, size = body.optLongFlexible("size"), version = version, - shards = shards + shards = shards, ) } @@ -209,13 +209,5 @@ class InternxtApiClient( const val DEFAULT_PAGE_SIZE = 50 private val JSON = "application/json; charset=utf-8".toMediaType() - - internal val sharedClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .callTimeout(30, TimeUnit.SECONDS) - .build() - } } } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt index 2b4a39965..346463ccd 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt @@ -7,4 +7,5 @@ sealed class InternxtApiException(message: String, cause: Throwable? = null) : I class NotFoundException(message: String = "404 Not Found") : InternxtApiException(message) class ApiError(val code: Int, val body: String?) : InternxtApiException("HTTP $code: ${body ?: ""}") class NetworkException(cause: Throwable) : InternxtApiException("Network error", cause) + class MalformedResponse(message: String) : InternxtApiException("Malformed response: $message") } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt index fcf8c2f78..f7a96f958 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt @@ -11,41 +11,35 @@ object DocumentCache { private const val DEC_SUFFIX = ".dec" private const val ENC_SUFFIX = ".enc" - fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File { - val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR").apply { mkdirs() } - return File(dir, "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") - } + fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File = + File(cacheDir(context), "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") fun tempPaths(context: Context, uuid: String): Pair { - val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR").apply { mkdirs() } + val dir = tmpDir(context) val token = "${uuid}_${System.nanoTime()}" return File(dir, "$token$ENC_SUFFIX") to File(dir, "$token$DEC_SUFFIX") } fun pruneSiblings(context: Context, uuid: String, keep: File) { - val dir = File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR") - val children = dir.listFiles() ?: return - for (file in children) { - if (file == keep) continue - if (file.name.startsWith("${uuid}_") && file.name.endsWith(DEC_SUFFIX)) { - file.delete() - } + deleteMatching(cacheDir(context)) { + it != keep && it.name.startsWith("${uuid}_") && it.name.endsWith(DEC_SUFFIX) } } fun deleteTempsFor(context: Context, uuid: String) { - val dir = File(context.cacheDir, "$ROOT_DIR/$TMP_DIR") - val children = dir.listFiles() ?: return - for (file in children) { - if (file.name.startsWith("${uuid}_")) file.delete() - } + deleteMatching(tmpDir(context)) { it.name.startsWith("${uuid}_") } } - fun slugFromUpdatedAt(updatedAt: String): String { - val sb = StringBuilder(updatedAt.length) - for (c in updatedAt) { - if (c.isLetterOrDigit()) sb.append(c) - } - return if (sb.isEmpty()) "0" else sb.toString() + private fun cacheDir(context: Context): File = + File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR").apply { mkdirs() } + + private fun tmpDir(context: Context): File = + File(context.cacheDir, "$ROOT_DIR/$TMP_DIR").apply { mkdirs() } + + private inline fun deleteMatching(dir: File, predicate: (File) -> Boolean) { + dir.listFiles()?.forEach { if (predicate(it)) it.delete() } } + + private fun slugFromUpdatedAt(updatedAt: String): String = + updatedAt.filter { it.isLetterOrDigit() }.ifEmpty { "0" } } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt index 50d8ab1cc..1a54930da 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt @@ -29,6 +29,6 @@ object FileKeyDeriver { } fun deriveIv(indexHex: String): ByteArray = Hex.decodeHex(indexHex).copyOf(IV_LENGTH) - - fun toHex(bytes: ByteArray): String = Hex.encodeHex(bytes, /* truncateAtFirstZero = */ false) } + +fun ByteArray.toHex(): String = Hex.encodeHex(this, /* truncateAtFirstZero = */ false) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt index c3aac66fa..281647ea9 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -58,7 +58,7 @@ object EncryptedFileDownloader { try { call.execute().use { response -> writeShardResponse(shard, response, out) } } catch (e: IOException) { - if (signal?.isCanceled == true) { + if (signal != null && signal.isCanceled) { throw OperationCanceledException("Download cancelled").apply { initCause(e) } } throw e diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt new file mode 100644 index 000000000..af37d302b --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt @@ -0,0 +1,24 @@ +package com.internxt.cloud.documents.http + +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object HttpClients { + + val api: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .build() + } + + val download: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.MINUTES) + .writeTimeout(2, TimeUnit.MINUTES) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + } +} From 296cfff86837ca797fd7a3ca1db3599cac2b0bee Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 24 May 2026 16:06:47 -0400 Subject: [PATCH 28/80] feat(tests): add unit tests for FileKeyDeriver key and IV derivation --- .../documents/crypto/FileKeyDeriverTest.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt new file mode 100644 index 000000000..5d14888fa --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt @@ -0,0 +1,31 @@ +package com.internxt.cloud.documents.crypto + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FileKeyDeriverTest { + + @Test + fun deriveFileKeyMatchesDriveWebForCanonicalTriple() { + val fileKey = FileKeyDeriver.deriveFileKey(MNEMONIC, BUCKET_ID, INDEX_HEX) + assertEquals(EXPECTED_FILE_KEY_HEX, fileKey.toHex().lowercase()) + } + + @Test + fun deriveIvMatchesFirst16BytesOfIndex() { + val iv = FileKeyDeriver.deriveIv(INDEX_HEX) + assertEquals(EXPECTED_IV_HEX, iv.toHex().lowercase()) + } + + companion object { + private const val MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private const val BUCKET_ID = "a1b2c3d4e5f6a1b2c1d2e3f4a5b6c7d8" + private const val INDEX_HEX = + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + private const val EXPECTED_FILE_KEY_HEX = + "186473ae7deaa32c1b5b9b1c7708c2c29098f24664ece11abfff56839c630fbb" + private const val EXPECTED_IV_HEX = "abcdef1234567890abcdef1234567890" + } +} From 8006d65bb81f18e77cc41fd3dc760606c86324b1 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 1 Jun 2026 17:37:05 -0400 Subject: [PATCH 29/80] feat(cache): enhance existingCacheFor method to retrieve non-empty cached files by UUID --- .../internxt/cloud/documents/InternxtDocumentsProvider.kt | 7 ++++++- .../com/internxt/cloud/documents/cache/DocumentCache.kt | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 63fa200c0..5466f6525 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 @@ -396,7 +396,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") } val api = InternxtApiClient(cfg) - val file = requireFileMetadata(api, fileUuid) + val file = try { + requireFileMetadata(api, fileUuid) + } catch (e: FileNotFoundException) { + DocumentCache.existingCacheFor(ctx, id)?.let { return openCached(ctx, id, it) } + throw e + } val cacheFile = DocumentCache.cacheFileFor(ctx, id, file.updatedAt) if (cacheFile.exists() && cacheFile.length() > 0) { diff --git a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt index f7a96f958..393b8b387 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt @@ -14,6 +14,11 @@ object DocumentCache { fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File = File(cacheDir(context), "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") + fun existingCacheFor(context: Context, uuid: String): File? = + cacheDir(context).listFiles() + ?.filter { it.name.startsWith("${uuid}_") && it.name.endsWith(DEC_SUFFIX) && it.length() > 0 } + ?.maxByOrNull { it.lastModified() } + fun tempPaths(context: Context, uuid: String): Pair { val dir = tmpDir(context) val token = "${uuid}_${System.nanoTime()}" From 8615411fceefe4767932dfcabca7493ad726ca21 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 28 May 2026 00:38:43 -0400 Subject: [PATCH 30/80] feat(android): add encrypted file upload flow with multipart support - add bridge API methods/models for upload start, multipart finish, and file entry creation - introduce encrypted uploader with SHA-256 part hashing and RIPEMD160 shard hash - add upload token helpers in DocumentId and JSON parsing utilities - add upload HTTP client config and Bouncy Castle RIPEMD160 dependency - add crypto callback await helper and pending upload model --- android/app/build.gradle | 1 + .../internxt/cloud/documents/DocumentId.kt | 6 + .../cloud/documents/api/InternxtApiClient.kt | 103 +++++++ .../cloud/documents/api/JsonExtensions.kt | 7 + .../documents/api/model/CreateFileEntry.kt | 15 + .../documents/api/model/FinishUploadShard.kt | 8 + .../api/model/UploadFinishResponse.kt | 6 + .../api/model/UploadStartResponse.kt | 23 ++ .../cloud/documents/api/model/UploadedPart.kt | 6 + .../documents/crypto/CryptoServiceAwait.kt | 26 ++ .../cloud/documents/crypto/Ripemd160.kt | 14 + .../cloud/documents/http/HttpClients.kt | 8 +- .../documents/upload/EncryptedFileUploader.kt | 257 ++++++++++++++++++ .../cloud/documents/upload/PendingUpload.kt | 8 + 14 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f59a10bc..10be92532 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -199,6 +199,7 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("androidx.security:security-crypto:1.1.0") + implementation("org.bouncycastle:bcprov-jdk15to18:1.78.1") testImplementation("junit:junit:4.13.2") testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") 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 index 1076c58be..84f8b7586 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt @@ -8,9 +8,15 @@ object DocumentId { private const val FOLDER_PREFIX = "f:" private const val FILE_PREFIX = "d:" + const val UPLOAD_PREFIX = "u:" fun encodeFolder(uuid: String): String = FOLDER_PREFIX + uuid fun encodeFile(uuid: String): String = FILE_PREFIX + uuid + fun encodeUpload(token: String): String = UPLOAD_PREFIX + token + + fun isUploadToken(id: String): Boolean = id.startsWith(UPLOAD_PREFIX) + fun decodeUpload(id: String): String? = + if (isUploadToken(id)) id.removePrefix(UPLOAD_PREFIX) else null fun decode(id: String): Decoded? = when { id.startsWith(FOLDER_PREFIX) -> Decoded(Kind.FOLDER, id.removePrefix(FOLDER_PREFIX)) 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 61ff56d81..a55103441 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 @@ -1,10 +1,15 @@ package com.internxt.cloud.documents.api +import com.internxt.cloud.documents.api.model.CreateFileEntry import com.internxt.cloud.documents.api.model.DownloadLinks import com.internxt.cloud.documents.api.model.DriveFile import com.internxt.cloud.documents.api.model.DriveFolder +import com.internxt.cloud.documents.api.model.FinishUploadShard import com.internxt.cloud.documents.api.model.Shard import com.internxt.cloud.documents.api.model.TrashItem +import com.internxt.cloud.documents.api.model.UploadFinishResponse +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadStartResponse import com.internxt.cloud.documents.crypto.HashUtil import com.internxt.cloud.documents.http.HttpClients import okhttp3.HttpUrl.Companion.toHttpUrl @@ -112,6 +117,104 @@ class InternxtApiClient( executeApiRequest(req) } + fun startUpload(bucketId: String, encryptedSize: Long, parts: Int = 1): UploadStartResponse { + require(parts >= 1) { "parts must be >= 1" } + val payload = JSONObject().put( + "uploads", + JSONArray().put( + JSONObject() + .put("index", 0) + .put("size", encryptedSize) + ) + ) + val url = bridgeUrl("v2/buckets/$bucketId/files/start") + .newBuilder() + .addQueryParameter("multiparts", parts.toString()) + .build() + val req = bridgeRequest(url) + .post(payload.toString().toRequestBody(JSON)) + .build() + return parseUploadStart(executeApiRequest(req)) + } + + fun finishUpload( + bucketId: String, + indexHex: String, + shards: List, + ): UploadFinishResponse { + require(shards.isNotEmpty()) { "shards cannot be empty" } + val shardsJson = JSONArray() + for (shard in shards) { + val obj = JSONObject() + .put("uuid", shard.uuid) + .put("hash", shard.hash) + if (shard.uploadId != null) obj.put("UploadId", shard.uploadId) + if (shard.parts != null) { + val parts = JSONArray() + shard.parts.sortedBy { it.partNumber }.forEach { part -> + parts.put( + JSONObject() + .put("PartNumber", part.partNumber) + .put("ETag", part.etag) + ) + } + obj.put("parts", parts) + } + shardsJson.put(obj) + } + val payload = JSONObject() + .put("index", indexHex) + .put("shards", shardsJson) + val req = bridgeRequest(bridgeUrl("v2/buckets/$bucketId/files/finish")) + .post(payload.toString().toRequestBody(JSON)) + .build() + val body = executeApiRequest(req) + val id = body.optStringOrNull("id") + ?: throw InternxtApiException.MalformedResponse("Bridge /finish missing 'id'") + return UploadFinishResponse(id = id, bucket = body.optStringOrNull("bucket")) + } + + fun createFileEntry(entry: CreateFileEntry): DriveFile { + val payload = JSONObject() + .put("fileId", entry.fileId) + .put("type", entry.type) + .put("size", entry.size) + .put("plainName", entry.plainName) + .put("bucket", entry.bucket) + .put("folderUuid", entry.folderUuid) + .put("encryptVersion", entry.encryptVersion) + entry.modificationTime?.let { payload.put("modificationTime", it) } + entry.creationTime?.let { payload.put("creationTime", it) } + val req = driveRequest(driveUrl("files")) + .post(payload.toString().toRequestBody(JSON)) + .build() + return parseFile(executeApiRequest(req)) + } + + private fun parseUploadStart(body: JSONObject): UploadStartResponse { + val uploadsJson = body.optJSONArray("uploads") + ?: throw InternxtApiException.MalformedResponse("Bridge /start missing 'uploads'") + return UploadStartResponse(uploads = uploadsJson.map(::parseUploadSlot)) + } + + private fun parseUploadSlot(obj: JSONObject): UploadSlot { + val index = obj.optInt("index") + val uuid = obj.requireString("uuid") + return when (val urlsArr = obj.optJSONArray("urls")) { + null -> UploadSlot.Single( + index = index, + uuid = uuid, + url = obj.requireString("url"), + ) + else -> UploadSlot.Multipart( + index = index, + uuid = uuid, + urls = urlsArr.toStringList(), + uploadId = obj.requireString("UploadId"), + ) + } + } + fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { val url = bridgeUrl("buckets/$bucketId/files/$fileId/info") val request = bridgeRequest(url).header("x-api-version", "2").get().build() diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt index 7c7760cd8..e9fb0ebe1 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt @@ -14,6 +14,13 @@ internal inline fun JSONArray.map(transform: (JSONObject) -> T): List { internal fun JSONObject.optStringOrNull(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } +internal fun JSONObject.requireString( + key: String, + error: String = "Missing required field: $key", +): String = optStringOrNull(key) ?: throw InternxtApiException.MalformedResponse(error) + +internal fun JSONArray.toStringList(): List = List(length()) { getString(it) } + internal fun JSONObject.optLongFlexible(key: String): Long = when (val v = opt(key)) { is Number -> v.toLong() is String -> v.toLongOrNull() ?: 0L diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt new file mode 100644 index 000000000..eb04042b2 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt @@ -0,0 +1,15 @@ +package com.internxt.cloud.documents.api.model + +const val ENCRYPT_VERSION_AES03 = "Aes03" + +data class CreateFileEntry( + val fileId: String, + val type: String, + val size: Long, + val plainName: String, + val bucket: String, + val folderUuid: String, + val encryptVersion: String = ENCRYPT_VERSION_AES03, + val modificationTime: String? = null, + val creationTime: String? = null, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt new file mode 100644 index 000000000..67255c84a --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt @@ -0,0 +1,8 @@ +package com.internxt.cloud.documents.api.model + +data class FinishUploadShard( + val uuid: String, + val hash: String, + val uploadId: String? = null, + val parts: List? = null, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt new file mode 100644 index 000000000..b0217e9e6 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt @@ -0,0 +1,6 @@ +package com.internxt.cloud.documents.api.model + +data class UploadFinishResponse( + val id: String, + val bucket: String?, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt new file mode 100644 index 000000000..4f2e1c56f --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt @@ -0,0 +1,23 @@ +package com.internxt.cloud.documents.api.model + +data class UploadStartResponse( + val uploads: List, +) + +sealed class UploadSlot { + abstract val index: Int + abstract val uuid: String + + data class Single( + override val index: Int, + override val uuid: String, + val url: String, + ) : UploadSlot() + + data class Multipart( + override val index: Int, + override val uuid: String, + val urls: List, + val uploadId: String, + ) : UploadSlot() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt new file mode 100644 index 000000000..55d2c6e81 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt @@ -0,0 +1,6 @@ +package com.internxt.cloud.documents.api.model + +data class UploadedPart( + val partNumber: Int, + val etag: String, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt new file mode 100644 index 000000000..5b040f1ab --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt @@ -0,0 +1,26 @@ +package com.internxt.cloud.documents.crypto + +import com.rncrypto.util.OnlyErrorCallback +import java.io.IOException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException + +/** + * Bridges `CryptoService.encryptFile` / `decryptFile` (callback-based) to a blocking call. + * Pass `runInBackground = false` to those methods — the caller is already on a background + * executor thread, so there's no point bouncing onto the rn-crypto thread pool just to + * block on it. Any [Throwable] surfaced by the callback is rethrown as [IOException] + * preserving the original cause. + */ +internal inline fun awaitCryptoService( + failureMessage: String, + invoke: (OnlyErrorCallback) -> Unit, +) { + val done = CompletableFuture() + invoke { err -> if (err != null) done.completeExceptionally(err) else done.complete(Unit) } + try { + done.get() + } catch (e: ExecutionException) { + throw (e.cause as? IOException) ?: IOException(failureMessage, e.cause) + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt new file mode 100644 index 000000000..f9ffca98f --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.documents.crypto + +import org.bouncycastle.crypto.digests.RIPEMD160Digest + +object Ripemd160 { + + fun digest(input: ByteArray): ByteArray { + val md = RIPEMD160Digest() + md.update(input, 0, input.size) + val out = ByteArray(md.digestSize) + md.doFinal(out, 0) + return out + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt index af37d302b..4413d4328 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt @@ -13,12 +13,14 @@ object HttpClients { .build() } - val download: OkHttpClient by lazy { + val download: OkHttpClient by lazy { largeTransferClient(writeTimeoutMinutes = 2L) } + val upload: OkHttpClient by lazy { largeTransferClient(writeTimeoutMinutes = 0L) } + + private fun largeTransferClient(writeTimeoutMinutes: Long): OkHttpClient = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(2, TimeUnit.MINUTES) - .writeTimeout(2, TimeUnit.MINUTES) + .writeTimeout(writeTimeoutMinutes, TimeUnit.MINUTES) .callTimeout(0, TimeUnit.MILLISECONDS) .build() - } } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt new file mode 100644 index 000000000..a3f954c8a --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt @@ -0,0 +1,257 @@ +package com.internxt.cloud.documents.upload + +import android.os.CancellationSignal +import android.os.OperationCanceledException +import com.internxt.cloud.documents.api.model.UploadedPart +import com.internxt.cloud.documents.crypto.Ripemd160 +import com.internxt.cloud.documents.crypto.awaitCryptoService +import com.internxt.cloud.documents.crypto.toHex +import com.rncrypto.util.CryptoService +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.RandomAccessFile +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicReference + +object EncryptedFileUploader { + + internal const val COPY_BUFFER_SIZE = 16 * 1024 + private val OCTET_STREAM = "application/octet-stream".toMediaType() + + data class Encrypted( + val size: Long, + val wholeSha256Hex: String, + ) + + /** + * Encrypt [plain] → [tempEnc] using the official `@internxt/rn-crypto` CryptoService — + * the same code path the RN side calls into (NetworkFacade.ts -> encryptFile). The + * package does not return a digest, so SHA-256 over the ciphertext is computed here + * by streaming the produced file back through MessageDigest. + */ + fun encryptFile(plain: File, tempEnc: File, key: ByteArray, iv: ByteArray): Encrypted { + prepareTarget(tempEnc) + awaitCryptoService("Encryption failed") { cb -> + CryptoService.getInstance().encryptFile( + plain.absolutePath, + tempEnc.absolutePath, + key.toHex(), + iv.toHex(), + /* runInBackground = */ false, + cb, + ) + } + return Encrypted(size = tempEnc.length(), wholeSha256Hex = computeSha256Hex(tempEnc)) + } + + private fun computeSha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(COPY_BUFFER_SIZE) + file.inputStream().use { input -> + while (true) { + val n = input.read(buffer) + if (n == -1) break + digest.update(buffer, 0, n) + } + } + return digest.digest().toHex() + } + + fun computePartSha256(tempEnc: File, partSize: Long): List { + require(partSize > 0) { "partSize must be > 0" } + val total = tempEnc.length() + if (total == 0L) return emptyList() + val hashes = mutableListOf() + val buffer = ByteArray(COPY_BUFFER_SIZE) + RandomAccessFile(tempEnc, "r").use { raf -> + var consumed = 0L + while (consumed < total) { + val partEnd = (consumed + partSize).coerceAtMost(total) + val digest = MessageDigest.getInstance("SHA-256") + var partRemaining = partEnd - consumed + while (partRemaining > 0) { + val toRead = partRemaining.coerceAtMost(buffer.size.toLong()).toInt() + val n = raf.read(buffer, 0, toRead) + if (n == -1) throw IOException("Unexpected EOF computing part hashes") + digest.update(buffer, 0, n) + partRemaining -= n + consumed += n + } + hashes.add(digest.digest().toHex()) + } + } + return hashes + } + + fun uploadSingle( + client: OkHttpClient, + tempEnc: File, + url: String, + signal: CancellationSignal?, + onProgress: ((Long) -> Unit)? = null, + ) { + val active = AtomicReference(null) + signal?.setOnCancelListener { active.get()?.cancel() } + val body = fileRangeBody(tempEnc, 0L, tempEnc.length(), onProgress, baseSent = 0L) + val request = Request.Builder().url(url).put(body).build() + runCall(client, request, signal, active) { /* ETag not needed for single */ } + } + + fun uploadMultipart( + client: OkHttpClient, + tempEnc: File, + urls: List, + partSize: Long, + signal: CancellationSignal?, + onProgress: ((Long) -> Unit)? = null, + ): List { + require(urls.isNotEmpty()) { "urls cannot be empty" } + val total = tempEnc.length() + val active = AtomicReference(null) + signal?.setOnCancelListener { active.get()?.cancel() } + + val parts = ArrayList(urls.size) + var offset = 0L + urls.forEachIndexed { index, url -> + signal?.throwIfCanceled() + val length = (total - offset).coerceAtMost(partSize) + require(length > 0) { "Computed empty part for index $index" } + val body = fileRangeBody(tempEnc, offset, length, onProgress, baseSent = offset) + val request = Request.Builder().url(url).put(body).build() + var etag: String? = null + runCall(client, request, signal, active) { response -> + etag = response.header("ETag") ?: response.header("Etag") + ?: throw IOException("Part ${index + 1} missing ETag in response") + } + parts.add(UploadedPart(partNumber = index + 1, etag = etag!!)) + offset += length + } + return parts + } + + /** + * `ripemd160(hex_decode(concat(sha256_hex_per_part)))`. For single-part upload, + * pass a 1-element list with the whole-file SHA-256. + */ + fun computeShardHash(partHashesHex: List): String { + require(partHashesHex.isNotEmpty()) { "no part hashes" } + val concatenated = partHashesHex.joinToString(separator = "") + return Ripemd160.digest(hexDecode(concatenated)).toHex() + } + + private inline fun runCall( + client: OkHttpClient, + request: Request, + signal: CancellationSignal?, + active: AtomicReference, + onResponse: (okhttp3.Response) -> Unit, + ) { + val call = client.newCall(request) + active.set(call) + try { + call.execute().use { response -> + if (!response.isSuccessful) { + throw IOException("PUT failed HTTP ${response.code}") + } + onResponse(response) + } + } catch (e: IOException) { + if (signal != null && signal.isCanceled) { + throw OperationCanceledException("Upload cancelled").apply { initCause(e) } + } + throw e + } finally { + active.set(null) + } + } + + private fun fileRangeBody( + file: File, + offset: Long, + length: Long, + onProgress: ((Long) -> Unit)? = null, + baseSent: Long = 0L, + ): RequestBody = + object : RequestBody() { + override fun contentType() = OCTET_STREAM + override fun contentLength(): Long = length + override fun writeTo(sink: BufferedSink) { + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + if (onProgress == null) { + val limited = LimitedInputStream(raf, length) + limited.source().use { sink.writeAll(it) } + } else { + val buffer = ByteArray(COPY_BUFFER_SIZE) + var remaining = length + var sent = 0L + while (remaining > 0) { + val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt() + val n = raf.read(buffer, 0, toRead) + if (n == -1) throw IOException("Unexpected EOF reading upload body") + sink.write(buffer, 0, n) + remaining -= n + sent += n + onProgress(baseSent + sent) + } + } + } + } + } + + private fun prepareTarget(target: File) { + target.parentFile?.mkdirs() + if (target.exists() && !target.delete()) { + throw IOException("Failed to delete existing temp file: ${target.absolutePath}") + } + } + + private fun hexDecode(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Invalid hex length" } + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + val hi = Character.digit(hex[i * 2], 16) + val lo = Character.digit(hex[i * 2 + 1], 16) + require(hi >= 0 && lo >= 0) { "Invalid hex char" } + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } + + private class LimitedInputStream( + private val raf: RandomAccessFile, + private var remaining: Long, + ) : InputStream() { + + override fun read(): Int { + if (remaining <= 0) return -1 + val b = raf.read() + if (b == -1) { + remaining = 0 + return -1 + } + remaining-- + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (remaining <= 0) return -1 + val toRead = remaining.coerceAtMost(len.toLong()).toInt() + val n = raf.read(b, off, toRead) + if (n == -1) { + remaining = 0 + return -1 + } + remaining -= n + return n + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt new file mode 100644 index 000000000..d52a68c7d --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt @@ -0,0 +1,8 @@ +package com.internxt.cloud.documents.upload + +data class PendingUpload( + val parentUuid: String, + val plainName: String, + val mimeType: String, + val createdAtMillis: Long = System.currentTimeMillis(), +) From d5c381f711c46f149b07a8f5a81676c1c7590210 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 1 Jun 2026 19:11:15 -0400 Subject: [PATCH 31/80] fix: correct encryption version format in CreateFileEntry --- .../com/internxt/cloud/documents/api/model/CreateFileEntry.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt index eb04042b2..f2d936744 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt @@ -1,6 +1,6 @@ package com.internxt.cloud.documents.api.model -const val ENCRYPT_VERSION_AES03 = "Aes03" +const val ENCRYPT_VERSION_AES03 = "03-aes" data class CreateFileEntry( val fileId: String, From 09db1ae8358bc3c42905f31d6816c62d0da11ed7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 5 Jun 2026 10:46:51 -0400 Subject: [PATCH 32/80] refactor(upload): streamline ETag handling in uploadMultipart method --- .../documents/upload/EncryptedFileUploader.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt index a3f954c8a..8a69a4b1a 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt @@ -126,12 +126,11 @@ object EncryptedFileUploader { require(length > 0) { "Computed empty part for index $index" } val body = fileRangeBody(tempEnc, offset, length, onProgress, baseSent = offset) val request = Request.Builder().url(url).put(body).build() - var etag: String? = null - runCall(client, request, signal, active) { response -> - etag = response.header("ETag") ?: response.header("Etag") + val etag = runCall(client, request, signal, active) { response -> + response.header("ETag") ?: response.header("Etag") ?: throw IOException("Part ${index + 1} missing ETag in response") } - parts.add(UploadedPart(partNumber = index + 1, etag = etag!!)) + parts.add(UploadedPart(partNumber = index + 1, etag = etag)) offset += length } return parts @@ -147,17 +146,17 @@ object EncryptedFileUploader { return Ripemd160.digest(hexDecode(concatenated)).toHex() } - private inline fun runCall( + private inline fun runCall( client: OkHttpClient, request: Request, signal: CancellationSignal?, active: AtomicReference, - onResponse: (okhttp3.Response) -> Unit, - ) { + onResponse: (okhttp3.Response) -> T, + ): T { val call = client.newCall(request) active.set(call) try { - call.execute().use { response -> + return call.execute().use { response -> if (!response.isSuccessful) { throw IOException("PUT failed HTTP ${response.code}") } From 2d5fb5c9987e2cf6b4fe1b0bf1b2ebfc7afc3249 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 28 May 2026 00:45:51 -0400 Subject: [PATCH 33/80] feat(android): add DocumentsProvider write/upload pipeline - enable write mode in openDocument using pending upload tokens - implement createDocument flow with unique-name resolution and stale token cleanup - add pipe-to-temp encryption + upload orchestration (single and multipart) - finish bridge upload and create drive file entry with metadata timestamps - refactor decrypt callback handling via awaitCryptoService and shared auth error constant --- .../documents/InternxtDocumentsProvider.kt | 374 +++++++++++++++++- 1 file changed, 353 insertions(+), 21 deletions(-) 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 5466f6525..60e195eec 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 @@ -1,5 +1,6 @@ package com.internxt.cloud.documents +import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.net.Uri @@ -15,19 +16,30 @@ import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider import android.util.Log import com.internxt.cloud.R +import com.internxt.cloud.documents.api.AuthConfig import com.internxt.cloud.documents.api.InternxtApiClient import com.internxt.cloud.documents.api.InternxtApiException +import com.internxt.cloud.documents.api.model.CreateFileEntry +import com.internxt.cloud.documents.api.model.FinishUploadShard import com.internxt.cloud.documents.api.model.TrashItem +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadedPart import com.internxt.cloud.documents.auth.InternxtAuthManager import com.internxt.cloud.documents.cache.DocumentCache import com.internxt.cloud.documents.crypto.FileKeyDeriver +import com.internxt.cloud.documents.crypto.awaitCryptoService import com.internxt.cloud.documents.crypto.toHex import com.internxt.cloud.documents.download.EncryptedFileDownloader import com.internxt.cloud.documents.http.HttpClients +import com.internxt.cloud.documents.upload.EncryptedFileUploader +import com.internxt.cloud.documents.upload.PendingUpload import com.rncrypto.util.CryptoService import java.io.File import java.io.FileNotFoundException -import java.util.concurrent.CompletableFuture +import java.io.IOException +import java.security.SecureRandom +import java.time.Instant +import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -44,6 +56,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private val folderLoads = ConcurrentHashMap() + private val pendingUploads = ConcurrentHashMap() private enum class LoadState { LOADING, DONE, ERROR } @@ -92,6 +105,13 @@ class InternxtDocumentsProvider : DocumentsProvider() { cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildDocumentUri(AUTHORITY, id)) Log.d(TAG, "queryDocument id=$id") + if (DocumentId.isUploadToken(id)) { + pendingUploads[DocumentId.decodeUpload(id)]?.let { pending -> + cursor.addDocumentRow(pendingUploadRow(id, pending)) + } + return cursor + } + val decoded = DocumentId.decode(id) val uuid = decoded?.uuid ?: id @@ -171,7 +191,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { loaderExecutor.execute { val api = apiClient(op = "queryChildDocuments[bg]") if (api == null) { - finishLoad(load, notifyUri, LoadState.ERROR, "Not authenticated") + finishLoad(load, notifyUri, LoadState.ERROR, NOT_AUTHENTICATED) return@execute } try { @@ -361,8 +381,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { ): ParcelFileDescriptor { val ctx = context ?: throw FileNotFoundException("No context") val id = documentId ?: throw FileNotFoundException("No document id") - if (mode != null && mode != "r") { - throw UnsupportedOperationException("Only read-only access is supported (mode=$mode)") + val effectiveMode = mode ?: "r" + if (effectiveMode.contains('w')) { + return openForWrite(ctx, id, signal) + } + if (effectiveMode != "r") { + throw UnsupportedOperationException("Unsupported mode=$effectiveMode") } val decoded = DocumentId.decode(id) @@ -386,12 +410,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private fun openDocumentBlocking( - ctx: android.content.Context, + ctx: Context, id: String, signal: CancellationSignal?, fileUuid: String, ): ParcelFileDescriptor { - val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException("Not authenticated") + val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException(NOT_AUTHENTICATED) if (cfg.mnemonic.isBlank()) { throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") } @@ -431,7 +455,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private fun materializeIntoCache( - ctx: android.content.Context, + ctx: Context, id: String, api: InternxtApiClient, mnemonic: String, @@ -468,28 +492,330 @@ class InternxtDocumentsProvider : DocumentsProvider() { } } - private fun openCached(ctx: android.content.Context, id: String, cacheFile: File): ParcelFileDescriptor = + private fun openCached(ctx: Context, id: String, cacheFile: File): ParcelFileDescriptor = ParcelFileDescriptor.open( cacheFile, ParcelFileDescriptor.MODE_READ_ONLY, closeHandler, ) { DocumentCache.deleteTempsFor(ctx, id) } - private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { - val done = CompletableFuture() - CryptoService.getInstance().decryptFile( - src.absolutePath, - dst.absolutePath, - hexKey, - hexIv, - /* runInBackground = */ true, - ) { err -> - if (err != null) done.completeExceptionally(err) else done.complete(Unit) + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + val api = apiClient("createDocument") ?: throw FileNotFoundException(NOT_AUTHENTICATED) + val parentUuid = rawUuid(parentDocumentId) + try { + api.getFolder(parentUuid) ?: throw FileNotFoundException("Parent not found: $parentDocumentId") + } catch (e: InternxtApiException) { + throw FileNotFoundException("Parent lookup failed: ${e.message}") + } + + val normalized = collapseRedundantExtensions(displayName, mimeType) + val resolvedName = pickUniqueName(api, parentUuid, normalized) + evictStalePendingUploads() + val token = UUID.randomUUID().toString() + pendingUploads[token] = PendingUpload(parentUuid, resolvedName, mimeType) + return DocumentId.encodeUpload(token) + } + + private fun evictStalePendingUploads() { + val cutoff = System.currentTimeMillis() - PENDING_UPLOAD_TTL_MS + pendingUploads.entries.removeAll { it.value.createdAtMillis < cutoff } + } + + private fun collapseRedundantExtensions(displayName: String, mimeType: String): String { + val canonicalExt = android.webkit.MimeTypeMap.getSingleton() + .getExtensionFromMimeType(mimeType)?.lowercase() + if (canonicalExt.isNullOrBlank()) return displayName + val suffix = ".$canonicalExt" + var name = displayName + while (name.length > suffix.length && name.endsWith(suffix, ignoreCase = true)) { + val candidate = name.dropLast(suffix.length) + if (candidate.endsWith(suffix, ignoreCase = true)) { + name = candidate + } else { + break + } } + return name + } + + private fun pickUniqueName(api: InternxtApiClient, parentUuid: String, requested: String): String { + val existing = HashSet() try { - done.get() - } catch (e: java.util.concurrent.ExecutionException) { - throw e.cause ?: e + streamPages({ offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { page -> + page.forEach { existing.add(joinNameType(it.plainName, it.type)) } + } + } catch (e: InternxtApiException) { + Log.w(TAG, "pickUniqueName: listing failed, using requested name. ${e.message}") + return requested + } + if (requested !in existing) return requested + val (base, ext) = splitNameExt(requested) + for (i in 1..MAX_SUFFIX_ATTEMPTS) { + val candidate = "$base ($i)$ext" + if (candidate !in existing) return candidate + } + // Backstop for the (practically impossible) case where 1..1000 are all taken — + // the backend permits duplicate names, so a UUID-suffixed name is always safe. + return "$base (${UUID.randomUUID()})$ext" + } + + private fun splitNameExt(name: String): Pair { + val dot = name.lastIndexOf('.') + val hasExt = dot > 0 && dot < name.length - 1 + return if (hasExt) name.substring(0, dot) to name.substring(dot) else name to "" + } + + private fun joinNameType(plainName: String, type: String?): String = + if (type.isNullOrBlank()) plainName else "$plainName.$type" + + private fun openForWrite( + ctx: Context, + documentId: String, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + val token = DocumentId.decodeUpload(documentId) + ?: throw FileNotFoundException("Write only supported on pending uploads: $documentId") + val pending = pendingUploads[token] + ?: throw FileNotFoundException("Unknown upload token: $token") + + try { + val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException(NOT_AUTHENTICATED) + if (cfg.mnemonic.isBlank()) { + throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") + } + val pipe = ParcelFileDescriptor.createReliablePipe() + val readEnd = pipe[0] + val writeEnd = pipe[1] + openExecutor.execute { + runUpload(ctx, token, pending, cfg, readEnd, signal) + } + return writeEnd + } catch (t: Throwable) { + pendingUploads.remove(token) + throw t + } + } + + private fun runUpload( + ctx: Context, + token: String, + pending: PendingUpload, + cfg: AuthConfig, + readEnd: ParcelFileDescriptor, + signal: CancellationSignal?, + ) { + val temps = uploadTempsFor(ctx, token) + var failure: Throwable? = null + try { + val api = InternxtApiClient(cfg) + val bucketId = resolveBucket(api, pending.parentUuid) + val crypto = prepareEncryption(cfg.mnemonic, bucketId) + val encrypted = encryptInputToTemp(temps, readEnd, crypto, signal) + val outcome = uploadEncryptedFile(api, temps.enc, bucketId, encrypted, signal) + finalizeAndRecordFile(api, pending, bucketId, crypto.indexHex, encrypted.size, outcome) + invalidateChildren(DocumentId.encodeFolder(pending.parentUuid)) + } catch (t: Throwable) { + if (t !is OperationCanceledException) { + Log.w(TAG, "upload failed token=$token: ${t.javaClass.simpleName}: ${t.message}") + } + failure = t + } finally { + closePipeEnd(readEnd, failure) + deleteTempQuietly(temps.plain) + deleteTempQuietly(temps.enc) + pendingUploads.remove(token) + } + } + + /** `tempEnc` holds the ciphertext PUT to bridge; `plain` holds the pipe's plaintext + * while we hand it to CryptoService (which only accepts file paths). */ + private data class UploadTemps(val plain: File, val enc: File) + + private fun uploadTempsFor(ctx: Context, token: String): UploadTemps { + val (enc, plain) = DocumentCache.tempPaths(ctx, token) + return UploadTemps(plain = plain, enc = enc) + } + + private fun resolveBucket(api: InternxtApiClient, parentUuid: String): String { + val parent = api.getFolder(parentUuid) + ?: throw IOException("Parent folder not found: $parentUuid") + return parent.bucket + ?: throw IOException("Parent folder $parentUuid has no bucket") + } + + private fun prepareEncryption(mnemonic: String, bucketId: String): EncryptionContext { + val indexHex = ByteArray(32).also { SecureRandom().nextBytes(it) }.toHex() + return EncryptionContext( + key = FileKeyDeriver.deriveFileKey(mnemonic, bucketId, indexHex), + iv = FileKeyDeriver.deriveIv(indexHex), + indexHex = indexHex, + ) + } + + private fun encryptInputToTemp( + temps: UploadTemps, + readEnd: ParcelFileDescriptor, + crypto: EncryptionContext, + signal: CancellationSignal?, + ): EncryptedFileUploader.Encrypted { + // Phase 1: drain the pipe into a plaintext file on disk — CryptoService is + // path-based, so we must materialize before encrypting. Cancellation lives here + // because this is the long-running streaming phase. + drainPipeToFile(readEnd, temps.plain, signal) + signal?.throwIfCanceled() + // Phase 2: hand the plaintext file to the official rn-crypto CryptoService — same + // call the RN side makes from NetworkFacade.ts, identical AES-CTR pipeline. + return EncryptedFileUploader.encryptFile(temps.plain, temps.enc, crypto.key, crypto.iv) + } + + private fun drainPipeToFile( + readEnd: ParcelFileDescriptor, + target: File, + signal: CancellationSignal?, + ) { + val buffer = ByteArray(EncryptedFileUploader.COPY_BUFFER_SIZE) + // dup() so the InputStream owns its own FD; the original `readEnd` survives for + // the eventual close()/closeWithError() in `runUpload`'s finally. + readEnd.dup().use { dup -> + ParcelFileDescriptor.AutoCloseInputStream(dup).use { source -> + target.outputStream().use { sink -> + while (true) { + signal?.throwIfCanceled() + val n = source.read(buffer) + if (n == -1) break + sink.write(buffer, 0, n) + } + sink.flush() + } + } + } + } + + private fun uploadEncryptedFile( + api: InternxtApiClient, + tempEnc: File, + bucketId: String, + encrypted: EncryptedFileUploader.Encrypted, + signal: CancellationSignal?, + ): UploadOutcome { + val partsCount = if (encrypted.size >= MULTIPART_THRESHOLD) { + ((encrypted.size + MULTIPART_PART_SIZE - 1) / MULTIPART_PART_SIZE).toInt().coerceAtLeast(1) + } else 1 + + val slot = api.startUpload(bucketId, encrypted.size, partsCount).uploads.firstOrNull() + ?: throw IOException("Bridge /start returned no upload slot") + + return when (slot) { + is UploadSlot.Single -> { + EncryptedFileUploader.uploadSingle(HttpClients.upload, tempEnc, slot.url, signal) + UploadOutcome.Single(slot.uuid, listOf(encrypted.wholeSha256Hex)) + } + is UploadSlot.Multipart -> { + val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, MULTIPART_PART_SIZE) + val parts = EncryptedFileUploader.uploadMultipart( + HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, signal, + ) + UploadOutcome.Multipart(slot.uuid, partHashes, parts, slot.uploadId) + } + } + } + + private fun finalizeAndRecordFile( + api: InternxtApiClient, + pending: PendingUpload, + bucketId: String, + indexHex: String, + encryptedSize: Long, + outcome: UploadOutcome, + ) { + val hash = EncryptedFileUploader.computeShardHash(outcome.partHashes) + val shard = when (outcome) { + is UploadOutcome.Single -> FinishUploadShard(uuid = outcome.slotUuid, hash = hash) + is UploadOutcome.Multipart -> FinishUploadShard( + uuid = outcome.slotUuid, + hash = hash, + uploadId = outcome.uploadId, + parts = outcome.parts, + ) + } + val finish = api.finishUpload(bucketId, indexHex, listOf(shard)) + + val nowIso = Instant.now().toString() + val (basePlain, ext) = splitNameExt(pending.plainName) + api.createFileEntry( + CreateFileEntry( + fileId = finish.id, + type = ext.removePrefix("."), + size = encryptedSize, + plainName = basePlain, + bucket = bucketId, + folderUuid = pending.parentUuid, + modificationTime = nowIso, + creationTime = nowIso, + ) + ) + } + + private fun closePipeEnd(readEnd: ParcelFileDescriptor, failure: Throwable?) { + try { + if (failure != null) { + readEnd.closeWithError(failure.message ?: "Upload failed") + } else { + readEnd.close() + } + } catch (e: IOException) { + Log.w(TAG, "closing readEnd failed: ${e.message}") + } + } + + private fun deleteTempQuietly(tempEnc: File) { + if (!tempEnc.delete() && tempEnc.exists()) { + Log.w(TAG, "Failed to delete temp upload file: ${tempEnc.absolutePath}") + } + } + + private data class EncryptionContext( + val key: ByteArray, + val iv: ByteArray, + val indexHex: String, + ) + + private sealed class UploadOutcome { + abstract val slotUuid: String + abstract val partHashes: List + + data class Single( + override val slotUuid: String, + override val partHashes: List, + ) : UploadOutcome() + + data class Multipart( + override val slotUuid: String, + override val partHashes: List, + val parts: List, + val uploadId: String, + ) : UploadOutcome() + } + + private fun pendingUploadRow(documentId: String, pending: PendingUpload): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to documentId, + Document.COLUMN_MIME_TYPE to pending.mimeType, + Document.COLUMN_DISPLAY_NAME to pending.plainName, + Document.COLUMN_LAST_MODIFIED to null, + Document.COLUMN_FLAGS to 0, + Document.COLUMN_SIZE to null, + ) + + private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { + awaitCryptoService("Decryption failed") { cb -> + CryptoService.getInstance().decryptFile( + src.absolutePath, + dst.absolutePath, + hexKey, + hexIv, + /* runInBackground = */ false, + cb, + ) } } @@ -504,6 +830,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { private const val ROOT_ID = "internxt-root" private const val TAG = "InternxtDocsProvider" + private const val MULTIPART_THRESHOLD = 100L * 1024L * 1024L + private const val MULTIPART_PART_SIZE = 30L * 1024L * 1024L + private const val MAX_SUFFIX_ATTEMPTS = 1000 + private const val PENDING_UPLOAD_TTL_MS = 60L * 60L * 1000L + private const val NOT_AUTHENTICATED = "Not authenticated" + private val DEFAULT_ROOT_PROJECTION = arrayOf( Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, From 16103bda7facaf045f81e9288cbf49bbe2fcec9e Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 28 May 2026 00:52:05 -0400 Subject: [PATCH 34/80] feat(android): add foreground upload notifications with cancel and progress tracking - add UploadForegroundService + CancelUploadReceiver and manifest declarations - request Android 13+ POST_NOTIFICATIONS permission before uploads - wire DocumentsProvider upload flow to foreground service with throttled encrypt/upload progress - map upload failures to user-friendly localized error messages - add upload notification/error string resources and Android API helper in AppService --- android/app/src/main/AndroidManifest.xml | 10 + .../documents/InternxtDocumentsProvider.kt | 89 ++++++- .../documents/upload/CancelUploadReceiver.kt | 17 ++ .../upload/UploadForegroundService.kt | 245 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 11 + src/components/modals/AddModal/index.tsx | 13 +- src/services/AppService.ts | 4 + 7 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ecfbf5bb..b444ff764 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,9 @@ + + + @@ -59,6 +62,13 @@ + + + UploadForegroundService.reportProgress( + token, UploadForegroundService.Phase.ENCRYPTING, bytes, 0L, + ) + } + drainPipeToFile(readEnd, temps.plain, signal, onProgress) + signal.throwIfCanceled() // Phase 2: hand the plaintext file to the official rn-crypto CryptoService — same // call the RN side makes from NetworkFacade.ts, identical AES-CTR pipeline. return EncryptedFileUploader.encryptFile(temps.plain, temps.enc, crypto.key, crypto.iv) @@ -671,19 +683,23 @@ class InternxtDocumentsProvider : DocumentsProvider() { private fun drainPipeToFile( readEnd: ParcelFileDescriptor, target: File, - signal: CancellationSignal?, + signal: CancellationSignal, + onProgress: (Long) -> Unit, ) { val buffer = ByteArray(EncryptedFileUploader.COPY_BUFFER_SIZE) + var written = 0L // dup() so the InputStream owns its own FD; the original `readEnd` survives for // the eventual close()/closeWithError() in `runUpload`'s finally. readEnd.dup().use { dup -> ParcelFileDescriptor.AutoCloseInputStream(dup).use { source -> target.outputStream().use { sink -> while (true) { - signal?.throwIfCanceled() + signal.throwIfCanceled() val n = source.read(buffer) if (n == -1) break sink.write(buffer, 0, n) + written += n + onProgress(written) } sink.flush() } @@ -692,11 +708,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private fun uploadEncryptedFile( + token: String, api: InternxtApiClient, tempEnc: File, bucketId: String, encrypted: EncryptedFileUploader.Encrypted, - signal: CancellationSignal?, + signal: CancellationSignal, ): UploadOutcome { val partsCount = if (encrypted.size >= MULTIPART_THRESHOLD) { ((encrypted.size + MULTIPART_PART_SIZE - 1) / MULTIPART_PART_SIZE).toInt().coerceAtLeast(1) @@ -705,15 +722,23 @@ class InternxtDocumentsProvider : DocumentsProvider() { val slot = api.startUpload(bucketId, encrypted.size, partsCount).uploads.firstOrNull() ?: throw IOException("Bridge /start returned no upload slot") + val onProgress = throttledProgress { bytes -> + UploadForegroundService.reportProgress( + token, UploadForegroundService.Phase.UPLOADING, bytes, encrypted.size, + ) + } + return when (slot) { is UploadSlot.Single -> { - EncryptedFileUploader.uploadSingle(HttpClients.upload, tempEnc, slot.url, signal) + EncryptedFileUploader.uploadSingle( + HttpClients.upload, tempEnc, slot.url, signal, onProgress, + ) UploadOutcome.Single(slot.uuid, listOf(encrypted.wholeSha256Hex)) } is UploadSlot.Multipart -> { val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, MULTIPART_PART_SIZE) val parts = EncryptedFileUploader.uploadMultipart( - HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, signal, + HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, signal, onProgress, ) UploadOutcome.Multipart(slot.uuid, partHashes, parts, slot.uploadId) } @@ -774,6 +799,45 @@ class InternxtDocumentsProvider : DocumentsProvider() { } } + private fun notifyServiceOfOutcome(ctx: Context, token: String, failure: Throwable?) { + when { + failure == null -> UploadForegroundService.complete(token) + failure is OperationCanceledException -> UploadForegroundService.complete(token) + else -> UploadForegroundService.fail(token, friendlyUploadError(ctx, failure)) + } + } + + private fun friendlyUploadError(ctx: Context, t: Throwable): String { + if (t is InternxtApiException.ApiError) { + val body = t.body.orEmpty() + if (t.code == 420 || body.contains("Max space used", ignoreCase = true)) { + return ctx.getString(R.string.upload_error_storage_full) + } + if (t.code == 413) return ctx.getString(R.string.upload_error_too_large) + if (t.code in 500..599) return ctx.getString(R.string.upload_error_server) + } + if (t is InternxtApiException.UnauthorizedException) { + return ctx.getString(R.string.upload_error_signed_out) + } + if (t is InternxtApiException.NetworkException) { + return ctx.getString(R.string.upload_error_network) + } + return ctx.getString(R.string.upload_error_generic) + } + + private fun throttledProgress(emit: (Long) -> Unit): (Long) -> Unit { + var lastEmittedMs = 0L + var lastEmittedBytes = -1L + return { bytes -> + val now = System.currentTimeMillis() + if (bytes != lastEmittedBytes && now - lastEmittedMs >= PROGRESS_THROTTLE_MS) { + lastEmittedMs = now + lastEmittedBytes = bytes + emit(bytes) + } + } + } + private data class EncryptionContext( val key: ByteArray, val iv: ByteArray, @@ -832,6 +896,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { private const val MULTIPART_THRESHOLD = 100L * 1024L * 1024L private const val MULTIPART_PART_SIZE = 30L * 1024L * 1024L + private const val PROGRESS_THROTTLE_MS = 300L private const val MAX_SUFFIX_ATTEMPTS = 1000 private const val PENDING_UPLOAD_TTL_MS = 60L * 60L * 1000L private const val NOT_AUTHENTICATED = "Not authenticated" diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt new file mode 100644 index 000000000..b20a0dbdb --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt @@ -0,0 +1,17 @@ +package com.internxt.cloud.documents.upload + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class CancelUploadReceiver : BroadcastReceiver() { + + override fun onReceive(ctx: Context, intent: Intent) { + val token = intent.getStringExtra(UploadForegroundService.EXTRA_TOKEN) ?: return + val forward = Intent(ctx, UploadForegroundService::class.java) + .setAction(UploadForegroundService.ACTION_CANCEL) + .putExtra(UploadForegroundService.EXTRA_TOKEN, token) + ContextCompat.startForegroundService(ctx, forward) + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt new file mode 100644 index 000000000..1a30d83fc --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt @@ -0,0 +1,245 @@ +package com.internxt.cloud.documents.upload + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.CancellationSignal +import android.os.IBinder +import android.text.format.Formatter +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.internxt.cloud.R +import java.util.concurrent.ConcurrentHashMap + +class UploadForegroundService : Service() { + + private val notificationManager: NotificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onCreate() { + super.onCreate() + instance = this + ensureChannel(this) + cancelOrphanedNotifications() + } + + /** + * After process death, the static `uploads` map is empty but any in-flight foreground + * notification posted by a previous incarnation survives. Cancel anything in our channel + * that isn't backed by a live upload state so the tray doesn't show ghost progress bars. + */ + private fun cancelOrphanedNotifications() { + val live = uploads.values + .flatMap { listOf(it.notificationId, it.errorNotificationId) } + .toHashSet() + notificationManager.activeNotifications + .filter { it.notification.channelId == CHANNEL_ID && it.id !in live } + .forEach { notificationManager.cancel(it.id) } + } + + override fun onDestroy() { + if (instance === this) instance = null + isForeground = false + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + val token = intent?.getStringExtra(EXTRA_TOKEN) + when (action) { + ACTION_START -> { + val state = token?.let { uploads[it] } + if (state != null) { + intent.getStringExtra(EXTRA_DISPLAY_NAME)?.takeIf { it.isNotEmpty() } + ?.let { state.displayName = it } + postNotification(state, foregroundOnFirst = true) + } else { + ensureForeground() + maybeStop() + } + } + ACTION_CANCEL -> { + token?.let { signals[it]?.cancel() } + ensureForeground() + maybeStop() + } + else -> { + ensureForeground() + maybeStop() + } + } + return START_NOT_STICKY + } + + private fun ensureForeground() { + if (isForeground) return + val state = uploads.values.firstOrNull() ?: UploadState("placeholder", "") + postNotification(state, foregroundOnFirst = true) + } + + private fun postNotification(state: UploadState, foregroundOnFirst: Boolean) { + val notification = buildProgressNotification(state) + if (foregroundOnFirst && !isForeground) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + state.notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(state.notificationId, notification) + } + isForeground = true + } else { + notificationManager.notify(state.notificationId, notification) + } + } + + private fun postFailure(state: UploadState, message: String) { + // Use a distinct id so stopForeground(STOP_FOREGROUND_REMOVE) doesn't tear this down. + notificationManager.notify(state.errorNotificationId, buildErrorNotification(state, message)) + } + + private fun buildProgressNotification(state: UploadState): Notification { + val cancelPi = PendingIntent.getBroadcast( + this, + state.notificationId, + Intent(this, CancelUploadReceiver::class.java) + .setAction(ACTION_CANCEL) + .putExtra(EXTRA_TOKEN, state.token), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val title = when (state.phase) { + Phase.ENCRYPTING -> getString(R.string.upload_notification_encrypting, state.displayName) + Phase.UPLOADING -> getString(R.string.upload_notification_uploading, state.displayName) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(formatProgressText(state)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, getString(R.string.upload_notification_cancel), cancelPi) + + if (state.phase == Phase.UPLOADING && state.total > 0) { + val pct = ((state.bytes * 100L) / state.total).toInt().coerceIn(0, 100) + builder.setProgress(100, pct, false) + } else { + builder.setProgress(0, 0, true) + } + return builder.build() + } + + private fun buildErrorNotification(state: UploadState, message: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.upload_notification_failed, state.displayName)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setOngoing(false) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + } + + private fun formatProgressText(state: UploadState): String = when { + state.phase == Phase.UPLOADING && state.total > 0 -> + "${Formatter.formatShortFileSize(this, state.bytes)} / " + + Formatter.formatShortFileSize(this, state.total) + state.bytes > 0 -> Formatter.formatShortFileSize(this, state.bytes) + else -> "" + } + + private fun maybeStop() { + if (uploads.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + isForeground = false + } + } + + enum class Phase { ENCRYPTING, UPLOADING } + + private class UploadState(val token: String, var displayName: String) { + @Volatile var bytes: Long = 0L + @Volatile var total: Long = 0L + @Volatile var phase: Phase = Phase.ENCRYPTING + val notificationId: Int = stableNotificationId(token) + val errorNotificationId: Int = stableNotificationId("err:$token") + } + + companion object { + const val CHANNEL_ID = "internxt_uploads" + + const val ACTION_START = "com.internxt.cloud.documents.upload.START" + const val ACTION_CANCEL = "com.internxt.cloud.documents.upload.CANCEL" + const val EXTRA_TOKEN = "token" + const val EXTRA_DISPLAY_NAME = "display_name" + + @Volatile private var instance: UploadForegroundService? = null + @Volatile private var isForeground = false + + private val signals = ConcurrentHashMap() + private val uploads = ConcurrentHashMap() + + fun start(ctx: Context, token: String, displayName: String, signal: CancellationSignal) { + signals[token] = signal + uploads.getOrPut(token) { UploadState(token, displayName) } + val intent = Intent(ctx, UploadForegroundService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_TOKEN, token) + .putExtra(EXTRA_DISPLAY_NAME, displayName) + ContextCompat.startForegroundService(ctx, intent) + } + + fun reportProgress(token: String, phase: Phase, bytes: Long, total: Long) { + val state = uploads[token] ?: return + state.phase = phase + state.bytes = bytes + state.total = total + instance?.postNotification(state, foregroundOnFirst = false) + } + + fun complete(token: String) { + val state = uploads.remove(token) ?: run { signals.remove(token); return } + signals.remove(token) + instance?.notificationManager?.cancel(state.notificationId) + instance?.maybeStop() + } + + fun fail(token: String, message: String) { + val state = uploads.remove(token) ?: run { signals.remove(token); return } + signals.remove(token) + instance?.postFailure(state, message) + instance?.maybeStop() + } + + private fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val name = ctx.getString(R.string.upload_notification_channel_name) + nm.createNotificationChannel( + NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW) + ) + } + + private fun stableNotificationId(token: String): Int { + val h = token.hashCode() + return if (h == Int.MIN_VALUE) 1 else (h and Int.MAX_VALUE).coerceAtLeast(1) + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ae178cb3c..78d63f405 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,4 +5,15 @@ 1.10.2 contain false + Internxt uploads + Encrypting %1$s + Uploading %1$s + Upload failed: %1$s + Cancel + Your Internxt Drive is full. Free up space or upgrade your plan. + You\'ve been signed out. Open Internxt to sign in again. + This file is too large to upload. + Internxt is having trouble right now. Please try again in a moment. + No internet connection. The upload will not complete until you reconnect. + Couldn\'t upload the file. Please try again. \ No newline at end of file diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx index bf96c27c6..30c7c2b05 100644 --- a/src/components/modals/AddModal/index.tsx +++ b/src/components/modals/AddModal/index.tsx @@ -55,7 +55,7 @@ import { isValidFilename } from '../../../helpers'; import useGetColor from '../../../hooks/useColor'; import network from '../../../network'; import analytics, { DriveAnalyticsEvent } from '../../../services/AnalyticsService'; -import { constants } from '../../../services/AppService'; +import appService, { constants } from '../../../services/AppService'; import { uploadQueueService } from '../../../services/drive/file/uploadQueue.service'; import { createUploadingFiles, @@ -153,6 +153,17 @@ function AddModal(): JSX.Element { } } + // Android 13+ requires runtime permission for POST_NOTIFICATIONS. + // Fire-and-forget: a denial only hides the progress UI, the upload itself still runs. + if (appService.isAndroidApiAtLeast(33)) { + await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, { + title: 'Notifications Permission', + message: 'Internxt needs to show notifications to display upload progress', + buttonNegative: strings.buttons.cancel, + buttonPositive: strings.buttons.grant, + }); + } + const createdFileEntry = await uploadAndCreateFileEntry( destPath, name, diff --git a/src/services/AppService.ts b/src/services/AppService.ts index 697d19773..90ed0ab81 100644 --- a/src/services/AppService.ts +++ b/src/services/AppService.ts @@ -73,6 +73,10 @@ class AppService { public get isIOS() { return Platform.OS === 'ios'; } + + public isAndroidApiAtLeast(api: number): boolean { + return this.isAndroid && (Platform.Version as number) >= api; + } } const appService = new AppService(); From 69e9315bbfeb6f20cf1cd51daf9df83b4b67510f Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 28 May 2026 00:53:51 -0400 Subject: [PATCH 35/80] test(android): add unit tests for upload API and encrypted uploader - add MockWebServer tests for start/finish upload and createFileEntry requests/parsing - verify single and multipart upload request bodies, ETag collection, and part ordering - validate SHA-256 part hashing and RIPEMD160 shard hash computation parity --- .../api/InternxtApiClientUploadTest.kt | 196 ++++++++++++++++++ .../upload/EncryptedFileUploaderTest.kt | 143 +++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt diff --git a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt new file mode 100644 index 000000000..1a2173dc5 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt @@ -0,0 +1,196 @@ +package com.internxt.cloud.documents.api + +import com.internxt.cloud.documents.api.model.CreateFileEntry +import com.internxt.cloud.documents.api.model.ENCRYPT_VERSION_AES03 +import com.internxt.cloud.documents.api.model.FinishUploadShard +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadedPart +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InternxtApiClientUploadTest { + + private lateinit var server: MockWebServer + private lateinit var client: InternxtApiClient + + companion object { + private const val BUCKET_ID = "bucket-123" + private const val PARENT_UUID = "parent-uuid-1" + private const val SLOT_UUID = "slot-uuid" + private const val BUCKET_FILE_ID = "bucket-file-id" + private const val TIMESTAMP = "2026-05-25T00:00:00Z" + } + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + val base = server.url("/").toString().trimEnd('/') + client = InternxtApiClient( + AuthConfig( + driveBaseUrl = base, + bridgeBaseUrl = base, + bearerToken = "test-token", + bridgeUser = "user@example.com", + userId = "1234567890", + mnemonic = "test mnemonic phrase", + clientName = "drive-mobile", + clientVersion = "v1.9.0", + desktopToken = null, + ) + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun enqueue(body: String, code: Int = 200) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun startUploadSinglePostsExpectedBodyAndParsesSlot() { + enqueue( + """{"uploads":[{"index":0,"uuid":"$SLOT_UUID","url":"https://shard/up"}]}""" + ) + + val response = client.startUpload(BUCKET_ID, encryptedSize = 4096, parts = 1) + + assertEquals(1, response.uploads.size) + val slot = response.uploads[0] + assertTrue("expected Single slot, got ${slot::class.simpleName}", slot is UploadSlot.Single) + slot as UploadSlot.Single + assertEquals(SLOT_UUID, slot.uuid) + assertEquals("https://shard/up", slot.url) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/v2/buckets/$BUCKET_ID/files/start?multiparts=1", recorded.path) + val sent = JSONObject(recorded.body.readUtf8()) + val uploads = sent.getJSONArray("uploads") + assertEquals(1, uploads.length()) + assertEquals(0, uploads.getJSONObject(0).getInt("index")) + assertEquals(4096L, uploads.getJSONObject(0).getLong("size")) + assertNotNull(recorded.getHeader("Authorization")) + assertEquals(true, recorded.getHeader("Authorization")!!.startsWith("Basic ")) + } + + @Test + fun startUploadMultipartPassesPartsCountAndParsesUrls() { + enqueue( + """{"uploads":[{"index":0,"uuid":"$SLOT_UUID","urls":["https://p/1","https://p/2","https://p/3"],"UploadId":"up-1"}]}""" + ) + + val response = client.startUpload(BUCKET_ID, encryptedSize = 300L * 1024L * 1024L, parts = 4) + + val slot = response.uploads.single() + assertTrue("expected Multipart slot, got ${slot::class.simpleName}", slot is UploadSlot.Multipart) + slot as UploadSlot.Multipart + assertEquals(listOf("https://p/1", "https://p/2", "https://p/3"), slot.urls) + assertEquals("up-1", slot.uploadId) + + val recorded = server.takeRequest() + assertEquals("/v2/buckets/$BUCKET_ID/files/start?multiparts=4", recorded.path) + } + + @Test + fun finishUploadSingleShardBody() { + enqueue("""{"id":"$BUCKET_FILE_ID","bucket":"$BUCKET_ID"}""") + + val finish = client.finishUpload( + BUCKET_ID, + indexHex = "ab".repeat(32), + shards = listOf(FinishUploadShard(uuid = SLOT_UUID, hash = "deadbeef")), + ) + + assertEquals(BUCKET_FILE_ID, finish.id) + assertEquals(BUCKET_ID, finish.bucket) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/v2/buckets/$BUCKET_ID/files/finish", recorded.path) + val sent = JSONObject(recorded.body.readUtf8()) + assertEquals("ab".repeat(32), sent.getString("index")) + val shards = sent.getJSONArray("shards") + assertEquals(1, shards.length()) + val shard = shards.getJSONObject(0) + assertEquals(SLOT_UUID, shard.getString("uuid")) + assertEquals("deadbeef", shard.getString("hash")) + assertEquals(false, shard.has("UploadId")) + assertEquals(false, shard.has("parts")) + } + + @Test + fun finishUploadMultipartIncludesUploadIdAndParts() { + enqueue("""{"id":"$BUCKET_FILE_ID"}""") + + client.finishUpload( + BUCKET_ID, + indexHex = "cd".repeat(32), + shards = listOf( + FinishUploadShard( + uuid = SLOT_UUID, + hash = "deadbeef", + uploadId = "up-1", + parts = listOf( + UploadedPart(partNumber = 2, etag = "etag-2"), + UploadedPart(partNumber = 1, etag = "etag-1"), + ), + ) + ), + ) + + val recorded = server.takeRequest() + val shard = JSONObject(recorded.body.readUtf8()).getJSONArray("shards").getJSONObject(0) + assertEquals("up-1", shard.getString("UploadId")) + val parts = shard.getJSONArray("parts") + assertEquals(2, parts.length()) + assertEquals(1, parts.getJSONObject(0).getInt("PartNumber")) + assertEquals("etag-1", parts.getJSONObject(0).getString("ETag")) + assertEquals(2, parts.getJSONObject(1).getInt("PartNumber")) + assertEquals("etag-2", parts.getJSONObject(1).getString("ETag")) + } + + @Test + fun createFileEntryPostsToDriveFilesWithBearer() { + enqueue("""{"uuid":"new-file-uuid","plainName":"report.pdf","type":"pdf","size":4096}""") + + val created = client.createFileEntry( + CreateFileEntry( + fileId = BUCKET_FILE_ID, + type = "pdf", + size = 4096L, + plainName = "report.pdf", + bucket = BUCKET_ID, + folderUuid = PARENT_UUID, + modificationTime = TIMESTAMP, + creationTime = TIMESTAMP, + ) + ) + + assertEquals("new-file-uuid", created.uuid) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/files", recorded.path) + assertEquals("Bearer test-token", recorded.getHeader("Authorization")) + val sent = JSONObject(recorded.body.readUtf8()) + assertEquals(BUCKET_FILE_ID, sent.getString("fileId")) + assertEquals("pdf", sent.getString("type")) + assertEquals(4096L, sent.getLong("size")) + assertEquals("report.pdf", sent.getString("plainName")) + assertEquals(BUCKET_ID, sent.getString("bucket")) + assertEquals(PARENT_UUID, sent.getString("folderUuid")) + assertEquals(ENCRYPT_VERSION_AES03, sent.getString("encryptVersion")) + assertEquals(TIMESTAMP, sent.getString("modificationTime")) + assertEquals(TIMESTAMP, sent.getString("creationTime")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt new file mode 100644 index 000000000..0f1c155b0 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt @@ -0,0 +1,143 @@ +package com.internxt.cloud.documents.upload + +import com.internxt.cloud.documents.crypto.Ripemd160 +import com.internxt.cloud.documents.crypto.toHex +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Encryption itself is exercised by the `@internxt/rn-crypto` package's own test suite + * (`EncryptFileRepositoryTest`) — we don't re-verify the cipher here. These tests cover + * the upload + hashing logic, building the encrypted input with a control AES-CTR call + * so we know exactly what bytes the uploader is meant to PUT. + */ +class EncryptedFileUploaderTest { + + companion object { + private const val SHA_256 = "SHA-256" + } + + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var tempDir: File + + private val key = ByteArray(32) { it.toByte() } + private val iv = ByteArray(16) { (it + 1).toByte() } + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + client = OkHttpClient() + tempDir = Files.createTempDirectory("uploader-test").toFile() + } + + @After + fun tearDown() { + server.shutdown() + tempDir.deleteRecursively() + } + + private fun controlEncrypt(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(plain) + } + + private fun writeEncrypted(name: String, plain: ByteArray): File = + File(tempDir, name).apply { writeBytes(controlEncrypt(plain)) } + + @Test + fun uploadSinglePutsTempFileContentsAndReportsCorrectHash() { + val plain = ByteArray(8_000) { it.toByte() } + val tempEnc = writeEncrypted("single.enc", plain) + val encBytes = tempEnc.readBytes() + val sha = MessageDigest.getInstance(SHA_256).digest(encBytes) + + server.enqueue(MockResponse().setResponseCode(200)) + val url = server.url("/upload").toString() + EncryptedFileUploader.uploadSingle(client, tempEnc, url, signal = null) + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/upload", recorded.path) + assertArrayEquals(encBytes, recorded.body.readByteArray()) + + val computed = EncryptedFileUploader.computeShardHash(listOf(sha.toHex())) + val expected = Ripemd160.digest(sha).toHex() + assertEquals(expected, computed) + } + + @Test + fun uploadMultipartCollectsEtagsAndPartHashesMatchSlices() { + val partSize = 4_000L + val plain = ByteArray(13_000) { it.toByte() } + val tempEnc = writeEncrypted("multi.enc", plain) + val totalSize = tempEnc.length() + + val urls = (1..4).map { idx -> + server.enqueue(MockResponse().setResponseCode(200).setHeader("ETag", "etag-$idx")) + server.url("/part-$idx").toString() + } + + val parts = EncryptedFileUploader.uploadMultipart( + client = client, + tempEnc = tempEnc, + urls = urls, + partSize = partSize, + signal = null, + ) + + assertEquals(listOf(1, 2, 3, 4), parts.map { it.partNumber }) + assertEquals(listOf("etag-1", "etag-2", "etag-3", "etag-4"), parts.map { it.etag }) + + val sentParts = (1..4).map { server.takeRequest() } + var offset = 0 + sentParts.forEachIndexed { i, req: RecordedRequest -> + val expectedLength = ((offset + partSize).coerceAtMost(totalSize) - offset).toInt() + val body = req.body.readByteArray() + assertEquals("part ${i + 1} length", expectedLength, body.size) + assertArrayEquals( + "part ${i + 1} bytes", + tempEnc.readBytes().copyOfRange(offset, offset + expectedLength), + body, + ) + offset += expectedLength + } + + // hash parity with the RN multipart formula + val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, partSize) + val bytes = tempEnc.readBytes() + val expected = partHashes.mapIndexed { i, h -> + val start = (i * partSize).toInt() + val end = (start + partSize.toInt()).coerceAtMost(bytes.size) + assertEquals(MessageDigest.getInstance(SHA_256).digest(bytes.copyOfRange(start, end)).toHex(), h) + h + } + val computed = EncryptedFileUploader.computeShardHash(expected) + val expectedHash = Ripemd160.digest(hexDecode(expected.joinToString(""))).toHex() + assertEquals(expectedHash, computed) + } + + private fun hexDecode(hex: String): ByteArray { + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + val hi = Character.digit(hex[i * 2], 16) + val lo = Character.digit(hex[i * 2 + 1], 16) + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } +} From 1b4a08e11d7de66b9a0071040080956857dddfe3 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 2 Jun 2026 23:56:14 -0400 Subject: [PATCH 36/80] feat(android): create folders from DocumentsProvider createDocument Implement the folder branch of InternxtDocumentsProvider.createDocument so users can create folders from the Android system file picker (SAF "New folder"). When the requested mimeType is MIME_TYPE_DIR, the provider resolves a non-colliding name, POSTs via InternxtApiClient.createFolder, invalidates the parent's cached children, and returns the encoded folder document id. The existing pending-upload path is preserved for files. Extract the pure naming logic (uniqueName, splitNameExt, joinNameType) into a standalone DocumentNaming object and collapse the duplicated file/folder name resolvers into a single generic uniqueChildName helper, so the only difference between them is the list endpoint and name extractor. Add DocumentNaming unit tests covering collision suffixing, extension preservation, the bare-folder case, and the split/join edge cases. --- .../cloud/documents/DocumentNaming.kt | 34 ++++++++ .../documents/InternxtDocumentsProvider.kt | 81 +++++++++++++------ .../cloud/documents/DocumentNamingTest.kt | 64 +++++++++++++++ 3 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt new file mode 100644 index 000000000..a779edefd --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt @@ -0,0 +1,34 @@ +package com.internxt.cloud.documents + +import java.util.UUID + +/** Pure naming helpers shared by the folder and file branches of [InternxtDocumentsProvider.createDocument]. */ +object DocumentNaming { + + private const val MAX_SUFFIX_ATTEMPTS = 1000 + + /** + * Returns [requested] if it is not already in [existing]; otherwise appends a + * ` (n)` suffix (preserving any file extension) until a free name is found. + */ + fun uniqueName(requested: String, existing: Set): String { + if (requested !in existing) return requested + val (base, ext) = splitNameExt(requested) + for (i in 1..MAX_SUFFIX_ATTEMPTS) { + val candidate = "$base ($i)$ext" + if (candidate !in existing) return candidate + } + // Backstop for the (practically impossible) case where 1..1000 are all taken — + // the backend permits duplicate names, so a UUID-suffixed name is always safe. + return "$base (${UUID.randomUUID()})$ext" + } + + fun splitNameExt(name: String): Pair { + val dot = name.lastIndexOf('.') + val hasExt = dot > 0 && dot < name.length - 1 + return if (hasExt) name.substring(0, dot) to name.substring(dot) else name to "" + } + + fun joinNameType(plainName: String, type: String?): String = + if (type.isNullOrBlank()) plainName else "$plainName.$type" +} 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 076f64bfc..937c0b4b9 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 @@ -509,8 +509,38 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw FileNotFoundException("Parent lookup failed: ${e.message}") } + return if (mimeType == Document.MIME_TYPE_DIR) { + createFolderDocument(api, parentDocumentId, parentUuid, displayName) + } else { + createPendingFileDocument(api, parentUuid, mimeType, displayName) + } + } + + private fun createFolderDocument( + api: InternxtApiClient, + parentDocumentId: String, + parentUuid: String, + displayName: String, + ): String { + val resolvedName = uniqueFolderName(api, parentUuid, displayName) + val folder = try { + api.createFolder(parentUuid, resolvedName) + } catch (e: InternxtApiException) { + Log.w(TAG, "createDocument folder failed parent=$parentDocumentId: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException("Folder creation failed: ${e.message}") + } + invalidateChildren(parentDocumentId) + return DocumentId.encodeFolder(folder.uuid) + } + + private fun createPendingFileDocument( + api: InternxtApiClient, + parentUuid: String, + mimeType: String, + displayName: String, + ): String { val normalized = collapseRedundantExtensions(displayName, mimeType) - val resolvedName = pickUniqueName(api, parentUuid, normalized) + val resolvedName = uniqueFileName(api, parentUuid, normalized) evictStalePendingUploads() val token = UUID.randomUUID().toString() pendingUploads[token] = PendingUpload(parentUuid, resolvedName, mimeType) @@ -539,36 +569,36 @@ class InternxtDocumentsProvider : DocumentsProvider() { return name } - private fun pickUniqueName(api: InternxtApiClient, parentUuid: String, requested: String): String { + private fun uniqueFileName(api: InternxtApiClient, parentUuid: String, requested: String): String = + uniqueChildName("uniqueFileName", requested, { offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { + DocumentNaming.joinNameType(it.plainName, it.type) + } + + private fun uniqueFolderName(api: InternxtApiClient, parentUuid: String, requested: String): String = + uniqueChildName("uniqueFolderName", requested, { offset, size -> api.listFolderFolders(parentUuid, offset, size) }) { + it.plainName + } + + /** + * Resolves [requested] to a name that does not collide with the parent's existing children + * (listed via [listPage], named via [nameOf]). Falls back to [requested] if the listing fails. + */ + private inline fun uniqueChildName( + op: String, + requested: String, + listPage: (offset: Int, size: Int) -> List, + nameOf: (T) -> String, + ): String { val existing = HashSet() try { - streamPages({ offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { page -> - page.forEach { existing.add(joinNameType(it.plainName, it.type)) } - } + streamPages(listPage) { page -> page.forEach { existing.add(nameOf(it)) } } } catch (e: InternxtApiException) { - Log.w(TAG, "pickUniqueName: listing failed, using requested name. ${e.message}") + Log.w(TAG, "$op: listing failed, using requested name. ${e.message}") return requested } - if (requested !in existing) return requested - val (base, ext) = splitNameExt(requested) - for (i in 1..MAX_SUFFIX_ATTEMPTS) { - val candidate = "$base ($i)$ext" - if (candidate !in existing) return candidate - } - // Backstop for the (practically impossible) case where 1..1000 are all taken — - // the backend permits duplicate names, so a UUID-suffixed name is always safe. - return "$base (${UUID.randomUUID()})$ext" + return DocumentNaming.uniqueName(requested, existing) } - private fun splitNameExt(name: String): Pair { - val dot = name.lastIndexOf('.') - val hasExt = dot > 0 && dot < name.length - 1 - return if (hasExt) name.substring(0, dot) to name.substring(dot) else name to "" - } - - private fun joinNameType(plainName: String, type: String?): String = - if (type.isNullOrBlank()) plainName else "$plainName.$type" - private fun openForWrite( ctx: Context, documentId: String, @@ -766,7 +796,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { val finish = api.finishUpload(bucketId, indexHex, listOf(shard)) val nowIso = Instant.now().toString() - val (basePlain, ext) = splitNameExt(pending.plainName) + val (basePlain, ext) = DocumentNaming.splitNameExt(pending.plainName) api.createFileEntry( CreateFileEntry( fileId = finish.id, @@ -897,7 +927,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { private const val MULTIPART_THRESHOLD = 100L * 1024L * 1024L private const val MULTIPART_PART_SIZE = 30L * 1024L * 1024L private const val PROGRESS_THROTTLE_MS = 300L - private const val MAX_SUFFIX_ATTEMPTS = 1000 private const val PENDING_UPLOAD_TTL_MS = 60L * 60L * 1000L private const val NOT_AUTHENTICATED = "Not authenticated" diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt new file mode 100644 index 000000000..e5e5f3e1e --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt @@ -0,0 +1,64 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DocumentNamingTest { + + @Test + fun uniqueNameReturnsRequestedWhenNoCollision() { + assertEquals("Reports", DocumentNaming.uniqueName("Reports", setOf("Photos", "Music"))) + } + + @Test + fun uniqueNameAppendsSuffixOnCollision() { + assertEquals("Reports (1)", DocumentNaming.uniqueName("Reports", setOf("Reports"))) + } + + @Test + fun uniqueNameSkipsTakenSuffixes() { + val existing = setOf("Reports", "Reports (1)", "Reports (2)") + assertEquals("Reports (3)", DocumentNaming.uniqueName("Reports", existing)) + } + + @Test + fun uniqueNamePreservesFileExtension() { + assertEquals("report (1).pdf", DocumentNaming.uniqueName("report.pdf", setOf("report.pdf"))) + } + + @Test + fun uniqueNameForFolderHasNoExtension() { + assertEquals("My Folder (1)", DocumentNaming.uniqueName("My Folder", setOf("My Folder"))) + } + + @Test + fun splitNameExtSplitsOnLastDot() { + assertEquals("archive.tar" to ".gz", DocumentNaming.splitNameExt("archive.tar.gz")) + } + + @Test + fun splitNameExtNoExtension() { + assertEquals("My Folder" to "", DocumentNaming.splitNameExt("My Folder")) + } + + @Test + fun splitNameExtLeadingDotIsNotAnExtension() { + assertEquals(".gitignore" to "", DocumentNaming.splitNameExt(".gitignore")) + } + + @Test + fun splitNameExtTrailingDotIsNotAnExtension() { + assertEquals("name." to "", DocumentNaming.splitNameExt("name.")) + } + + @Test + fun joinNameTypeAppendsType() { + assertEquals("report.pdf", DocumentNaming.joinNameType("report", "pdf")) + } + + @Test + fun joinNameTypeWithoutTypeReturnsName() { + assertEquals("report", DocumentNaming.joinNameType("report", null)) + assertEquals("report", DocumentNaming.joinNameType("report", "")) + } +} From 7b6f18c1d04ddc3371ea029cb8d3c90ba900ab6d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 3 Jun 2026 00:01:53 -0400 Subject: [PATCH 37/80] refactor(android): remove obsolete uniqueChildName documentation --- .../com/internxt/cloud/documents/InternxtDocumentsProvider.kt | 4 ---- 1 file changed, 4 deletions(-) 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 937c0b4b9..93b13f86c 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 @@ -579,10 +579,6 @@ class InternxtDocumentsProvider : DocumentsProvider() { it.plainName } - /** - * Resolves [requested] to a name that does not collide with the parent's existing children - * (listed via [listPage], named via [nameOf]). Falls back to [requested] if the listing fails. - */ private inline fun uniqueChildName( op: String, requested: String, From 8c6940b834503ba16e66bc6ae77dcb04547e7efe Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 3 Jun 2026 00:04:06 -0400 Subject: [PATCH 38/80] test(android): update DocumentNamingTest to use constants for folder and report names --- .../internxt/cloud/documents/DocumentNamingTest.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt index e5e5f3e1e..0e8766e10 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt @@ -23,12 +23,12 @@ class DocumentNamingTest { @Test fun uniqueNamePreservesFileExtension() { - assertEquals("report (1).pdf", DocumentNaming.uniqueName("report.pdf", setOf("report.pdf"))) + assertEquals("report (1).pdf", DocumentNaming.uniqueName(REPORT_PDF, setOf(REPORT_PDF))) } @Test fun uniqueNameForFolderHasNoExtension() { - assertEquals("My Folder (1)", DocumentNaming.uniqueName("My Folder", setOf("My Folder"))) + assertEquals("$FOLDER_NAME (1)", DocumentNaming.uniqueName(FOLDER_NAME, setOf(FOLDER_NAME))) } @Test @@ -38,7 +38,7 @@ class DocumentNamingTest { @Test fun splitNameExtNoExtension() { - assertEquals("My Folder" to "", DocumentNaming.splitNameExt("My Folder")) + assertEquals(FOLDER_NAME to "", DocumentNaming.splitNameExt(FOLDER_NAME)) } @Test @@ -53,7 +53,7 @@ class DocumentNamingTest { @Test fun joinNameTypeAppendsType() { - assertEquals("report.pdf", DocumentNaming.joinNameType("report", "pdf")) + assertEquals(REPORT_PDF, DocumentNaming.joinNameType("report", "pdf")) } @Test @@ -61,4 +61,9 @@ class DocumentNamingTest { assertEquals("report", DocumentNaming.joinNameType("report", null)) assertEquals("report", DocumentNaming.joinNameType("report", "")) } + + companion object { + private const val FOLDER_NAME = "My Folder" + private const val REPORT_PDF = "report.pdf" + } } From 1e95b36503308d34386a9e32e49b34a79112c7de Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Jun 2026 20:26:05 -0400 Subject: [PATCH 39/80] fix(android): refresh file picker after uploads and folder creation Add a native signaling bridge from React Native to the Android SAF provider. Reuse provider cache invalidation for RN-originated parent folder changes. Signal after successful file uploads and folder creation, with iOS/no-module no-op behavior. Add Jest coverage for the wrapper, upload signaling, and folder signaling. --- .../com/internxt/cloud/MainApplication.kt | 2 + .../documents/InternxtDocumentsProvider.kt | 20 ++++ .../signaling/InternxtSignalingModule.kt | 35 +++++++ .../signaling/InternxtSignalingPackage.kt | 14 +++ .../utils/uploadFileUtils.signaling.spec.ts | 92 +++++++++++++++++++ .../drive/file/utils/uploadFileUtils.ts | 2 + .../driveFolder.service.signaling.spec.ts | 45 +++++++++ .../drive/folder/driveFolder.service.ts | 8 +- .../native/InternxtSignalingModule.spec.ts | 82 +++++++++++++++++ .../native/InternxtSignalingModule.ts | 22 +++++ 10 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt create mode 100644 src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts create mode 100644 src/services/drive/folder/driveFolder.service.signaling.spec.ts create mode 100644 src/services/native/InternxtSignalingModule.spec.ts create mode 100644 src/services/native/InternxtSignalingModule.ts diff --git a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt index 9a3426c08..20bc7423f 100644 --- a/android/app/src/main/java/com/internxt/cloud/MainApplication.kt +++ b/android/app/src/main/java/com/internxt/cloud/MainApplication.kt @@ -14,6 +14,7 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost import com.internxt.cloud.auth.InternxtAuthCredentialsPackage +import com.internxt.cloud.documents.signaling.InternxtSignalingPackage import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper @@ -27,6 +28,7 @@ class MainApplication : Application(), ReactApplication { PackageList(this).packages.apply { add(ShareIntentPackage()) add(InternxtAuthCredentialsPackage()) + add(InternxtSignalingPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" 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 93b13f86c..452c3046e 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 @@ -76,9 +76,15 @@ class InternxtDocumentsProvider : DocumentsProvider() { authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false // Process-scoped: ContentProvider has no shutdown hook, so the thread lives until the process dies. closeHandler = Handler(HandlerThread("InternxtDocsClose").apply { start() }.looper) + activeInstance = this return true } + override fun shutdown() { + if (activeInstance === this) activeInstance = null + super.shutdown() + } + override fun queryRoots(projection: Array?): Cursor { val cursor = MatrixCursor(resolveRootProjection(projection)) val ctx = context ?: return cursor @@ -920,6 +926,20 @@ class InternxtDocumentsProvider : DocumentsProvider() { private const val ROOT_ID = "internxt-root" private const val TAG = "InternxtDocsProvider" + @Volatile + private var activeInstance: InternxtDocumentsProvider? = null + + /** + * Entry point for the React Native app to signal that a folder's children changed + * (e.g. after uploading a file or creating a folder from the app UI). Reuses the same + * cache invalidation + notifyChange path as in-picker mutations. No-op if the provider + * is not currently alive in this process. + */ + fun signalParentChanged(folderUuid: String) { + if (folderUuid.isBlank()) return + activeInstance?.invalidateChildren(DocumentId.encodeFolder(folderUuid)) + } + private const val MULTIPART_THRESHOLD = 100L * 1024L * 1024L private const val MULTIPART_PART_SIZE = 30L * 1024L * 1024L private const val PROGRESS_THROTTLE_MS = 300L diff --git a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt new file mode 100644 index 000000000..3b783dbef --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt @@ -0,0 +1,35 @@ +package com.internxt.cloud.documents.signaling + +import android.util.Log +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.internxt.cloud.documents.InternxtDocumentsProvider + +class InternxtSignalingModule(ctx: ReactApplicationContext) : + ReactContextBaseJavaModule(ctx) { + + override fun getName() = MODULE_NAME + + @ReactMethod + fun notifyParentChanged(parentFolderUuid: String?, promise: Promise) { + val folderUuid = parentFolderUuid?.takeIf { it.isNotBlank() } ?: run { + promise.reject("E_INVALID_FOLDER", "parentFolderUuid must be a non-empty string") + return + } + try { + InternxtDocumentsProvider.signalParentChanged(folderUuid) + promise.resolve(null) + } catch (e: Exception) { + // Best-effort signal: never let a failed refresh break the JS caller. + Log.w(TAG, "notifyParentChanged failed for $folderUuid", e) + promise.resolve(null) + } + } + + companion object { + const val MODULE_NAME = "InternxtSignalingModule" + private const val TAG = "InternxtSignaling" + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt new file mode 100644 index 000000000..0e66c3d06 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.documents.signaling + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class InternxtSignalingPackage : ReactPackage { + override fun createNativeModules(context: ReactApplicationContext): List = + listOf(InternxtSignalingModule(context)) + + override fun createViewManagers(context: ReactApplicationContext): List> = + emptyList() +} diff --git a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts new file mode 100644 index 000000000..caba6db07 --- /dev/null +++ b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts @@ -0,0 +1,92 @@ +import { Action } from 'redux'; +import { Dispatch } from 'react'; +import { UploadingFile } from '../../../../types/drive/operations'; + +const mockNotifyParentChanged = jest.fn().mockResolvedValue(undefined); + +// Sibling modules not exercised by `uploadSingleFile` but pulling in heavy native/network +// chains at import time — stubbed so the unit under test loads in isolation. +jest.mock('./checkDuplicatedFiles', () => ({ checkDuplicatedFiles: jest.fn() })); +jest.mock('./prepareFilesToUpload', () => ({ prepareFilesToUpload: jest.fn() })); +jest.mock('../../../common/network/upload/upload.service', () => ({ + uploadService: { createFileEntry: jest.fn(), uploadFile: jest.fn() }, +})); + +jest.mock('../../../../store/slices/drive', () => ({ + driveActions: { + uploadFileStart: jest.fn(), + uploadFileFailed: jest.fn(), + uploadFileFinished: jest.fn(), + }, +})); + +jest.mock('../../../native/InternxtSignalingModule', () => ({ + notifyParentChanged: (...args: unknown[]) => mockNotifyParentChanged(...args), +})); + +jest.mock('../../../ErrorService', () => ({ + __esModule: true, + default: { reportError: jest.fn(), castError: (e: Error) => e }, +})); + +jest.mock('../../../AnalyticsService', () => ({ + __esModule: true, + default: { track: jest.fn() }, + DriveAnalyticsEvent: {}, +})); + +jest.mock('../../../common', () => ({ + logger: { error: jest.fn(), info: jest.fn() }, +})); + +import { uploadSingleFile } from './uploadFileUtils'; + +const buildUploadingFile = (overrides: Partial = {}): UploadingFile => ({ + id: 1, + uuid: 'file-uuid', + uri: 'file:///tmp/file.txt', + name: 'file.txt', + type: 'txt', + parentId: 10, + parentUuid: 'destination-folder-uuid', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + size: 1024, + progress: 0, + uploaded: false, + ...overrides, +}); + +describe('uploadSingleFile — picker signaling', () => { + const dispatch = jest.fn() as unknown as Dispatch; + + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + }); + + it('when a file upload succeeds, then it signals the destination folder uuid', async () => { + // given + const file = buildUploadingFile({ parentUuid: 'destination-folder-uuid' }); + const uploadFile = jest.fn().mockResolvedValue(undefined); + const uploadSuccess = jest.fn(); + + // when + await uploadSingleFile(file, dispatch, uploadFile, uploadSuccess); + + // then + expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-folder-uuid'); + }); + + it('when the file upload fails, then it does not signal the file picker', async () => { + // given + const file = buildUploadingFile(); + const uploadFile = jest.fn().mockRejectedValue(new Error('network down')); + const uploadSuccess = jest.fn(); + + // when + await expect(uploadSingleFile(file, dispatch, uploadFile, uploadSuccess)).rejects.toThrow('network down'); + + // then + expect(mockNotifyParentChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/drive/file/utils/uploadFileUtils.ts b/src/services/drive/file/utils/uploadFileUtils.ts index 1e574b2c4..8149e236a 100644 --- a/src/services/drive/file/utils/uploadFileUtils.ts +++ b/src/services/drive/file/utils/uploadFileUtils.ts @@ -18,6 +18,7 @@ import { uiActions } from '../../../../store/slices/ui'; import analyticsService, { DriveAnalyticsEvent } from '../../../AnalyticsService'; import { logger } from '../../../common'; import { uploadService } from '../../../common/network/upload/upload.service'; +import { notifyParentChanged } from '../../../native/InternxtSignalingModule'; import { EmptyFileNotAllowedError, isEmptyFilePlanError } from './emptyFileErrors'; import { FileSizeExceededError, isFileSizeExceededError } from './fileSizeErrors'; import { BucketNotFoundError } from './upload.errors'; @@ -228,6 +229,7 @@ export async function uploadSingleFile( await uploadFile(file, 'document'); } uploadSuccess(file); + void notifyParentChanged(file.parentUuid).catch(() => undefined); } catch (e) { if (e instanceof EmptyFileNotAllowedError) { dispatch(uiActions.setShowEmptyFileNotAllowedModal(true)); diff --git a/src/services/drive/folder/driveFolder.service.signaling.spec.ts b/src/services/drive/folder/driveFolder.service.signaling.spec.ts new file mode 100644 index 000000000..29e9e995f --- /dev/null +++ b/src/services/drive/folder/driveFolder.service.signaling.spec.ts @@ -0,0 +1,45 @@ +const mockNotifyParentChanged = jest.fn().mockResolvedValue(undefined); +const mockCreateFolderByUuid = jest.fn(); + +jest.mock('../../native/InternxtSignalingModule', () => ({ + notifyParentChanged: (...args: unknown[]) => mockNotifyParentChanged(...args), +})); + +jest.mock('@internxt-mobile/services/common', () => ({ + SdkManager: { + getInstance: () => ({ + storageV2: { + createFolderByUuid: (...args: unknown[]) => mockCreateFolderByUuid(...args), + }, + }), + }, +})); + +import { driveFolderService } from './driveFolder.service'; + +describe('driveFolderService.createFolder — picker signaling', () => { + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + mockCreateFolderByUuid.mockReset(); + }); + + it('when a folder is created, then it signals the parent folder uuid', async () => { + // given + mockCreateFolderByUuid.mockReturnValue([Promise.resolve({ uuid: 'new-folder', name: 'docs' })]); + + // when + await driveFolderService.createFolder('parent-folder-uuid', 'docs'); + + // then + expect(mockNotifyParentChanged).toHaveBeenCalledWith('parent-folder-uuid'); + }); + + it('when the SDK returns no result, then it rejects and does not signal', async () => { + // given + mockCreateFolderByUuid.mockReturnValue(undefined); + + // when / then + await expect(driveFolderService.createFolder('parent-folder-uuid', 'docs')).rejects.toBeDefined(); + expect(mockNotifyParentChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts index 5facefcf3..28bbf97d7 100644 --- a/src/services/drive/folder/driveFolder.service.ts +++ b/src/services/drive/folder/driveFolder.service.ts @@ -5,6 +5,7 @@ import { FolderAncestor as SdkFolderAncestor } from '@internxt/sdk/dist/drive/st import { getHeaders } from '../../../helpers/headers'; import { ModifiedFolder } from '../../../types/drive/folder'; import { constants } from '../../AppService'; +import { notifyParentChanged } from '../../native/InternxtSignalingModule'; export type FolderAncestor = SdkFolderAncestor & { parentUuid: string | null }; @@ -32,7 +33,12 @@ class DriveFolderService { parentFolderUuid: parentFolderId, plainName: folderName, }); - return sdkResult ? sdkResult[0] : Promise.reject('createFolder Sdk method did not return a valid result'); + if (!sdkResult) { + return Promise.reject('createFolder Sdk method did not return a valid result'); + } + const folder = await sdkResult[0]; + void notifyParentChanged(parentFolderId).catch(() => undefined); + return folder; } public async checkDuplicatedFolders(parentFolderUuid: string, folderNamesList: string[]) { diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts new file mode 100644 index 000000000..32aeab775 --- /dev/null +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -0,0 +1,82 @@ +type Wrapper = typeof import('./InternxtSignalingModule'); + +/** + * The wrapper resolves the native bridge at module-load time from `Platform.OS` and + * `NativeModules`, so each scenario loads a fresh copy of the module under a tailored + * `react-native` mock. + */ +const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): { wrapper: Wrapper } => { + let wrapper!: Wrapper; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: platformOS }, + NativeModules: nativeModule === undefined ? {} : { InternxtSignalingModule: nativeModule }, + })); + wrapper = require('./InternxtSignalingModule'); + }); + return { wrapper }; +}; + +describe('InternxtSignalingModule wrapper', () => { + afterEach(() => { + jest.resetModules(); + }); + + it('when on Android with a valid uuid, then it invokes the native module with that uuid', async () => { + // given + const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + + // when + await wrapper.notifyParentChanged('folder-uuid-123'); + + // then + expect(notifyParentChangedNative).toHaveBeenCalledWith('folder-uuid-123'); + }); + + it('when on iOS, then it resolves without invoking the native module', async () => { + // given + const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper('ios', { notifyParentChanged: notifyParentChangedNative }); + + // when + await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); + + // then + expect(notifyParentChangedNative).not.toHaveBeenCalled(); + }); + + it('when the uuid is empty, then it resolves without invoking the native module', async () => { + // given + const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + + // when + await expect(wrapper.notifyParentChanged('')).resolves.toBeUndefined(); + + // then + expect(notifyParentChangedNative).not.toHaveBeenCalled(); + }); + + it('when the uuid is not a string, then it resolves without invoking the native module', async () => { + // given + const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + + // when + await expect( + wrapper.notifyParentChanged(undefined as unknown as string), + ).resolves.toBeUndefined(); + + // then + expect(notifyParentChangedNative).not.toHaveBeenCalled(); + }); + + it('when the native module is absent, then it resolves without throwing', async () => { + // given + const { wrapper } = loadWrapper('android', undefined); + + // when / then + await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); + }); +}); diff --git a/src/services/native/InternxtSignalingModule.ts b/src/services/native/InternxtSignalingModule.ts new file mode 100644 index 000000000..e65ac7563 --- /dev/null +++ b/src/services/native/InternxtSignalingModule.ts @@ -0,0 +1,22 @@ +import { NativeModules, Platform } from 'react-native'; + +interface NativeBridge { + notifyParentChanged(parentFolderUuid: string): Promise; +} + +const bridge: NativeBridge | undefined = + Platform.OS === 'android' ? NativeModules.InternxtSignalingModule : undefined; + +/** + * Signals the Android file picker (SAF DocumentsProvider) that a folder's children changed + * after a mutation originated in the React Native app (file upload, folder creation), so it + * invalidates its cache and re-queries. + * + * Best-effort and cross-platform: no-op on iOS and when the native module is unavailable, + * never throws to the caller and never invokes the native side with a malformed uuid. + */ +export async function notifyParentChanged(parentFolderUuid: string): Promise { + if (!bridge) return; + if (typeof parentFolderUuid !== 'string' || parentFolderUuid.length === 0) return; + await bridge.notifyParentChanged(parentFolderUuid); +} From 13059f48fba67985c99ec45c54a599c54e586f48 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Jun 2026 20:27:28 -0400 Subject: [PATCH 40/80] refactor(test): streamline test cases by removing redundant comments --- .../native/InternxtSignalingModule.spec.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts index 32aeab775..079e9dd57 100644 --- a/src/services/native/InternxtSignalingModule.spec.ts +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -23,60 +23,44 @@ describe('InternxtSignalingModule wrapper', () => { }); it('when on Android with a valid uuid, then it invokes the native module with that uuid', async () => { - // given const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); - // when await wrapper.notifyParentChanged('folder-uuid-123'); - // then expect(notifyParentChangedNative).toHaveBeenCalledWith('folder-uuid-123'); }); it('when on iOS, then it resolves without invoking the native module', async () => { - // given const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); const { wrapper } = loadWrapper('ios', { notifyParentChanged: notifyParentChangedNative }); - // when await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); - // then expect(notifyParentChangedNative).not.toHaveBeenCalled(); }); it('when the uuid is empty, then it resolves without invoking the native module', async () => { - // given const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); - // when await expect(wrapper.notifyParentChanged('')).resolves.toBeUndefined(); - // then expect(notifyParentChangedNative).not.toHaveBeenCalled(); }); it('when the uuid is not a string, then it resolves without invoking the native module', async () => { - // given const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); - // when - await expect( - wrapper.notifyParentChanged(undefined as unknown as string), - ).resolves.toBeUndefined(); + await expect(wrapper.notifyParentChanged(undefined as unknown as string)).resolves.toBeUndefined(); - // then expect(notifyParentChangedNative).not.toHaveBeenCalled(); }); it('when the native module is absent, then it resolves without throwing', async () => { - // given const { wrapper } = loadWrapper('android', undefined); - // when / then await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); }); }); From 6dbca3123cb6de9050d7c9e0aadeefc1b1be9c3b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Jun 2026 20:37:12 -0400 Subject: [PATCH 41/80] refactor(test): simplify test setup by using arrangePresentNative function --- .../native/InternxtSignalingModule.spec.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts index 079e9dd57..2441996ff 100644 --- a/src/services/native/InternxtSignalingModule.spec.ts +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -17,45 +17,51 @@ const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): { wr return { wrapper }; }; +/** + * Builds a native bridge mock and loads the wrapper against it, returning the spy so each + * test only states the platform and asserts the resulting behaviour. + */ +const arrangePresentNative = (platformOS: 'android' | 'ios') => { + const native = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper(platformOS, { notifyParentChanged: native }); + return { wrapper, native }; +}; + describe('InternxtSignalingModule wrapper', () => { afterEach(() => { jest.resetModules(); }); it('when on Android with a valid uuid, then it invokes the native module with that uuid', async () => { - const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); - const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + const { wrapper, native } = arrangePresentNative('android'); await wrapper.notifyParentChanged('folder-uuid-123'); - expect(notifyParentChangedNative).toHaveBeenCalledWith('folder-uuid-123'); + expect(native).toHaveBeenCalledWith('folder-uuid-123'); }); it('when on iOS, then it resolves without invoking the native module', async () => { - const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); - const { wrapper } = loadWrapper('ios', { notifyParentChanged: notifyParentChangedNative }); + const { wrapper, native } = arrangePresentNative('ios'); await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); - expect(notifyParentChangedNative).not.toHaveBeenCalled(); + expect(native).not.toHaveBeenCalled(); }); it('when the uuid is empty, then it resolves without invoking the native module', async () => { - const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); - const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + const { wrapper, native } = arrangePresentNative('android'); await expect(wrapper.notifyParentChanged('')).resolves.toBeUndefined(); - expect(notifyParentChangedNative).not.toHaveBeenCalled(); + expect(native).not.toHaveBeenCalled(); }); it('when the uuid is not a string, then it resolves without invoking the native module', async () => { - const notifyParentChangedNative = jest.fn().mockResolvedValue(undefined); - const { wrapper } = loadWrapper('android', { notifyParentChanged: notifyParentChangedNative }); + const { wrapper, native } = arrangePresentNative('android'); await expect(wrapper.notifyParentChanged(undefined as unknown as string)).resolves.toBeUndefined(); - expect(notifyParentChangedNative).not.toHaveBeenCalled(); + expect(native).not.toHaveBeenCalled(); }); it('when the native module is absent, then it resolves without throwing', async () => { From f69d15bb97227746116c70d15160890a0a0cc1c3 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 4 Jun 2026 22:46:59 -0400 Subject: [PATCH 42/80] refactor(folder): improve error handling in createFolder method and clean up test cases --- .../drive/file/utils/uploadFileUtils.signaling.spec.ts | 6 ------ .../drive/folder/driveFolder.service.signaling.spec.ts | 5 ----- src/services/drive/folder/driveFolder.service.ts | 2 +- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts index caba6db07..c92ed161b 100644 --- a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts +++ b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts @@ -65,28 +65,22 @@ describe('uploadSingleFile — picker signaling', () => { }); it('when a file upload succeeds, then it signals the destination folder uuid', async () => { - // given const file = buildUploadingFile({ parentUuid: 'destination-folder-uuid' }); const uploadFile = jest.fn().mockResolvedValue(undefined); const uploadSuccess = jest.fn(); - // when await uploadSingleFile(file, dispatch, uploadFile, uploadSuccess); - // then expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-folder-uuid'); }); it('when the file upload fails, then it does not signal the file picker', async () => { - // given const file = buildUploadingFile(); const uploadFile = jest.fn().mockRejectedValue(new Error('network down')); const uploadSuccess = jest.fn(); - // when await expect(uploadSingleFile(file, dispatch, uploadFile, uploadSuccess)).rejects.toThrow('network down'); - // then expect(mockNotifyParentChanged).not.toHaveBeenCalled(); }); }); diff --git a/src/services/drive/folder/driveFolder.service.signaling.spec.ts b/src/services/drive/folder/driveFolder.service.signaling.spec.ts index 29e9e995f..4bdf9682f 100644 --- a/src/services/drive/folder/driveFolder.service.signaling.spec.ts +++ b/src/services/drive/folder/driveFolder.service.signaling.spec.ts @@ -24,21 +24,16 @@ describe('driveFolderService.createFolder — picker signaling', () => { }); it('when a folder is created, then it signals the parent folder uuid', async () => { - // given mockCreateFolderByUuid.mockReturnValue([Promise.resolve({ uuid: 'new-folder', name: 'docs' })]); - // when await driveFolderService.createFolder('parent-folder-uuid', 'docs'); - // then expect(mockNotifyParentChanged).toHaveBeenCalledWith('parent-folder-uuid'); }); it('when the SDK returns no result, then it rejects and does not signal', async () => { - // given mockCreateFolderByUuid.mockReturnValue(undefined); - // when / then await expect(driveFolderService.createFolder('parent-folder-uuid', 'docs')).rejects.toBeDefined(); expect(mockNotifyParentChanged).not.toHaveBeenCalled(); }); diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts index 28bbf97d7..ec2842786 100644 --- a/src/services/drive/folder/driveFolder.service.ts +++ b/src/services/drive/folder/driveFolder.service.ts @@ -34,7 +34,7 @@ class DriveFolderService { plainName: folderName, }); if (!sdkResult) { - return Promise.reject('createFolder Sdk method did not return a valid result'); + throw new Error('Sdk method did not return a valid result'); } const folder = await sdkResult[0]; void notifyParentChanged(parentFolderId).catch(() => undefined); From 1281bc692250c39b26fb8a1c9fa1a024174da1c2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 6 Jun 2026 14:24:22 -0400 Subject: [PATCH 43/80] fix(native): handle errors in notifyParentChanged and log warnings --- src/services/drive/file/utils/uploadFileUtils.ts | 2 +- src/services/drive/folder/driveFolder.service.ts | 2 +- .../native/InternxtSignalingModule.spec.ts | 16 ++++++++++++++++ src/services/native/InternxtSignalingModule.ts | 10 +++++++--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/services/drive/file/utils/uploadFileUtils.ts b/src/services/drive/file/utils/uploadFileUtils.ts index 8149e236a..70c6852c6 100644 --- a/src/services/drive/file/utils/uploadFileUtils.ts +++ b/src/services/drive/file/utils/uploadFileUtils.ts @@ -229,7 +229,7 @@ export async function uploadSingleFile( await uploadFile(file, 'document'); } uploadSuccess(file); - void notifyParentChanged(file.parentUuid).catch(() => undefined); + void notifyParentChanged(file.parentUuid); } catch (e) { if (e instanceof EmptyFileNotAllowedError) { dispatch(uiActions.setShowEmptyFileNotAllowedModal(true)); diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts index ec2842786..1420bd894 100644 --- a/src/services/drive/folder/driveFolder.service.ts +++ b/src/services/drive/folder/driveFolder.service.ts @@ -37,7 +37,7 @@ class DriveFolderService { throw new Error('Sdk method did not return a valid result'); } const folder = await sdkResult[0]; - void notifyParentChanged(parentFolderId).catch(() => undefined); + void notifyParentChanged(parentFolderId); return folder; } diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts index 2441996ff..49a58b718 100644 --- a/src/services/native/InternxtSignalingModule.spec.ts +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -1,3 +1,9 @@ +const mockLoggerWarn = jest.fn(); + +jest.mock('../common', () => ({ + logger: { info: jest.fn(), warn: mockLoggerWarn, error: jest.fn() }, +})); + type Wrapper = typeof import('./InternxtSignalingModule'); /** @@ -30,6 +36,7 @@ const arrangePresentNative = (platformOS: 'android' | 'ios') => { describe('InternxtSignalingModule wrapper', () => { afterEach(() => { jest.resetModules(); + mockLoggerWarn.mockReset(); }); it('when on Android with a valid uuid, then it invokes the native module with that uuid', async () => { @@ -69,4 +76,13 @@ describe('InternxtSignalingModule wrapper', () => { await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); }); + + it('when the native module rejects, then it resolves without throwing', async () => { + const { wrapper, native } = arrangePresentNative('android'); + native.mockRejectedValueOnce(new Error('E_INVALID_FOLDER')); + + await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); + + expect(mockLoggerWarn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/services/native/InternxtSignalingModule.ts b/src/services/native/InternxtSignalingModule.ts index e65ac7563..f7c6ae11c 100644 --- a/src/services/native/InternxtSignalingModule.ts +++ b/src/services/native/InternxtSignalingModule.ts @@ -1,11 +1,11 @@ import { NativeModules, Platform } from 'react-native'; +import { logger } from '../common'; interface NativeBridge { notifyParentChanged(parentFolderUuid: string): Promise; } -const bridge: NativeBridge | undefined = - Platform.OS === 'android' ? NativeModules.InternxtSignalingModule : undefined; +const bridge: NativeBridge | undefined = Platform.OS === 'android' ? NativeModules.InternxtSignalingModule : undefined; /** * Signals the Android file picker (SAF DocumentsProvider) that a folder's children changed @@ -18,5 +18,9 @@ const bridge: NativeBridge | undefined = export async function notifyParentChanged(parentFolderUuid: string): Promise { if (!bridge) return; if (typeof parentFolderUuid !== 'string' || parentFolderUuid.length === 0) return; - await bridge.notifyParentChanged(parentFolderUuid); + try { + await bridge.notifyParentChanged(parentFolderUuid); + } catch (error) { + logger.warn('InternxtSignalingModule.notifyParentChanged failed', error); + } } From 8f3c76b5452efe9c66680d171504cf4359457be4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 23:19:11 -0400 Subject: [PATCH 44/80] refactor(test): replace 'it' with 'test' for consistency in upload signaling tests --- .../drive/file/utils/uploadFileUtils.signaling.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts index c92ed161b..0a3ab85ba 100644 --- a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts +++ b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts @@ -64,7 +64,7 @@ describe('uploadSingleFile — picker signaling', () => { mockNotifyParentChanged.mockClear(); }); - it('when a file upload succeeds, then it signals the destination folder uuid', async () => { + test('when a file upload succeeds, then it signals the destination folder uuid', async () => { const file = buildUploadingFile({ parentUuid: 'destination-folder-uuid' }); const uploadFile = jest.fn().mockResolvedValue(undefined); const uploadSuccess = jest.fn(); @@ -74,7 +74,7 @@ describe('uploadSingleFile — picker signaling', () => { expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-folder-uuid'); }); - it('when the file upload fails, then it does not signal the file picker', async () => { + test('when the file upload fails, then it does not signal the file picker', async () => { const file = buildUploadingFile(); const uploadFile = jest.fn().mockRejectedValue(new Error('network down')); const uploadSuccess = jest.fn(); From 1b216f064ee5b688849a6d197a908d2444ae712a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 6 Jun 2026 17:51:19 -0400 Subject: [PATCH 45/80] refactor(android): migrate DocumentsProvider IO to kotlinx coroutines Replace CompletableFuture.get(), Call.execute() and the manual AtomicReference + setOnCancelListener cancellation plumbing with kotlinx-coroutines across the native DocumentsProvider stack: crypto, download and upload now suspend via Call.await()/CallAwait and CryptoServiceAwait, and openDocument bridges to coroutines through runBlockingIo (BlockingIo) instead of a plain runBlocking, surfacing cancellation as FileNotFoundException. Adds kotlinx-coroutines-android and kotlinx-coroutines-test to android/app/build.gradle and JVM unit tests for BlockingIo, CryptoServiceAwait, EncryptedFileDownloader and EncryptedFileUploader. NOTE: a separate, unrelated change by the maintainer lives in the same file (InternxtDocumentsProvider.kt) and could not be split out because interactive staging (git add -p) is unavailable and editing source is out of scope for the git-manager: refresh() now triggers a background revalidateChildren (new revalidate/fetchAllRows helpers and the FolderLoad.isRevalidating flag) instead of invalidateChildren. It is bundled here intentionally; see progress/git_android_coroutines_migration.md. Refs: PB-6282 --- android/app/build.gradle | 2 + .../internxt/cloud/documents/BlockingIo.kt | 16 +++ .../documents/InternxtDocumentsProvider.kt | 132 +++++++++++++----- .../documents/crypto/CryptoServiceAwait.kt | 32 ++--- .../download/EncryptedFileDownloader.kt | 36 ++--- .../cloud/documents/http/CallAwait.kt | 28 ++++ .../documents/upload/EncryptedFileUploader.kt | 50 ++----- .../cloud/documents/BlockingIoTest.kt | 33 +++++ .../crypto/CryptoServiceAwaitTest.kt | 50 +++++++ .../download/EncryptedFileDownloaderTest.kt | 92 ++++++++++++ .../upload/EncryptedFileUploaderTest.kt | 49 ++++++- 11 files changed, 398 insertions(+), 122 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/BlockingIo.kt create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 10be92532..bf3a82ccb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -200,8 +200,10 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("androidx.security:security-crypto:1.1.0") implementation("org.bouncycastle:bcprov-jdk15to18:1.78.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") testImplementation("junit:junit:4.13.2") testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") testImplementation("org.json:json:20240303") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/BlockingIo.kt b/android/app/src/main/java/com/internxt/cloud/documents/BlockingIo.kt new file mode 100644 index 000000000..698c7a384 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/BlockingIo.kt @@ -0,0 +1,16 @@ +package com.internxt.cloud.documents + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * Bridges a binder thread to a suspending body, running it on [Dispatchers.IO]. + * + * SAF entry points such as `openDocument` arrive on a binder thread governed by a StrictMode + * policy that forbids blocking network. Some collaborators (e.g. the synchronous OkHttp calls in + * InternxtApiClient) still block, so the body must not run on the caller thread or it trips + * NetworkOnMainThreadException. Plain `runBlocking { }` would do exactly that. + */ +internal fun runBlockingIo(body: suspend CoroutineScope.() -> T): T = + runBlocking(Dispatchers.IO, body) 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 452c3046e..8cad697ce 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 @@ -43,6 +43,14 @@ import java.time.Instant import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext class InternxtDocumentsProvider : DocumentsProvider() { @@ -52,9 +60,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } } - private val openExecutor = Executors.newCachedThreadPool { r -> - Thread(r, "InternxtDocsProvider-open").apply { isDaemon = true } - } + private val uploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val folderLoads = ConcurrentHashMap() private val pendingUploads = ConcurrentHashMap() @@ -64,6 +70,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { private class FolderLoad { @Volatile var state: LoadState = LoadState.LOADING @Volatile var errorMessage: String? = null + @Volatile var isRevalidating: Boolean = false val rows = mutableListOf>() } private val itemKinds = ConcurrentHashMap() @@ -82,6 +89,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { override fun shutdown() { if (activeInstance === this) activeInstance = null + uploadScope.cancel() super.shutdown() } @@ -377,10 +385,59 @@ class InternxtDocumentsProvider : DocumentsProvider() { val documentId = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { null } Log.d(TAG, "refresh uri=$uri documentId=$documentId") if (documentId == null) return false - invalidateChildren(documentId) + revalidateChildren(documentId) return true } + private fun revalidateChildren(parentDocumentId: String) { + val load = folderLoads[parentDocumentId] + if (load == null) { + notifyChildren(parentDocumentId) + return + } + synchronized(load) { + if (load.state == LoadState.LOADING || load.isRevalidating) return + load.isRevalidating = true + } + val parentUuid = rawUuid(parentDocumentId) + val notifyUri = DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId) + loaderExecutor.execute { revalidate(parentUuid, load, notifyUri) } + } + + private fun revalidate(parentUuid: String, load: FolderLoad, notifyUri: Uri) { + val api = apiClient(op = "refresh[bg]") + if (api == null) { + synchronized(load) { load.isRevalidating = false } + return + } + try { + val fresh = fetchAllRows(api, parentUuid) + synchronized(load) { + load.rows.clear() + load.rows.addAll(fresh) + load.state = LoadState.DONE + load.errorMessage = null + load.isRevalidating = false + } + context?.contentResolver?.notifyChange(notifyUri, null) + Log.d(TAG, "refresh parent=$parentUuid revalidated rows=${fresh.size}") + } catch (e: InternxtApiException) { + synchronized(load) { load.isRevalidating = false } + Log.w(TAG, "refresh parent=$parentUuid revalidation failed: ${e.javaClass.simpleName}: ${e.message}") + } + } + + private fun fetchAllRows(api: InternxtApiClient, parentUuid: String): List> { + val rows = mutableListOf>() + streamPages({ offset, size -> api.listFolderFolders(parentUuid, offset, size) }) { page -> + rows.addAll(page.map { DocumentRowBuilder.folderRow(it) }) + } + streamPages({ offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { page -> + rows.addAll(page.map { DocumentRowBuilder.fileRow(it) }) + } + return rows + } + override fun openDocument( documentId: String?, mode: String?, @@ -402,24 +459,24 @@ class InternxtDocumentsProvider : DocumentsProvider() { } val fileUuid = decoded.uuid - val future = openExecutor.submit { - openDocumentBlocking(ctx, id, signal, fileUuid) - } return try { - future.get() - } catch (e: java.util.concurrent.ExecutionException) { - val cause = e.cause - if (cause is FileNotFoundException) throw cause - throw FileNotFoundException("openDocument failed: ${cause?.message ?: e.message}").apply { - if (cause != null) initCause(cause) + runBlockingIo { + signal?.setOnCancelListener { coroutineContext.cancel() } + openDocumentSuspending(ctx, id, fileUuid) } + } catch (e: FileNotFoundException) { + throw e + } catch (e: CancellationException) { + throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + } catch (e: Exception) { + Log.w(TAG, "openDocument $id failed", e) + throw FileNotFoundException("openDocument failed: ${e.message}").apply { initCause(e) } } } - private fun openDocumentBlocking( + private suspend fun openDocumentSuspending( ctx: Context, id: String, - signal: CancellationSignal?, fileUuid: String, ): ParcelFileDescriptor { val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException(NOT_AUTHENTICATED) @@ -438,7 +495,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { if (cacheFile.exists() && cacheFile.length() > 0) { return openCached(ctx, id, cacheFile) } - materializeIntoCache(ctx, id, api, cfg.mnemonic, file, cacheFile, signal) + materializeIntoCache(ctx, id, api, cfg.mnemonic, file, cacheFile) return openCached(ctx, id, cacheFile) } @@ -461,25 +518,22 @@ class InternxtDocumentsProvider : DocumentsProvider() { ) } - private fun materializeIntoCache( + private suspend fun materializeIntoCache( ctx: Context, id: String, api: InternxtApiClient, mnemonic: String, file: FileMetadata, cacheFile: File, - signal: CancellationSignal?, ) { val (tempEnc, tempDec) = DocumentCache.tempPaths(ctx, id) try { - signal?.throwIfCanceled() val links = api.getDownloadLinks(file.bucket, file.fileId) - EncryptedFileDownloader.download(HttpClients.download, links.shards, tempEnc, signal) + EncryptedFileDownloader.download(HttpClients.download, links.shards, tempEnc) - signal?.throwIfCanceled() val key = FileKeyDeriver.deriveFileKey(mnemonic, file.bucket, links.index) val iv = FileKeyDeriver.deriveIv(links.index) - decryptBlocking(tempEnc, tempDec, key.toHex(), iv.toHex()) + decryptFile(tempEnc, tempDec, key.toHex(), iv.toHex()) if (!tempDec.renameTo(cacheFile)) { throw FileNotFoundException("Failed to promote temp file to cache for $id") @@ -491,8 +545,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { tempDec.delete() throw when (e) { is FileNotFoundException -> e - is OperationCanceledException -> - FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + is CancellationException -> e else -> FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } } @@ -619,9 +672,10 @@ class InternxtDocumentsProvider : DocumentsProvider() { val pipe = ParcelFileDescriptor.createReliablePipe() val readEnd = pipe[0] val writeEnd = pipe[1] - openExecutor.execute { - runUpload(ctx, token, pending, cfg, readEnd, signal) + val job: Job = uploadScope.launch { + runUpload(ctx, token, pending, cfg, readEnd) } + signal?.setOnCancelListener { job.cancel() } return writeEnd } catch (t: Throwable) { pendingUploads.remove(token) @@ -629,17 +683,17 @@ class InternxtDocumentsProvider : DocumentsProvider() { } } - private fun runUpload( + private suspend fun runUpload( ctx: Context, token: String, pending: PendingUpload, cfg: AuthConfig, readEnd: ParcelFileDescriptor, - signal: CancellationSignal?, ) { val temps = uploadTempsFor(ctx, token) + val uploadJob = coroutineContext[Job] val uploadSignal = CancellationSignal() - signal?.setOnCancelListener { uploadSignal.cancel() } + uploadSignal.setOnCancelListener { uploadJob?.cancel() } UploadForegroundService.start(ctx, token, pending.plainName, uploadSignal) var failure: Throwable? = null @@ -648,11 +702,11 @@ class InternxtDocumentsProvider : DocumentsProvider() { val bucketId = resolveBucket(api, pending.parentUuid) val crypto = prepareEncryption(cfg.mnemonic, bucketId) val encrypted = encryptInputToTemp(token, temps, readEnd, crypto, uploadSignal) - val outcome = uploadEncryptedFile(token, api, temps.enc, bucketId, encrypted, uploadSignal) + val outcome = uploadEncryptedFile(token, api, temps.enc, bucketId, encrypted) finalizeAndRecordFile(api, pending, bucketId, crypto.indexHex, encrypted.size, outcome) invalidateChildren(DocumentId.encodeFolder(pending.parentUuid)) } catch (t: Throwable) { - if (t !is OperationCanceledException) { + if (!isCancellation(t)) { Log.w(TAG, "upload failed token=$token: ${t.javaClass.simpleName}: ${t.message}") } failure = t @@ -665,6 +719,9 @@ class InternxtDocumentsProvider : DocumentsProvider() { } } + private fun isCancellation(t: Throwable): Boolean = + t is OperationCanceledException || t is CancellationException + /** `tempEnc` holds the ciphertext PUT to bridge; `plain` holds the pipe's plaintext * while we hand it to CryptoService (which only accepts file paths). */ private data class UploadTemps(val plain: File, val enc: File) @@ -690,7 +747,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { ) } - private fun encryptInputToTemp( + private suspend fun encryptInputToTemp( token: String, temps: UploadTemps, readEnd: ParcelFileDescriptor, @@ -739,13 +796,12 @@ class InternxtDocumentsProvider : DocumentsProvider() { } } - private fun uploadEncryptedFile( + private suspend fun uploadEncryptedFile( token: String, api: InternxtApiClient, tempEnc: File, bucketId: String, encrypted: EncryptedFileUploader.Encrypted, - signal: CancellationSignal, ): UploadOutcome { val partsCount = if (encrypted.size >= MULTIPART_THRESHOLD) { ((encrypted.size + MULTIPART_PART_SIZE - 1) / MULTIPART_PART_SIZE).toInt().coerceAtLeast(1) @@ -763,14 +819,14 @@ class InternxtDocumentsProvider : DocumentsProvider() { return when (slot) { is UploadSlot.Single -> { EncryptedFileUploader.uploadSingle( - HttpClients.upload, tempEnc, slot.url, signal, onProgress, + HttpClients.upload, tempEnc, slot.url, onProgress, ) UploadOutcome.Single(slot.uuid, listOf(encrypted.wholeSha256Hex)) } is UploadSlot.Multipart -> { val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, MULTIPART_PART_SIZE) val parts = EncryptedFileUploader.uploadMultipart( - HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, signal, onProgress, + HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, onProgress, ) UploadOutcome.Multipart(slot.uuid, partHashes, parts, slot.uploadId) } @@ -834,7 +890,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { private fun notifyServiceOfOutcome(ctx: Context, token: String, failure: Throwable?) { when { failure == null -> UploadForegroundService.complete(token) - failure is OperationCanceledException -> UploadForegroundService.complete(token) + isCancellation(failure) -> UploadForegroundService.complete(token) else -> UploadForegroundService.fail(token, friendlyUploadError(ctx, failure)) } } @@ -902,7 +958,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { Document.COLUMN_SIZE to null, ) - private fun decryptBlocking(src: File, dst: File, hexKey: String, hexIv: String) { + private suspend fun decryptFile(src: File, dst: File, hexKey: String, hexIv: String) { awaitCryptoService("Decryption failed") { cb -> CryptoService.getInstance().decryptFile( src.absolutePath, diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt index 5b040f1ab..f29fd3b26 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt @@ -2,25 +2,25 @@ package com.internxt.cloud.documents.crypto import com.rncrypto.util.OnlyErrorCallback import java.io.IOException -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine /** - * Bridges `CryptoService.encryptFile` / `decryptFile` (callback-based) to a blocking call. - * Pass `runInBackground = false` to those methods — the caller is already on a background - * executor thread, so there's no point bouncing onto the rn-crypto thread pool just to - * block on it. Any [Throwable] surfaced by the callback is rethrown as [IOException] - * preserving the original cause. + * Suspends until `CryptoService.encryptFile` / `decryptFile` (callback-based) reports an + * outcome via [OnlyErrorCallback]. No thread is blocked waiting for the callback: the rn-crypto + * callback resumes the coroutine. Any non-null error is surfaced as [IOException] preserving the + * original cause. */ -internal inline fun awaitCryptoService( +internal suspend inline fun awaitCryptoService( failureMessage: String, - invoke: (OnlyErrorCallback) -> Unit, -) { - val done = CompletableFuture() - invoke { err -> if (err != null) done.completeExceptionally(err) else done.complete(Unit) } - try { - done.get() - } catch (e: ExecutionException) { - throw (e.cause as? IOException) ?: IOException(failureMessage, e.cause) + crossinline invoke: (OnlyErrorCallback) -> Unit, +) = suspendCancellableCoroutine { continuation -> + invoke { err -> + if (err != null) { + continuation.resumeWithException((err as? IOException) ?: IOException(failureMessage, err)) + } else { + continuation.resume(Unit) + } } } diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt index 281647ea9..7a1334720 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -1,37 +1,31 @@ package com.internxt.cloud.documents.download -import android.os.CancellationSignal -import android.os.OperationCanceledException import com.internxt.cloud.documents.api.model.Shard -import okhttp3.Call +import com.internxt.cloud.documents.http.await import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext object EncryptedFileDownloader { private const val COPY_BUFFER_SIZE = 16 * 1024 - @Throws(IOException::class, OperationCanceledException::class) - fun download( + suspend fun download( client: OkHttpClient, shards: List, target: File, - signal: CancellationSignal? ) { require(shards.isNotEmpty()) { "No shards to download" } prepareTarget(target) - val activeCall = AtomicReference(null) - signal?.setOnCancelListener { activeCall.get()?.cancel() } - FileOutputStream(target, /* append = */ true).use { out -> shards.sortedBy { it.index }.forEach { shard -> - signal?.throwIfCanceled() - downloadShard(client, shard, out, activeCall, signal) + coroutineContext.ensureActive() + downloadShard(client, shard, out) } out.flush() } @@ -44,27 +38,13 @@ object EncryptedFileDownloader { } } - private fun downloadShard( + private suspend fun downloadShard( client: OkHttpClient, shard: Shard, out: FileOutputStream, - activeCall: AtomicReference, - signal: CancellationSignal? ) { val request = Request.Builder().url(shard.url).get().build() - val call = client.newCall(request) - activeCall.set(call) - - try { - call.execute().use { response -> writeShardResponse(shard, response, out) } - } catch (e: IOException) { - if (signal != null && signal.isCanceled) { - throw OperationCanceledException("Download cancelled").apply { initCause(e) } - } - throw e - } finally { - activeCall.set(null) - } + client.newCall(request).await().use { response -> writeShardResponse(shard, response, out) } } private fun writeShardResponse(shard: Shard, response: okhttp3.Response, out: FileOutputStream) { diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt new file mode 100644 index 000000000..89007652c --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt @@ -0,0 +1,28 @@ +package com.internxt.cloud.documents.http + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { response.closeQuietly() } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) + continuation.invokeOnCancellation { cancel() } +} + +private fun Response.closeQuietly() { + try { + close() + } catch (_: Throwable) { + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt index 8a69a4b1a..7d75e4be5 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt @@ -1,13 +1,11 @@ package com.internxt.cloud.documents.upload -import android.os.CancellationSignal -import android.os.OperationCanceledException import com.internxt.cloud.documents.api.model.UploadedPart import com.internxt.cloud.documents.crypto.Ripemd160 import com.internxt.cloud.documents.crypto.awaitCryptoService import com.internxt.cloud.documents.crypto.toHex +import com.internxt.cloud.documents.http.await import com.rncrypto.util.CryptoService -import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -19,7 +17,8 @@ import java.io.IOException import java.io.InputStream import java.io.RandomAccessFile import java.security.MessageDigest -import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext object EncryptedFileUploader { @@ -37,7 +36,7 @@ object EncryptedFileUploader { * package does not return a digest, so SHA-256 over the ciphertext is computed here * by streaming the produced file back through MessageDigest. */ - fun encryptFile(plain: File, tempEnc: File, key: ByteArray, iv: ByteArray): Encrypted { + suspend fun encryptFile(plain: File, tempEnc: File, key: ByteArray, iv: ByteArray): Encrypted { prepareTarget(tempEnc) awaitCryptoService("Encryption failed") { cb -> CryptoService.getInstance().encryptFile( @@ -91,42 +90,36 @@ object EncryptedFileUploader { return hashes } - fun uploadSingle( + suspend fun uploadSingle( client: OkHttpClient, tempEnc: File, url: String, - signal: CancellationSignal?, onProgress: ((Long) -> Unit)? = null, ) { - val active = AtomicReference(null) - signal?.setOnCancelListener { active.get()?.cancel() } val body = fileRangeBody(tempEnc, 0L, tempEnc.length(), onProgress, baseSent = 0L) val request = Request.Builder().url(url).put(body).build() - runCall(client, request, signal, active) { /* ETag not needed for single */ } + runCall(client, request) { /* ETag not needed for single */ } } - fun uploadMultipart( + suspend fun uploadMultipart( client: OkHttpClient, tempEnc: File, urls: List, partSize: Long, - signal: CancellationSignal?, onProgress: ((Long) -> Unit)? = null, ): List { require(urls.isNotEmpty()) { "urls cannot be empty" } val total = tempEnc.length() - val active = AtomicReference(null) - signal?.setOnCancelListener { active.get()?.cancel() } val parts = ArrayList(urls.size) var offset = 0L urls.forEachIndexed { index, url -> - signal?.throwIfCanceled() + coroutineContext.ensureActive() val length = (total - offset).coerceAtMost(partSize) require(length > 0) { "Computed empty part for index $index" } val body = fileRangeBody(tempEnc, offset, length, onProgress, baseSent = offset) val request = Request.Builder().url(url).put(body).build() - val etag = runCall(client, request, signal, active) { response -> + val etag = runCall(client, request) { response -> response.header("ETag") ?: response.header("Etag") ?: throw IOException("Part ${index + 1} missing ETag in response") } @@ -146,30 +139,15 @@ object EncryptedFileUploader { return Ripemd160.digest(hexDecode(concatenated)).toHex() } - private inline fun runCall( + private suspend fun runCall( client: OkHttpClient, request: Request, - signal: CancellationSignal?, - active: AtomicReference, onResponse: (okhttp3.Response) -> T, - ): T { - val call = client.newCall(request) - active.set(call) - try { - return call.execute().use { response -> - if (!response.isSuccessful) { - throw IOException("PUT failed HTTP ${response.code}") - } - onResponse(response) - } - } catch (e: IOException) { - if (signal != null && signal.isCanceled) { - throw OperationCanceledException("Upload cancelled").apply { initCause(e) } - } - throw e - } finally { - active.set(null) + ): T = client.newCall(request).await().use { response -> + if (!response.isSuccessful) { + throw IOException("PUT failed HTTP ${response.code}") } + onResponse(response) } private fun fileRangeBody( diff --git a/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt new file mode 100644 index 000000000..7fac8c704 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt @@ -0,0 +1,33 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlockingIoTest { + + @Test + fun `when bridging from the caller thread, then the body runs on a different IO thread`() { + val callerThread = Thread.currentThread() + + val bodyThread = runBlockingIo { Thread.currentThread() } + + assertNotEquals( + "body must not run on the caller (binder) thread, otherwise blocking network trips StrictMode", + callerThread, + bodyThread, + ) + assertTrue( + "expected an IO dispatcher thread but ran on ${bodyThread.name}", + bodyThread.name.contains("DefaultDispatcher") || bodyThread.name.contains("IO"), + ) + } + + @Test + fun `when the body returns a value, then it is propagated to the caller`() { + val result = runBlockingIo { 42 } + + assertEquals(42, result) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt new file mode 100644 index 000000000..3c9d9ea3b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt @@ -0,0 +1,50 @@ +package com.internxt.cloud.documents.crypto + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.IOException + +class CryptoServiceAwaitTest { + + @Test + fun `when the callback reports no error, then it resumes without throwing`() = runTest { + awaitCryptoService("should not surface") { cb -> cb.onComplete(null) } + } + + @Test + fun `when the callback reports an IOException, then it surfaces it as an IOException`() = runTest { + val original = IOException("boom") + + val thrown = runCatching { + awaitCryptoService("crypto failed") { cb -> cb.onComplete(original) } + }.exceptionOrNull() + + val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") + assertEquals("boom", io.message) + } + + @Test + fun `when the callback reports a non-IO error, then it wraps it in an IOException keeping the cause`() = runTest { + val original = IllegalStateException("nope") + + val thrown = runCatching { + awaitCryptoService("crypto failed") { cb -> cb.onComplete(original) } + }.exceptionOrNull() + + val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") + assertEquals("crypto failed", io.message) + assertTrue("original cause not preserved in chain", io.hasCauseWithMessage("nope")) + } + + private fun Throwable.hasCauseWithMessage(message: String): Boolean { + var current: Throwable? = cause + while (current != null) { + if (current.message == message) return true + current = current.cause + } + return false + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt new file mode 100644 index 000000000..be34ab61b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt @@ -0,0 +1,92 @@ +package com.internxt.cloud.documents.download + +import com.internxt.cloud.documents.api.model.Shard +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.coroutines.cancellation.CancellationException + +class EncryptedFileDownloaderTest { + + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var tempDir: File + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + client = OkHttpClient() + tempDir = Files.createTempDirectory("downloader-test").toFile() + } + + @After + fun tearDown() { + server.shutdown() + tempDir.deleteRecursively() + } + + private fun shard(index: Int, path: String, size: Int): Shard = + Shard(index = index, size = size.toLong(), hash = "h$index", url = server.url(path).toString()) + + @Test + fun `when shards are served out of order, then bytes are written in index order`() = runTest { + val part0 = ByteArray(2_000) { it.toByte() } + val part1 = ByteArray(1_500) { (it + 7).toByte() } + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(part0))) + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(part1))) + val target = File(tempDir, "out.bin") + + val shards = listOf(shard(1, "/p1", part1.size), shard(0, "/p0", part0.size)) + EncryptedFileDownloader.download(client, shards, target) + + assertArrayEquals(part0 + part1, target.readBytes()) + } + + @Test + fun `when the coroutine is cancelled mid transfer, then it raises cancellation not a network error`() { + val requestReceived = CompletableDeferred() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requestReceived.complete(Unit) + Thread.sleep(2_000) + return MockResponse().setResponseCode(200).setBody("late") + } + } + val target = File(tempDir, "cancel.bin") + val shards = listOf(shard(0, "/slow", 4)) + val thrown = CompletableDeferred() + + runBlocking { + val job = launch(Dispatchers.IO) { + try { + EncryptedFileDownloader.download(client, shards, target) + } catch (t: Throwable) { + thrown.complete(t) + throw t + } + } + requestReceived.await() + job.cancel() + job.join() + } + + assertTrue( + "expected cancellation but got ${thrown.getCompleted().javaClass.name}", + thrown.getCompleted() is CancellationException, + ) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt index 0f1c155b0..2fcbee3b5 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt @@ -2,13 +2,20 @@ package com.internxt.cloud.documents.upload import com.internxt.cloud.documents.crypto.Ripemd160 import com.internxt.cloud.documents.crypto.toHex +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File @@ -17,6 +24,7 @@ import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.cancellation.CancellationException /** * Encryption itself is exercised by the `@internxt/rn-crypto` package's own test suite @@ -60,7 +68,7 @@ class EncryptedFileUploaderTest { File(tempDir, name).apply { writeBytes(controlEncrypt(plain)) } @Test - fun uploadSinglePutsTempFileContentsAndReportsCorrectHash() { + fun uploadSinglePutsTempFileContentsAndReportsCorrectHash() = runTest { val plain = ByteArray(8_000) { it.toByte() } val tempEnc = writeEncrypted("single.enc", plain) val encBytes = tempEnc.readBytes() @@ -68,7 +76,7 @@ class EncryptedFileUploaderTest { server.enqueue(MockResponse().setResponseCode(200)) val url = server.url("/upload").toString() - EncryptedFileUploader.uploadSingle(client, tempEnc, url, signal = null) + EncryptedFileUploader.uploadSingle(client, tempEnc, url) val recorded = server.takeRequest() assertEquals("PUT", recorded.method) @@ -81,7 +89,7 @@ class EncryptedFileUploaderTest { } @Test - fun uploadMultipartCollectsEtagsAndPartHashesMatchSlices() { + fun uploadMultipartCollectsEtagsAndPartHashesMatchSlices() = runTest { val partSize = 4_000L val plain = ByteArray(13_000) { it.toByte() } val tempEnc = writeEncrypted("multi.enc", plain) @@ -97,7 +105,6 @@ class EncryptedFileUploaderTest { tempEnc = tempEnc, urls = urls, partSize = partSize, - signal = null, ) assertEquals(listOf(1, 2, 3, 4), parts.map { it.partNumber }) @@ -131,6 +138,40 @@ class EncryptedFileUploaderTest { assertEquals(expectedHash, computed) } + @Test + fun `when the upload coroutine is cancelled mid PUT, then it raises cancellation not a failure`() { + val requestReceived = CompletableDeferred() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requestReceived.complete(Unit) + Thread.sleep(2_000) + return MockResponse().setResponseCode(200) + } + } + val tempEnc = writeEncrypted("cancel.enc", ByteArray(8_000) { it.toByte() }) + val url = server.url("/slow").toString() + val thrown = CompletableDeferred() + + runBlocking { + val job = launch(Dispatchers.IO) { + try { + EncryptedFileUploader.uploadSingle(client, tempEnc, url) + } catch (t: Throwable) { + thrown.complete(t) + throw t + } + } + requestReceived.await() + job.cancel() + job.join() + } + + assertTrue( + "expected cancellation but got ${thrown.getCompleted().javaClass.name}", + thrown.getCompleted() is CancellationException, + ) + } + private fun hexDecode(hex: String): ByteArray { val out = ByteArray(hex.length / 2) for (i in out.indices) { From c29e486a064ecb3a6b95ef17018b48faddb14c70 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sat, 6 Jun 2026 18:10:35 -0400 Subject: [PATCH 46/80] refactor(test): replace hardcoded error message with constant in CryptoServiceAwaitTest --- .../cloud/documents/crypto/CryptoServiceAwaitTest.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt index 3c9d9ea3b..95038f5c9 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt @@ -19,7 +19,7 @@ class CryptoServiceAwaitTest { val original = IOException("boom") val thrown = runCatching { - awaitCryptoService("crypto failed") { cb -> cb.onComplete(original) } + awaitCryptoService(CRYPTO_FAILED_MESSAGE) { cb -> cb.onComplete(original) } }.exceptionOrNull() val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") @@ -31,11 +31,11 @@ class CryptoServiceAwaitTest { val original = IllegalStateException("nope") val thrown = runCatching { - awaitCryptoService("crypto failed") { cb -> cb.onComplete(original) } + awaitCryptoService(CRYPTO_FAILED_MESSAGE) { cb -> cb.onComplete(original) } }.exceptionOrNull() val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") - assertEquals("crypto failed", io.message) + assertEquals(CRYPTO_FAILED_MESSAGE, io.message) assertTrue("original cause not preserved in chain", io.hasCauseWithMessage("nope")) } @@ -47,4 +47,8 @@ class CryptoServiceAwaitTest { } return false } + + companion object { + private const val CRYPTO_FAILED_MESSAGE = "crypto failed" + } } From 0b61efafed3e1b54f90cd504f73ac28ef4f8a482 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 23:25:45 -0400 Subject: [PATCH 47/80] fix: improve exception handling in openDocument method --- .../internxt/cloud/documents/InternxtDocumentsProvider.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8cad697ce..305099177 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 @@ -543,9 +543,9 @@ class InternxtDocumentsProvider : DocumentsProvider() { } catch (e: Exception) { tempEnc.delete() tempDec.delete() - throw when (e) { - is FileNotFoundException -> e - is CancellationException -> e + throw when { + e is FileNotFoundException -> e + isCancellation(e) -> e else -> FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } } From 20755095c27500fd7b36111d58e057b14460d0cd Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 7 Jun 2026 23:56:21 -0400 Subject: [PATCH 48/80] feat(ios): add File Provider extension sources (InternxtFileProvider) Co-Authored-By: Claude Opus 4.8 --- .../FileProviderEnumerator.swift | 57 +++++++++++++++++ .../FileProviderExtension.swift | 61 +++++++++++++++++++ .../FileProviderItem.swift | 45 ++++++++++++++ ios/InternxtFileProvider/Info.plist | 19 ++++++ .../InternxtFileProvider.entitlements | 15 +++++ .../InternxtFileProvider.plist | 12 ++++ 6 files changed, 209 insertions(+) create mode 100644 ios/InternxtFileProvider/FileProviderEnumerator.swift create mode 100644 ios/InternxtFileProvider/FileProviderExtension.swift create mode 100644 ios/InternxtFileProvider/FileProviderItem.swift create mode 100644 ios/InternxtFileProvider/Info.plist create mode 100644 ios/InternxtFileProvider/InternxtFileProvider.entitlements create mode 100644 ios/InternxtFileProvider/InternxtFileProvider.plist diff --git a/ios/InternxtFileProvider/FileProviderEnumerator.swift b/ios/InternxtFileProvider/FileProviderEnumerator.swift new file mode 100644 index 000000000..2bbf8263b --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderEnumerator.swift @@ -0,0 +1,57 @@ +// +// FileProviderEnumerator.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider + +class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { + + private let enumeratedItemIdentifier: NSFileProviderItemIdentifier + private let anchor = NSFileProviderSyncAnchor("an anchor".data(using: .utf8)!) + + init(enumeratedItemIdentifier: NSFileProviderItemIdentifier) { + self.enumeratedItemIdentifier = enumeratedItemIdentifier + super.init() + } + + func invalidate() { + // TODO: perform invalidation of server connection if necessary + } + + func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { + /* TODO: + - inspect the page to determine whether this is an initial or a follow-up request + + If this is an enumerator for a directory, the root container or all directories: + - perform a server request to fetch directory contents + If this is an enumerator for the active set: + - perform a server request to update your local database + - fetch the active set from your local database + + - inform the observer about the items returned by the server (possibly multiple times) + - inform the observer that you are finished with this page + */ + observer.didEnumerate([FileProviderItem(identifier: NSFileProviderItemIdentifier("a file"))]) + observer.finishEnumerating(upTo: nil) + } + + func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + /* TODO: + - query the server for updates since the passed-in sync anchor + + If this is an enumerator for the active set: + - note the changes in your local database + + - inform the observer about item deletions and updates (modifications + insertions) + - inform the observer when you have finished enumerating up to a subsequent sync anchor + */ + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + } + + func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + completionHandler(anchor) + } +} diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift new file mode 100644 index 000000000..8002e1259 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -0,0 +1,61 @@ +// +// FileProviderExtension.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { + required init(domain: NSFileProviderDomain) { + // TODO: The containing application must create a domain using `NSFileProviderManager.add(_:, completionHandler:)`. The system will then launch the application extension process, call `FileProviderExtension.init(domain:)` to instantiate the extension for that domain, and call methods on the instance. + super.init() + } + + func invalidate() { + // TODO: cleanup any resources + } + + func item(for identifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { + // resolve the given identifier to a record in the model + + // TODO: implement the actual lookup + + completionHandler(FileProviderItem(identifier: identifier), nil) + return Progress() + } + + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + // TODO: implement fetching of the contents for the itemIdentifier at the specified version + + completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + return Progress() + } + + func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + // TODO: a new item was created on disk, process the item's creation + + completionHandler(itemTemplate, [], false, nil) + return Progress() + } + + func modifyItem(_ item: NSFileProviderItem, baseVersion version: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents newContents: URL?, options: NSFileProviderModifyItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + // TODO: an item was modified on disk, process the item's modification + + completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + return Progress() + } + + func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion version: NSFileProviderItemVersion, options: NSFileProviderDeleteItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { + // TODO: an item was deleted on disk, process the item's deletion + + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + return Progress() + } + + func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator { + return FileProviderEnumerator(enumeratedItemIdentifier: containerItemIdentifier) + } +} diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift new file mode 100644 index 000000000..12ad83dc6 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -0,0 +1,45 @@ +// +// FileProviderItem.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import UniformTypeIdentifiers + +class FileProviderItem: NSObject, NSFileProviderItem { + + // TODO: implement an initializer to create an item from your extension's backing model + // TODO: implement the accessors to return the values from your extension's backing model + + private let identifier: NSFileProviderItemIdentifier + + init(identifier: NSFileProviderItemIdentifier) { + self.identifier = identifier + } + + var itemIdentifier: NSFileProviderItemIdentifier { + return identifier + } + + var parentItemIdentifier: NSFileProviderItemIdentifier { + return .rootContainer + } + + var capabilities: NSFileProviderItemCapabilities { + return [.allowsReading, .allowsWriting, .allowsRenaming, .allowsReparenting, .allowsTrashing, .allowsDeleting] + } + + var itemVersion: NSFileProviderItemVersion { + NSFileProviderItemVersion(contentVersion: "a content version".data(using: .utf8)!, metadataVersion: "a metadata version".data(using: .utf8)!) + } + + var filename: String { + return identifier.rawValue + } + + var contentType: UTType { + return identifier == NSFileProviderItemIdentifier.rootContainer ? .folder : .plainText + } +} diff --git a/ios/InternxtFileProvider/Info.plist b/ios/InternxtFileProvider/Info.plist new file mode 100644 index 000000000..5236f8de8 --- /dev/null +++ b/ios/InternxtFileProvider/Info.plist @@ -0,0 +1,19 @@ + + + + + NSExtension + + NSExtensionFileProviderDocumentGroup + group.com.internxt.snacks + NSExtensionFileProviderSupportsEnumeration + + NSExtensionPointIdentifier + com.apple.fileprovider-nonui + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).FileProviderExtension + + RCTNewArchEnabled + + + diff --git a/ios/InternxtFileProvider/InternxtFileProvider.entitlements b/ios/InternxtFileProvider/InternxtFileProvider.entitlements new file mode 100644 index 000000000..2b5a4e963 --- /dev/null +++ b/ios/InternxtFileProvider/InternxtFileProvider.entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.com.internxt.snacks + + keychain-access-groups + + $(AppIdentifierPrefix)group.com.internxt.snacks + + + + diff --git a/ios/InternxtFileProvider/InternxtFileProvider.plist b/ios/InternxtFileProvider/InternxtFileProvider.plist new file mode 100644 index 000000000..7a62a22e9 --- /dev/null +++ b/ios/InternxtFileProvider/InternxtFileProvider.plist @@ -0,0 +1,12 @@ + + + + + + From cde2b098d355e2f632132540f59b9b0c35672f1b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 7 Jun 2026 23:56:54 -0400 Subject: [PATCH 49/80] feat(ios): wire InternxtFileProvider target into Xcode project (objectVersion 54) --- ios/Internxt.xcodeproj/project.pbxproj | 215 ++++++++++++++++++ .../xcschemes/InternxtFileProvider.xcscheme | 98 ++++++++ .../xcshareddata/swiftpm/Package.resolved | 24 ++ ios/Podfile | 4 + 4 files changed, 341 insertions(+) create mode 100644 ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme create mode 100644 ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index b35200d7a..b1a451a16 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0BF8BD03596D167E534E4B02 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 55EA068F694B454131066FDC /* libPods-Internxt.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */; }; 18D7DE9587FA2B2314575ED6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */; }; @@ -24,9 +25,18 @@ DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */; }; DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + F2C595E5ECBAD697016C9410 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */; }; + FEB260B2B281D37EEABD1ECA /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + DE1D474D2F87A94000E14783 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DE1D47402F87A94000E14783; + remoteInfo = InternxtFileProvider; + }; DEA227B339134920AF95B704 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; @@ -43,6 +53,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + DE1D474F2F87A94000E14783 /* InternxtFileProvider.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -61,9 +72,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0512BC474AAE3164D3544B18 /* Pods-InternxtShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Internxt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Internxt.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Internxt/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Internxt/Info.plist; sourceTree = ""; }; + 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtFileProvider.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 30C3C55FF8AE435F8A82A9BC /* InternxtShareExtension.entitlements */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = InternxtShareExtension.entitlements; sourceTree = ""; }; 37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Internxt/PrivacyInfo.xcprivacy; sourceTree = ""; }; 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = ShareExtensionViewController.swift; sourceTree = ""; }; @@ -78,15 +91,25 @@ AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; + D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; D330686328BD4286AB778B88 /* PHAssetExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHAssetExportModule.m; path = Internxt/PHAssetExportModule.m; sourceTree = ""; }; D83881814DD0A20237F63249 /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; }; DAE15E8DB4DF4E048E04B0E0 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InternxtFileProvider.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DE1D47422F87A94000E14783 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; + DE1D47632F87A94000E14783 /* FileProviderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderExtension.swift; sourceTree = ""; }; + DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderEnumerator.swift; sourceTree = ""; }; + DE1D47652F87A94000E14783 /* FileProviderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItem.swift; sourceTree = ""; }; + DE1D47662F87A94000E14783 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InternxtFileProvider.entitlements; sourceTree = ""; }; + DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = InternxtFileProvider.plist; sourceTree = ""; }; DE74E3972FACD7205D98C2BD /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupPendingShareModule.m; path = Internxt/AppGroupPendingShareModule.m; sourceTree = ""; }; DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupPendingShareModule.swift; path = Internxt/AppGroupPendingShareModule.swift; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; + FDEA9EEAEC8CE666D14877B2 /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -181,6 +204,7 @@ children = ( 13B07FAE1A68108700A75B9A /* Internxt */, 832341AE1AAA6A7D00B99B32 /* Libraries */, + DE1D47442F87A94000E14783 /* InternxtFileProvider */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 7151A997BCA04521BD294E7B /* InternxtShareExtension */, @@ -197,6 +221,7 @@ children = ( 13B07F961A680F5B00A75B9A /* Internxt.app */, 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */, + DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */, ); name = Products; sourceTree = ""; @@ -227,6 +252,19 @@ name = InternxtShareExtension; sourceTree = ""; }; + DE1D47442F87A94000E14783 /* InternxtFileProvider */ = { + isa = PBXGroup; + children = ( + DE1D47632F87A94000E14783 /* FileProviderExtension.swift */, + DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */, + DE1D47652F87A94000E14783 /* FileProviderItem.swift */, + DE1D47662F87A94000E14783 /* Info.plist */, + DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */, + DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */, + ); + path = InternxtFileProvider; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -249,6 +287,7 @@ ); dependencies = ( 03ED76FE58CE4782913172E7 /* PBXTargetDependency */, + DE1D474E2F87A94000E14783 /* PBXTargetDependency */, ); name = Internxt; productName = Internxt; @@ -277,12 +316,34 @@ productReference = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + DE1D47402F87A94000E14783 /* InternxtFileProvider */ = { + isa = PBXNativeTarget; + buildConfigurationList = DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */; + buildPhases = ( + 04C47D43FAB17BFF5826C486 /* [CP] Check Pods Manifest.lock */, + DE1D473D2F87A94000E14783 /* Sources */, + DE1D473E2F87A94000E14783 /* Frameworks */, + DE1D473F2F87A94000E14783 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = InternxtFileProvider; + packageProductDependencies = ( + DE1D47552F87AB4000E14783 /* InternxtSwiftCore */, + ); + productName = InternxtFileProvider; + productReference = DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { @@ -295,6 +356,9 @@ LastSwiftMigration = 1250; ProvisioningStyle = Automatic; }; + DE1D47402F87A94000E14783 = { + CreatedOnToolsVersion = 26.4; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Internxt" */; @@ -306,12 +370,16 @@ Base, ); mainGroup = 83CBB9F61A601CBA00E9B192; + packageReferences = ( + DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */, + ); productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* Internxt */, 573EC8782D084646801534FA /* InternxtShareExtension */, + DE1D47402F87A94000E14783 /* InternxtFileProvider */, ); }; /* End PBXProject section */ @@ -335,6 +403,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DE1D473F2F87A94000E14783 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -620,6 +695,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DE1D473D2F87A94000E14783 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, + DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, + DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -628,6 +713,11 @@ target = 573EC8782D084646801534FA /* InternxtShareExtension */; targetProxy = DEA227B339134920AF95B704 /* PBXContainerItemProxy */; }; + DE1D474E2F87A94000E14783 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DE1D47402F87A94000E14783 /* InternxtFileProvider */; + targetProxy = DE1D474D2F87A94000E14783 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -878,6 +968,103 @@ }; name = Release; }; + DE1D47502F87A94000E14783 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AC8B28CC3C9A8FDF21A8C1E7 /* Pods-InternxtFileProvider.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = InternxtFileProvider/InternxtFileProvider.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JR4S3SY396; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = InternxtFileProvider/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = InternxtFileProvider; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DE1D47512F87A94000E14783 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8C48FB43821216C363EBF3FC /* Pods-InternxtFileProvider.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = InternxtFileProvider/InternxtFileProvider.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JR4S3SY396; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = InternxtFileProvider/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = InternxtFileProvider; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -908,7 +1095,35 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DE1D47502F87A94000E14783 /* Debug */, + DE1D47512F87A94000E14783 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/internxt/swift-core"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DE1D47552F87AB4000E14783 /* InternxtSwiftCore */ = { + isa = XCSwiftPackageProductDependency; + package = DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */; + productName = InternxtSwiftCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; } diff --git a/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme new file mode 100644 index 000000000..dbf6fd211 --- /dev/null +++ b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..2e8b82375 --- /dev/null +++ b/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f8d6cbc56ef3d7b4288ed213a329a2171e922e22cab926463add5c4d1f99c732", + "pins" : [ + { + "identity" : "idzswiftcommoncrypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/iosdevzone/IDZSwiftCommonCrypto.git", + "state" : { + "revision" : "47f9747d12137cd59d783ab376e3ccab7206319b", + "version" : "0.16.1" + } + }, + { + "identity" : "swift-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/internxt/swift-core", + "state" : { + "branch" : "main", + "revision" : "ab849424b8e12641207184fe3695de0c69a0de80" + } + } + ], + "version" : 3 +} diff --git a/ios/Podfile b/ios/Podfile index a90164094..f3a78cd04 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -94,6 +94,10 @@ target 'Internxt' do end end +target 'InternxtFileProvider' do + # No use_expo_modules! — InternxtSwiftCore comes via SPM +end + target 'InternxtShareExtension' do exclude = ["expo-updates", "expo-splash-screen", "expo-dev-client", "react-native-reanimated", "react-native-screens", "react-native-safe-area-context", "react-native-gesture-handler", "react-native-video", "react-native-webview", "react-native-svg", "@shopify/flash-list", "react-native-pdf", "jail-monkey"] use_expo_modules!(exclude: exclude) From 43ff69ad3def602faf684812b1119f3b8f298375 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Jun 2026 09:30:01 -0400 Subject: [PATCH 50/80] feat(ios): register File Provider domain on login/logout (PB-5920) Sync auth credentials to a shared App Group Keychain so the File Provider extension can read them; register the com.internxt.drive domain on login and call removeAllDomains on logout so the provider appears/disappears in Files.app. Bump InternxtFileProvider MARKETING_VERSION 1.0 -> 1.9.0 (Debug+Release) so the extension version matches the host or iOS won't load it. Device-verified on a real iPhone. Refs: PB-5920 --- ios/Internxt.xcodeproj/project.pbxproj | 23 +++- ios/Internxt/AppDelegate.swift | 77 +----------- ios/Internxt/FileProviderDomainManager.swift | 34 ++++++ ios/Internxt/InternxtAuthCredentialsModule.m | 9 ++ .../InternxtAuthCredentialsModule.swift | 65 ++++++++++ ios/Internxt/SharedAuthKeychain.swift | 114 ++++++++++++++++++ .../FileProviderExtension.swift | 22 ++-- ios/InternxtFileProvider/Info.plist | 2 + .../InternxtAuthCredentialsModule.spec.ts | 75 ++++++++++++ .../native/InternxtAuthCredentialsModule.ts | 2 +- .../auth/auth.syncNativeCredentials.spec.ts | 109 +++++++++++++++++ 11 files changed, 444 insertions(+), 88 deletions(-) create mode 100644 ios/Internxt/FileProviderDomainManager.swift create mode 100644 ios/Internxt/InternxtAuthCredentialsModule.m create mode 100644 ios/Internxt/InternxtAuthCredentialsModule.swift create mode 100644 ios/Internxt/SharedAuthKeychain.swift create mode 100644 src/services/native/InternxtAuthCredentialsModule.spec.ts create mode 100644 src/store/slices/auth/auth.syncNativeCredentials.spec.ts diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index b1a451a16..17ed249d3 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -17,6 +17,11 @@ 5EECD9AA8FE2941A3CB8B8EF /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */; }; 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; + AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */; }; + AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; + AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */; }; + AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; @@ -88,6 +93,10 @@ A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHBurstExportModule.swift; path = Internxt/PHBurstExportModule.swift; sourceTree = ""; }; A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHBurstExportModule.m; path = Internxt/PHBurstExportModule.m; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; + AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SharedAuthKeychain.swift; path = Internxt/SharedAuthKeychain.swift; sourceTree = ""; }; + AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderDomainManager.swift; path = Internxt/FileProviderDomainManager.swift; sourceTree = ""; }; + AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; + AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InternxtAuthCredentialsModule.m; path = Internxt/InternxtAuthCredentialsModule.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; @@ -138,6 +147,10 @@ children = ( DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */, DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */, + AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */, + AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */, + AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */, + AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */, D330686328BD4286AB778B88 /* PHAssetExportModule.m */, 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */, A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */, @@ -347,7 +360,6 @@ LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { - DevelopmentTeam = JR4S3SY396; LastSwiftMigration = 1250; ProvisioningStyle = Automatic; }; @@ -679,6 +691,10 @@ 1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */, DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */, DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */, + AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, + AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */, + AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */, + AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */, 1A3E7803EDA342F39787DC8F /* PHAssetExportModule.swift in Sources */, 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */, A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */, @@ -702,6 +718,7 @@ DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, + AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -999,7 +1016,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.9.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -1049,7 +1066,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.9.0; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift index 574040176..f733c7a96 100644 --- a/ios/Internxt/AppDelegate.swift +++ b/ios/Internxt/AppDelegate.swift @@ -1,7 +1,6 @@ import Expo import React import ReactAppDependencyProvider -import Security @UIApplicationMain public class AppDelegate: ExpoAppDelegate { @@ -44,81 +43,7 @@ public class AppDelegate: ExpoAppDelegate { // MARK: - App Group auth sync private func syncAuthStatusToAppGroup() { - guard let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String - else { return } - - let isAuthenticated = privateKeychainItemExists(key: "photosToken") - - if isAuthenticated { - copyToSharedKeychain(privateKey: "photosToken", sharedKey: "shared_photosToken", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_mnemonic", sharedKey: "shared_mnemonic", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_rootFolderId", sharedKey: "shared_rootFolderId", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_bucket", sharedKey: "shared_bucket", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_bridgeUser", sharedKey: "shared_bridgeUser", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_userId", sharedKey: "shared_userId", accessGroup: sharedGroup) - } else { - deleteFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_rootFolderId", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_bucket", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_bridgeUser", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_userId", accessGroup: sharedGroup) - } - - if privateKeychainItemExists(key: "themePreference") { - copyToSharedKeychain(privateKey: "themePreference", sharedKey: "shared_themePreference", accessGroup: sharedGroup) - } else { - deleteFromSharedKeychain(key: "shared_themePreference", accessGroup: sharedGroup) - } - } - - private func privateKeychainItemExists(key: String) -> Bool { - return readFromPrivateKeychain(key: key) != nil - } - - private func copyToSharedKeychain(privateKey: String, sharedKey: String, accessGroup: String) { - guard let data = readFromPrivateKeychain(key: privateKey) else { return } - writeToSharedKeychain(data: data, key: sharedKey, accessGroup: accessGroup) - } - - private func readFromPrivateKeychain(key: String) -> Data? { - var result: AnyObject? - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { return nil } - return data - } - - private func writeToSharedKeychain(data: Data, key: String, accessGroup: String) { - deleteFromSharedKeychain(key: key, accessGroup: accessGroup) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecAttrAccessGroup as String: accessGroup, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, - kSecValueData as String: data, - ] - SecItemAdd(query as CFDictionary, nil) - } - - private func deleteFromSharedKeychain(key: String, accessGroup: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecAttrAccessGroup as String: accessGroup, - ] - SecItemDelete(query as CFDictionary) + SharedAuthKeychain.syncFromPrivateKeychain() } // Linking API diff --git a/ios/Internxt/FileProviderDomainManager.swift b/ios/Internxt/FileProviderDomainManager.swift new file mode 100644 index 000000000..2d714704e --- /dev/null +++ b/ios/Internxt/FileProviderDomainManager.swift @@ -0,0 +1,34 @@ +import FileProvider +import Foundation + +@available(iOS 16.0, *) +enum FileProviderDomainManager { + static let domainIdentifier = NSFileProviderDomainIdentifier("com.internxt.drive") + static let displayName = "Internxt Drive" + + private static var domain: NSFileProviderDomain { + NSFileProviderDomain(identifier: domainIdentifier, displayName: displayName) + } + + static func registerDomain(completion: @escaping (Error?) -> Void) { + NSFileProviderManager.add(domain) { error in + completion(isAlreadyExists(error) ? nil : error) + } + } + + static func unregisterDomain(completion: @escaping (Error?) -> Void) { + NSFileProviderManager.removeAllDomains { removeError in + completion(isNotFound(removeError) ? nil : removeError) + } + } + + private static func isAlreadyExists(_ error: Error?) -> Bool { + guard let error = error as NSError? else { return false } + return error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError + } + + private static func isNotFound(_ error: Error?) -> Bool { + guard let error = error as NSError? else { return false } + return error.domain == NSCocoaErrorDomain && error.code == NSFileNoSuchFileError + } +} diff --git a/ios/Internxt/InternxtAuthCredentialsModule.m b/ios/Internxt/InternxtAuthCredentialsModule.m new file mode 100644 index 000000000..cf2088f40 --- /dev/null +++ b/ios/Internxt/InternxtAuthCredentialsModule.m @@ -0,0 +1,9 @@ +#import + +@interface RCT_EXTERN_MODULE(InternxtAuthCredentialsModule, NSObject) +RCT_EXTERN_METHOD(setCredentials:(NSDictionary *)creds + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(clearCredentials:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +@end diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift new file mode 100644 index 000000000..d5f7613f7 --- /dev/null +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -0,0 +1,65 @@ +import Foundation +import React + +/// Bridges the RN auth lifecycle to iOS. On login the host app writes the auth +/// credentials into the shared Keychain (read by the File Provider extension) and +/// registers the File Provider domain; on logout it clears them and removes the +/// domain. iOS counterpart of the Android `InternxtAuthCredentialsModule` +/// (`setCredentials` + `notifyChange(roots)` / `clearCredentials` + `notifyChange(roots)`). +@objc(InternxtAuthCredentialsModule) +class InternxtAuthCredentialsModule: NSObject { + + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc func setCredentials( + _ creds: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + writeSharedCredentials(creds) + guard #available(iOS 16.0, *) else { + resolver(nil) + return + } + FileProviderDomainManager.registerDomain { error in + if let error = error { + rejecter("E_REGISTER_DOMAIN", error.localizedDescription, error as NSError) + return + } + resolver(nil) + } + } + + @objc func clearCredentials( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + SharedAuthKeychain.clearAll() + resolver(nil) + return + } + + FileProviderDomainManager.unregisterDomain { error in + SharedAuthKeychain.clearAll() + if let error = error { + rejecter("E_UNREGISTER_DOMAIN", error.localizedDescription, error as NSError) + return + } + resolver(nil) + } + } + + private func writeSharedCredentials(_ creds: NSDictionary) { + writeIfPresent(creds["bearerToken"], to: SharedAuthKeychain.photosTokenKey) + writeIfPresent(creds["mnemonic"], to: SharedAuthKeychain.mnemonicKey) + writeIfPresent(creds["rootFolderUuid"], to: SharedAuthKeychain.rootFolderIdKey) + writeIfPresent(creds["bridgeUser"], to: SharedAuthKeychain.bridgeUserKey) + writeIfPresent(creds["userId"], to: SharedAuthKeychain.userIdKey) + } + + private func writeIfPresent(_ value: Any?, to sharedKey: String) { + guard let value = value as? String, !value.isEmpty else { return } + SharedAuthKeychain.write(value, for: sharedKey) + } +} diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift new file mode 100644 index 000000000..504bc1fb1 --- /dev/null +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -0,0 +1,114 @@ +import Foundation +import Security + +/// Single source of truth for the shared Keychain access group used to hand off +/// auth credentials from the host app to the share extension and the File +/// Provider extension. Extracted from `AppDelegate` so the native module and the +/// app-lifecycle sync write to the exact same items. +enum SharedAuthKeychain { + static let service = "app:no-auth" + + static let photosTokenKey = "shared_photosToken" + static let mnemonicKey = "shared_mnemonic" + static let rootFolderIdKey = "shared_rootFolderId" + static let bucketKey = "shared_bucket" + static let bridgeUserKey = "shared_bridgeUser" + static let userIdKey = "shared_userId" + + static let allKeys = [ + photosTokenKey, mnemonicKey, rootFolderIdKey, bucketKey, bridgeUserKey, userIdKey, + ] + + static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String + } + + static func read(_ sharedKey: String) -> Data? { + guard let accessGroup = accessGroup else { return nil } + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return data + } + + static func write(_ value: Data, for sharedKey: String) { + guard let accessGroup = accessGroup else { return } + delete(sharedKey) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + kSecValueData as String: value, + ] + SecItemAdd(query as CFDictionary, nil) + } + + static func write(_ value: String, for sharedKey: String) { + write(Data(value.utf8), for: sharedKey) + } + + static func delete(_ sharedKey: String) { + guard let accessGroup = accessGroup else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + ] + SecItemDelete(query as CFDictionary) + } + + static func clearAll() { + allKeys.forEach(delete) + } + + static func readPrivate(_ privateKey: String) -> Data? { + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(privateKey.utf8), + kSecAttrAccount as String: Data(privateKey.utf8), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return data + } + + static func syncFromPrivateKeychain() { + guard accessGroup != nil else { return } + + let isAuthenticated = readPrivate("photosToken") != nil + guard isAuthenticated else { + clearAll() + return + } + + copyFromPrivate(privateKey: "photosToken", sharedKey: photosTokenKey) + copyFromPrivate(privateKey: "xUser_mnemonic", sharedKey: mnemonicKey) + copyFromPrivate(privateKey: "xUser_rootFolderId", sharedKey: rootFolderIdKey) + copyFromPrivate(privateKey: "xUser_bucket", sharedKey: bucketKey) + copyFromPrivate(privateKey: "xUser_bridgeUser", sharedKey: bridgeUserKey) + copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey) + } + + private static func copyFromPrivate(privateKey: String, sharedKey: String) { + guard let data = readPrivate(privateKey) else { return } + write(data, for: sharedKey) + } +} diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index 8002e1259..c1802b584 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -10,26 +10,32 @@ import InternxtSwiftCore class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { required init(domain: NSFileProviderDomain) { - // TODO: The containing application must create a domain using `NSFileProviderManager.add(_:, completionHandler:)`. The system will then launch the application extension process, call `FileProviderExtension.init(domain:)` to instantiate the extension for that domain, and call methods on the instance. super.init() } - + + private func currentAuthToken() -> String? { + guard let data = SharedAuthKeychain.read(SharedAuthKeychain.photosTokenKey) else { return nil } + return String(data: data, encoding: .utf8) + } + func invalidate() { // TODO: cleanup any resources } - + func item(for identifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { - // resolve the given identifier to a record in the model - - // TODO: implement the actual lookup + _ = currentAuthToken() + + // TODO: implement the actual lookup using the auth token completionHandler(FileProviderItem(identifier: identifier), nil) return Progress() } - + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + _ = currentAuthToken() + // TODO: implement fetching of the contents for the itemIdentifier at the specified version - + completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) return Progress() } diff --git a/ios/InternxtFileProvider/Info.plist b/ios/InternxtFileProvider/Info.plist index 5236f8de8..b11be0274 100644 --- a/ios/InternxtFileProvider/Info.plist +++ b/ios/InternxtFileProvider/Info.plist @@ -15,5 +15,7 @@ RCTNewArchEnabled + SharedKeychainGroup + $(AppIdentifierPrefix)group.com.internxt.snacks diff --git a/src/services/native/InternxtAuthCredentialsModule.spec.ts b/src/services/native/InternxtAuthCredentialsModule.spec.ts new file mode 100644 index 000000000..f815ba694 --- /dev/null +++ b/src/services/native/InternxtAuthCredentialsModule.spec.ts @@ -0,0 +1,75 @@ +type Wrapper = typeof import('./InternxtAuthCredentialsModule'); +type Credentials = import('./InternxtAuthCredentialsModule').InternxtAuthCredentials; + +const credentials: Credentials = { + bearerToken: 'token-abc', + userId: 'user-1', + bridgeUser: 'bridge@internxt.com', + mnemonic: 'pretty cloud secret words', + rootFolderUuid: 'root-uuid-123', + email: 'user@internxt.com', + driveBaseUrl: 'https://drive.example', + bridgeBaseUrl: 'https://bridge.example', + desktopToken: 'desktop-token', +}; + +const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): Wrapper => { + let wrapper!: Wrapper; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: platformOS }, + NativeModules: nativeModule === undefined ? {} : { InternxtAuthCredentialsModule: nativeModule }, + })); + wrapper = require('./InternxtAuthCredentialsModule'); + }); + return wrapper; +}; + +const arrangePresentNative = (platformOS: 'android' | 'ios') => { + const setCredentials = jest.fn().mockResolvedValue(undefined); + const clearCredentials = jest.fn().mockResolvedValue(undefined); + const wrapper = loadWrapper(platformOS, { setCredentials, clearCredentials }); + return { wrapper, setCredentials, clearCredentials }; +}; + +describe('InternxtAuthCredentialsModule wrapper', () => { + afterEach(() => { + jest.resetModules(); + }); + + it('when on iOS with the module present, then setCredentials delegates with the credentials', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials).toHaveBeenCalledWith(credentials); + }); + + it('when on iOS with the module present, then clearCredentials delegates to the native module', async () => { + const { wrapper, clearCredentials } = arrangePresentNative('ios'); + + await wrapper.clearCredentials(); + + expect(clearCredentials).toHaveBeenCalledTimes(1); + }); + + it('when on Android with the module present, then setCredentials delegates with the credentials', async () => { + const { wrapper, setCredentials } = arrangePresentNative('android'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials).toHaveBeenCalledWith(credentials); + }); + + it('when the native module is absent, then setCredentials resolves without throwing', async () => { + const wrapper = loadWrapper('ios', undefined); + + await expect(wrapper.setCredentials(credentials)).resolves.toBeUndefined(); + }); + + it('when the native module is absent, then clearCredentials resolves without throwing', async () => { + const wrapper = loadWrapper('ios', undefined); + + await expect(wrapper.clearCredentials()).resolves.toBeUndefined(); + }); +}); diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts index e3b64a9f6..aaa62bab5 100644 --- a/src/services/native/InternxtAuthCredentialsModule.ts +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -18,7 +18,7 @@ interface NativeBridge { } const bridge: NativeBridge | undefined = - Platform.OS === 'android' ? NativeModules.InternxtAuthCredentialsModule : undefined; + Platform.OS === 'android' || Platform.OS === 'ios' ? NativeModules.InternxtAuthCredentialsModule : undefined; export async function setCredentials(creds: InternxtAuthCredentials): Promise { if (!bridge) return; diff --git a/src/store/slices/auth/auth.syncNativeCredentials.spec.ts b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts new file mode 100644 index 000000000..67d411739 --- /dev/null +++ b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts @@ -0,0 +1,109 @@ +jest.mock('../../../services/native/InternxtAuthCredentialsModule', () => ({ + setCredentials: jest.fn().mockResolvedValue(undefined), + clearCredentials: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + imageService: {}, + PROFILE_PICTURE_CACHE_KEY: 'profile-picture', + SdkManager: { init: jest.fn(), setApiSecurity: jest.fn(), getInstance: jest.fn() }, +})); + +jest.mock('../drive', () => ({ driveActions: { resetState: jest.fn(() => ({ type: 'drive/resetState' })) } })); +jest.mock('../ui', () => ({ uiActions: { resetState: jest.fn(() => ({ type: 'ui/resetState' })) } })); + +jest.mock('@internxt-mobile/services/drive', () => ({ + __esModule: true, + default: { clear: jest.fn().mockResolvedValue(undefined) }, +})); + +jest.mock('src/services/ErrorService', () => ({ __esModule: true, default: { reportError: jest.fn() } })); + +jest.mock('../../../services/AppService', () => { + const appConstants = { + DRIVE_NEW_API_URL: 'https://drive', + BRIDGE_URL: 'https://bridge', + CLOUDFLARE_TOKEN: 'cf', + CRYPTO_SECRET: 'crypto-secret', + CRYPTO_SECRET2: 'crypto-secret-2', + }; + return { __esModule: true, default: { constants: appConstants }, constants: appConstants }; +}); + +jest.mock('../../../services/AsyncStorageService', () => ({ + __esModule: true, + default: { + saveItem: jest.fn().mockResolvedValue(undefined), + getItem: jest.fn().mockResolvedValue('current-photos-token'), + deleteItem: jest.fn().mockResolvedValue(undefined), + clearStorage: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('../../../services/AuthService', () => ({ + __esModule: true, + default: { + emitLoginEvent: jest.fn(), + emitLogoutEvent: jest.fn(), + signout: jest.fn().mockResolvedValue(undefined), + refreshAuthToken: jest.fn(), + getAuthCredentials: jest.fn(), + }, +})); + +jest.mock('../../../services/NotificationsService', () => ({ __esModule: true, default: {} })); +jest.mock('../../../services/UserService', () => ({ __esModule: true, default: {} })); + +import authService from '../../../services/AuthService'; +import { clearCredentials, setCredentials } from '../../../services/native/InternxtAuthCredentialsModule'; +import { refreshTokensThunk, signInThunk, signOutThunk } from './index'; + +const setCredentialsMock = setCredentials as jest.Mock; +const clearCredentialsMock = clearCredentials as jest.Mock; +const refreshAuthTokenMock = authService.refreshAuthToken as jest.Mock; +const getAuthCredentialsMock = authService.getAuthCredentials as jest.Mock; + +const user = { + userId: 'user-1', + bridgeUser: 'bridge@internxt.com', + mnemonic: 'pretty cloud secret words', + rootFolderUuid: 'root-uuid-123', + email: 'user@internxt.com', +} as never; + +type DispatchableThunk = (dispatch: jest.Mock, getState: jest.Mock, extra: undefined) => Promise; + +const runThunk = (action: unknown) => + (action as DispatchableThunk)(jest.fn(), jest.fn(), undefined); + +describe('auth thunks native credentials handoff', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('when the login completes, then the wrapper receives the new token', async () => { + await runThunk(signInThunk({ user, token: 'access-token', newToken: 'login-new-token' })); + + expect(setCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'login-new-token', rootFolderUuid: 'root-uuid-123' }), + ); + }); + + it('when the token is refreshed in the foreground, then the wrapper receives the refreshed token', async () => { + refreshAuthTokenMock.mockResolvedValue({ token: 'access-2', newToken: 'refreshed-new-token' }); + getAuthCredentialsMock.mockResolvedValue({ + credentials: { accessToken: 'access-2', photosToken: 'refreshed-new-token', user }, + }); + + await runThunk(refreshTokensThunk()); + + expect(setCredentialsMock).toHaveBeenCalledWith(expect.objectContaining({ bearerToken: 'refreshed-new-token' })); + }); + + it('when the logout completes, then clearCredentials is invoked', async () => { + await runThunk(signOutThunk({ reason: 'manual' })); + + expect(clearCredentialsMock).toHaveBeenCalledTimes(1); + }); +}); From 421a872ac626d11a8566d8bce1488564535ab3db Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Wed, 10 Jun 2026 14:53:55 -0400 Subject: [PATCH 51/80] feat(ios): update SharedAuthKeychain to support JSON encoded data and adjust accessibility for background reads --- ios/Internxt/SharedAuthKeychain.swift | 29 ++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift index 504bc1fb1..cb019e97c 100644 --- a/ios/Internxt/SharedAuthKeychain.swift +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -49,7 +49,13 @@ enum SharedAuthKeychain { kSecAttrGeneric as String: Data(sharedKey.utf8), kSecAttrAccount as String: Data(sharedKey.utf8), kSecAttrAccessGroup as String: accessGroup, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + // These credentials are read by the File Provider extension in the + // background with no UI, so a user-authentication gate (SecAccessControl / + // .userPresence) is intentionally NOT used — it would block every + // background read. `AfterFirstUnlock` keeps the item readable once the + // device has been unlocked after boot while still protecting it before + // first unlock at boot. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, kSecValueData as String: value, ] SecItemAdd(query as CFDictionary, nil) @@ -100,15 +106,28 @@ enum SharedAuthKeychain { } copyFromPrivate(privateKey: "photosToken", sharedKey: photosTokenKey) - copyFromPrivate(privateKey: "xUser_mnemonic", sharedKey: mnemonicKey) - copyFromPrivate(privateKey: "xUser_rootFolderId", sharedKey: rootFolderIdKey) + copyFromPrivate(privateKey: "xUser_mnemonic", sharedKey: mnemonicKey, isJSONEncoded: true) + copyFromPrivate(privateKey: "xUser_rootFolderId", sharedKey: rootFolderIdKey, isJSONEncoded: true) copyFromPrivate(privateKey: "xUser_bucket", sharedKey: bucketKey) copyFromPrivate(privateKey: "xUser_bridgeUser", sharedKey: bridgeUserKey) copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey) } - private static func copyFromPrivate(privateKey: String, sharedKey: String) { + private static func copyFromPrivate(privateKey: String, sharedKey: String, isJSONEncoded: Bool = false) { guard let data = readPrivate(privateKey) else { return } - write(data, for: sharedKey) + write(isJSONEncoded ? jsonDecoded(data) : data, for: sharedKey) + } + + private static func jsonDecoded(_ data: Data) -> Data { + guard let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + return data + } + if let string = object as? String { + return Data(string.utf8) + } + if let number = object as? NSNumber { + return Data(number.stringValue.utf8) + } + return data } } From 8e3a93016547be15cdeb5b4ab59ce0db5723cbef Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 22:06:27 -0400 Subject: [PATCH 52/80] feat(ios): add shared driveBaseUrl key for File Provider Drive API The File Provider extension needs the Drive API base URL to build authenticated requests from the App Group keychain. Add a dedicated shared key so it can be read alongside the existing auth token. --- ios/Internxt/SharedAuthKeychain.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift index cb019e97c..70675bb18 100644 --- a/ios/Internxt/SharedAuthKeychain.swift +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -14,9 +14,11 @@ enum SharedAuthKeychain { static let bucketKey = "shared_bucket" static let bridgeUserKey = "shared_bridgeUser" static let userIdKey = "shared_userId" + static let driveBaseUrlKey = "shared_driveBaseUrl" static let allKeys = [ photosTokenKey, mnemonicKey, rootFolderIdKey, bucketKey, bridgeUserKey, userIdKey, + driveBaseUrlKey, ] static var accessGroup: String? { From 78fb3d6884ced279329bf6f8bd92b3e693b14082 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 22:06:45 -0400 Subject: [PATCH 53/80] feat(ios): browse Drive folders in File Provider enumerator Replace the placeholder enumerator with real Drive folder browsing: paginate folders then files for a container, build FileProviderItem metadata (name, type, size, dates, capabilities) from Drive responses, and resolve identifiers via a typed folder/file id codec. Add a DriveAPIFactory that constructs an authenticated DriveAPI from the shared App Group keychain, and wire the new sources into the extension target. --- ios/Internxt.xcodeproj/project.pbxproj | 8 + .../DriveAPIFactory.swift | 36 ++++ .../FileProviderEnumerator.swift | 174 +++++++++++---- .../FileProviderItem.swift | 202 +++++++++++++++--- .../FileProviderItemIdentifier.swift | 49 +++++ 5 files changed, 397 insertions(+), 72 deletions(-) create mode 100644 ios/InternxtFileProvider/DriveAPIFactory.swift create mode 100644 ios/InternxtFileProvider/FileProviderItemIdentifier.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 17ed249d3..3e6b572e0 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */; }; AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; + AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */; }; 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; @@ -98,6 +100,8 @@ AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InternxtAuthCredentialsModule.m; path = Internxt/InternxtAuthCredentialsModule.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; + AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItemIdentifier.swift; sourceTree = ""; }; + AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveAPIFactory.swift; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -271,6 +275,8 @@ DE1D47632F87A94000E14783 /* FileProviderExtension.swift */, DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */, DE1D47652F87A94000E14783 /* FileProviderItem.swift */, + AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */, + AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */, DE1D47662F87A94000E14783 /* Info.plist */, DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */, DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */, @@ -718,6 +724,8 @@ DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, + AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, + AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */, AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/InternxtFileProvider/DriveAPIFactory.swift b/ios/InternxtFileProvider/DriveAPIFactory.swift new file mode 100644 index 000000000..5d29882d1 --- /dev/null +++ b/ios/InternxtFileProvider/DriveAPIFactory.swift @@ -0,0 +1,36 @@ +import Foundation +import InternxtSwiftCore + +enum DriveAPIFactory { + static func make() -> DriveAPI? { + guard let authToken = sharedString(SharedAuthKeychain.photosTokenKey), + let baseUrl = sharedString(SharedAuthKeychain.driveBaseUrlKey) else { + return nil + } + + return DriveAPI( + baseUrl: baseUrl, + authToken: authToken, + clientName: clientName, + clientVersion: clientVersion + ) + } + + private static func sharedString(_ key: String) -> String? { + guard let data = SharedAuthKeychain.read(key) else { return nil } + let value = String(decoding: data, as: UTF8.self) + return value.isEmpty ? nil : value + } + + private static var clientName: String { + bundleString("CFBundleName") ?? "drive-mobile" + } + + private static var clientVersion: String { + bundleString("CFBundleShortVersionString") ?? "0.0.0" + } + + private static func bundleString(_ key: String) -> String? { + Bundle(for: FileProviderExtension.self).object(forInfoDictionaryKey: key) as? String + } +} diff --git a/ios/InternxtFileProvider/FileProviderEnumerator.swift b/ios/InternxtFileProvider/FileProviderEnumerator.swift index 2bbf8263b..f05419496 100644 --- a/ios/InternxtFileProvider/FileProviderEnumerator.swift +++ b/ios/InternxtFileProvider/FileProviderEnumerator.swift @@ -6,52 +6,136 @@ // import FileProvider +import InternxtSwiftCore class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { - - private let enumeratedItemIdentifier: NSFileProviderItemIdentifier - private let anchor = NSFileProviderSyncAnchor("an anchor".data(using: .utf8)!) - - init(enumeratedItemIdentifier: NSFileProviderItemIdentifier) { - self.enumeratedItemIdentifier = enumeratedItemIdentifier - super.init() - } - - func invalidate() { - // TODO: perform invalidation of server connection if necessary - } - - func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { - /* TODO: - - inspect the page to determine whether this is an initial or a follow-up request - - If this is an enumerator for a directory, the root container or all directories: - - perform a server request to fetch directory contents - If this is an enumerator for the active set: - - perform a server request to update your local database - - fetch the active set from your local database - - - inform the observer about the items returned by the server (possibly multiple times) - - inform the observer that you are finished with this page - */ - observer.didEnumerate([FileProviderItem(identifier: NSFileProviderItemIdentifier("a file"))]) - observer.finishEnumerating(upTo: nil) - } - - func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { - /* TODO: - - query the server for updates since the passed-in sync anchor - - If this is an enumerator for the active set: - - note the changes in your local database - - - inform the observer about item deletions and updates (modifications + insertions) - - inform the observer when you have finished enumerating up to a subsequent sync anchor - */ - observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) - } - - func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { - completionHandler(anchor) + + private static let pageSize = 50 + private static let order = "ASC" + + private enum Phase: String { + case folders + case files + } + + private struct Cursor { + let phase: Phase + let offset: Int + + static let initial = Cursor(phase: .folders, offset: 0) + + func encoded() -> NSFileProviderPage { + NSFileProviderPage("\(phase.rawValue):\(offset)".data(using: .utf8)!) + } + + static func decode(_ page: NSFileProviderPage) -> Cursor { + guard let raw = String(data: page.rawValue, encoding: .utf8), + let separatorIndex = raw.firstIndex(of: ":"), + let phase = Phase(rawValue: String(raw[raw.startIndex.. Error { + NSFileProviderError(.notAuthenticated) + } + + private func enumerationError(from error: Error) -> Error { + if let apiError = error as? APIClientError, apiError.statusCode == 401 { + return notAuthenticatedError() + } + return error + } + + func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + } + + func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + completionHandler(anchor) + } } diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift index 12ad83dc6..60bbc24a6 100644 --- a/ios/InternxtFileProvider/FileProviderItem.swift +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -6,40 +6,188 @@ // import FileProvider +import InternxtSwiftCore import UniformTypeIdentifiers class FileProviderItem: NSObject, NSFileProviderItem { - // TODO: implement an initializer to create an item from your extension's backing model - // TODO: implement the accessors to return the values from your extension's backing model - - private let identifier: NSFileProviderItemIdentifier - - init(identifier: NSFileProviderItemIdentifier) { - self.identifier = identifier - } - - var itemIdentifier: NSFileProviderItemIdentifier { - return identifier - } - - var parentItemIdentifier: NSFileProviderItemIdentifier { - return .rootContainer + private let identifier: NSFileProviderItemIdentifier + private let parent: NSFileProviderItemIdentifier + private let name: String + private let kind: DriveItemKind + private let fileExtension: String? + private let updatedAt: String? + private let createdAt: String? + private let sizeInBytes: String? + + private init( + identifier: NSFileProviderItemIdentifier, + parent: NSFileProviderItemIdentifier, + name: String, + kind: DriveItemKind, + fileExtension: String?, + createdAt: String?, + updatedAt: String?, + sizeInBytes: String? + ) { + self.identifier = identifier + self.parent = parent + self.name = name + self.kind = kind + self.fileExtension = fileExtension + self.createdAt = createdAt + self.updatedAt = updatedAt + self.sizeInBytes = sizeInBytes + } + + convenience init(folder: GetFolderFoldersResult, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.folder, uuid: folder.uuid ?? ""), + parent: parent, + name: folder.plainName ?? folder.name, + kind: .folder, + fileExtension: nil, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + sizeInBytes: nil + ) + } + + convenience init(file: GetFolderFilesResultV2, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.file, uuid: file.uuid), + parent: parent, + name: file.plainName ?? file.name ?? file.uuid, + kind: .file, + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) + } + + static func root(displayName: String) -> FileProviderItem { + FileProviderItem( + identifier: .rootContainer, + parent: .rootContainer, + name: displayName, + kind: .folder, + fileExtension: nil, + createdAt: nil, + updatedAt: nil, + sizeInBytes: nil + ) + } + + convenience init(folderMeta: GetFolderMetaByIdResponse, identifier: NSFileProviderItemIdentifier) { + self.init( + identifier: identifier, + parent: FileProviderItemID.parentIdentifier(folderUuid: folderMeta.parentUuid), + name: folderMeta.plainName ?? folderMeta.name ?? identifier.rawValue, + kind: .folder, + fileExtension: nil, + createdAt: folderMeta.createdAt, + updatedAt: folderMeta.updatedAt, + sizeInBytes: nil + ) + } + + convenience init(fileMeta: GetFileMetaByIdResponse, identifier: NSFileProviderItemIdentifier) { + self.init( + identifier: identifier, + parent: FileProviderItemID.parentIdentifier(folderUuid: fileMeta.folderUuid), + name: fileMeta.plainName ?? fileMeta.name, + kind: .file, + fileExtension: fileMeta.type, + createdAt: fileMeta.createdAt, + updatedAt: fileMeta.updatedAt, + sizeInBytes: fileMeta.size + ) + } + + var itemIdentifier: NSFileProviderItemIdentifier { + identifier + } + + var parentItemIdentifier: NSFileProviderItemIdentifier { + parent + } + + var capabilities: NSFileProviderItemCapabilities { + switch kind { + case .folder: + return [.allowsReading, .allowsContentEnumerating] + case .file: + return [.allowsReading] } - - var capabilities: NSFileProviderItemCapabilities { - return [.allowsReading, .allowsWriting, .allowsRenaming, .allowsReparenting, .allowsTrashing, .allowsDeleting] + } + + var itemVersion: NSFileProviderItemVersion { + let version = Data((updatedAt ?? identifier.rawValue).utf8) + return NSFileProviderItemVersion(contentVersion: version, metadataVersion: version) + } + + var filename: String { + guard kind == .file, let fileExtension = fileExtension, !fileExtension.isEmpty else { + return name } - - var itemVersion: NSFileProviderItemVersion { - NSFileProviderItemVersion(contentVersion: "a content version".data(using: .utf8)!, metadataVersion: "a metadata version".data(using: .utf8)!) + return "\(name).\(fileExtension)" + } + + var contentType: UTType { + guard kind == .file else { return .folder } + if let fileExtension = fileExtension, !fileExtension.isEmpty, + let type = UTType(filenameExtension: fileExtension) { + return type } - - var filename: String { - return identifier.rawValue + return .data + } + + var documentSize: NSNumber? { + guard kind == .file, let sizeInBytes = sizeInBytes, + let bytes = Int64(sizeInBytes) else { + return nil } - - var contentType: UTType { - return identifier == NSFileProviderItemIdentifier.rootContainer ? .folder : .plainText + return NSNumber(value: bytes) + } + + private var isFolder: Bool { + kind == .folder + } + + var isUploaded: Bool { + true + } + + var isDownloaded: Bool { + isFolder + } + + var isMostRecentVersionDownloaded: Bool { + isFolder + } + + var creationDate: Date? { + Self.parseISO8601(createdAt) + } + + var contentModificationDate: Date? { + Self.parseISO8601(updatedAt) + } + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func parseISO8601(_ value: String?) -> Date? { + guard let value = value, !value.isEmpty else { return nil } + if let date = iso8601Formatter.date(from: value) { + return date } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: value) + } } diff --git a/ios/InternxtFileProvider/FileProviderItemIdentifier.swift b/ios/InternxtFileProvider/FileProviderItemIdentifier.swift new file mode 100644 index 000000000..d7536c623 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderItemIdentifier.swift @@ -0,0 +1,49 @@ +import FileProvider + +enum DriveItemKind: String { + case folder = "f" + case file = "d" +} + +enum FileProviderItemID { + private static let separator: Character = ":" + + static func encode(_ kind: DriveItemKind, uuid: String) -> NSFileProviderItemIdentifier { + NSFileProviderItemIdentifier("\(kind.rawValue)\(separator)\(uuid)") + } + + static func decode(_ identifier: NSFileProviderItemIdentifier) -> (kind: DriveItemKind, uuid: String)? { + let raw = identifier.rawValue + guard let separatorIndex = raw.firstIndex(of: separator) else { return nil } + let prefix = String(raw[raw.startIndex.. Bool { + if container == .rootContainer { return true } + guard let decoded = decode(container) else { return false } + return decoded.kind == .folder + } + + static func folderUuid(for container: NSFileProviderItemIdentifier) -> String? { + if container == .rootContainer { + return rootFolderUuid() + } + guard let decoded = decode(container), decoded.kind == .folder else { return nil } + return decoded.uuid + } + + static func rootFolderUuid() -> String? { + guard let data = SharedAuthKeychain.read(SharedAuthKeychain.rootFolderIdKey) else { return nil } + let uuid = String(decoding: data, as: UTF8.self) + return uuid.isEmpty ? nil : uuid + } + + static func parentIdentifier(folderUuid: String?) -> NSFileProviderItemIdentifier { + guard let folderUuid = folderUuid, !folderUuid.isEmpty else { return .rootContainer } + if folderUuid == rootFolderUuid() { return .rootContainer } + return encode(.folder, uuid: folderUuid) + } +} From 9e39c3676b6e4f3ffc0e16023997b46f1698c7b2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 6 Jul 2026 11:37:28 -0400 Subject: [PATCH 54/80] feat(ios): refactor date parsing methods in FileProviderItem --- ios/InternxtFileProvider/FileProviderItem.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift index 60bbc24a6..259997f10 100644 --- a/ios/InternxtFileProvider/FileProviderItem.swift +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -168,11 +168,11 @@ class FileProviderItem: NSObject, NSFileProviderItem { } var creationDate: Date? { - Self.parseISO8601(createdAt) + Self.parseDate(createdAt) } var contentModificationDate: Date? { - Self.parseISO8601(updatedAt) + Self.parseDate(updatedAt) } private static let iso8601Formatter: ISO8601DateFormatter = { @@ -181,13 +181,17 @@ class FileProviderItem: NSObject, NSFileProviderItem { return formatter }() - private static func parseISO8601(_ value: String?) -> Date? { + private static let iso8601FormatterNoFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static func parseDate(_ value: String?) -> Date? { guard let value = value, !value.isEmpty else { return nil } if let date = iso8601Formatter.date(from: value) { return date } - let fallback = ISO8601DateFormatter() - fallback.formatOptions = [.withInternetDateTime] - return fallback.date(from: value) + return iso8601FormatterNoFractionalSeconds.date(from: value) } } From b649e44aaa0948825ac2b483fd7c0c6588b51eb4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 22:07:18 -0400 Subject: [PATCH 55/80] feat(ios): resolve File Provider items via item(for:) lookup Implement item(for:) so the system can resolve any identifier to a FileProviderItem: return the root container item, decode folder/file identifiers and fetch their metadata from the Drive API, and map 401 responses to NSFileProviderError.notAuthenticated so the system can re-request credentials. --- .../FileProviderExtension.swift | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index c1802b584..f59fba9c9 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -9,31 +9,70 @@ import FileProvider import InternxtSwiftCore class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { + private let rootDisplayName: String + required init(domain: NSFileProviderDomain) { + self.rootDisplayName = domain.displayName super.init() } - private func currentAuthToken() -> String? { - guard let data = SharedAuthKeychain.read(SharedAuthKeychain.photosTokenKey) else { return nil } - return String(data: data, encoding: .utf8) - } - func invalidate() { // TODO: cleanup any resources } func item(for identifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { - _ = currentAuthToken() + if identifier == .rootContainer { + let rootItem = FileProviderItem.root(displayName: rootDisplayName) + completionHandler(rootItem, nil) + return Progress() + } - // TODO: implement the actual lookup using the auth token + guard let decoded = FileProviderItemID.decode(identifier) else { + completionHandler(nil, NSFileProviderError(.noSuchItem)) + return Progress() + } - completionHandler(FileProviderItem(identifier: identifier), nil) - return Progress() + guard let driveAPI = DriveAPIFactory.make() else { + completionHandler(nil, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + let progress = Progress(totalUnitCount: 1) + Task { + do { + let item = try await resolveItem(decoded, identifier: identifier, driveAPI: driveAPI) + progress.completedUnitCount = 1 + completionHandler(item, nil) + } catch { + completionHandler(nil, lookupError(from: error)) + } + } + return progress } - func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { - _ = currentAuthToken() + private func resolveItem( + _ decoded: (kind: DriveItemKind, uuid: String), + identifier: NSFileProviderItemIdentifier, + driveAPI: DriveAPI + ) async throws -> FileProviderItem { + switch decoded.kind { + case .folder: + let meta = try await driveAPI.getFolderMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(folderMeta: meta, identifier: identifier) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(fileMeta: meta, identifier: identifier) + } + } + private func lookupError(from error: Error) -> Error { + if let apiError = error as? APIClientError, apiError.statusCode == 401 { + return NSFileProviderError(.notAuthenticated) + } + return error + } + + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { // TODO: implement fetching of the contents for the itemIdentifier at the specified version completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) From cfa3a5df38b031dfdb090f92af4cc093e119e7b0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 22:07:49 -0400 Subject: [PATCH 56/80] feat(auth): forward driveBaseUrl to shared keychain on credential write Persist the Drive API base URL in the App Group keychain when credentials are written, so the File Provider extension can build authenticated Drive requests. Add a wrapper test asserting the native module receives driveBaseUrl. --- ios/Internxt/InternxtAuthCredentialsModule.swift | 1 + src/services/native/InternxtAuthCredentialsModule.spec.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift index d5f7613f7..b19c5e249 100644 --- a/ios/Internxt/InternxtAuthCredentialsModule.swift +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -56,6 +56,7 @@ class InternxtAuthCredentialsModule: NSObject { writeIfPresent(creds["rootFolderUuid"], to: SharedAuthKeychain.rootFolderIdKey) writeIfPresent(creds["bridgeUser"], to: SharedAuthKeychain.bridgeUserKey) writeIfPresent(creds["userId"], to: SharedAuthKeychain.userIdKey) + writeIfPresent(creds["driveBaseUrl"], to: SharedAuthKeychain.driveBaseUrlKey) } private func writeIfPresent(_ value: Any?, to sharedKey: String) { diff --git a/src/services/native/InternxtAuthCredentialsModule.spec.ts b/src/services/native/InternxtAuthCredentialsModule.spec.ts index f815ba694..7f5193263 100644 --- a/src/services/native/InternxtAuthCredentialsModule.spec.ts +++ b/src/services/native/InternxtAuthCredentialsModule.spec.ts @@ -45,6 +45,14 @@ describe('InternxtAuthCredentialsModule wrapper', () => { expect(setCredentials).toHaveBeenCalledWith(credentials); }); + it('when on iOS with the module present, then setCredentials forwards driveBaseUrl to the native module', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials.mock.calls[0][0]).toMatchObject({ driveBaseUrl: 'https://drive.example' }); + }); + it('when on iOS with the module present, then clearCredentials delegates to the native module', async () => { const { wrapper, clearCredentials } = arrangePresentNative('ios'); From 33f3185a010cd9666897f3850f9e64060a683919 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 11 Jun 2026 22:08:03 -0400 Subject: [PATCH 57/80] feat(ios): signal working set after domain registration Add FileProviderDomainManager.signalEnumeration to notify the working set enumerator, and call it once the File Provider domain is registered so the Files app refreshes Drive contents right after credentials change. Signal failures are logged and ignored to avoid blocking login. --- ios/Internxt/FileProviderDomainManager.swift | 14 ++++++++++++++ ios/Internxt/InternxtAuthCredentialsModule.swift | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ios/Internxt/FileProviderDomainManager.swift b/ios/Internxt/FileProviderDomainManager.swift index 2d714704e..60ae0360c 100644 --- a/ios/Internxt/FileProviderDomainManager.swift +++ b/ios/Internxt/FileProviderDomainManager.swift @@ -22,6 +22,20 @@ enum FileProviderDomainManager { } } + static func signalEnumeration(completion: @escaping (Error?) -> Void) { + guard let manager = NSFileProviderManager(for: domain) else { + completion(NSError( + domain: "FileProviderDomainManager", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No NSFileProviderManager for domain \(domainIdentifier.rawValue)"] + )) + return + } + manager.signalEnumerator(for: .workingSet) { error in + completion(error) + } + } + private static func isAlreadyExists(_ error: Error?) -> Bool { guard let error = error as NSError? else { return false } return error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift index b19c5e249..2f8391722 100644 --- a/ios/Internxt/InternxtAuthCredentialsModule.swift +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -26,7 +26,12 @@ class InternxtAuthCredentialsModule: NSObject { rejecter("E_REGISTER_DOMAIN", error.localizedDescription, error as NSError) return } - resolver(nil) + FileProviderDomainManager.signalEnumeration { signalError in + if let signalError = signalError { + NSLog("InternxtAuthCredentialsModule: signalEnumeration failed (ignored): \(signalError.localizedDescription)") + } + resolver(nil) + } } } From 643ee4025237cf5b2da7531703623e69f0113cd4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 12 Jun 2026 14:36:00 -0400 Subject: [PATCH 58/80] feat(ios): download and decrypt File Provider files for open in Files Adds NetworkFacadeFactory and SharedKeychainCredentials to the File Provider extension so items can be downloaded from the network and decrypted on demand. Persists bridgeBaseUrl to the shared keychain and JSON-decodes bridgeUser/userId so the extension reads matching credentials. --- ios/Internxt.xcodeproj/project.pbxproj | 8 ++ .../InternxtAuthCredentialsModule.swift | 1 + ios/Internxt/SharedAuthKeychain.swift | 7 +- .../DriveAPIFactory.swift | 26 +---- .../FileProviderExtension.swift | 96 ++++++++++++++++++- .../NetworkFacadeFactory.swift | 35 +++++++ .../SharedKeychainCredentials.swift | 21 ++++ .../InternxtAuthCredentialsModule.spec.ts | 20 ++-- 8 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 ios/InternxtFileProvider/NetworkFacadeFactory.swift create mode 100644 ios/InternxtFileProvider/SharedKeychainCredentials.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 3e6b572e0..22070c5ce 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */; }; + AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */; }; + AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */; }; 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; @@ -102,6 +104,8 @@ AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItemIdentifier.swift; sourceTree = ""; }; AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveAPIFactory.swift; sourceTree = ""; }; + AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFacadeFactory.swift; sourceTree = ""; }; + AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainCredentials.swift; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -277,6 +281,8 @@ DE1D47652F87A94000E14783 /* FileProviderItem.swift */, AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */, AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */, + AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */, + AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */, DE1D47662F87A94000E14783 /* Info.plist */, DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */, DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */, @@ -726,6 +732,8 @@ DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */, + AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */, + AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */, AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift index 2f8391722..41d762585 100644 --- a/ios/Internxt/InternxtAuthCredentialsModule.swift +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -62,6 +62,7 @@ class InternxtAuthCredentialsModule: NSObject { writeIfPresent(creds["bridgeUser"], to: SharedAuthKeychain.bridgeUserKey) writeIfPresent(creds["userId"], to: SharedAuthKeychain.userIdKey) writeIfPresent(creds["driveBaseUrl"], to: SharedAuthKeychain.driveBaseUrlKey) + writeIfPresent(creds["bridgeBaseUrl"], to: SharedAuthKeychain.bridgeBaseUrlKey) } private func writeIfPresent(_ value: Any?, to sharedKey: String) { diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift index 70675bb18..eb4adc984 100644 --- a/ios/Internxt/SharedAuthKeychain.swift +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -15,10 +15,11 @@ enum SharedAuthKeychain { static let bridgeUserKey = "shared_bridgeUser" static let userIdKey = "shared_userId" static let driveBaseUrlKey = "shared_driveBaseUrl" + static let bridgeBaseUrlKey = "shared_bridgeBaseUrl" static let allKeys = [ photosTokenKey, mnemonicKey, rootFolderIdKey, bucketKey, bridgeUserKey, userIdKey, - driveBaseUrlKey, + driveBaseUrlKey, bridgeBaseUrlKey, ] static var accessGroup: String? { @@ -111,8 +112,8 @@ enum SharedAuthKeychain { copyFromPrivate(privateKey: "xUser_mnemonic", sharedKey: mnemonicKey, isJSONEncoded: true) copyFromPrivate(privateKey: "xUser_rootFolderId", sharedKey: rootFolderIdKey, isJSONEncoded: true) copyFromPrivate(privateKey: "xUser_bucket", sharedKey: bucketKey) - copyFromPrivate(privateKey: "xUser_bridgeUser", sharedKey: bridgeUserKey) - copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey) + copyFromPrivate(privateKey: "xUser_bridgeUser", sharedKey: bridgeUserKey, isJSONEncoded: true) + copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey, isJSONEncoded: true) } private static func copyFromPrivate(privateKey: String, sharedKey: String, isJSONEncoded: Bool = false) { diff --git a/ios/InternxtFileProvider/DriveAPIFactory.swift b/ios/InternxtFileProvider/DriveAPIFactory.swift index 5d29882d1..359159d8c 100644 --- a/ios/InternxtFileProvider/DriveAPIFactory.swift +++ b/ios/InternxtFileProvider/DriveAPIFactory.swift @@ -3,34 +3,16 @@ import InternxtSwiftCore enum DriveAPIFactory { static func make() -> DriveAPI? { - guard let authToken = sharedString(SharedAuthKeychain.photosTokenKey), - let baseUrl = sharedString(SharedAuthKeychain.driveBaseUrlKey) else { + guard let authToken = SharedKeychainCredentials.string(SharedAuthKeychain.photosTokenKey), + let baseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.driveBaseUrlKey) else { return nil } return DriveAPI( baseUrl: baseUrl, authToken: authToken, - clientName: clientName, - clientVersion: clientVersion + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion ) } - - private static func sharedString(_ key: String) -> String? { - guard let data = SharedAuthKeychain.read(key) else { return nil } - let value = String(decoding: data, as: UTF8.self) - return value.isEmpty ? nil : value - } - - private static var clientName: String { - bundleString("CFBundleName") ?? "drive-mobile" - } - - private static var clientVersion: String { - bundleString("CFBundleShortVersionString") ?? "0.0.0" - } - - private static func bundleString(_ key: String) -> String? { - Bundle(for: FileProviderExtension.self).object(forInfoDictionaryKey: key) as? String - } } diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index f59fba9c9..4ce67635a 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -65,18 +65,106 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } } + private static let offlineURLErrorCodes: Set = [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotConnectToHost, + .dataNotAllowed, + .cannotFindHost + ] + + private static let offlineErrorCodes: Set = [ + .networkNoConnection, + .networkConnectionLost, + .networkTimeout, + .networkCannotConnect + ] + private func lookupError(from error: Error) -> Error { - if let apiError = error as? APIClientError, apiError.statusCode == 401 { + if isUnauthorized(error) { return NSFileProviderError(.notAuthenticated) } + if isOffline(error) { + return NSFileProviderError(.serverUnreachable) + } return error } + private func isUnauthorized(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if enriched.code == .apiUnauthorized { + return true + } + if let cause = enriched.cause { + return isUnauthorized(cause) + } + return false + } + if let apiError = error as? APIClientError { + return apiError.statusCode == 401 + } + return false + } + + private func isOffline(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if Self.offlineErrorCodes.contains(enriched.code) { + return true + } + if let cause = enriched.cause { + return isOffline(cause) + } + return false + } + if let urlError = error as? URLError { + return Self.offlineURLErrorCodes.contains(urlError.code) + } + if let apiError = error as? APIClientError { + return apiError.statusCode <= 0 + } + return false + } + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { - // TODO: implement fetching of the contents for the itemIdentifier at the specified version + guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { + completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + return Progress() + } - completionHandler(nil, nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) - return Progress() + guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { + completionHandler(nil, nil, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + let progress = Progress(totalUnitCount: 100) + Task { + let encryptedTmp = Self.temporaryFileURL() + let plainTmp = Self.temporaryFileURL() + defer { try? FileManager.default.removeItem(at: encryptedTmp) } + + do { + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + _ = try await networkFacade.downloadFile( + bucketId: meta.bucket, + fileId: meta.fileId, + encryptedFileDestination: encryptedTmp, + destinationURL: plainTmp, + progressHandler: { fraction in + progress.completedUnitCount = Int64(fraction * 100) + } + ) + let item = FileProviderItem(fileMeta: meta, identifier: itemIdentifier) + completionHandler(plainTmp, item, nil) + } catch { + completionHandler(nil, nil, lookupError(from: error)) + } + } + return progress + } + + private static func temporaryFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) } func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { diff --git a/ios/InternxtFileProvider/NetworkFacadeFactory.swift b/ios/InternxtFileProvider/NetworkFacadeFactory.swift new file mode 100644 index 000000000..d67c88af2 --- /dev/null +++ b/ios/InternxtFileProvider/NetworkFacadeFactory.swift @@ -0,0 +1,35 @@ +import Foundation +import CryptoKit +import InternxtSwiftCore + +enum NetworkFacadeFactory { + static func make() -> NetworkFacade? { + guard let mnemonic = SharedKeychainCredentials.string(SharedAuthKeychain.mnemonicKey) else { + return nil + } + guard let bridgeBaseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.bridgeBaseUrlKey) else { + return nil + } + guard let bridgeUser = SharedKeychainCredentials.string(SharedAuthKeychain.bridgeUserKey) else { + return nil + } + guard let userId = SharedKeychainCredentials.string(SharedAuthKeychain.userIdKey) else { + return nil + } + + let bridgePass = deriveBridgePass(userId: userId) + let basicAuthToken = Data("\(bridgeUser):\(bridgePass)".utf8).base64EncodedString() + let networkAPI = NetworkAPI( + baseUrl: bridgeBaseUrl, + basicAuthToken: basicAuthToken, + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion + ) + return NetworkFacade(mnemonic: mnemonic, networkAPI: networkAPI) + } + + private static func deriveBridgePass(userId: String) -> String { + let digest = SHA256.hash(data: Data(userId.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/ios/InternxtFileProvider/SharedKeychainCredentials.swift b/ios/InternxtFileProvider/SharedKeychainCredentials.swift new file mode 100644 index 000000000..7597682d5 --- /dev/null +++ b/ios/InternxtFileProvider/SharedKeychainCredentials.swift @@ -0,0 +1,21 @@ +import Foundation + +enum SharedKeychainCredentials { + static func string(_ key: String) -> String? { + guard let data = SharedAuthKeychain.read(key) else { return nil } + let value = String(decoding: data, as: UTF8.self) + return value.isEmpty ? nil : value + } + + static var clientName: String { + bundleString("CFBundleName") ?? "drive-mobile" + } + + static var clientVersion: String { + bundleString("CFBundleShortVersionString") ?? "0.0.0" + } + + private static func bundleString(_ key: String) -> String? { + Bundle(for: FileProviderExtension.self).object(forInfoDictionaryKey: key) as? String + } +} diff --git a/src/services/native/InternxtAuthCredentialsModule.spec.ts b/src/services/native/InternxtAuthCredentialsModule.spec.ts index 7f5193263..deaa58732 100644 --- a/src/services/native/InternxtAuthCredentialsModule.spec.ts +++ b/src/services/native/InternxtAuthCredentialsModule.spec.ts @@ -37,7 +37,7 @@ describe('InternxtAuthCredentialsModule wrapper', () => { jest.resetModules(); }); - it('when on iOS with the module present, then setCredentials delegates with the credentials', async () => { + test('when on iOS with the module present, then setCredentials delegates with the credentials', async () => { const { wrapper, setCredentials } = arrangePresentNative('ios'); await wrapper.setCredentials(credentials); @@ -45,7 +45,7 @@ describe('InternxtAuthCredentialsModule wrapper', () => { expect(setCredentials).toHaveBeenCalledWith(credentials); }); - it('when on iOS with the module present, then setCredentials forwards driveBaseUrl to the native module', async () => { + test('when on iOS with the module present, then setCredentials forwards driveBaseUrl to the native module', async () => { const { wrapper, setCredentials } = arrangePresentNative('ios'); await wrapper.setCredentials(credentials); @@ -53,7 +53,15 @@ describe('InternxtAuthCredentialsModule wrapper', () => { expect(setCredentials.mock.calls[0][0]).toMatchObject({ driveBaseUrl: 'https://drive.example' }); }); - it('when on iOS with the module present, then clearCredentials delegates to the native module', async () => { + test('when on iOS with the module present, then setCredentials forwards bridgeBaseUrl to the native module', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials.mock.calls[0][0]).toMatchObject({ bridgeBaseUrl: 'https://bridge.example' }); + }); + + test('when on iOS with the module present, then clearCredentials delegates to the native module', async () => { const { wrapper, clearCredentials } = arrangePresentNative('ios'); await wrapper.clearCredentials(); @@ -61,7 +69,7 @@ describe('InternxtAuthCredentialsModule wrapper', () => { expect(clearCredentials).toHaveBeenCalledTimes(1); }); - it('when on Android with the module present, then setCredentials delegates with the credentials', async () => { + test('when on Android with the module present, then setCredentials delegates with the credentials', async () => { const { wrapper, setCredentials } = arrangePresentNative('android'); await wrapper.setCredentials(credentials); @@ -69,13 +77,13 @@ describe('InternxtAuthCredentialsModule wrapper', () => { expect(setCredentials).toHaveBeenCalledWith(credentials); }); - it('when the native module is absent, then setCredentials resolves without throwing', async () => { + test('when the native module is absent, then setCredentials resolves without throwing', async () => { const wrapper = loadWrapper('ios', undefined); await expect(wrapper.setCredentials(credentials)).resolves.toBeUndefined(); }); - it('when the native module is absent, then clearCredentials resolves without throwing', async () => { + test('when the native module is absent, then clearCredentials resolves without throwing', async () => { const wrapper = loadWrapper('ios', undefined); await expect(wrapper.clearCredentials()).resolves.toBeUndefined(); From 4368c77a3d7ee108ab55f08a71f59e4ee7f60ff4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 15 Jun 2026 23:10:55 -0400 Subject: [PATCH 59/80] feat(ios): create folders and upload encrypted files via File Provider Implement the write path in the NSFileProviderReplicatedExtension so the Files.app can create folders and upload files to Drive. createItem routes folder creation to the Drive API and file uploads through InternxtSwiftCore encryption plus network upload, then registers the file and returns the created NSFileProviderItem. Adds rename support via modifyItem and friendly non-crashing errors for offline/unauthenticated/generic failures. --- .../FileProviderExtension.swift | 237 ++++++++++++++++-- .../FileProviderItem.swift | 61 ++++- 2 files changed, 282 insertions(+), 16 deletions(-) diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index 4ce67635a..55af6897b 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -7,15 +7,43 @@ import FileProvider import InternxtSwiftCore +import UniformTypeIdentifiers class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private let rootDisplayName: String + private enum FriendlyError { + static let offlineDescription = "No internet connection" + static let offlineReason = "Connect to the internet and try again to upload to Internxt Drive." + static let notAuthenticatedDescription = "Sign in required" + static let notAuthenticatedReason = "Open the Internxt app and sign in to continue." + static let genericDescription = "Couldn’t complete the operation" + static let genericReason = "Something went wrong with Internxt Drive. Please try again." + } + required init(domain: NSFileProviderDomain) { self.rootDisplayName = domain.displayName super.init() } + private static func fileProviderError(_ code: NSFileProviderError.Code, description: String, reason: String? = nil) -> NSError { + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: description] + userInfo[NSLocalizedFailureReasonErrorKey] = reason ?? description + return NSError(domain: NSFileProviderError.errorDomain, code: code.rawValue, userInfo: userInfo) + } + + private static var offlineError: NSError { + fileProviderError(.serverUnreachable, description: FriendlyError.offlineDescription, reason: FriendlyError.offlineReason) + } + + private static var notAuthenticatedError: NSError { + fileProviderError(.notAuthenticated, description: FriendlyError.notAuthenticatedDescription, reason: FriendlyError.notAuthenticatedReason) + } + + private static var noSuchItemError: NSError { + fileProviderError(.noSuchItem, description: FriendlyError.genericDescription, reason: FriendlyError.genericReason) + } + func invalidate() { // TODO: cleanup any resources } @@ -28,12 +56,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } guard let decoded = FileProviderItemID.decode(identifier) else { - completionHandler(nil, NSFileProviderError(.noSuchItem)) + completionHandler(nil, Self.noSuchItemError) return Progress() } guard let driveAPI = DriveAPIFactory.make() else { - completionHandler(nil, NSFileProviderError(.notAuthenticated)) + completionHandler(nil, Self.notAuthenticatedError) return Progress() } @@ -83,12 +111,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private func lookupError(from error: Error) -> Error { if isUnauthorized(error) { - return NSFileProviderError(.notAuthenticated) + return Self.notAuthenticatedError } if isOffline(error) { - return NSFileProviderError(.serverUnreachable) + return Self.offlineError } - return error + return Self.noSuchItemError } private func isUnauthorized(_ error: Error) -> Bool { @@ -128,12 +156,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { - completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + completionHandler(nil, nil, Self.noSuchItemError) return Progress() } guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { - completionHandler(nil, nil, NSFileProviderError(.notAuthenticated)) + completionHandler(nil, nil, Self.notAuthenticatedError) return Progress() } @@ -168,18 +196,199 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { - // TODO: a new item was created on disk, process the item's creation - - completionHandler(itemTemplate, [], false, nil) - return Progress() + guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { + completionHandler(nil, [], false, Self.notAuthenticatedError) + return Progress() + } + + guard let parentUuid = FileProviderItemID.folderUuid(for: itemTemplate.parentItemIdentifier) else { + completionHandler(nil, [], false, Self.noSuchItemError) + return Progress() + } + + let parentIdentifier = itemTemplate.parentItemIdentifier + + if itemTemplate.contentType?.conforms(to: .folder) == true { + return createFolder( + name: itemTemplate.filename, + parentUuid: parentUuid, + parentIdentifier: parentIdentifier, + driveAPI: driveAPI, + completionHandler: completionHandler + ) + } + + guard let contentsURL = url else { + completionHandler(nil, [], false, Self.noSuchItemError) + return Progress() + } + + return uploadFile( + filename: itemTemplate.filename, + contentsURL: contentsURL, + parentUuid: parentUuid, + parentIdentifier: parentIdentifier, + driveAPI: driveAPI, + networkFacade: networkFacade, + completionHandler: completionHandler + ) + } + + private func createFolder( + name: String, + parentUuid: String, + parentIdentifier: NSFileProviderItemIdentifier, + driveAPI: DriveAPI, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void + ) -> Progress { + let progress = Progress(totalUnitCount: 1) + Task { + do { + let response = try await driveAPI.createFolderNew(parentFolderUuid: parentUuid, folderName: name) + progress.completedUnitCount = 1 + let item = FileProviderItem(folder: response, parent: parentIdentifier) + completionHandler(item, [], false, nil) + } catch { + completionHandler(nil, [], false, lookupError(from: error)) + } + } + return progress + } + + private func uploadFile( + filename: String, + contentsURL: URL, + parentUuid: String, + parentIdentifier: NSFileProviderItemIdentifier, + driveAPI: DriveAPI, + networkFacade: NetworkFacade, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void + ) -> Progress { + let progress = Progress(totalUnitCount: 100) + Task { + let encryptedTmp = Self.temporaryFileURL() + defer { try? FileManager.default.removeItem(at: encryptedTmp) } + + do { + let meta = try await driveAPI.getFolderMetaByUuid(uuid: parentUuid) + guard let bucket = try await Self.resolveBucket(parentMeta: meta, driveAPI: driveAPI) else { + completionHandler(nil, [], false, Self.notAuthenticatedError) + return + } + guard let input = InputStream(url: contentsURL) else { + completionHandler(nil, [], false, Self.noSuchItemError) + return + } + + let fileSize = Self.fileSize(of: contentsURL) + let finish = try await networkFacade.uploadFile( + input: input, + encryptedOutput: encryptedTmp, + fileSize: fileSize, + bucketId: bucket, + progressHandler: { fraction in + progress.completedUnitCount = Int64(fraction * 100) + } + ) + + let (baseName, fileExtension) = Self.splitNameExtension(filename) + let created = try await driveAPI.createFileNew(createFile: CreateFileDataNew( + fileId: finish.id, + type: fileExtension, + bucket: bucket, + size: fileSize, + folderId: meta.id, + name: baseName, + plainName: baseName, + folderUuid: parentUuid + )) + + let item = FileProviderItem(file: created, parent: parentIdentifier) + completionHandler(item, [], false, nil) + } catch { + completionHandler(nil, [], false, lookupError(from: error)) + } + } + return progress + } + + private static func resolveBucket(parentMeta: GetFolderMetaByIdResponse, driveAPI: DriveAPI) async throws -> String? { + if let bucket = parentMeta.bucket { + return bucket + } + guard let rootUuid = FileProviderItemID.rootFolderUuid() else { + return nil + } + let rootMeta = try await driveAPI.getFolderMetaByUuid(uuid: rootUuid) + return rootMeta.bucket + } + + private static func fileSize(of url: URL) -> Int { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize ?? 0 + } + + private static func splitNameExtension(_ filename: String) -> (base: String, fileExtension: String?) { + FileProviderItem.splitNameExtension(filename, kind: .file) } func modifyItem(_ item: NSFileProviderItem, baseVersion version: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents newContents: URL?, options: NSFileProviderModifyItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { - // TODO: an item was modified on disk, process the item's modification - - completionHandler(nil, [], false, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) + if changedFields.contains(.contents) || changedFields.contains(.parentItemIdentifier) { + completionHandler(item, [], false, nil) + return Progress() + } + + if changedFields.contains(.filename) { + return renameItem(item, completionHandler: completionHandler) + } + + completionHandler(item, [], false, nil) return Progress() } + + private func renameItem( + _ item: NSFileProviderItem, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void + ) -> Progress { + guard let driveAPI = DriveAPIFactory.make() else { + completionHandler(nil, [], false, Self.notAuthenticatedError) + return Progress() + } + + guard let decoded = FileProviderItemID.decode(item.itemIdentifier) else { + completionHandler(nil, [], false, Self.noSuchItemError) + return Progress() + } + + let newFilename = item.filename + let progress = Progress(totalUnitCount: 1) + Task { + do { + try await rename(decoded, to: newFilename, driveAPI: driveAPI) + progress.completedUnitCount = 1 + let renamed = FileProviderItem.renamed(from: item, newFilename: newFilename) ?? item + completionHandler(renamed, [], false, nil) + } catch { + completionHandler(nil, [], false, lookupError(from: error)) + } + } + return progress + } + + private func rename( + _ decoded: (kind: DriveItemKind, uuid: String), + to newFilename: String, + driveAPI: DriveAPI + ) async throws { + switch decoded.kind { + case .folder: + _ = try await driveAPI.updateFolderNew(folderUuid: decoded.uuid, folderName: newFilename) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + let (baseName, _) = Self.splitNameExtension(newFilename) + _ = try await driveAPI.updateFileNew(uuid: decoded.uuid, bucketId: meta.bucket, newFilename: baseName) + } + } func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion version: NSFileProviderItemVersion, options: NSFileProviderDeleteItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { // TODO: an item was deleted on disk, process the item's deletion diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift index 259997f10..9d79aa746 100644 --- a/ios/InternxtFileProvider/FileProviderItem.swift +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -66,6 +66,63 @@ class FileProviderItem: NSObject, NSFileProviderItem { ) } + convenience init(folder: CreateFolderResponseNew, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.folder, uuid: folder.uuid), + parent: parent, + name: folder.plainName ?? folder.name, + kind: .folder, + fileExtension: nil, + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + sizeInBytes: nil + ) + } + + convenience init(file: CreateFileResponseNew, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.file, uuid: file.uuid), + parent: parent, + name: file.plain_name, + kind: .file, + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) + } + + static func renamed(from item: NSFileProviderItem, newFilename: String) -> FileProviderItem? { + guard let decoded = FileProviderItemID.decode(item.itemIdentifier) else { return nil } + let (baseName, newExtension) = splitNameExtension(newFilename, kind: decoded.kind) + return FileProviderItem( + identifier: item.itemIdentifier, + parent: item.parentItemIdentifier, + name: baseName, + kind: decoded.kind, + fileExtension: newExtension, + createdAt: iso8601String(from: item.creationDate ?? nil), + updatedAt: iso8601String(from: item.contentModificationDate ?? nil), + sizeInBytes: (item.documentSize ?? nil)?.stringValue + ) + } + + private static func iso8601String(from date: Date?) -> String? { + guard let date = date else { return nil } + return iso8601Formatter.string(from: date) + } + + static func splitNameExtension(_ filename: String, kind: DriveItemKind) -> (base: String, fileExtension: String?) { + guard kind == .file else { return (filename, nil) } + let url = URL(fileURLWithPath: filename) + let fileExtension = url.pathExtension + let base = url.deletingPathExtension().lastPathComponent + if fileExtension.isEmpty || base.isEmpty { + return (filename, nil) + } + return (base, fileExtension) + } + static func root(displayName: String) -> FileProviderItem { FileProviderItem( identifier: .rootContainer, @@ -116,9 +173,9 @@ class FileProviderItem: NSObject, NSFileProviderItem { var capabilities: NSFileProviderItemCapabilities { switch kind { case .folder: - return [.allowsReading, .allowsContentEnumerating] + return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems, .allowsRenaming] case .file: - return [.allowsReading] + return [.allowsReading, .allowsRenaming] } } From 3dbfc46833207f1b21d6d412f44b27e14a1edfc5 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 15 Jun 2026 23:31:00 -0400 Subject: [PATCH 60/80] refactor(ios): drop custom File Provider error messages, use plain NSFileProviderError Revert the FriendlyError helper and fileProviderError factory; surface plain NSFileProviderError values while keeping the same error codes (noSuchItem, notAuthenticated, serverUnreachable). --- .../FileProviderExtension.swift | 55 +++++-------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index 55af6897b..b5e6d81d4 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -12,38 +12,11 @@ import UniformTypeIdentifiers class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private let rootDisplayName: String - private enum FriendlyError { - static let offlineDescription = "No internet connection" - static let offlineReason = "Connect to the internet and try again to upload to Internxt Drive." - static let notAuthenticatedDescription = "Sign in required" - static let notAuthenticatedReason = "Open the Internxt app and sign in to continue." - static let genericDescription = "Couldn’t complete the operation" - static let genericReason = "Something went wrong with Internxt Drive. Please try again." - } - required init(domain: NSFileProviderDomain) { self.rootDisplayName = domain.displayName super.init() } - private static func fileProviderError(_ code: NSFileProviderError.Code, description: String, reason: String? = nil) -> NSError { - var userInfo: [String: Any] = [NSLocalizedDescriptionKey: description] - userInfo[NSLocalizedFailureReasonErrorKey] = reason ?? description - return NSError(domain: NSFileProviderError.errorDomain, code: code.rawValue, userInfo: userInfo) - } - - private static var offlineError: NSError { - fileProviderError(.serverUnreachable, description: FriendlyError.offlineDescription, reason: FriendlyError.offlineReason) - } - - private static var notAuthenticatedError: NSError { - fileProviderError(.notAuthenticated, description: FriendlyError.notAuthenticatedDescription, reason: FriendlyError.notAuthenticatedReason) - } - - private static var noSuchItemError: NSError { - fileProviderError(.noSuchItem, description: FriendlyError.genericDescription, reason: FriendlyError.genericReason) - } - func invalidate() { // TODO: cleanup any resources } @@ -56,12 +29,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } guard let decoded = FileProviderItemID.decode(identifier) else { - completionHandler(nil, Self.noSuchItemError) + completionHandler(nil, NSFileProviderError(.noSuchItem)) return Progress() } guard let driveAPI = DriveAPIFactory.make() else { - completionHandler(nil, Self.notAuthenticatedError) + completionHandler(nil, NSFileProviderError(.notAuthenticated)) return Progress() } @@ -111,12 +84,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { private func lookupError(from error: Error) -> Error { if isUnauthorized(error) { - return Self.notAuthenticatedError + return NSFileProviderError(.notAuthenticated) } if isOffline(error) { - return Self.offlineError + return NSFileProviderError(.serverUnreachable) } - return Self.noSuchItemError + return NSFileProviderError(.noSuchItem) } private func isUnauthorized(_ error: Error) -> Bool { @@ -156,12 +129,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { - completionHandler(nil, nil, Self.noSuchItemError) + completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) return Progress() } guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { - completionHandler(nil, nil, Self.notAuthenticatedError) + completionHandler(nil, nil, NSFileProviderError(.notAuthenticated)) return Progress() } @@ -197,12 +170,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { - completionHandler(nil, [], false, Self.notAuthenticatedError) + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) return Progress() } guard let parentUuid = FileProviderItemID.folderUuid(for: itemTemplate.parentItemIdentifier) else { - completionHandler(nil, [], false, Self.noSuchItemError) + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) return Progress() } @@ -219,7 +192,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } guard let contentsURL = url else { - completionHandler(nil, [], false, Self.noSuchItemError) + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) return Progress() } @@ -272,11 +245,11 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { do { let meta = try await driveAPI.getFolderMetaByUuid(uuid: parentUuid) guard let bucket = try await Self.resolveBucket(parentMeta: meta, driveAPI: driveAPI) else { - completionHandler(nil, [], false, Self.notAuthenticatedError) + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) return } guard let input = InputStream(url: contentsURL) else { - completionHandler(nil, [], false, Self.noSuchItemError) + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) return } @@ -351,12 +324,12 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { guard let driveAPI = DriveAPIFactory.make() else { - completionHandler(nil, [], false, Self.notAuthenticatedError) + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) return Progress() } guard let decoded = FileProviderItemID.decode(item.itemIdentifier) else { - completionHandler(nil, [], false, Self.noSuchItemError) + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) return Progress() } From d478e035ad10139b1c375c31e60f5cb50485c1d4 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Jun 2026 11:17:48 -0400 Subject: [PATCH 61/80] refactor(ios): split FileProviderExtension into focused services Extract upload, download, mutation, and error-mapping logic out of the monolithic FileProviderExtension into dedicated services so the extension becomes a thin coordinator, easing testability and maintenance ahead of move/trash/versioning work. --- ios/Internxt.xcodeproj/project.pbxproj | 16 ++ .../FileProviderDownloadService.swift | 37 +++ .../FileProviderErrorMapper.swift | 72 ++++++ .../FileProviderExtension.swift | 214 +++--------------- .../FileProviderMutationService.swift | 41 ++++ .../FileProviderUploadService.swift | 86 +++++++ 6 files changed, 289 insertions(+), 177 deletions(-) create mode 100644 ios/InternxtFileProvider/FileProviderDownloadService.swift create mode 100644 ios/InternxtFileProvider/FileProviderErrorMapper.swift create mode 100644 ios/InternxtFileProvider/FileProviderMutationService.swift create mode 100644 ios/InternxtFileProvider/FileProviderUploadService.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 22070c5ce..a3723ea49 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */; }; AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */; }; AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */; }; + AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */; }; + AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */; }; + AB1D47962F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; @@ -106,6 +110,10 @@ AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveAPIFactory.swift; sourceTree = ""; }; AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFacadeFactory.swift; sourceTree = ""; }; AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainCredentials.swift; sourceTree = ""; }; + AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapper.swift; sourceTree = ""; }; + AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderUploadService.swift; sourceTree = ""; }; + AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDownloadService.swift; sourceTree = ""; }; + AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationService.swift; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -279,6 +287,10 @@ DE1D47632F87A94000E14783 /* FileProviderExtension.swift */, DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */, DE1D47652F87A94000E14783 /* FileProviderItem.swift */, + AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */, + AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */, + AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */, + AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */, AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */, AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */, AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */, @@ -730,6 +742,10 @@ DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, + AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, + AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */, + AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */, + AB1D47962F87A94000E14783 /* FileProviderMutationService.swift in Sources */, AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */, AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */, diff --git a/ios/InternxtFileProvider/FileProviderDownloadService.swift b/ios/InternxtFileProvider/FileProviderDownloadService.swift new file mode 100644 index 000000000..b2af83292 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderDownloadService.swift @@ -0,0 +1,37 @@ +// +// FileProviderDownloadService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderDownloadService { + let driveAPI: DriveAPI + let networkFacade: NetworkFacade + + func fetchContents( + uuid: String, + progressHandler: @escaping (Double) -> Void + ) async throws -> (url: URL, meta: GetFileMetaByIdResponse) { + let encryptedTmp = Self.temporaryFileURL() + let plainTmp = Self.temporaryFileURL() + defer { try? FileManager.default.removeItem(at: encryptedTmp) } + + let meta = try await driveAPI.getFileMetaByUuid(uuid: uuid) + _ = try await networkFacade.downloadFile( + bucketId: meta.bucket, + fileId: meta.fileId, + encryptedFileDestination: encryptedTmp, + destinationURL: plainTmp, + progressHandler: progressHandler + ) + return (plainTmp, meta) + } + + static func temporaryFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} diff --git a/ios/InternxtFileProvider/FileProviderErrorMapper.swift b/ios/InternxtFileProvider/FileProviderErrorMapper.swift new file mode 100644 index 000000000..cc68ac064 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderErrorMapper.swift @@ -0,0 +1,72 @@ +// +// FileProviderErrorMapper.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +enum FileProviderErrorMapper { + private static let offlineURLErrorCodes: Set = [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotConnectToHost, + .dataNotAllowed, + .cannotFindHost + ] + + private static let offlineErrorCodes: Set = [ + .networkNoConnection, + .networkConnectionLost, + .networkTimeout, + .networkCannotConnect + ] + + static func lookupError(from error: Error) -> Error { + if isUnauthorized(error) { + return NSFileProviderError(.notAuthenticated) + } + if isOffline(error) { + return NSFileProviderError(.serverUnreachable) + } + return NSFileProviderError(.noSuchItem) + } + + static func isUnauthorized(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if enriched.code == .apiUnauthorized { + return true + } + if let cause = enriched.cause { + return isUnauthorized(cause) + } + return false + } + if let apiError = error as? APIClientError { + return apiError.statusCode == 401 + } + return false + } + + static func isOffline(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if offlineErrorCodes.contains(enriched.code) { + return true + } + if let cause = enriched.cause { + return isOffline(cause) + } + return false + } + if let urlError = error as? URLError { + return offlineURLErrorCodes.contains(urlError.code) + } + if let apiError = error as? APIClientError { + return apiError.statusCode <= 0 + } + return false + } +} diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index b5e6d81d4..d103f436d 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -38,95 +38,20 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return Progress() } + let mutationService = FileProviderMutationService(driveAPI: driveAPI) let progress = Progress(totalUnitCount: 1) Task { do { - let item = try await resolveItem(decoded, identifier: identifier, driveAPI: driveAPI) + let item = try await mutationService.resolveItem(decoded, identifier: identifier) progress.completedUnitCount = 1 completionHandler(item, nil) } catch { - completionHandler(nil, lookupError(from: error)) + completionHandler(nil, FileProviderErrorMapper.lookupError(from: error)) } } return progress } - private func resolveItem( - _ decoded: (kind: DriveItemKind, uuid: String), - identifier: NSFileProviderItemIdentifier, - driveAPI: DriveAPI - ) async throws -> FileProviderItem { - switch decoded.kind { - case .folder: - let meta = try await driveAPI.getFolderMetaByUuid(uuid: decoded.uuid) - return FileProviderItem(folderMeta: meta, identifier: identifier) - case .file: - let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) - return FileProviderItem(fileMeta: meta, identifier: identifier) - } - } - - private static let offlineURLErrorCodes: Set = [ - .notConnectedToInternet, - .networkConnectionLost, - .timedOut, - .cannotConnectToHost, - .dataNotAllowed, - .cannotFindHost - ] - - private static let offlineErrorCodes: Set = [ - .networkNoConnection, - .networkConnectionLost, - .networkTimeout, - .networkCannotConnect - ] - - private func lookupError(from error: Error) -> Error { - if isUnauthorized(error) { - return NSFileProviderError(.notAuthenticated) - } - if isOffline(error) { - return NSFileProviderError(.serverUnreachable) - } - return NSFileProviderError(.noSuchItem) - } - - private func isUnauthorized(_ error: Error) -> Bool { - if let enriched = error as? EnrichedError { - if enriched.code == .apiUnauthorized { - return true - } - if let cause = enriched.cause { - return isUnauthorized(cause) - } - return false - } - if let apiError = error as? APIClientError { - return apiError.statusCode == 401 - } - return false - } - - private func isOffline(_ error: Error) -> Bool { - if let enriched = error as? EnrichedError { - if Self.offlineErrorCodes.contains(enriched.code) { - return true - } - if let cause = enriched.cause { - return isOffline(cause) - } - return false - } - if let urlError = error as? URLError { - return Self.offlineURLErrorCodes.contains(urlError.code) - } - if let apiError = error as? APIClientError { - return apiError.statusCode <= 0 - } - return false - } - func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) @@ -138,36 +63,22 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return Progress() } + let downloadService = FileProviderDownloadService(driveAPI: driveAPI, networkFacade: networkFacade) let progress = Progress(totalUnitCount: 100) Task { - let encryptedTmp = Self.temporaryFileURL() - let plainTmp = Self.temporaryFileURL() - defer { try? FileManager.default.removeItem(at: encryptedTmp) } - do { - let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) - _ = try await networkFacade.downloadFile( - bucketId: meta.bucket, - fileId: meta.fileId, - encryptedFileDestination: encryptedTmp, - destinationURL: plainTmp, - progressHandler: { fraction in - progress.completedUnitCount = Int64(fraction * 100) - } - ) - let item = FileProviderItem(fileMeta: meta, identifier: itemIdentifier) - completionHandler(plainTmp, item, nil) + let result = try await downloadService.fetchContents(uuid: decoded.uuid) { fraction in + progress.completedUnitCount = Int64(fraction * 100) + } + let item = FileProviderItem(fileMeta: result.meta, identifier: itemIdentifier) + completionHandler(result.url, item, nil) } catch { - completionHandler(nil, nil, lookupError(from: error)) + completionHandler(nil, nil, FileProviderErrorMapper.lookupError(from: error)) } } return progress } - private static func temporaryFileURL() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - } - func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) @@ -180,13 +91,14 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } let parentIdentifier = itemTemplate.parentItemIdentifier + let uploadService = FileProviderUploadService(driveAPI: driveAPI, networkFacade: networkFacade) if itemTemplate.contentType?.conforms(to: .folder) == true { return createFolder( name: itemTemplate.filename, parentUuid: parentUuid, parentIdentifier: parentIdentifier, - driveAPI: driveAPI, + uploadService: uploadService, completionHandler: completionHandler ) } @@ -201,8 +113,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { contentsURL: contentsURL, parentUuid: parentUuid, parentIdentifier: parentIdentifier, - driveAPI: driveAPI, - networkFacade: networkFacade, + uploadService: uploadService, completionHandler: completionHandler ) } @@ -211,18 +122,18 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { name: String, parentUuid: String, parentIdentifier: NSFileProviderItemIdentifier, - driveAPI: DriveAPI, + uploadService: FileProviderUploadService, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { let progress = Progress(totalUnitCount: 1) Task { do { - let response = try await driveAPI.createFolderNew(parentFolderUuid: parentUuid, folderName: name) + let response = try await uploadService.createFolder(name: name, parentUuid: parentUuid) progress.completedUnitCount = 1 let item = FileProviderItem(folder: response, parent: parentIdentifier) completionHandler(item, [], false, nil) } catch { - completionHandler(nil, [], false, lookupError(from: error)) + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) } } return progress @@ -233,78 +144,41 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { contentsURL: URL, parentUuid: String, parentIdentifier: NSFileProviderItemIdentifier, - driveAPI: DriveAPI, - networkFacade: NetworkFacade, + uploadService: FileProviderUploadService, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void ) -> Progress { let progress = Progress(totalUnitCount: 100) Task { - let encryptedTmp = Self.temporaryFileURL() + let encryptedTmp = FileProviderDownloadService.temporaryFileURL() defer { try? FileManager.default.removeItem(at: encryptedTmp) } do { - let meta = try await driveAPI.getFolderMetaByUuid(uuid: parentUuid) - guard let bucket = try await Self.resolveBucket(parentMeta: meta, driveAPI: driveAPI) else { - completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) - return - } - guard let input = InputStream(url: contentsURL) else { - completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) - return - } - - let fileSize = Self.fileSize(of: contentsURL) - let finish = try await networkFacade.uploadFile( - input: input, + let result = try await uploadService.uploadFile( + filename: filename, + contentsURL: contentsURL, + parentUuid: parentUuid, encryptedOutput: encryptedTmp, - fileSize: fileSize, - bucketId: bucket, progressHandler: { fraction in progress.completedUnitCount = Int64(fraction * 100) } ) - let (baseName, fileExtension) = Self.splitNameExtension(filename) - let created = try await driveAPI.createFileNew(createFile: CreateFileDataNew( - fileId: finish.id, - type: fileExtension, - bucket: bucket, - size: fileSize, - folderId: meta.id, - name: baseName, - plainName: baseName, - folderUuid: parentUuid - )) - - let item = FileProviderItem(file: created, parent: parentIdentifier) - completionHandler(item, [], false, nil) + switch result { + case .created(let created): + let item = FileProviderItem(file: created, parent: parentIdentifier) + completionHandler(item, [], false, nil) + case .notAuthenticated: + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + case .noSuchItem: + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + } } catch { - completionHandler(nil, [], false, lookupError(from: error)) + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) } } return progress } - private static func resolveBucket(parentMeta: GetFolderMetaByIdResponse, driveAPI: DriveAPI) async throws -> String? { - if let bucket = parentMeta.bucket { - return bucket - } - guard let rootUuid = FileProviderItemID.rootFolderUuid() else { - return nil - } - let rootMeta = try await driveAPI.getFolderMetaByUuid(uuid: rootUuid) - return rootMeta.bucket - } - - private static func fileSize(of url: URL) -> Int { - let values = try? url.resourceValues(forKeys: [.fileSizeKey]) - return values?.fileSize ?? 0 - } - - private static func splitNameExtension(_ filename: String) -> (base: String, fileExtension: String?) { - FileProviderItem.splitNameExtension(filename, kind: .file) - } - func modifyItem(_ item: NSFileProviderItem, baseVersion version: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents newContents: URL?, options: NSFileProviderModifyItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { if changedFields.contains(.contents) || changedFields.contains(.parentItemIdentifier) { completionHandler(item, [], false, nil) @@ -333,43 +207,29 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return Progress() } + let mutationService = FileProviderMutationService(driveAPI: driveAPI) let newFilename = item.filename let progress = Progress(totalUnitCount: 1) Task { do { - try await rename(decoded, to: newFilename, driveAPI: driveAPI) + try await mutationService.rename(decoded, to: newFilename) progress.completedUnitCount = 1 let renamed = FileProviderItem.renamed(from: item, newFilename: newFilename) ?? item completionHandler(renamed, [], false, nil) } catch { - completionHandler(nil, [], false, lookupError(from: error)) + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) } } return progress } - private func rename( - _ decoded: (kind: DriveItemKind, uuid: String), - to newFilename: String, - driveAPI: DriveAPI - ) async throws { - switch decoded.kind { - case .folder: - _ = try await driveAPI.updateFolderNew(folderUuid: decoded.uuid, folderName: newFilename) - case .file: - let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) - let (baseName, _) = Self.splitNameExtension(newFilename) - _ = try await driveAPI.updateFileNew(uuid: decoded.uuid, bucketId: meta.bucket, newFilename: baseName) - } - } - func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion version: NSFileProviderItemVersion, options: NSFileProviderDeleteItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { // TODO: an item was deleted on disk, process the item's deletion - + completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) return Progress() } - + func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator { return FileProviderEnumerator(enumeratedItemIdentifier: containerItemIdentifier) } diff --git a/ios/InternxtFileProvider/FileProviderMutationService.swift b/ios/InternxtFileProvider/FileProviderMutationService.swift new file mode 100644 index 000000000..108dbe00a --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderMutationService.swift @@ -0,0 +1,41 @@ +// +// FileProviderMutationService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderMutationService { + let driveAPI: DriveAPI + + func resolveItem( + _ decoded: (kind: DriveItemKind, uuid: String), + identifier: NSFileProviderItemIdentifier + ) async throws -> FileProviderItem { + switch decoded.kind { + case .folder: + let meta = try await driveAPI.getFolderMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(folderMeta: meta, identifier: identifier) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(fileMeta: meta, identifier: identifier) + } + } + + func rename( + _ decoded: (kind: DriveItemKind, uuid: String), + to newFilename: String + ) async throws { + switch decoded.kind { + case .folder: + _ = try await driveAPI.updateFolderNew(folderUuid: decoded.uuid, folderName: newFilename) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + let (baseName, _) = FileProviderItem.splitNameExtension(newFilename, kind: .file) + _ = try await driveAPI.updateFileNew(uuid: decoded.uuid, bucketId: meta.bucket, newFilename: baseName) + } + } +} diff --git a/ios/InternxtFileProvider/FileProviderUploadService.swift b/ios/InternxtFileProvider/FileProviderUploadService.swift new file mode 100644 index 000000000..5b7b7431e --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderUploadService.swift @@ -0,0 +1,86 @@ +// +// FileProviderUploadService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderUploadService { + let driveAPI: DriveAPI + let networkFacade: NetworkFacade + + func createFolder( + name: String, + parentUuid: String + ) async throws -> CreateFolderResponseNew { + try await driveAPI.createFolderNew(parentFolderUuid: parentUuid, folderName: name) + } + + func uploadFile( + filename: String, + contentsURL: URL, + parentUuid: String, + encryptedOutput: URL, + progressHandler: @escaping (Double) -> Void + ) async throws -> UploadResult { + let meta = try await driveAPI.getFolderMetaByUuid(uuid: parentUuid) + guard let bucket = try await resolveBucket(parentMeta: meta) else { + return .notAuthenticated + } + guard let input = InputStream(url: contentsURL) else { + return .noSuchItem + } + + let fileSize = Self.fileSize(of: contentsURL) + let finish = try await networkFacade.uploadFile( + input: input, + encryptedOutput: encryptedOutput, + fileSize: fileSize, + bucketId: bucket, + progressHandler: progressHandler + ) + + let (baseName, fileExtension) = Self.splitNameExtension(filename) + let created = try await driveAPI.createFileNew(createFile: CreateFileDataNew( + fileId: finish.id, + type: fileExtension, + bucket: bucket, + size: fileSize, + folderId: meta.id, + name: baseName, + plainName: baseName, + folderUuid: parentUuid + )) + + return .created(created) + } + + private func resolveBucket(parentMeta: GetFolderMetaByIdResponse) async throws -> String? { + if let bucket = parentMeta.bucket { + return bucket + } + guard let rootUuid = FileProviderItemID.rootFolderUuid() else { + return nil + } + let rootMeta = try await driveAPI.getFolderMetaByUuid(uuid: rootUuid) + return rootMeta.bucket + } + + private static func fileSize(of url: URL) -> Int { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize ?? 0 + } + + private static func splitNameExtension(_ filename: String) -> (base: String, fileExtension: String?) { + FileProviderItem.splitNameExtension(filename, kind: .file) + } + + enum UploadResult { + case created(CreateFileResponseNew) + case notAuthenticated + case noSuchItem + } +} From 8b324b53ab992137d1c40ee801f508e75d9f9410 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Jun 2026 11:18:40 -0400 Subject: [PATCH 62/80] test(ios): add FileProviderErrorMapper unit test target Introduce a host-less InternxtFileProviderTests target covering the pure NSFileProviderError mapping logic now isolated in FileProviderErrorMapper. --- ios/Internxt.xcodeproj/project.pbxproj | 120 ++++++++++++++++++ .../InternxtFileProviderTests.xcscheme | 69 ++++++++++ .../FileProviderErrorMapperTests.swift | 103 +++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme create mode 100644 ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index a3723ea49..da8579694 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -39,6 +39,9 @@ DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F2C595E5ECBAD697016C9410 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */; }; + FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */; }; + FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB1D480412F87A94000E14783 /* InternxtSwiftCore */; }; FEB260B2B281D37EEABD1ECA /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */; }; /* End PBXBuildFile section */ @@ -134,6 +137,8 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; + FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; + FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InternxtFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FDEA9EEAEC8CE666D14877B2 /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -155,6 +160,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FB1D480612F87A94000E14783 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -234,6 +247,7 @@ 13B07FAE1A68108700A75B9A /* Internxt */, 832341AE1AAA6A7D00B99B32 /* Libraries */, DE1D47442F87A94000E14783 /* InternxtFileProvider */, + FB1D480712F87A94000E14783 /* InternxtFileProviderTests */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 7151A997BCA04521BD294E7B /* InternxtShareExtension */, @@ -251,6 +265,7 @@ 13B07F961A680F5B00A75B9A /* Internxt.app */, 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */, DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */, + FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */, ); name = Products; sourceTree = ""; @@ -302,6 +317,14 @@ path = InternxtFileProvider; sourceTree = ""; }; + FB1D480712F87A94000E14783 /* InternxtFileProviderTests */ = { + isa = PBXGroup; + children = ( + FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */, + ); + path = InternxtFileProviderTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -374,6 +397,25 @@ productReference = DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */; productType = "com.apple.product-type.app-extension"; }; + FB1D480812F87A94000E14783 /* InternxtFileProviderTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FB1D480912F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProviderTests" */; + buildPhases = ( + FB1D480A12F87A94000E14783 /* Sources */, + FB1D480612F87A94000E14783 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = InternxtFileProviderTests; + packageProductDependencies = ( + FB1D480412F87A94000E14783 /* InternxtSwiftCore */, + ); + productName = InternxtFileProviderTests; + productReference = FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -395,6 +437,9 @@ DE1D47402F87A94000E14783 = { CreatedOnToolsVersion = 26.4; }; + FB1D480812F87A94000E14783 = { + CreatedOnToolsVersion = 26.4; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Internxt" */; @@ -416,6 +461,7 @@ 13B07F861A680F5B00A75B9A /* Internxt */, 573EC8782D084646801534FA /* InternxtShareExtension */, DE1D47402F87A94000E14783 /* InternxtFileProvider */, + FB1D480812F87A94000E14783 /* InternxtFileProviderTests */, ); }; /* End PBXProject section */ @@ -754,6 +800,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FB1D480A12F87A94000E14783 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */, + FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1114,6 +1169,57 @@ }; name = Release; }; + FB1D480B12F87A94000E14783 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProviderTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FB1D480C12F87A94000E14783 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProviderTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1153,6 +1259,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FB1D480912F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProviderTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FB1D480B12F87A94000E14783 /* Debug */, + FB1D480C12F87A94000E14783 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -1172,6 +1287,11 @@ package = DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */; productName = InternxtSwiftCore; }; + FB1D480412F87A94000E14783 /* InternxtSwiftCore */ = { + isa = XCSwiftPackageProductDependency; + package = DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */; + productName = InternxtSwiftCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; diff --git a/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme new file mode 100644 index 000000000..5492a35d3 --- /dev/null +++ b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift new file mode 100644 index 000000000..0175dcae3 --- /dev/null +++ b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift @@ -0,0 +1,103 @@ +// +// FileProviderErrorMapperTests.swift +// InternxtFileProviderTests +// + +import XCTest +import FileProvider +import InternxtSwiftCore + +final class FileProviderErrorMapperTests: XCTestCase { + + private func enriched(_ code: ErrorCode, cause: Error? = nil) -> EnrichedError { + EnrichedError(code: code, step: .downloadGetInfo, cause: cause) + } + + private func apiError(statusCode: Int) -> APIClientError { + APIClientError(statusCode: statusCode, message: "test") + } + + private func nsError(from error: Error) -> NSError { + FileProviderErrorMapper.lookupError(from: error) as NSError + } + + private func assertCode( + _ error: Error, + _ expected: NSFileProviderError.Code, + file: StaticString = #filePath, + line: UInt = #line + ) { + let mapped = nsError(from: error) + XCTAssertEqual(mapped.domain, NSFileProviderErrorDomain, file: file, line: line) + XCTAssertEqual(mapped.code, expected.rawValue, file: file, line: line) + } + + func test_whenEnrichedApiUnauthorized_thenNotAuthenticated() { + assertCode(enriched(.apiUnauthorized), .notAuthenticated) + } + + func test_whenApiClientError401_thenNotAuthenticated() { + assertCode(apiError(statusCode: 401), .notAuthenticated) + } + + func test_whenEnrichedCauseIsUnauthorized_thenNotAuthenticated() { + let nested = enriched(.downloadInfoFailed, cause: apiError(statusCode: 401)) + assertCode(nested, .notAuthenticated) + } + + func test_whenDeeplyNestedCauseIsUnauthorized_thenNotAuthenticated() { + let leaf = enriched(.apiUnauthorized) + let middle = enriched(.downloadMirrorsFailed, cause: leaf) + let root = enriched(.downloadFailed, cause: middle) + assertCode(root, .notAuthenticated) + } + + func test_whenEnrichedNetworkCodes_thenServerUnreachable() { + let offlineCodes: [ErrorCode] = [ + .networkNoConnection, + .networkConnectionLost, + .networkTimeout, + .networkCannotConnect + ] + for code in offlineCodes { + assertCode(enriched(code), .serverUnreachable) + } + } + + func test_whenApiClientErrorStatusCodeZeroOrNegative_thenServerUnreachable() { + for statusCode in [0, -1, -2] { + assertCode(apiError(statusCode: statusCode), .serverUnreachable) + } + } + + func test_whenURLErrorOfflineCodes_thenServerUnreachable() { + let offlineURLCodes: [URLError.Code] = [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotConnectToHost, + .dataNotAllowed, + .cannotFindHost + ] + for code in offlineURLCodes { + assertCode(URLError(code), .serverUnreachable) + } + } + + func test_whenEnrichedCauseIsOfflineURLError_thenServerUnreachable() { + let nested = enriched(.downloadInfoFailed, cause: URLError(.notConnectedToInternet)) + assertCode(nested, .serverUnreachable) + } + + func test_whenUnknownEnrichedCodeWithoutCause_thenNoSuchItem() { + assertCode(enriched(.apiNotFound), .noSuchItem) + } + + func test_whenApiClientErrorNonAuthPositiveStatus_thenNoSuchItem() { + assertCode(apiError(statusCode: 500), .noSuchItem) + } + + func test_whenUnrelatedError_thenNoSuchItem() { + assertCode(URLError(.badURL), .noSuchItem) + } +} From a24c4605023be476f86c56a57e559e0f0f3769c9 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Jun 2026 11:33:55 -0400 Subject: [PATCH 63/80] refactor(ios): update test method names for consistency and clarity --- .../FileProviderErrorMapperTests.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift index 0175dcae3..c54b4bfa0 100644 --- a/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift +++ b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift @@ -32,27 +32,27 @@ final class FileProviderErrorMapperTests: XCTestCase { XCTAssertEqual(mapped.code, expected.rawValue, file: file, line: line) } - func test_whenEnrichedApiUnauthorized_thenNotAuthenticated() { + func testWhenEnrichedApiUnauthorizedThenNotAuthenticated() { assertCode(enriched(.apiUnauthorized), .notAuthenticated) } - func test_whenApiClientError401_thenNotAuthenticated() { + func testWhenApiClientError401ThenNotAuthenticated() { assertCode(apiError(statusCode: 401), .notAuthenticated) } - func test_whenEnrichedCauseIsUnauthorized_thenNotAuthenticated() { + func testWhenEnrichedCauseIsUnauthorizedThenNotAuthenticated() { let nested = enriched(.downloadInfoFailed, cause: apiError(statusCode: 401)) assertCode(nested, .notAuthenticated) } - func test_whenDeeplyNestedCauseIsUnauthorized_thenNotAuthenticated() { + func testWhenDeeplyNestedCauseIsUnauthorizedThenNotAuthenticated() { let leaf = enriched(.apiUnauthorized) let middle = enriched(.downloadMirrorsFailed, cause: leaf) let root = enriched(.downloadFailed, cause: middle) assertCode(root, .notAuthenticated) } - func test_whenEnrichedNetworkCodes_thenServerUnreachable() { + func testWhenEnrichedNetworkCodesThenServerUnreachable() { let offlineCodes: [ErrorCode] = [ .networkNoConnection, .networkConnectionLost, @@ -64,13 +64,13 @@ final class FileProviderErrorMapperTests: XCTestCase { } } - func test_whenApiClientErrorStatusCodeZeroOrNegative_thenServerUnreachable() { + func testWhenApiClientErrorStatusCodeZeroOrNegativeThenServerUnreachable() { for statusCode in [0, -1, -2] { assertCode(apiError(statusCode: statusCode), .serverUnreachable) } } - func test_whenURLErrorOfflineCodes_thenServerUnreachable() { + func testWhenURLErrorOfflineCodesThenServerUnreachable() { let offlineURLCodes: [URLError.Code] = [ .notConnectedToInternet, .networkConnectionLost, @@ -84,20 +84,20 @@ final class FileProviderErrorMapperTests: XCTestCase { } } - func test_whenEnrichedCauseIsOfflineURLError_thenServerUnreachable() { + func testWhenEnrichedCauseIsOfflineURLErrorThenServerUnreachable() { let nested = enriched(.downloadInfoFailed, cause: URLError(.notConnectedToInternet)) assertCode(nested, .serverUnreachable) } - func test_whenUnknownEnrichedCodeWithoutCause_thenNoSuchItem() { + func testWhenUnknownEnrichedCodeWithoutCauseThenNoSuchItem() { assertCode(enriched(.apiNotFound), .noSuchItem) } - func test_whenApiClientErrorNonAuthPositiveStatus_thenNoSuchItem() { + func testWhenApiClientErrorNonAuthPositiveStatusThenNoSuchItem() { assertCode(apiError(statusCode: 500), .noSuchItem) } - func test_whenUnrelatedError_thenNoSuchItem() { + func testWhenUnrelatedErrorThenNoSuchItem() { assertCode(URLError(.badURL), .noSuchItem) } } From 9eca26090bd865fe436cdb02c5c5914becb7497d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 16 Jun 2026 18:54:29 -0400 Subject: [PATCH 64/80] feat(ios): File Provider rename, move and trash Implement modifyItem move/rename and deleteItem-to-backend-trash in the File Provider extension. Item capabilities use .allowsDeleting (not .allowsTrashing) so the system routes deletes through deleteItem, which maps to the Drive backend trash endpoint instead of a local-only trash. --- ios/Internxt.xcodeproj/project.pbxproj | 12 ++ .../DriveAPIFactory.swift | 14 ++ .../FileProviderErrorMapper.swift | 19 +++ .../FileProviderExtension.swift | 67 +++++--- .../FileProviderItem.swift | 4 +- .../FileProviderMutationService.swift | 30 ++++ .../FileProviderMutationTests.swift | 147 ++++++++++++++++++ 7 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 ios/InternxtFileProviderTests/FileProviderMutationTests.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index da8579694..4e880d2b0 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -41,6 +41,11 @@ F2C595E5ECBAD697016C9410 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */; }; FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */; }; FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */; }; + FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; + FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; + FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; + FB1D481512F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB1D480412F87A94000E14783 /* InternxtSwiftCore */; }; FEB260B2B281D37EEABD1ECA /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */; }; /* End PBXBuildFile section */ @@ -138,6 +143,7 @@ F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; + FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationTests.swift; sourceTree = ""; }; FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InternxtFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FDEA9EEAEC8CE666D14877B2 /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -321,6 +327,7 @@ isa = PBXGroup; children = ( FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */, + FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */, ); path = InternxtFileProviderTests; sourceTree = ""; @@ -806,6 +813,11 @@ files = ( FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */, FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, + FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */, + FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */, + FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, + FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */, + FB1D481512F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/InternxtFileProvider/DriveAPIFactory.swift b/ios/InternxtFileProvider/DriveAPIFactory.swift index 359159d8c..dc498248e 100644 --- a/ios/InternxtFileProvider/DriveAPIFactory.swift +++ b/ios/InternxtFileProvider/DriveAPIFactory.swift @@ -15,4 +15,18 @@ enum DriveAPIFactory { clientVersion: SharedKeychainCredentials.clientVersion ) } + + static func makeTrash() -> TrashAPI? { + guard let authToken = SharedKeychainCredentials.string(SharedAuthKeychain.photosTokenKey), + let baseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.driveBaseUrlKey) else { + return nil + } + + return TrashAPI( + baseUrl: baseUrl, + authToken: authToken, + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion + ) + } } diff --git a/ios/InternxtFileProvider/FileProviderErrorMapper.swift b/ios/InternxtFileProvider/FileProviderErrorMapper.swift index cc68ac064..6e2dba37d 100644 --- a/ios/InternxtFileProvider/FileProviderErrorMapper.swift +++ b/ios/InternxtFileProvider/FileProviderErrorMapper.swift @@ -26,15 +26,34 @@ enum FileProviderErrorMapper { ] static func lookupError(from error: Error) -> Error { + if let alreadyMapped = error as? NSFileProviderError { + return alreadyMapped + } if isUnauthorized(error) { return NSFileProviderError(.notAuthenticated) } if isOffline(error) { return NSFileProviderError(.serverUnreachable) } + if isNameCollision(error) { + return NSFileProviderError(.filenameCollision) + } return NSFileProviderError(.noSuchItem) } + static func isNameCollision(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if let cause = enriched.cause { + return isNameCollision(cause) + } + return false + } + if let apiError = error as? APIClientError { + return apiError.statusCode == 409 + } + return false + } + static func isUnauthorized(_ error: Error) -> Bool { if let enriched = error as? EnrichedError { if enriched.code == .apiUnauthorized { diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index d103f436d..b455ac56a 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -93,7 +93,9 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { let parentIdentifier = itemTemplate.parentItemIdentifier let uploadService = FileProviderUploadService(driveAPI: driveAPI, networkFacade: networkFacade) - if itemTemplate.contentType?.conforms(to: .folder) == true { + let isFolder = itemTemplate.contentType?.conforms(to: .folder) == true + + if isFolder { return createFolder( name: itemTemplate.filename, parentUuid: parentUuid, @@ -180,23 +182,14 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } func modifyItem(_ item: NSFileProviderItem, baseVersion version: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents newContents: URL?, options: NSFileProviderModifyItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { - if changedFields.contains(.contents) || changedFields.contains(.parentItemIdentifier) { + let shouldRename = changedFields.contains(.filename) + let shouldMove = changedFields.contains(.parentItemIdentifier) + + guard shouldRename || shouldMove else { completionHandler(item, [], false, nil) return Progress() } - if changedFields.contains(.filename) { - return renameItem(item, completionHandler: completionHandler) - } - - completionHandler(item, [], false, nil) - return Progress() - } - - private func renameItem( - _ item: NSFileProviderItem, - completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void - ) -> Progress { guard let driveAPI = DriveAPIFactory.make() else { completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) return Progress() @@ -207,15 +200,31 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return Progress() } + var destinationFolderUuid: String? + if shouldMove { + guard let resolved = FileProviderItemID.folderUuid(for: item.parentItemIdentifier) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + destinationFolderUuid = resolved + } + let mutationService = FileProviderMutationService(driveAPI: driveAPI) let newFilename = item.filename let progress = Progress(totalUnitCount: 1) Task { do { - try await mutationService.rename(decoded, to: newFilename) + if shouldRename { + try await mutationService.rename(decoded, to: newFilename) + } + if let destinationFolderUuid { + try await mutationService.move(decoded, toParentUuid: destinationFolderUuid) + } progress.completedUnitCount = 1 - let renamed = FileProviderItem.renamed(from: item, newFilename: newFilename) ?? item - completionHandler(renamed, [], false, nil) + let modified = shouldRename + ? (FileProviderItem.renamed(from: item, newFilename: newFilename) ?? item) + : item + completionHandler(modified, [], false, nil) } catch { completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) } @@ -224,10 +233,28 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion version: NSFileProviderItemVersion, options: NSFileProviderDeleteItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { - // TODO: an item was deleted on disk, process the item's deletion + guard let driveAPI = DriveAPIFactory.make(), let trashAPI = DriveAPIFactory.makeTrash() else { + completionHandler(NSFileProviderError(.notAuthenticated)) + return Progress() + } - completionHandler(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError, userInfo:[:])) - return Progress() + guard let decoded = FileProviderItemID.decode(identifier) else { + completionHandler(NSFileProviderError(.noSuchItem)) + return Progress() + } + + let mutationService = FileProviderMutationService(driveAPI: driveAPI, trashAPI: trashAPI) + let progress = Progress(totalUnitCount: 1) + Task { + do { + try await mutationService.trash(decoded) + progress.completedUnitCount = 1 + completionHandler(nil) + } catch { + completionHandler(FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress } func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator { diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift index 9d79aa746..b96e69808 100644 --- a/ios/InternxtFileProvider/FileProviderItem.swift +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -173,9 +173,9 @@ class FileProviderItem: NSObject, NSFileProviderItem { var capabilities: NSFileProviderItemCapabilities { switch kind { case .folder: - return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems, .allowsRenaming] + return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems, .allowsRenaming, .allowsReparenting, .allowsDeleting] case .file: - return [.allowsReading, .allowsRenaming] + return [.allowsReading, .allowsRenaming, .allowsReparenting, .allowsDeleting] } } diff --git a/ios/InternxtFileProvider/FileProviderMutationService.swift b/ios/InternxtFileProvider/FileProviderMutationService.swift index 108dbe00a..94569416b 100644 --- a/ios/InternxtFileProvider/FileProviderMutationService.swift +++ b/ios/InternxtFileProvider/FileProviderMutationService.swift @@ -10,6 +10,7 @@ import InternxtSwiftCore struct FileProviderMutationService { let driveAPI: DriveAPI + var trashAPI: TrashAPI? = nil func resolveItem( _ decoded: (kind: DriveItemKind, uuid: String), @@ -38,4 +39,33 @@ struct FileProviderMutationService { _ = try await driveAPI.updateFileNew(uuid: decoded.uuid, bucketId: meta.bucket, newFilename: baseName) } } + + func move( + _ decoded: (kind: DriveItemKind, uuid: String), + toParentUuid destinationFolderUuid: String + ) async throws { + switch decoded.kind { + case .folder: + _ = try await driveAPI.moveFolderNew(uuid: decoded.uuid, destinationFolder: destinationFolderUuid) + case .file: + _ = try await driveAPI.moveFileNew(uuid: decoded.uuid, destinationFolder: destinationFolderUuid) + } + } + + func trash(_ decoded: (kind: DriveItemKind, uuid: String)) async throws { + guard let trashAPI else { throw NSFileProviderError(.notAuthenticated) } + let didTrash = try await trashAPI.trashItemsByUuid(itemsToTrash: Self.trashItems(for: decoded)) + try Self.validateTrashOutcome(didTrash) + } + + static func validateTrashOutcome(_ didTrash: Bool) throws { + guard didTrash else { throw NSFileProviderError(.serverUnreachable) } + } + + static func trashItems( + for decoded: (kind: DriveItemKind, uuid: String) + ) -> [ItemToTrashV2] { + let type: ItemToTrashType = decoded.kind == .folder ? .Folder : .File + return [ItemToTrashV2(uuid: decoded.uuid, type: type)] + } } diff --git a/ios/InternxtFileProviderTests/FileProviderMutationTests.swift b/ios/InternxtFileProviderTests/FileProviderMutationTests.swift new file mode 100644 index 000000000..87ecc16c9 --- /dev/null +++ b/ios/InternxtFileProviderTests/FileProviderMutationTests.swift @@ -0,0 +1,147 @@ +// +// FileProviderMutationTests.swift +// InternxtFileProviderTests +// + +import XCTest +import FileProvider +import InternxtSwiftCore + +private final class StubItem: NSObject, NSFileProviderItem { + let itemIdentifier: NSFileProviderItemIdentifier + let parentItemIdentifier: NSFileProviderItemIdentifier + let filename: String + + init( + identifier: NSFileProviderItemIdentifier, + parent: NSFileProviderItemIdentifier, + filename: String + ) { + self.itemIdentifier = identifier + self.parentItemIdentifier = parent + self.filename = filename + } +} + +final class FileProviderMutationTests: XCTestCase { + + private let fileIdentifier = FileProviderItemID.encode(.file, uuid: "file-uuid") + private let folderIdentifier = FileProviderItemID.encode(.folder, uuid: "folder-uuid") + private let destinationIdentifier = FileProviderItemID.encode(.folder, uuid: "destination-uuid") + + private func decodedFile() -> (kind: DriveItemKind, uuid: String) { + (kind: .file, uuid: "file-uuid") + } + + private func decodedFolder() -> (kind: DriveItemKind, uuid: String) { + (kind: .folder, uuid: "folder-uuid") + } + + private func apiError(statusCode: Int) -> APIClientError { + APIClientError(statusCode: statusCode, message: "test") + } + + private func mappedCode(from error: Error) -> Int { + (FileProviderErrorMapper.lookupError(from: error) as NSError).code + } + + func testWhenItemIsFileThenCapabilitiesAllowReparentingAndDeletingButNotTrashing() { + let source = StubItem(identifier: fileIdentifier, parent: .rootContainer, filename: "report.pdf") + + let item = FileProviderItem.renamed(from: source, newFilename: "report.pdf") + + let capabilities = item?.capabilities ?? [] + XCTAssertTrue(capabilities.contains(.allowsReparenting)) + XCTAssertTrue(capabilities.contains(.allowsDeleting)) + XCTAssertFalse(capabilities.contains(.allowsTrashing)) + } + + func testWhenItemIsFolderThenCapabilitiesAllowReparentingAndDeletingButNotTrashing() { + let source = StubItem(identifier: folderIdentifier, parent: .rootContainer, filename: "Docs") + + let item = FileProviderItem.renamed(from: source, newFilename: "Docs") + + let capabilities = item?.capabilities ?? [] + XCTAssertTrue(capabilities.contains(.allowsReparenting)) + XCTAssertTrue(capabilities.contains(.allowsDeleting)) + XCTAssertFalse(capabilities.contains(.allowsTrashing)) + } + + func testWhenRenamingAFileThenItKeepsTheBaseNameWithoutExtension() { + let source = StubItem(identifier: fileIdentifier, parent: .rootContainer, filename: "old.pdf") + + let item = FileProviderItem.renamed(from: source, newFilename: "renamed.pdf") + + XCTAssertEqual(item?.filename, "renamed.pdf") + } + + func testWhenRenamingAFileThenSplitProducesTheBaseNameOnly() { + let split = FileProviderItem.splitNameExtension("renamed.pdf", kind: .file) + + XCTAssertEqual(split.base, "renamed") + } + + func testWhenRenamingAFolderThenItUsesTheFullName() { + let split = FileProviderItem.splitNameExtension("My Folder", kind: .folder) + + XCTAssertEqual(split.base, "My Folder") + } + + func testWhenAMoveSucceedsThenTheReturnedItemKeepsItsIdentifierAndDestinationParent() { + let moved = StubItem(identifier: fileIdentifier, parent: destinationIdentifier, filename: "report.pdf") + + let item = FileProviderItem.renamed(from: moved, newFilename: "report.pdf") + + XCTAssertEqual(item?.itemIdentifier, fileIdentifier) + XCTAssertEqual(item?.parentItemIdentifier, destinationIdentifier) + } + + func testWhenTheDestinationParentIdentifierIsInvalidThenFolderUuidIsNil() { + let invalidParent = NSFileProviderItemIdentifier("not-a-valid-id") + + let resolved = FileProviderItemID.folderUuid(for: invalidParent) + + XCTAssertNil(resolved) + } + + func testWhenDeletingAFileThenTrashItemsUseTheFileType() { + let items = FileProviderMutationService.trashItems(for: decodedFile()) + + XCTAssertEqual(items.first?.type, ItemToTrashType.File.rawValue) + } + + func testWhenDeletingAFolderThenTrashItemsUseTheFolderType() { + let items = FileProviderMutationService.trashItems(for: decodedFolder()) + + XCTAssertEqual(items.first?.type, ItemToTrashType.Folder.rawValue) + } + + func testWhenDeletingAnItemThenTrashItemCarriesItsUuid() { + let items = FileProviderMutationService.trashItems(for: decodedFile()) + + XCTAssertEqual(items.first?.uuid, "file-uuid") + } + + func testWhenTheMoveApiThrowsA409ConflictThenFilenameCollision() { + let code = mappedCode(from: apiError(statusCode: 409)) + + XCTAssertEqual(code, NSFileProviderError.Code.filenameCollision.rawValue) + } + + func testWhenTheTrashBackendReturnsFalseThenTheOutcomeThrows() { + XCTAssertThrowsError(try FileProviderMutationService.validateTrashOutcome(false)) + } + + func testWhenTheTrashBackendReturnsFalseThenTheErrorMapsToServerUnreachable() { + do { + try FileProviderMutationService.validateTrashOutcome(false) + XCTFail("expected validateTrashOutcome to throw on false") + } catch { + XCTAssertEqual(mappedCode(from: error), NSFileProviderError.Code.serverUnreachable.rawValue) + } + } + + func testWhenTheTrashBackendReturnsTrueThenTheOutcomeDoesNotThrow() { + XCTAssertNoThrow(try FileProviderMutationService.validateTrashOutcome(true)) + } +} From 1baaa2fa00fd7678b7ea309888831f3a3f796779 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 23 Jun 2026 00:07:33 -0400 Subject: [PATCH 65/80] feat(ios): working-set change-feed signaling for File Provider Add a per-folder snapshot/diff change-feed in the File Provider extension so working-set enumeration surfaces upload, create-folder, move, rename, trash and restore as changes. SyncAnchorStore persists the per-domain anchor; FileProviderModule exposes a native bridge so the host app can signal the enumerator on RN mutations. --- ios/Internxt.xcodeproj/project.pbxproj | 22 +++ ios/Internxt/FileProviderDomainManager.swift | 9 +- ios/Internxt/FileProviderModule.m | 7 + ios/Internxt/FileProviderModule.swift | 35 +++++ .../FileProviderEnumerator.swift | 135 +++++++++++++++-- .../SyncAnchorStore.swift | 126 ++++++++++++++++ .../SyncAnchorStoreTests.swift | 141 ++++++++++++++++++ 7 files changed, 462 insertions(+), 13 deletions(-) create mode 100644 ios/Internxt/FileProviderModule.m create mode 100644 ios/Internxt/FileProviderModule.swift create mode 100644 ios/InternxtFileProvider/SyncAnchorStore.swift create mode 100644 ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 4e880d2b0..98fb13dc3 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -18,6 +18,13 @@ 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010002F90000000E14783 /* FileProviderModule.swift */; }; + FF2010012F90000000E14783 /* FileProviderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1010012F90000000E14783 /* FileProviderModule.m */; }; + FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; + FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */; }; AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */; }; AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */; }; @@ -110,6 +117,10 @@ A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHBurstExportModule.m; path = Internxt/PHBurstExportModule.m; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SharedAuthKeychain.swift; path = Internxt/SharedAuthKeychain.swift; sourceTree = ""; }; + FF1010002F90000000E14783 /* FileProviderModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderModule.swift; path = Internxt/FileProviderModule.swift; sourceTree = ""; }; + FF1010012F90000000E14783 /* FileProviderModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FileProviderModule.m; path = Internxt/FileProviderModule.m; sourceTree = ""; }; + FF1010022F90000000E14783 /* SyncAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStore.swift; sourceTree = ""; }; + FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStoreTests.swift; sourceTree = ""; }; AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderDomainManager.swift; path = Internxt/FileProviderDomainManager.swift; sourceTree = ""; }; AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InternxtAuthCredentialsModule.m; path = Internxt/InternxtAuthCredentialsModule.m; sourceTree = ""; }; @@ -186,6 +197,8 @@ AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */, AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */, AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */, + FF1010002F90000000E14783 /* FileProviderModule.swift */, + FF1010012F90000000E14783 /* FileProviderModule.m */, D330686328BD4286AB778B88 /* PHAssetExportModule.m */, 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */, A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */, @@ -306,6 +319,7 @@ isa = PBXGroup; children = ( DE1D47632F87A94000E14783 /* FileProviderExtension.swift */, + FF1010022F90000000E14783 /* SyncAnchorStore.swift */, DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */, DE1D47652F87A94000E14783 /* FileProviderItem.swift */, AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */, @@ -328,6 +342,7 @@ children = ( FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */, FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */, + FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */, ); path = InternxtFileProviderTests; sourceTree = ""; @@ -772,6 +787,10 @@ AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */, AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */, AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */, + FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */, + FF2010012F90000000E14783 /* FileProviderModule.m in Sources */, + FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */, + FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */, 1A3E7803EDA342F39787DC8F /* PHAssetExportModule.swift in Sources */, 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */, A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */, @@ -793,6 +812,7 @@ buildActionMask = 2147483647; files = ( DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, + FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */, DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, @@ -814,6 +834,8 @@ FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */, FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */, + FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */, + FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */, FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */, FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */, diff --git a/ios/Internxt/FileProviderDomainManager.swift b/ios/Internxt/FileProviderDomainManager.swift index 60ae0360c..eaadd6e02 100644 --- a/ios/Internxt/FileProviderDomainManager.swift +++ b/ios/Internxt/FileProviderDomainManager.swift @@ -23,6 +23,13 @@ enum FileProviderDomainManager { } static func signalEnumeration(completion: @escaping (Error?) -> Void) { + signalEnumeration(for: .workingSet, completion: completion) + } + + static func signalEnumeration( + for container: NSFileProviderItemIdentifier, + completion: @escaping (Error?) -> Void + ) { guard let manager = NSFileProviderManager(for: domain) else { completion(NSError( domain: "FileProviderDomainManager", @@ -31,7 +38,7 @@ enum FileProviderDomainManager { )) return } - manager.signalEnumerator(for: .workingSet) { error in + manager.signalEnumerator(for: container) { error in completion(error) } } diff --git a/ios/Internxt/FileProviderModule.m b/ios/Internxt/FileProviderModule.m new file mode 100644 index 000000000..ac7e5c3f0 --- /dev/null +++ b/ios/Internxt/FileProviderModule.m @@ -0,0 +1,7 @@ +#import + +@interface RCT_EXTERN_MODULE(InternxtSignalingModule, NSObject) +RCT_EXTERN_METHOD(notifyParentChanged:(NSString *)parentFolderUuid + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +@end diff --git a/ios/Internxt/FileProviderModule.swift b/ios/Internxt/FileProviderModule.swift new file mode 100644 index 000000000..993a3e7db --- /dev/null +++ b/ios/Internxt/FileProviderModule.swift @@ -0,0 +1,35 @@ +import FileProvider +import Foundation +import React + +@objc(InternxtSignalingModule) +class FileProviderModule: NSObject { + + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc func notifyParentChanged( + _ parentFolderUuid: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter _: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + resolver(nil) + return + } + + let store = SyncAnchorStore() + _ = store?.recordChange(parentUuid: parentFolderUuid) + + signalParent(parentFolderUuid, resolver: resolver) + } + + @available(iOS 16.0, *) + private func signalParent(_ parentFolderUuid: String, resolver: @escaping RCTPromiseResolveBlock) { + let container = FileProviderItemID.parentIdentifier(folderUuid: parentFolderUuid) + FileProviderDomainManager.signalEnumeration(for: container) { _ in + FileProviderDomainManager.signalEnumeration { _ in + resolver(nil) + } + } + } +} diff --git a/ios/InternxtFileProvider/FileProviderEnumerator.swift b/ios/InternxtFileProvider/FileProviderEnumerator.swift index f05419496..d03832ec3 100644 --- a/ios/InternxtFileProvider/FileProviderEnumerator.swift +++ b/ios/InternxtFileProvider/FileProviderEnumerator.swift @@ -12,6 +12,7 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { private static let pageSize = 50 private static let order = "ASC" + private static let folderContainerAnchor = NSFileProviderSyncAnchor(SyncAnchorStore.encode(0)) private enum Phase: String { case folders @@ -40,7 +41,12 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { } private let enumeratedItemIdentifier: NSFileProviderItemIdentifier - private let anchor = NSFileProviderSyncAnchor("an anchor".data(using: .utf8)!) + private let syncAnchorStore = SyncAnchorStore() + private var browsedChildIds: [String] = [] + + private var isWorkingSet: Bool { + enumeratedItemIdentifier == .workingSet + } init(enumeratedItemIdentifier: NSFileProviderItemIdentifier) { self.enumeratedItemIdentifier = enumeratedItemIdentifier @@ -50,9 +56,7 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { func invalidate() {} func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { - let cursor = Cursor.decode(page) - - guard FileProviderItemID.isDriveFolderContainer(enumeratedItemIdentifier) else { + guard !isWorkingSet, FileProviderItemID.isDriveFolderContainer(enumeratedItemIdentifier) else { observer.didEnumerate([]) observer.finishEnumerating(upTo: nil) return @@ -64,6 +68,7 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { return } + let cursor = Cursor.decode(page) Task { do { switch cursor.phase { @@ -91,6 +96,7 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { FileProviderItem(folder: $0, parent: enumeratedItemIdentifier) } observer.didEnumerate(items) + accumulate(items) if response.folders.count == Self.pageSize { observer.finishEnumerating(upTo: Cursor(phase: .folders, offset: offset + Self.pageSize).encoded()) @@ -112,14 +118,127 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { FileProviderItem(file: $0, parent: enumeratedItemIdentifier) } observer.didEnumerate(items) + accumulate(items) if response.files.count == Self.pageSize { observer.finishEnumerating(upTo: Cursor(phase: .files, offset: offset + Self.pageSize).encoded()) } else { + seedSnapshot(forFolderUuid: folderUuid) observer.finishEnumerating(upTo: nil) } } + private func accumulate(_ items: [FileProviderItem]) { + browsedChildIds.append(contentsOf: items.map { $0.itemIdentifier.rawValue }) + } + + private func seedSnapshot(forFolderUuid folderUuid: String) { + syncAnchorStore?.saveSnapshot(browsedChildIds, forFolderUuid: folderUuid) + } + + func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + completionHandler(isWorkingSet ? workingSetAnchor() : Self.folderContainerAnchor) + } + + func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + guard isWorkingSet else { + observer.finishEnumeratingChanges(upTo: Self.folderContainerAnchor, moreComing: false) + return + } + enumerateWorkingSetChanges(for: observer, from: anchor) + } + + private func enumerateWorkingSetChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + let incoming = SyncAnchorStore.decode(anchor.rawValue) ?? 0 + let current = workingSetAnchor() + + guard let store = syncAnchorStore else { + observer.finishEnumeratingChanges(upTo: NSFileProviderSyncAnchor(SyncAnchorStore.encode(incoming)), moreComing: false) + return + } + + let parents = store.changedParents(after: incoming) + + guard !parents.isEmpty, let driveAPI = DriveAPIFactory.make() else { + observer.finishEnumeratingChanges(upTo: current, moreComing: false) + return + } + + Task { + do { + for parentUuid in parents { + try await diffFolder(parentUuid, store: store, driveAPI: driveAPI, observer: observer) + } + observer.finishEnumeratingChanges(upTo: current, moreComing: false) + } catch { + observer.finishEnumeratingChanges( + upTo: NSFileProviderSyncAnchor(SyncAnchorStore.encode(incoming)), + moreComing: false + ) + } + } + } + + private func diffFolder( + _ parentUuid: String, + store: SyncAnchorStore, + driveAPI: DriveAPI, + observer: NSFileProviderChangeObserver + ) async throws { + let parent = FileProviderItemID.parentIdentifier(folderUuid: parentUuid) + let folderUuid = FileProviderItemID.folderUuid(for: parent) ?? parentUuid + + let currentItems = try await currentChildren(of: folderUuid, parent: parent, driveAPI: driveAPI) + let currentIds = currentItems.map { $0.itemIdentifier.rawValue } + let prevIds = store.snapshot(forFolderUuid: parentUuid) + let deletedIds = Set(prevIds).subtracting(currentIds) + + observer.didUpdate(currentItems) + if !deletedIds.isEmpty { + observer.didDeleteItems(withIdentifiers: deletedIds.map { NSFileProviderItemIdentifier($0) }) + } + store.saveSnapshot(currentIds, forFolderUuid: parentUuid) + } + + private func currentChildren( + of folderUuid: String, + parent: NSFileProviderItemIdentifier, + driveAPI: DriveAPI + ) async throws -> [FileProviderItem] { + var items: [FileProviderItem] = [] + try await forEachPage { offset in + let response = try await driveAPI.getFolderFolders( + folderUuid: folderUuid, offset: offset, limit: Self.pageSize, order: Self.order + ) + items.append(contentsOf: response.folders.map { FileProviderItem(folder: $0, parent: parent) }) + return response.folders.count + } + try await forEachPage { offset in + let response = try await driveAPI.getFolderFilesV2( + folderUuid: folderUuid, offset: offset, limit: Self.pageSize, order: Self.order + ) + items.append(contentsOf: response.files.map { FileProviderItem(file: $0, parent: parent) }) + return response.files.count + } + return items + } + + private func forEachPage(_ fetchPage: (_ offset: Int) async throws -> Int) async throws { + var offset = 0 + while true { + let count = try await fetchPage(offset) + if count < Self.pageSize { break } + offset += Self.pageSize + } + } + + private func workingSetAnchor() -> NSFileProviderSyncAnchor { + guard let store = syncAnchorStore else { + return NSFileProviderSyncAnchor(SyncAnchorStore.encode(0)) + } + return NSFileProviderSyncAnchor(store.currentData) + } + private func notAuthenticatedError() -> Error { NSFileProviderError(.notAuthenticated) } @@ -130,12 +249,4 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { } return error } - - func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { - observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) - } - - func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { - completionHandler(anchor) - } } diff --git a/ios/InternxtFileProvider/SyncAnchorStore.swift b/ios/InternxtFileProvider/SyncAnchorStore.swift new file mode 100644 index 000000000..db1cbff4e --- /dev/null +++ b/ios/InternxtFileProvider/SyncAnchorStore.swift @@ -0,0 +1,126 @@ +import Foundation + +struct SyncAnchorStore { + static let appGroupIdentifier = "group.com.internxt.snacks" + + private static let counterFileName = "InternxtFileProviderSyncAnchorCounter" + private static let changesFileName = "InternxtFileProviderPendingChanges" + private static let snapshotsFileName = "InternxtFileProviderFolderSnapshots" + private static let maxEntries = 50 + private static let maxTrackedFolders = 50 + + private let counterURL: URL + private let changesURL: URL + private let snapshotsURL: URL + + init?(appGroupIdentifier: String = SyncAnchorStore.appGroupIdentifier) { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { return nil } + self.counterURL = container.appendingPathComponent(Self.counterFileName) + self.changesURL = container.appendingPathComponent(Self.changesFileName) + self.snapshotsURL = container.appendingPathComponent(Self.snapshotsFileName) + } + + init(directory: URL) { + self.counterURL = directory.appendingPathComponent(Self.counterFileName) + self.changesURL = directory.appendingPathComponent(Self.changesFileName) + self.snapshotsURL = directory.appendingPathComponent(Self.snapshotsFileName) + } + + var currentValue: UInt64 { + guard let data = try? Data(contentsOf: counterURL), + let value = Self.decode(data) else { + return 0 + } + return value + } + + var currentData: Data { + Self.encode(currentValue) + } + + @discardableResult + func recordChange(parentUuid: String) -> UInt64 { + let next = currentValue &+ 1 + try? Self.encode(next).write(to: counterURL, options: .atomic) + + var map = readChanges() + map[parentUuid] = next + writeChanges(boundedChanges(map)) + + return next + } + + func changedParents(after incoming: UInt64) -> [String] { + readChanges() + .filter { $0.value > incoming } + .sorted { $0.value < $1.value } + .map { $0.key } + } + + func snapshot(forFolderUuid folderUuid: String) -> [String] { + readSnapshots()[folderUuid] ?? [] + } + + func saveSnapshot(_ identifiers: [String], forFolderUuid folderUuid: String) { + var snapshots = readSnapshots() + snapshots[folderUuid] = identifiers + writeSnapshots(boundedSnapshots(snapshots, keeping: folderUuid)) + } + + static func encode(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + static func decode(_ data: Data) -> UInt64? { + guard data.count == MemoryLayout.size else { return nil } + return data.withUnsafeBytes { $0.load(as: UInt64.self).bigEndian } + } + + private func readChanges() -> [String: UInt64] { + guard let data = try? Data(contentsOf: changesURL), + let map = try? JSONDecoder().decode([String: UInt64].self, from: data) else { + return [:] + } + return map + } + + private func writeChanges(_ map: [String: UInt64]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: changesURL, options: .atomic) + } + + private func boundedChanges(_ map: [String: UInt64]) -> [String: UInt64] { + guard map.count > Self.maxEntries else { return map } + let mostRecent = map.sorted { $0.value > $1.value }.prefix(Self.maxEntries) + return Dictionary(uniqueKeysWithValues: mostRecent.map { ($0.key, $0.value) }) + } + + private func readSnapshots() -> [String: [String]] { + guard let data = try? Data(contentsOf: snapshotsURL), + let map = try? JSONDecoder().decode([String: [String]].self, from: data) else { + return [:] + } + return map + } + + private func writeSnapshots(_ map: [String: [String]]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: snapshotsURL, options: .atomic) + } + + // Simple eviction: when over the cap, keep the folder just written and an + // arbitrary subset of the rest. The snapshot is only an optimization for + // detecting removals; a missing snapshot just means "no removals reported", + // never incorrect state. + private func boundedSnapshots(_ map: [String: [String]], keeping folderUuid: String) -> [String: [String]] { + guard map.count > Self.maxTrackedFolders else { return map } + var trimmed = map + for key in trimmed.keys where key != folderUuid { + if trimmed.count <= Self.maxTrackedFolders { break } + trimmed.removeValue(forKey: key) + } + return trimmed + } +} diff --git a/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift new file mode 100644 index 000000000..20368a338 --- /dev/null +++ b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift @@ -0,0 +1,141 @@ +// +// SyncAnchorStoreTests.swift +// InternxtFileProviderTests +// + +import XCTest + +final class SyncAnchorStoreTests: XCTestCase { + + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("SyncAnchorStoreTests.\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + directory = nil + super.tearDown() + } + + func test_whenChangeRecorded_thenCurrentValueAdvancesByOne() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentValue + + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.currentValue, before + 1) + } + + func test_whenChangeRecorded_thenCurrentDataDiffersFromBefore() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentData + + store.recordChange(parentUuid: "parent-a") + + XCTAssertNotEqual(store.currentData, before) + } + + func test_whenReadFromSeparateInstance_thenSeesPersistedChange() { + SyncAnchorStore(directory: directory).recordChange(parentUuid: "parent-a") + + let reader = SyncAnchorStore(directory: directory) + + XCTAssertEqual(reader.currentValue, 1) + } + + func test_whenChangeRecorded_thenChangedParentsAfterPreviousAnchorIncludesIt() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentValue + + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: before), ["parent-a"]) + } + + func test_whenAnchorIsAtRecordedValue_thenChangedParentsExcludesIt() { + let store = SyncAnchorStore(directory: directory) + let recordedAt = store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: recordedAt), []) + } + + func test_whenMultipleParentsRecorded_thenChangedParentsReturnsOnlyNewerOnesInOrder() { + let store = SyncAnchorStore(directory: directory) + let afterFirst = store.recordChange(parentUuid: "parent-a") + store.recordChange(parentUuid: "parent-b") + store.recordChange(parentUuid: "parent-c") + + XCTAssertEqual(store.changedParents(after: afterFirst), ["parent-b", "parent-c"]) + } + + func test_whenSameParentRecordedTwice_thenItUsesTheLatestAnchorValue() { + let store = SyncAnchorStore(directory: directory) + store.recordChange(parentUuid: "parent-a") + let afterSecond = store.recordChange(parentUuid: "parent-b") + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: afterSecond), ["parent-a"]) + } + + func test_whenNoSnapshotSaved_thenSnapshotIsEmpty() { + let store = SyncAnchorStore(directory: directory) + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), []) + } + + func test_whenSnapshotSaved_thenSnapshotRoundTrips() { + let store = SyncAnchorStore(directory: directory) + + store.saveSnapshot(["d:file-1", "f:folder-1"], forFolderUuid: "folder-a") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-1", "f:folder-1"]) + } + + func test_whenSnapshotSavedFromSeparateInstance_thenItIsPersisted() { + SyncAnchorStore(directory: directory).saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") + + let reader = SyncAnchorStore(directory: directory) + + XCTAssertEqual(reader.snapshot(forFolderUuid: "folder-a"), ["d:file-1"]) + } + + func test_whenSnapshotSavedForDifferentFolders_thenTheyAreIndependent() { + let store = SyncAnchorStore(directory: directory) + + store.saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") + store.saveSnapshot(["d:file-2"], forFolderUuid: "folder-b") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-1"]) + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-b"), ["d:file-2"]) + } + + func test_whenSnapshotSavedAgain_thenItReplacesThePrevious() { + let store = SyncAnchorStore(directory: directory) + store.saveSnapshot(["d:file-1", "d:file-2"], forFolderUuid: "folder-a") + + store.saveSnapshot(["d:file-2"], forFolderUuid: "folder-a") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-2"]) + } + + func test_whenEncodingThenDecoding_thenRoundTripIsStable() { + let value: UInt64 = 42 + + let decoded = SyncAnchorStore.decode(SyncAnchorStore.encode(value)) + + XCTAssertEqual(decoded, value) + } + + func test_whenDataLengthIsWrong_thenDecodeReturnsNil() { + let malformed = Data([0x01, 0x02]) + + let decoded = SyncAnchorStore.decode(malformed) + + XCTAssertNil(decoded) + } +} From 52316ef3ddc2441442901d2baa39c8dd604b47e0 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 25 Jun 2026 00:16:11 -0400 Subject: [PATCH 66/80] refactor(tests): update test method names for consistency and clarity --- .../SyncAnchorStoreTests.swift | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift index 20368a338..7a5da5d7d 100644 --- a/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift +++ b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift @@ -22,7 +22,7 @@ final class SyncAnchorStoreTests: XCTestCase { super.tearDown() } - func test_whenChangeRecorded_thenCurrentValueAdvancesByOne() { + func testWhenChangeRecordedThenCurrentValueAdvancesByOne() { let store = SyncAnchorStore(directory: directory) let before = store.currentValue @@ -31,7 +31,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.currentValue, before + 1) } - func test_whenChangeRecorded_thenCurrentDataDiffersFromBefore() { + func testWhenChangeRecordedThenCurrentDataDiffersFromBefore() { let store = SyncAnchorStore(directory: directory) let before = store.currentData @@ -40,7 +40,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertNotEqual(store.currentData, before) } - func test_whenReadFromSeparateInstance_thenSeesPersistedChange() { + func testWhenReadFromSeparateInstanceThenSeesPersistedChange() { SyncAnchorStore(directory: directory).recordChange(parentUuid: "parent-a") let reader = SyncAnchorStore(directory: directory) @@ -48,7 +48,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(reader.currentValue, 1) } - func test_whenChangeRecorded_thenChangedParentsAfterPreviousAnchorIncludesIt() { + func testWhenChangeRecordedThenChangedParentsAfterPreviousAnchorIncludesIt() { let store = SyncAnchorStore(directory: directory) let before = store.currentValue @@ -57,14 +57,14 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.changedParents(after: before), ["parent-a"]) } - func test_whenAnchorIsAtRecordedValue_thenChangedParentsExcludesIt() { + func testWhenAnchorIsAtRecordedValueThenChangedParentsExcludesIt() { let store = SyncAnchorStore(directory: directory) let recordedAt = store.recordChange(parentUuid: "parent-a") XCTAssertEqual(store.changedParents(after: recordedAt), []) } - func test_whenMultipleParentsRecorded_thenChangedParentsReturnsOnlyNewerOnesInOrder() { + func testWhenMultipleParentsRecordedThenChangedParentsReturnsOnlyNewerOnesInOrder() { let store = SyncAnchorStore(directory: directory) let afterFirst = store.recordChange(parentUuid: "parent-a") store.recordChange(parentUuid: "parent-b") @@ -73,7 +73,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.changedParents(after: afterFirst), ["parent-b", "parent-c"]) } - func test_whenSameParentRecordedTwice_thenItUsesTheLatestAnchorValue() { + func testWhenSameParentRecordedTwiceThenItUsesTheLatestAnchorValue() { let store = SyncAnchorStore(directory: directory) store.recordChange(parentUuid: "parent-a") let afterSecond = store.recordChange(parentUuid: "parent-b") @@ -82,13 +82,13 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.changedParents(after: afterSecond), ["parent-a"]) } - func test_whenNoSnapshotSaved_thenSnapshotIsEmpty() { + func testWhenNoSnapshotSavedThenSnapshotIsEmpty() { let store = SyncAnchorStore(directory: directory) XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), []) } - func test_whenSnapshotSaved_thenSnapshotRoundTrips() { + func testWhenSnapshotSavedThenSnapshotRoundTrips() { let store = SyncAnchorStore(directory: directory) store.saveSnapshot(["d:file-1", "f:folder-1"], forFolderUuid: "folder-a") @@ -96,7 +96,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-1", "f:folder-1"]) } - func test_whenSnapshotSavedFromSeparateInstance_thenItIsPersisted() { + func testWhenSnapshotSavedFromSeparateInstanceThenItIsPersisted() { SyncAnchorStore(directory: directory).saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") let reader = SyncAnchorStore(directory: directory) @@ -104,7 +104,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(reader.snapshot(forFolderUuid: "folder-a"), ["d:file-1"]) } - func test_whenSnapshotSavedForDifferentFolders_thenTheyAreIndependent() { + func testWhenSnapshotSavedForDifferentFoldersThenTheyAreIndependent() { let store = SyncAnchorStore(directory: directory) store.saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") @@ -114,7 +114,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.snapshot(forFolderUuid: "folder-b"), ["d:file-2"]) } - func test_whenSnapshotSavedAgain_thenItReplacesThePrevious() { + func testWhenSnapshotSavedAgainThenItReplacesThePrevious() { let store = SyncAnchorStore(directory: directory) store.saveSnapshot(["d:file-1", "d:file-2"], forFolderUuid: "folder-a") @@ -123,7 +123,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-2"]) } - func test_whenEncodingThenDecoding_thenRoundTripIsStable() { + func testWhenEncodingThenDecodingThenRoundTripIsStable() { let value: UInt64 = 42 let decoded = SyncAnchorStore.decode(SyncAnchorStore.encode(value)) @@ -131,7 +131,7 @@ final class SyncAnchorStoreTests: XCTestCase { XCTAssertEqual(decoded, value) } - func test_whenDataLengthIsWrong_thenDecodeReturnsNil() { + func testWhenDataLengthIsWrongThenDecodeReturnsNil() { let malformed = Data([0x01, 0x02]) let decoded = SyncAnchorStore.decode(malformed) From 1012a6c577e7cca98f8285fe944a71831126c676 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 23 Jun 2026 00:08:12 -0400 Subject: [PATCH 67/80] feat: signal File Provider on RN drive mutations Add a cross-platform InternxtSignalingModule with a single notifyParentChanged(parentUuid) bridge (symmetric to Android) and call it from the upload, create-folder, move, rename, trash and restore paths so the native File Provider change-feed refreshes the affected folder. --- .../signaling/InternxtSignalingModule.kt | 6 ++- .../modals/DriveItemInfoModal/index.tsx | 8 ++++ .../modals/DriveRenameModal/index.tsx | 3 ++ src/services/drive/file/driveFile.service.ts | 5 ++- .../driveFolder.service.signaling.spec.ts | 21 ++++++++- .../drive/folder/driveFolder.service.ts | 4 +- .../native/InternxtSignalingModule.spec.ts | 44 ++++++------------- .../native/InternxtSignalingModule.ts | 15 ++++--- src/store/slices/drive/index.ts | 3 ++ 9 files changed, 66 insertions(+), 43 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt index 3b783dbef..c2f24c3fe 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt @@ -18,12 +18,16 @@ class InternxtSignalingModule(ctx: ReactApplicationContext) : promise.reject("E_INVALID_FOLDER", "parentFolderUuid must be a non-empty string") return } + signalParent(folderUuid, promise) + } + + private fun signalParent(folderUuid: String, promise: Promise) { try { InternxtDocumentsProvider.signalParentChanged(folderUuid) promise.resolve(null) } catch (e: Exception) { // Best-effort signal: never let a failed refresh break the JS caller. - Log.w(TAG, "notifyParentChanged failed for $folderUuid", e) + Log.w(TAG, "signalParent failed for $folderUuid", e) promise.resolve(null) } } diff --git a/src/components/modals/DriveItemInfoModal/index.tsx b/src/components/modals/DriveItemInfoModal/index.tsx index 95aa76d33..c0c6410ed 100644 --- a/src/components/modals/DriveItemInfoModal/index.tsx +++ b/src/components/modals/DriveItemInfoModal/index.tsx @@ -12,6 +12,7 @@ import { logger } from '@internxt-mobile/services/common'; import { time } from '@internxt-mobile/services/common/time'; import drive from '@internxt-mobile/services/drive'; import { driveLocalDB } from '@internxt-mobile/services/drive/database'; +import { notifyParentChanged } from '@internxt-mobile/services/native/InternxtSignalingModule'; import { Abortable } from '@internxt-mobile/types/index'; import * as driveUseCases from '@internxt-mobile/useCases/drive'; import { @@ -109,6 +110,13 @@ function DriveItemInfoModal(): JSX.Element { () => handleUndoMoveToTrash(), ); + if (success) { + const parentFolderUuid = isFolder ? item.parentUuid : item.folderUuid; + if (parentFolderUuid) { + void notifyParentChanged(parentFolderUuid); + } + } + if (success && dbItem?.id) { await driveLocalDB.deleteItem({ id: dbItem.id }); } diff --git a/src/components/modals/DriveRenameModal/index.tsx b/src/components/modals/DriveRenameModal/index.tsx index b108523b6..1135049d1 100644 --- a/src/components/modals/DriveRenameModal/index.tsx +++ b/src/components/modals/DriveRenameModal/index.tsx @@ -11,6 +11,7 @@ import strings from '../../../../assets/lang/strings'; import useGetColor from '../../../hooks/useColor'; import { logger } from '../../../services/common'; import errorService from '../../../services/ErrorService'; +import { notifyParentChanged } from '../../../services/native/InternxtSignalingModule'; import notificationsService from '../../../services/NotificationsService'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { driveActions } from '../../../store/slices/drive'; @@ -63,6 +64,8 @@ function RenameModal(): JSX.Element { await drive.file.updateMetaData(focusedItem.uuid, trimmedNewName); } + void notifyParentChanged(driveCtx.focusedFolder.uuid); + notificationsService.show({ text1: strings.messages.renamedSuccessfully, type: NotificationType.Success, diff --git a/src/services/drive/file/driveFile.service.ts b/src/services/drive/file/driveFile.service.ts index 80035b94b..1260b1fb0 100644 --- a/src/services/drive/file/driveFile.service.ts +++ b/src/services/drive/file/driveFile.service.ts @@ -16,6 +16,7 @@ import uuid from 'react-native-uuid'; import { getEnvironmentConfigFromUser } from 'src/lib/network'; import * as networkDownload from 'src/network/download'; import network from '../../../network'; +import { notifyParentChanged } from '../../native/InternxtSignalingModule'; import { uploadService } from '../../common/network/upload/upload.service'; import { DRIVE_THUMBNAILS_DIRECTORY } from '../constants'; import { driveFileCache } from './driveFileCache.service'; @@ -107,7 +108,9 @@ class DriveFileService { fileUuid: string; destinationFolderUuid: string; }): Promise { - return this.sdk.storageV2.moveFileByUuid(fileUuid, { destinationFolder: destinationFolderUuid }); + const moved = await this.sdk.storageV2.moveFileByUuid(fileUuid, { destinationFolder: destinationFolderUuid }); + void notifyParentChanged(destinationFolderUuid); + return moved; } public getSortFunction({ diff --git a/src/services/drive/folder/driveFolder.service.signaling.spec.ts b/src/services/drive/folder/driveFolder.service.signaling.spec.ts index 4bdf9682f..7b87298d1 100644 --- a/src/services/drive/folder/driveFolder.service.signaling.spec.ts +++ b/src/services/drive/folder/driveFolder.service.signaling.spec.ts @@ -1,5 +1,6 @@ const mockNotifyParentChanged = jest.fn().mockResolvedValue(undefined); const mockCreateFolderByUuid = jest.fn(); +const mockMoveFolderByUuid = jest.fn(); jest.mock('../../native/InternxtSignalingModule', () => ({ notifyParentChanged: (...args: unknown[]) => mockNotifyParentChanged(...args), @@ -10,6 +11,7 @@ jest.mock('@internxt-mobile/services/common', () => ({ getInstance: () => ({ storageV2: { createFolderByUuid: (...args: unknown[]) => mockCreateFolderByUuid(...args), + moveFolderByUuid: (...args: unknown[]) => mockMoveFolderByUuid(...args), }, }), }, @@ -23,7 +25,7 @@ describe('driveFolderService.createFolder — picker signaling', () => { mockCreateFolderByUuid.mockReset(); }); - it('when a folder is created, then it signals the parent folder uuid', async () => { + test('when a folder is created, then it signals the parent folder uuid', async () => { mockCreateFolderByUuid.mockReturnValue([Promise.resolve({ uuid: 'new-folder', name: 'docs' })]); await driveFolderService.createFolder('parent-folder-uuid', 'docs'); @@ -31,10 +33,25 @@ describe('driveFolderService.createFolder — picker signaling', () => { expect(mockNotifyParentChanged).toHaveBeenCalledWith('parent-folder-uuid'); }); - it('when the SDK returns no result, then it rejects and does not signal', async () => { + test('when the SDK returns no result, then it rejects and does not signal', async () => { mockCreateFolderByUuid.mockReturnValue(undefined); await expect(driveFolderService.createFolder('parent-folder-uuid', 'docs')).rejects.toBeDefined(); expect(mockNotifyParentChanged).not.toHaveBeenCalled(); }); }); + +describe('driveFolderService.moveFolder — picker signaling', () => { + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + mockMoveFolderByUuid.mockReset(); + }); + + test('when a folder is moved, then it signals the destination folder uuid', async () => { + mockMoveFolderByUuid.mockResolvedValue({ uuid: 'folder-uuid' }); + + await driveFolderService.moveFolder({ folderUuid: 'folder-uuid', destinationFolderUuid: 'destination-uuid' }); + + expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-uuid'); + }); +}); diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts index 1420bd894..c1e9e4f14 100644 --- a/src/services/drive/folder/driveFolder.service.ts +++ b/src/services/drive/folder/driveFolder.service.ts @@ -52,7 +52,9 @@ class DriveFolderService { folderUuid: string; destinationFolderUuid: string; }) { - return this.sdk.storageV2.moveFolderByUuid(folderUuid, { destinationFolder: destinationFolderUuid }); + const moved = await this.sdk.storageV2.moveFolderByUuid(folderUuid, { destinationFolder: destinationFolderUuid }); + void notifyParentChanged(destinationFolderUuid); + return moved; } public async updateMetaData(folderUuid: string, newName: string): Promise { diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts index 49a58b718..69370aa53 100644 --- a/src/services/native/InternxtSignalingModule.spec.ts +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -6,16 +6,10 @@ jest.mock('../common', () => ({ type Wrapper = typeof import('./InternxtSignalingModule'); -/** - * The wrapper resolves the native bridge at module-load time from `Platform.OS` and - * `NativeModules`, so each scenario loads a fresh copy of the module under a tailored - * `react-native` mock. - */ -const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): { wrapper: Wrapper } => { +const loadWrapper = (nativeModule: unknown): { wrapper: Wrapper } => { let wrapper!: Wrapper; jest.isolateModules(() => { jest.doMock('react-native', () => ({ - Platform: { OS: platformOS }, NativeModules: nativeModule === undefined ? {} : { InternxtSignalingModule: nativeModule }, })); wrapper = require('./InternxtSignalingModule'); @@ -23,13 +17,9 @@ const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): { wr return { wrapper }; }; -/** - * Builds a native bridge mock and loads the wrapper against it, returning the spy so each - * test only states the platform and asserts the resulting behaviour. - */ -const arrangePresentNative = (platformOS: 'android' | 'ios') => { +const arrangePresentNative = () => { const native = jest.fn().mockResolvedValue(undefined); - const { wrapper } = loadWrapper(platformOS, { notifyParentChanged: native }); + const { wrapper } = loadWrapper({ notifyParentChanged: native }); return { wrapper, native }; }; @@ -39,46 +29,38 @@ describe('InternxtSignalingModule wrapper', () => { mockLoggerWarn.mockReset(); }); - it('when on Android with a valid uuid, then it invokes the native module with that uuid', async () => { - const { wrapper, native } = arrangePresentNative('android'); + test('when the native bridge is present with a valid uuid, then it invokes the native module with that uuid', async () => { + const { wrapper, native } = arrangePresentNative(); await wrapper.notifyParentChanged('folder-uuid-123'); expect(native).toHaveBeenCalledWith('folder-uuid-123'); }); - it('when on iOS, then it resolves without invoking the native module', async () => { - const { wrapper, native } = arrangePresentNative('ios'); - - await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); - - expect(native).not.toHaveBeenCalled(); - }); - - it('when the uuid is empty, then it resolves without invoking the native module', async () => { - const { wrapper, native } = arrangePresentNative('android'); + test('when the uuid is empty, then it resolves without invoking the native module', async () => { + const { wrapper, native } = arrangePresentNative(); await expect(wrapper.notifyParentChanged('')).resolves.toBeUndefined(); expect(native).not.toHaveBeenCalled(); }); - it('when the uuid is not a string, then it resolves without invoking the native module', async () => { - const { wrapper, native } = arrangePresentNative('android'); + test('when the uuid is not a string, then it resolves without invoking the native module', async () => { + const { wrapper, native } = arrangePresentNative(); await expect(wrapper.notifyParentChanged(undefined as unknown as string)).resolves.toBeUndefined(); expect(native).not.toHaveBeenCalled(); }); - it('when the native module is absent, then it resolves without throwing', async () => { - const { wrapper } = loadWrapper('android', undefined); + test('when the native module is absent, then it resolves without throwing', async () => { + const { wrapper } = loadWrapper(undefined); await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); }); - it('when the native module rejects, then it resolves without throwing', async () => { - const { wrapper, native } = arrangePresentNative('android'); + test('when the native module rejects, then it resolves without throwing', async () => { + const { wrapper, native } = arrangePresentNative(); native.mockRejectedValueOnce(new Error('E_INVALID_FOLDER')); await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); diff --git a/src/services/native/InternxtSignalingModule.ts b/src/services/native/InternxtSignalingModule.ts index f7c6ae11c..548be1986 100644 --- a/src/services/native/InternxtSignalingModule.ts +++ b/src/services/native/InternxtSignalingModule.ts @@ -1,19 +1,20 @@ -import { NativeModules, Platform } from 'react-native'; +import { NativeModules } from 'react-native'; import { logger } from '../common'; interface NativeBridge { notifyParentChanged(parentFolderUuid: string): Promise; } -const bridge: NativeBridge | undefined = Platform.OS === 'android' ? NativeModules.InternxtSignalingModule : undefined; +const bridge: NativeBridge | undefined = NativeModules.InternxtSignalingModule; /** - * Signals the Android file picker (SAF DocumentsProvider) that a folder's children changed - * after a mutation originated in the React Native app (file upload, folder creation), so it - * invalidates its cache and re-queries. + * Signals the native file browser that a folder's children changed after a mutation originated + * in the React Native app (upload, create, move, rename, trash, restore): the Android SAF + * DocumentsProvider re-queries the parent; the iOS File Provider re-evaluates the parent against + * its child snapshot, so add/rename/move/trash all refresh Files.app live through a single call. * - * Best-effort and cross-platform: no-op on iOS and when the native module is unavailable, - * never throws to the caller and never invokes the native side with a malformed uuid. + * Best-effort and cross-platform: no-op when the native module is unavailable, never throws to + * the caller and never invokes the native side with a malformed uuid. */ export async function notifyParentChanged(parentFolderUuid: string): Promise { if (!bridge) return; diff --git a/src/store/slices/drive/index.ts b/src/store/slices/drive/index.ts index c263623be..98c318d77 100644 --- a/src/store/slices/drive/index.ts +++ b/src/store/slices/drive/index.ts @@ -7,6 +7,7 @@ import { items } from '@internxt/lib'; import { checkIsFolder, isValidFilename, mapRecentFile } from 'src/helpers'; import authService from 'src/services/AuthService'; import errorService from 'src/services/ErrorService'; +import { notifyParentChanged } from 'src/services/native/InternxtSignalingModule'; import { ErrorCodes } from 'src/types/errors'; import { RootState } from '../..'; import strings from '../../../../assets/lang/strings'; @@ -369,6 +370,8 @@ const moveItemThunk = createAsyncThunk Date: Mon, 13 Jul 2026 15:13:02 -0400 Subject: [PATCH 68/80] feat: add theme preference synchronization to SharedAuthKeychain --- ios/Internxt.xcodeproj/project.pbxproj | 61 +++++++++++++------------- ios/Internxt/SharedAuthKeychain.swift | 12 +++++ 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 98fb13dc3..58d8101e2 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -7,24 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - 0BF8BD03596D167E534E4B02 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 55EA068F694B454131066FDC /* libPods-Internxt.a */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */; }; 18D7DE9587FA2B2314575ED6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */; }; 1A3E7803EDA342F39787DC8F /* PHAssetExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */; }; 2F8E878CDCAC7D5C07B5C670 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 512CF1042062583CD79DC618 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = DE1D47552F87AB4000E14783 /* InternxtSwiftCore */; }; + 517593AA71C926C43E076321 /* FileProviderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47632F87A94000E14783 /* FileProviderExtension.swift */; }; 5EECD9AA8FE2941A3CB8B8EF /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */; }; 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; - FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010002F90000000E14783 /* FileProviderModule.swift */; }; - FF2010012F90000000E14783 /* FileProviderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1010012F90000000E14783 /* FileProviderModule.m */; }; - FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; - FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; - FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; - FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; - FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */; }; AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */; }; AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */; }; @@ -42,19 +36,26 @@ A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; AC90D7AE08E1AB3023417F70 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + CEF3BECD880147688CACD8B5 /* FileProviderEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */; }; DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */; }; DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; - F2C595E5ECBAD697016C9410 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */; }; + F603918B0E78918549FB9494 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */; }; FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB1D480412F87A94000E14783 /* InternxtSwiftCore */; }; FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */; }; FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; FB1D481512F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; - FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB1D480412F87A94000E14783 /* InternxtSwiftCore */; }; - FEB260B2B281D37EEABD1ECA /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */; }; + FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010002F90000000E14783 /* FileProviderModule.swift */; }; + FF2010012F90000000E14783 /* FileProviderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1010012F90000000E14783 /* FileProviderModule.m */; }; + FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */; }; + FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -81,7 +82,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - DE1D474F2F87A94000E14783 /* InternxtFileProvider.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -100,11 +100,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0512BC474AAE3164D3544B18 /* Pods-InternxtShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Internxt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Internxt.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Internxt/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Internxt/Info.plist; sourceTree = ""; }; - 20957EE3E4797FBBC2BD3192 /* libPods-InternxtFileProvider.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtFileProvider.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 30C3C55FF8AE435F8A82A9BC /* InternxtShareExtension.entitlements */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = InternxtShareExtension.entitlements; sourceTree = ""; }; 37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Internxt/PrivacyInfo.xcprivacy; sourceTree = ""; }; 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = ShareExtensionViewController.swift; sourceTree = ""; }; @@ -116,11 +114,8 @@ A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHBurstExportModule.swift; path = Internxt/PHBurstExportModule.swift; sourceTree = ""; }; A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHBurstExportModule.m; path = Internxt/PHBurstExportModule.m; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; + A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SharedAuthKeychain.swift; path = Internxt/SharedAuthKeychain.swift; sourceTree = ""; }; - FF1010002F90000000E14783 /* FileProviderModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderModule.swift; path = Internxt/FileProviderModule.swift; sourceTree = ""; }; - FF1010012F90000000E14783 /* FileProviderModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FileProviderModule.m; path = Internxt/FileProviderModule.m; sourceTree = ""; }; - FF1010022F90000000E14783 /* SyncAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStore.swift; sourceTree = ""; }; - FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStoreTests.swift; sourceTree = ""; }; AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderDomainManager.swift; path = Internxt/FileProviderDomainManager.swift; sourceTree = ""; }; AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InternxtAuthCredentialsModule.m; path = Internxt/InternxtAuthCredentialsModule.m; sourceTree = ""; }; @@ -135,12 +130,10 @@ AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationService.swift; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; - D0A0CA179360CA52F0B5054A /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; D330686328BD4286AB778B88 /* PHAssetExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHAssetExportModule.m; path = Internxt/PHAssetExportModule.m; sourceTree = ""; }; D83881814DD0A20237F63249 /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; }; DAE15E8DB4DF4E048E04B0E0 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InternxtFileProvider.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - DE1D47422F87A94000E14783 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; DE1D47632F87A94000E14783 /* FileProviderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderExtension.swift; sourceTree = ""; }; DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderEnumerator.swift; sourceTree = ""; }; DE1D47652F87A94000E14783 /* FileProviderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItem.swift; sourceTree = ""; }; @@ -154,9 +147,12 @@ F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; - FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationTests.swift; sourceTree = ""; }; FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InternxtFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - FDEA9EEAEC8CE666D14877B2 /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; + FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationTests.swift; sourceTree = ""; }; + FF1010002F90000000E14783 /* FileProviderModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderModule.swift; path = Internxt/FileProviderModule.swift; sourceTree = ""; }; + FF1010012F90000000E14783 /* FileProviderModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FileProviderModule.m; path = Internxt/FileProviderModule.m; sourceTree = ""; }; + FF1010022F90000000E14783 /* SyncAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStore.swift; sourceTree = ""; }; + FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStoreTests.swift; sourceTree = ""; }; F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -165,7 +161,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AC90D7AE08E1AB3023417F70 /* libPods-Internxt.a in Frameworks */, + 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A04AF4D95A14473303BAABA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 512CF1042062583CD79DC618 /* InternxtSwiftCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -402,10 +406,9 @@ isa = PBXNativeTarget; buildConfigurationList = DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */; buildPhases = ( - 04C47D43FAB17BFF5826C486 /* [CP] Check Pods Manifest.lock */, DE1D473D2F87A94000E14783 /* Sources */, - DE1D473E2F87A94000E14783 /* Frameworks */, DE1D473F2F87A94000E14783 /* Resources */, + 2A04AF4D95A14473303BAABA /* Frameworks */, ); buildRules = ( ); @@ -811,10 +814,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DE1D47602F87A94000E14783 /* FileProviderExtension.swift in Sources */, FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */, - DE1D47612F87A94000E14783 /* FileProviderEnumerator.swift in Sources */, - DE1D47622F87A94000E14783 /* FileProviderItem.swift in Sources */, AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */, AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */, @@ -824,6 +824,9 @@ AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */, AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */, AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, + F603918B0E78918549FB9494 /* FileProviderItem.swift in Sources */, + 517593AA71C926C43E076321 /* FileProviderExtension.swift in Sources */, + CEF3BECD880147688CACD8B5 /* FileProviderEnumerator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1108,7 +1111,6 @@ }; DE1D47502F87A94000E14783 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AC8B28CC3C9A8FDF21A8C1E7 /* Pods-InternxtFileProvider.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -1157,7 +1159,6 @@ }; DE1D47512F87A94000E14783 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8C48FB43821216C363EBF3FC /* Pods-InternxtFileProvider.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift index eb4adc984..f9e12bdb8 100644 --- a/ios/Internxt/SharedAuthKeychain.swift +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -16,6 +16,7 @@ enum SharedAuthKeychain { static let userIdKey = "shared_userId" static let driveBaseUrlKey = "shared_driveBaseUrl" static let bridgeBaseUrlKey = "shared_bridgeBaseUrl" + static let themePreferenceKey = "shared_themePreference" static let allKeys = [ photosTokenKey, mnemonicKey, rootFolderIdKey, bucketKey, bridgeUserKey, userIdKey, @@ -102,6 +103,8 @@ enum SharedAuthKeychain { static func syncFromPrivateKeychain() { guard accessGroup != nil else { return } + syncThemePreference() + let isAuthenticated = readPrivate("photosToken") != nil guard isAuthenticated else { clearAll() @@ -116,6 +119,15 @@ enum SharedAuthKeychain { copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey, isJSONEncoded: true) } + static func syncThemePreference() { + guard accessGroup != nil else { return } + if let data = readPrivate("themePreference") { + write(data, for: themePreferenceKey) + } else { + delete(themePreferenceKey) + } + } + private static func copyFromPrivate(privateKey: String, sharedKey: String, isJSONEncoded: Bool = false) { guard let data = readPrivate(privateKey) else { return } write(isJSONEncoded ? jsonDecoded(data) : data, for: sharedKey) From 197fffd6c7a902e19dbd793daadf28494095bfcb Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 23 Aug 2026 01:11:58 -0400 Subject: [PATCH 69/80] fix: file-provider integration fixes for Android and iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android DocumentsProvider: - Fall back to the root (user) bucket when the destination folder's metadata has no bucket — the API only populates bucket on the root folder, so copies into subfolders failed with "Parent folder has no bucket". The resolved bucket is cached per process. - Serve queryDocument from a new in-memory DocumentRowCache populated by folder listings and invalidated on rename/move/delete and child refresh; cache-miss fetches now run off the binder thread. Callers that query from their main thread (e.g. Google PDF viewer) no longer crash with NetworkOnMainThreadException, since the caller's StrictMode policy propagates across binder. Adds DocumentRowCacheTest. iOS: - Embed InternxtFileProvider.appex into the app bundle (the Embed Foundation Extensions phase was empty, so the extension never shipped and the provider could not appear in Files.app). - Align the extension MARKETING_VERSION with the host app (1.10.0); a mismatched version prevents the extension from loading. - CocoaPods integration for the In (Podfile.lock checksum update). --- .../cloud/documents/DocumentRowCache.kt | 30 +++++++ .../documents/InternxtDocumentsProvider.kt | 38 +++++++- .../cloud/documents/DocumentRowCacheTest.kt | 86 +++++++++++++++++++ ios/Internxt.xcodeproj/project.pbxproj | 52 +++++++++-- ios/Podfile.lock | 2 +- 5 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/DocumentRowCache.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowCache.kt new file mode 100644 index 000000000..1a0f1baed --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowCache.kt @@ -0,0 +1,30 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import java.util.concurrent.ConcurrentHashMap + +internal class DocumentRowCache { + + private val rowsByUuid = ConcurrentHashMap>() + + operator fun get(uuid: String): Map? = rowsByUuid[uuid] + + fun put(uuid: String, row: Map) { + rowsByUuid[uuid] = row + } + + fun putAll(rows: List>) { + rows.forEach { row -> uuidOf(row)?.let { rowsByUuid[it] = row } } + } + + fun evict(uuid: String) { + rowsByUuid.remove(uuid) + } + + fun evictAll(rows: List>) { + rows.forEach { row -> uuidOf(row)?.let(rowsByUuid::remove) } + } + + private fun uuidOf(row: Map): String? = + (row[Document.COLUMN_DOCUMENT_ID] as? String)?.let { DocumentId.decode(it)?.uuid ?: it } +} 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 305099177..2532a3e50 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 @@ -63,8 +63,11 @@ class InternxtDocumentsProvider : DocumentsProvider() { private val uploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val folderLoads = ConcurrentHashMap() + private val documentRows = DocumentRowCache() private val pendingUploads = ConcurrentHashMap() + @Volatile private var cachedRootBucket: String? = null + private enum class LoadState { LOADING, DONE, ERROR } private class FolderLoad { @@ -137,14 +140,20 @@ class InternxtDocumentsProvider : DocumentsProvider() { return cursor } + documentRows[uuid]?.let { cached -> + cursor.addDocumentRow(cached) + return cursor + } + val api = apiClient(op = "queryDocument") val row = if (api == null) null else try { - fetchDocumentRow(api, decoded?.kind, uuid) + runBlockingIo { fetchDocumentRow(api, decoded?.kind, uuid) } } catch (e: InternxtApiException) { Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") null } + row?.let { documentRows.put(uuid, it) } cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) return cursor } @@ -228,6 +237,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { private fun appendRows(load: FolderLoad, notifyUri: Uri, rows: List>) { if (rows.isEmpty()) return synchronized(load) { load.rows.addAll(rows) } + documentRows.putAll(rows) context?.contentResolver?.notifyChange(notifyUri, null) } @@ -312,8 +322,11 @@ class InternxtDocumentsProvider : DocumentsProvider() { ): R { val api = apiClient(op) ?: throw FileNotFoundException("No auth") val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") + val uuid = rawUuid(documentId) return try { - block(api, kind, rawUuid(documentId)) + val result = block(api, kind, uuid) + documentRows.evict(uuid) + result } catch (e: InternxtApiException) { Log.w(TAG, "$op $documentId failed: ${e.javaClass.simpleName}: ${e.message}") throw FileNotFoundException(e.message) @@ -356,7 +369,9 @@ class InternxtDocumentsProvider : DocumentsProvider() { } private fun invalidateChildren(parentDocumentId: String) { - folderLoads.remove(parentDocumentId) + folderLoads.remove(parentDocumentId)?.let { load -> + documentRows.evictAll(synchronized(load) { load.rows.toList() }) + } notifyChildren(parentDocumentId) } @@ -412,6 +427,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { } try { val fresh = fetchAllRows(api, parentUuid) + documentRows.putAll(fresh) synchronized(load) { load.rows.clear() load.rows.addAll(fresh) @@ -734,8 +750,22 @@ class InternxtDocumentsProvider : DocumentsProvider() { private fun resolveBucket(api: InternxtApiClient, parentUuid: String): String { val parent = api.getFolder(parentUuid) ?: throw IOException("Parent folder not found: $parentUuid") + // The API only populates `bucket` on the root folder; subfolders return null, + // so uploads into subfolders fall back to the user's (root) bucket. return parent.bucket - ?: throw IOException("Parent folder $parentUuid has no bucket") + ?: rootBucket(api) + ?: throw IOException("Parent folder $parentUuid has no bucket and root bucket is unavailable") + } + + private fun rootBucket(api: InternxtApiClient): String? { + cachedRootBucket?.let { return it } + val rootUuid = authManager?.authenticatedRootUuid() ?: return null + val bucket = api.getFolder(rootUuid)?.bucket + if (bucket != null) { + Log.d(TAG, "resolveBucket: using root bucket fallback rootUuid=$rootUuid") + cachedRootBucket = bucket + } + return bucket } private fun prepareEncryption(mnemonic: String, bucketId: String): EncryptionContext { diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt new file mode 100644 index 000000000..e141e94b3 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt @@ -0,0 +1,86 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class DocumentRowCacheTest { + + private lateinit var cache: DocumentRowCache + + @Before + fun setUp() { + cache = DocumentRowCache() + } + + private fun row(documentId: String?, displayName: String = "name") = mapOf( + Document.COLUMN_DOCUMENT_ID to documentId, + Document.COLUMN_DISPLAY_NAME to displayName, + ) + + @Test + fun `when putAll receives listing rows, then they are retrievable by decoded uuid`() { + val folderRow = row("f:folder-uuid", "Documents") + val fileRow = row("d:file-uuid", "report.pdf") + + cache.putAll(listOf(folderRow, fileRow)) + + assertEquals(folderRow, cache["folder-uuid"]) + assertEquals(fileRow, cache["file-uuid"]) + } + + @Test + fun `when a row has an unprefixed document id, then it is keyed by the raw id`() { + val bareRow = row("bare-uuid") + + cache.putAll(listOf(bareRow)) + + assertEquals(bareRow, cache["bare-uuid"]) + } + + @Test + fun `when a row has no document id, then it is skipped`() { + cache.putAll(listOf(row(documentId = null))) + + assertNull(cache["null"]) + } + + @Test + fun `when put stores a row under a uuid, then a later put overwrites it`() { + cache.put("file-uuid", row("d:file-uuid", "old.pdf")) + val renamed = row("d:file-uuid", "new.pdf") + + cache.put("file-uuid", renamed) + + assertEquals(renamed, cache["file-uuid"]) + } + + @Test + fun `when evict removes a uuid, then only that entry is gone`() { + cache.putAll(listOf(row("d:file-uuid"), row("f:folder-uuid"))) + + cache.evict("file-uuid") + + assertNull(cache["file-uuid"]) + assertEquals(row("f:folder-uuid"), cache["folder-uuid"]) + } + + @Test + fun `when evictAll receives stale rows, then their uuids are removed and others remain`() { + val staying = row("d:kept-uuid") + cache.putAll(listOf(row("d:file-uuid"), row("f:folder-uuid"), staying)) + + cache.evictAll(listOf(row("d:file-uuid"), row("f:folder-uuid"))) + + assertNull(cache["file-uuid"]) + assertNull(cache["folder-uuid"]) + assertEquals(staying, cache["kept-uuid"]) + } + + @Test + fun `when a uuid was never cached, then get returns null`() { + assertNull(cache["missing-uuid"]) + } +} diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index 58d8101e2..f148391a4 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -17,7 +17,11 @@ 517593AA71C926C43E076321 /* FileProviderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47632F87A94000E14783 /* FileProviderExtension.swift */; }; 5EECD9AA8FE2941A3CB8B8EF /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */; }; 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; + AB12CD34EF56AB78CD90EF12 /* InternxtFileProvider.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; + 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; + A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; + A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */; }; AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; @@ -31,14 +35,12 @@ AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */; }; AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */; }; AB1D47962F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; - 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; - A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; - A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; - AC90D7AE08E1AB3023417F70 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */; }; + ABEEE0748E6ACDC97FD4A52E /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; CEF3BECD880147688CACD8B5 /* FileProviderEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */; }; DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */; }; DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; }; + E969AA12C7375D21048F65E7 /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F603918B0E78918549FB9494 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */; }; @@ -82,6 +84,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + AB12CD34EF56AB78CD90EF12 /* InternxtFileProvider.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -111,10 +114,10 @@ 8519E68C15D0C6CCDC034276 /* Pods-InternxtShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.release.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.release.xcconfig"; sourceTree = ""; }; 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHAssetExportModule.swift; path = Internxt/PHAssetExportModule.swift; sourceTree = ""; }; + 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtFileProvider.release.xcconfig"; path = "Target Support Files/Pods-InternxtFileProvider/Pods-InternxtFileProvider.release.xcconfig"; sourceTree = ""; }; A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHBurstExportModule.swift; path = Internxt/PHBurstExportModule.swift; sourceTree = ""; }; A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHBurstExportModule.m; path = Internxt/PHBurstExportModule.m; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; - A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SharedAuthKeychain.swift; path = Internxt/SharedAuthKeychain.swift; sourceTree = ""; }; AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderDomainManager.swift; path = Internxt/FileProviderDomainManager.swift; sourceTree = ""; }; AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; @@ -128,6 +131,8 @@ AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderUploadService.swift; sourceTree = ""; }; AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDownloadService.swift; sourceTree = ""; }; AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationService.swift; sourceTree = ""; }; + AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtFileProvider.debug.xcconfig"; path = "Target Support Files/Pods-InternxtFileProvider/Pods-InternxtFileProvider.debug.xcconfig"; sourceTree = ""; }; + B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtFileProvider.a"; sourceTree = BUILT_PRODUCTS_DIR; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; D330686328BD4286AB778B88 /* PHAssetExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHAssetExportModule.m; path = Internxt/PHAssetExportModule.m; sourceTree = ""; }; @@ -146,6 +151,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; + F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InternxtFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationTests.swift; sourceTree = ""; }; @@ -153,7 +159,6 @@ FF1010012F90000000E14783 /* FileProviderModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FileProviderModule.m; path = Internxt/FileProviderModule.m; sourceTree = ""; }; FF1010022F90000000E14783 /* SyncAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStore.swift; sourceTree = ""; }; FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStoreTests.swift; sourceTree = ""; }; - F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -161,7 +166,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */, + ABEEE0748E6ACDC97FD4A52E /* libPods-Internxt.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -170,6 +175,7 @@ buildActionMask = 2147483647; files = ( 512CF1042062583CD79DC618 /* InternxtSwiftCore in Frameworks */, + E969AA12C7375D21048F65E7 /* libPods-InternxtFileProvider.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -224,6 +230,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */, F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */, 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */, + B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */, ); name = Frameworks; sourceTree = ""; @@ -253,6 +260,8 @@ D83881814DD0A20237F63249 /* Pods-Internxt.release.xcconfig */, 7C701A830CDF1F4D18966C60 /* Pods-InternxtShareExtension.debug.xcconfig */, 8519E68C15D0C6CCDC034276 /* Pods-InternxtShareExtension.release.xcconfig */, + AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */, + 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -406,6 +415,7 @@ isa = PBXNativeTarget; buildConfigurationList = DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */; buildPhases = ( + 74E905CB0B70CDBE606791CD /* [CP] Check Pods Manifest.lock */, DE1D473D2F87A94000E14783 /* Sources */, DE1D473F2F87A94000E14783 /* Resources */, 2A04AF4D95A14473303BAABA /* Frameworks */, @@ -627,6 +637,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n"; showEnvVarsInLog = 0; }; + 74E905CB0B70CDBE606791CD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-InternxtFileProvider-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1111,6 +1143,7 @@ }; DE1D47502F87A94000E14783 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -1139,7 +1172,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.10.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -1159,6 +1192,7 @@ }; DE1D47512F87A94000E14783 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; @@ -1188,7 +1222,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.10.0; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3b558c57e..c1ab6f108 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3002,6 +3002,6 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a -PODFILE CHECKSUM: ad998c6337cfc81591a69440b19d65a401345467 +PODFILE CHECKSUM: 5ca1b45e9626b00295456e3d5aa77bea26c0bf29 COCOAPODS: 1.16.2 From 8a3a85d375e89551ae60b6d3145b4316194d188b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 24 Aug 2026 07:26:35 -0400 Subject: [PATCH 70/80] fix: address Sonar findings and repair auth credentials spec - Document intentionally empty blocks (CallAwait closeQuietly catch, FileProviderEnumerator invalidate) - Mark unused NSFileProviderReplicatedExtension parameters with `_` in FileProviderExtension - Extract duplicated string literals into constants in Android DocumentRowBuilder, DocumentRowCache, MimeTypes and InternxtApiClient tests - Mock BaseLogger in auth.syncNativeCredentials.spec so the suite runs again --- .../cloud/documents/http/CallAwait.kt | 2 + .../cloud/documents/DocumentRowBuilderTest.kt | 10 ++- .../cloud/documents/DocumentRowCacheTest.kt | 39 ++++++----- .../internxt/cloud/documents/MimeTypesTest.kt | 19 ++++-- .../documents/api/InternxtApiClientTest.kt | 65 ++++++++++--------- .../FileProviderEnumerator.swift | 2 +- .../FileProviderExtension.swift | 12 ++-- .../auth/auth.syncNativeCredentials.spec.ts | 1 + 8 files changed, 86 insertions(+), 64 deletions(-) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt index 89007652c..6f4ff81fe 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt @@ -24,5 +24,7 @@ private fun Response.closeQuietly() { try { close() } catch (_: Throwable) { + // Intentionally empty: close() failures on an already-abandoned response are irrelevant + // and must not mask the coroutine's cancellation or original result. } } 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 a38124c07..9f29b1e21 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 @@ -9,6 +9,10 @@ import org.junit.Test class DocumentRowBuilderTest { + companion object { + private const val UPDATED_AT = "2026-01-11T00:00:00.000Z" + } + @Test fun folderRowFields() { val folder = DriveFolder( @@ -17,7 +21,7 @@ class DocumentRowBuilderTest { parentUuid = "parent-uuid", bucket = null, createdAt = null, - updatedAt = "2026-01-11T00:00:00.000Z", + updatedAt = UPDATED_AT, ) val row = DocumentRowBuilder.folderRow(folder) @@ -44,7 +48,7 @@ class DocumentRowBuilderTest { bucket = "bucket-id", folderUuid = "parent-uuid", createdAt = null, - updatedAt = "2026-01-11T00:00:00.000Z", + updatedAt = UPDATED_AT, fileId = "file-id-1", ) @@ -96,7 +100,7 @@ class DocumentRowBuilderTest { @Test fun parseIsoToMillisHandlesValidInput() { - assertEquals(1768089600000L, DocumentRowBuilder.parseIsoToMillis("2026-01-11T00:00:00.000Z")) + assertEquals(1768089600000L, DocumentRowBuilder.parseIsoToMillis(UPDATED_AT)) } @Test diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt index e141e94b3..4a05abfe3 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt @@ -8,6 +8,13 @@ import org.junit.Test class DocumentRowCacheTest { + companion object { + private const val FOLDER_UUID = "folder-uuid" + private const val FILE_UUID = "file-uuid" + private const val FOLDER_DOC_ID = "f:$FOLDER_UUID" + private const val FILE_DOC_ID = "d:$FILE_UUID" + } + private lateinit var cache: DocumentRowCache @Before @@ -22,13 +29,13 @@ class DocumentRowCacheTest { @Test fun `when putAll receives listing rows, then they are retrievable by decoded uuid`() { - val folderRow = row("f:folder-uuid", "Documents") - val fileRow = row("d:file-uuid", "report.pdf") + val folderRow = row(FOLDER_DOC_ID, "Documents") + val fileRow = row(FILE_DOC_ID, "report.pdf") cache.putAll(listOf(folderRow, fileRow)) - assertEquals(folderRow, cache["folder-uuid"]) - assertEquals(fileRow, cache["file-uuid"]) + assertEquals(folderRow, cache[FOLDER_UUID]) + assertEquals(fileRow, cache[FILE_UUID]) } @Test @@ -49,33 +56,33 @@ class DocumentRowCacheTest { @Test fun `when put stores a row under a uuid, then a later put overwrites it`() { - cache.put("file-uuid", row("d:file-uuid", "old.pdf")) - val renamed = row("d:file-uuid", "new.pdf") + cache.put(FILE_UUID, row(FILE_DOC_ID, "old.pdf")) + val renamed = row(FILE_DOC_ID, "new.pdf") - cache.put("file-uuid", renamed) + cache.put(FILE_UUID, renamed) - assertEquals(renamed, cache["file-uuid"]) + assertEquals(renamed, cache[FILE_UUID]) } @Test fun `when evict removes a uuid, then only that entry is gone`() { - cache.putAll(listOf(row("d:file-uuid"), row("f:folder-uuid"))) + cache.putAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID))) - cache.evict("file-uuid") + cache.evict(FILE_UUID) - assertNull(cache["file-uuid"]) - assertEquals(row("f:folder-uuid"), cache["folder-uuid"]) + assertNull(cache[FILE_UUID]) + assertEquals(row(FOLDER_DOC_ID), cache[FOLDER_UUID]) } @Test fun `when evictAll receives stale rows, then their uuids are removed and others remain`() { val staying = row("d:kept-uuid") - cache.putAll(listOf(row("d:file-uuid"), row("f:folder-uuid"), staying)) + cache.putAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID), staying)) - cache.evictAll(listOf(row("d:file-uuid"), row("f:folder-uuid"))) + cache.evictAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID))) - assertNull(cache["file-uuid"]) - assertNull(cache["folder-uuid"]) + assertNull(cache[FILE_UUID]) + assertNull(cache[FOLDER_UUID]) assertEquals(staying, cache["kept-uuid"]) } 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 index 78b01faa2..4e9e063dc 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt @@ -5,11 +5,16 @@ import org.junit.Test class MimeTypesTest { + companion object { + private const val APPLICATION_PDF = "application/pdf" + private const val IMAGE_JPEG = "image/jpeg" + } + @Test fun mapsKnownExtensions() { - assertEquals("application/pdf", MimeTypes.fromExtension("pdf")) - assertEquals("image/jpeg", MimeTypes.fromExtension("jpg")) - assertEquals("image/jpeg", MimeTypes.fromExtension("jpeg")) + 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")) @@ -22,14 +27,14 @@ class MimeTypesTest { @Test fun isCaseInsensitive() { - assertEquals("application/pdf", MimeTypes.fromExtension("PDF")) - assertEquals("image/jpeg", MimeTypes.fromExtension("JPG")) - assertEquals("image/jpeg", MimeTypes.fromExtension("Jpeg")) + 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 ")) + assertEquals(APPLICATION_PDF, MimeTypes.fromExtension(" pdf ")) } @Test 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 f02ae1a7a..96eab77bd 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 @@ -21,6 +21,9 @@ class InternxtApiClientTest { private const val PARENT_UUID = "parent-uuid" private const val BUCKET_ID = "bucket-id" private const val NEW_FOLDER_NAME = "New Folder" + private const val FILE_UUID_1 = "file-uuid-1" + private const val FOLDER_UUID_1 = "folder-uuid-1" + private const val MISSING_UUID = "missing-uuid" } @Before @@ -58,7 +61,7 @@ class InternxtApiClientTest { { "files": [ { - "uuid": "file-uuid-1", + "uuid": "$FILE_UUID_1", "plainName": "report.pdf", "type": "pdf", "size": 102400, @@ -76,7 +79,7 @@ class InternxtApiClientTest { assertEquals(1, files.size) val file = files[0] - assertEquals("file-uuid-1", file.uuid) + assertEquals(FILE_UUID_1, file.uuid) assertEquals("report.pdf", file.plainName) assertEquals("pdf", file.type) assertEquals(102400L, file.size) @@ -118,7 +121,7 @@ class InternxtApiClientTest { enqueueJson("", code = 404) assertThrows(InternxtApiException.NotFoundException::class.java) { - client.listFolderFiles("missing-uuid") + client.listFolderFiles(MISSING_UUID) } } @@ -174,7 +177,7 @@ class InternxtApiClientTest { { "folders": [ { - "uuid": "folder-uuid-1", + "uuid": "$FOLDER_UUID_1", "plainName": "Documents", "parentUuid": "$PARENT_UUID", "bucket": "$BUCKET_ID", @@ -190,7 +193,7 @@ class InternxtApiClientTest { assertEquals(1, folders.size) val folder = folders[0] - assertEquals("folder-uuid-1", folder.uuid) + assertEquals(FOLDER_UUID_1, folder.uuid) assertEquals("Documents", folder.plainName) assertEquals(PARENT_UUID, folder.parentUuid) assertEquals(BUCKET_ID, folder.bucket) @@ -227,7 +230,7 @@ class InternxtApiClientTest { { "files": [ { - "uuid": "file-uuid-1", + "uuid": "$FILE_UUID_1", "plainName": "report.pdf", "type": null, "bucket": null, @@ -258,7 +261,7 @@ class InternxtApiClientTest { { "files": [ { - "uuid": "file-uuid-1", + "uuid": "$FILE_UUID_1", "plainName": "big.bin", "size": "9999999999" } @@ -274,56 +277,56 @@ class InternxtApiClientTest { @Test fun getFolderHitsMetaEndpointAndReturnsParsedFolder() { - enqueueJson("""{"uuid":"folder-uuid-1","plainName":"Documents"}""") + enqueueJson("""{"uuid":"$FOLDER_UUID_1","plainName":"Documents"}""") - val folder = client.getFolder("folder-uuid-1") + val folder = client.getFolder(FOLDER_UUID_1) - assertEquals("folder-uuid-1", folder?.uuid) + 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) + assertEquals("/folders/$FOLDER_UUID_1/meta", recorded.path) } @Test fun getFolderReturnsNullWhenNotFound() { enqueueJson("", code = 404) - assertNull(client.getFolder("missing-uuid")) + assertNull(client.getFolder(MISSING_UUID)) } @Test fun getFileHitsMetaEndpointAndReturnsParsedFile() { - enqueueJson("""{"uuid":"file-uuid-1","plainName":"report.pdf","type":"pdf","size":102400}""") + enqueueJson("""{"uuid":"$FILE_UUID_1","plainName":"report.pdf","type":"pdf","size":102400}""") - val file = client.getFile("file-uuid-1") + val file = client.getFile(FILE_UUID_1) - assertEquals("file-uuid-1", file?.uuid) + 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) + assertEquals("/files/$FILE_UUID_1/meta", recorded.path) } @Test fun getFileReturnsNullWhenNotFound() { enqueueJson("", code = 404) - assertNull(client.getFile("missing-uuid")) + assertNull(client.getFile(MISSING_UUID)) } @Test fun renameFilePutsPlainNameToMetaEndpoint() { enqueueJson("") - client.renameFile("file-uuid-1", "renamed.pdf") + client.renameFile(FILE_UUID_1, "renamed.pdf") val recorded = server.takeRequest() assertEquals("PUT", recorded.method) - assertEquals("/files/file-uuid-1/meta", recorded.path) + assertEquals("/files/$FILE_UUID_1/meta", recorded.path) assertEquals("renamed.pdf", JSONObject(recorded.body.readUtf8()).getString("plainName")) } @@ -331,35 +334,35 @@ class InternxtApiClientTest { fun renameFolderPutsPlainNameToMetaEndpoint() { enqueueJson("") - client.renameFolder("folder-uuid-1", "Renamed") + client.renameFolder(FOLDER_UUID_1, "Renamed") val recorded = server.takeRequest() assertEquals("PUT", recorded.method) - assertEquals("/folders/folder-uuid-1/meta", recorded.path) + 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"}""") + enqueueJson("""{"uuid":"$FILE_UUID_1","folderUuid":"$PARENT_UUID"}""") - client.moveFile("file-uuid-1", 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("/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"}""") + enqueueJson("""{"uuid":"$FOLDER_UUID_1","parentUuid":"$PARENT_UUID"}""") - client.moveFolder("folder-uuid-1", 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("/folders/$FOLDER_UUID_1", recorded.path) assertEquals(PARENT_UUID, JSONObject(recorded.body.readUtf8()).getString("destinationFolder")) } @@ -369,8 +372,8 @@ class InternxtApiClientTest { client.sendToTrash( listOf( - TrashItem("file-uuid-1", TrashItem.Type.FILE), - TrashItem("folder-uuid-1", TrashItem.Type.FOLDER), + TrashItem(FILE_UUID_1, TrashItem.Type.FILE), + TrashItem(FOLDER_UUID_1, TrashItem.Type.FOLDER), ) ) @@ -379,9 +382,9 @@ class InternxtApiClientTest { 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_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_UUID_1, items.getJSONObject(1).getString("uuid")) assertEquals("folder", items.getJSONObject(1).getString("type")) } diff --git a/ios/InternxtFileProvider/FileProviderEnumerator.swift b/ios/InternxtFileProvider/FileProviderEnumerator.swift index d03832ec3..b65ac2634 100644 --- a/ios/InternxtFileProvider/FileProviderEnumerator.swift +++ b/ios/InternxtFileProvider/FileProviderEnumerator.swift @@ -53,7 +53,7 @@ class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { super.init() } - func invalidate() {} + func invalidate() { /* Nothing to cancel: enumeration Tasks are not retained, run to completion on their own, and the system ignores observer callbacks after invalidation. */ } func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { guard !isWorkingSet, FileProviderItemID.isDriveFolderContainer(enumeratedItemIdentifier) else { diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index b455ac56a..ff22a80e0 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -21,7 +21,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { // TODO: cleanup any resources } - func item(for identifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { + func item(for identifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { if identifier == .rootContainer { let rootItem = FileProviderItem.root(displayName: rootDisplayName) completionHandler(rootItem, nil) @@ -52,7 +52,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } - func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version requestedVersion: NSFileProviderItemVersion?, request: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version _: NSFileProviderItemVersion?, request _: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) return Progress() @@ -79,7 +79,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } - func createItem(basedOn itemTemplate: NSFileProviderItem, fields: NSFileProviderItemFields, contents url: URL?, options: NSFileProviderCreateItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + func createItem(basedOn itemTemplate: NSFileProviderItem, fields _: NSFileProviderItemFields, contents url: URL?, options _: NSFileProviderCreateItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) return Progress() @@ -181,7 +181,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } - func modifyItem(_ item: NSFileProviderItem, baseVersion version: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents newContents: URL?, options: NSFileProviderModifyItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + func modifyItem(_ item: NSFileProviderItem, baseVersion _: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents _: URL?, options _: NSFileProviderModifyItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { let shouldRename = changedFields.contains(.filename) let shouldMove = changedFields.contains(.parentItemIdentifier) @@ -232,7 +232,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } - func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion version: NSFileProviderItemVersion, options: NSFileProviderDeleteItemOptions = [], request: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { + func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion _: NSFileProviderItemVersion, options _: NSFileProviderDeleteItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { guard let driveAPI = DriveAPIFactory.make(), let trashAPI = DriveAPIFactory.makeTrash() else { completionHandler(NSFileProviderError(.notAuthenticated)) return Progress() @@ -257,7 +257,7 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } - func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator { + func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest) throws -> NSFileProviderEnumerator { return FileProviderEnumerator(enumeratedItemIdentifier: containerItemIdentifier) } } diff --git a/src/store/slices/auth/auth.syncNativeCredentials.spec.ts b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts index 67d411739..51217d054 100644 --- a/src/store/slices/auth/auth.syncNativeCredentials.spec.ts +++ b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts @@ -5,6 +5,7 @@ jest.mock('../../../services/native/InternxtAuthCredentialsModule', () => ({ jest.mock('@internxt-mobile/services/common', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + BaseLogger: jest.fn().mockImplementation(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })), imageService: {}, PROFILE_PICTURE_CACHE_KEY: 'profile-picture', SdkManager: { init: jest.fn(), setApiSecurity: jest.fn(), getInstance: jest.fn() }, From 72150dbffd8d79f1631828f9dd53b911fecf4cbe Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 24 Aug 2026 08:11:49 -0400 Subject: [PATCH 71/80] refactor: consolidate file metadata into a single struct for improved clarity --- .../FileProviderItem.swift | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift index b96e69808..03bd5120e 100644 --- a/ios/InternxtFileProvider/FileProviderItem.swift +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -20,24 +20,28 @@ class FileProviderItem: NSObject, NSFileProviderItem { private let createdAt: String? private let sizeInBytes: String? + struct Metadata { + var fileExtension: String? = nil + var createdAt: String? = nil + var updatedAt: String? = nil + var sizeInBytes: String? = nil + } + private init( identifier: NSFileProviderItemIdentifier, parent: NSFileProviderItemIdentifier, name: String, kind: DriveItemKind, - fileExtension: String?, - createdAt: String?, - updatedAt: String?, - sizeInBytes: String? + metadata: Metadata ) { self.identifier = identifier self.parent = parent self.name = name self.kind = kind - self.fileExtension = fileExtension - self.createdAt = createdAt - self.updatedAt = updatedAt - self.sizeInBytes = sizeInBytes + self.fileExtension = metadata.fileExtension + self.createdAt = metadata.createdAt + self.updatedAt = metadata.updatedAt + self.sizeInBytes = metadata.sizeInBytes } convenience init(folder: GetFolderFoldersResult, parent: NSFileProviderItemIdentifier) { @@ -46,10 +50,7 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: parent, name: folder.plainName ?? folder.name, kind: .folder, - fileExtension: nil, - createdAt: folder.createdAt, - updatedAt: folder.updatedAt, - sizeInBytes: nil + metadata: Metadata(createdAt: folder.createdAt, updatedAt: folder.updatedAt) ) } @@ -59,10 +60,12 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: parent, name: file.plainName ?? file.name ?? file.uuid, kind: .file, - fileExtension: file.type, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - sizeInBytes: file.size + metadata: Metadata( + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) ) } @@ -72,10 +75,7 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: parent, name: folder.plainName ?? folder.name, kind: .folder, - fileExtension: nil, - createdAt: folder.createdAt, - updatedAt: folder.updatedAt, - sizeInBytes: nil + metadata: Metadata(createdAt: folder.createdAt, updatedAt: folder.updatedAt) ) } @@ -85,10 +85,12 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: parent, name: file.plain_name, kind: .file, - fileExtension: file.type, - createdAt: file.createdAt, - updatedAt: file.updatedAt, - sizeInBytes: file.size + metadata: Metadata( + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) ) } @@ -100,10 +102,12 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: item.parentItemIdentifier, name: baseName, kind: decoded.kind, - fileExtension: newExtension, - createdAt: iso8601String(from: item.creationDate ?? nil), - updatedAt: iso8601String(from: item.contentModificationDate ?? nil), - sizeInBytes: (item.documentSize ?? nil)?.stringValue + metadata: Metadata( + fileExtension: newExtension, + createdAt: iso8601String(from: item.creationDate ?? nil), + updatedAt: iso8601String(from: item.contentModificationDate ?? nil), + sizeInBytes: (item.documentSize ?? nil)?.stringValue + ) ) } @@ -129,10 +133,7 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: .rootContainer, name: displayName, kind: .folder, - fileExtension: nil, - createdAt: nil, - updatedAt: nil, - sizeInBytes: nil + metadata: Metadata() ) } @@ -142,10 +143,7 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: FileProviderItemID.parentIdentifier(folderUuid: folderMeta.parentUuid), name: folderMeta.plainName ?? folderMeta.name ?? identifier.rawValue, kind: .folder, - fileExtension: nil, - createdAt: folderMeta.createdAt, - updatedAt: folderMeta.updatedAt, - sizeInBytes: nil + metadata: Metadata(createdAt: folderMeta.createdAt, updatedAt: folderMeta.updatedAt) ) } @@ -155,10 +153,12 @@ class FileProviderItem: NSObject, NSFileProviderItem { parent: FileProviderItemID.parentIdentifier(folderUuid: fileMeta.folderUuid), name: fileMeta.plainName ?? fileMeta.name, kind: .file, - fileExtension: fileMeta.type, - createdAt: fileMeta.createdAt, - updatedAt: fileMeta.updatedAt, - sizeInBytes: fileMeta.size + metadata: Metadata( + fileExtension: fileMeta.type, + createdAt: fileMeta.createdAt, + updatedAt: fileMeta.updatedAt, + sizeInBytes: fileMeta.size + ) ) } From 7f38a939d23d17f08a284d1ce756a4991aaff380 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 24 Aug 2026 10:29:31 -0400 Subject: [PATCH 72/80] fix: update invalidate method comment for clarity on resource management --- ios/InternxtFileProvider/FileProviderExtension.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index ff22a80e0..a51d16575 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -18,7 +18,8 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { } func invalidate() { - // TODO: cleanup any resources + // Nothing to cancel: operations run in unretained Tasks that complete on + // their own, and the system ignores their callbacks after invalidation. } func item(for identifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { From 6af3e44e4c2824fabf1caa5865c3774387093b87 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 24 Aug 2026 11:24:51 -0400 Subject: [PATCH 73/80] chore: update version to 1.11.0 across configuration files --- android/app/build.gradle | 4 ++-- android/app/src/main/res/values/strings.xml | 2 +- ios/Internxt/Info.plist | 2 +- ios/Internxt/Supporting/Expo.plist | 2 +- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index bf3a82ccb..b694c38f3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -95,8 +95,8 @@ android { applicationId 'com.internxt.cloud' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 129 - versionName "1.10.2" + versionCode 130 + versionName "1.11.1" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "INTERNXT_CLIENT_NAME", "\"${packageJson.name}\"" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 78d63f405..6607cacf5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Internxt Internxt Drive automatic - 1.10.2 + 1.11.1 contain false Internxt uploads diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist index 941e5c8da..39047cabe 100644 --- a/ios/Internxt/Info.plist +++ b/ios/Internxt/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.10.2 + 1.11.1 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist index 778050ce9..93495a42e 100644 --- a/ios/Internxt/Supporting/Expo.plist +++ b/ios/Internxt/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.10.2 + 1.11.1 EXUpdatesURL https://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2 diff --git a/package.json b/package.json index 41aa2c033..e0016ea3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "drive-mobile", - "version": "v1.10.2", + "version": "v1.11.1", "private": true, "license": "GNU", "scripts": { From 85c0d54e09debf7ea549d9eb1460c5c5eca4b357 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Mon, 24 Aug 2026 13:38:32 -0400 Subject: [PATCH 74/80] fix: skip metadata extraction for IDZSwiftCommonCrypto to avoid duplication --- ios/Podfile | 11 +++++++++++ ios/Podfile.lock | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ios/Podfile b/ios/Podfile index f3a78cd04..c1638c650 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -68,6 +68,17 @@ target 'Internxt' do end end + # IDZSwiftCommonCrypto is built twice (rn-crypto pod + InternxtSwiftCore SPM + # dependency); both run ExtractAppIntentsMetadata into the same archive path. + # Neither defines App Intents, so skip extraction for the Pods copy. + installer.pods_project.targets.each do |target| + if target.name == 'IDZSwiftCommonCrypto' + target.build_configurations.each do |config| + config.build_settings['LM_SKIP_METADATA_EXTRACTION'] = 'YES' + end + end + end + # Fix: fmt/glog consteval incompatibility with Xcode 26 / Apple Clang 17. # consteval in C++20 is strictly enforced; forcing c++17 disables it entirely # in fmt (FMT_CONSTEVAL becomes a no-op when __cpp_consteval is not defined). diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c1ab6f108..a1921ade7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3002,6 +3002,6 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a -PODFILE CHECKSUM: 5ca1b45e9626b00295456e3d5aa77bea26c0bf29 +PODFILE CHECKSUM: 7c28a9e3ba87af9d136b3e7f68ff1b1c7b7abf2c COCOAPODS: 1.16.2 From 1d2aa3fcee84407f44772eb5bd3d6d9ec53778bc Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Tue, 25 Aug 2026 09:46:31 -0400 Subject: [PATCH 75/80] fix: add document row for newly created folder in InternxtDocumentsProvider --- .../com/internxt/cloud/documents/InternxtDocumentsProvider.kt | 1 + 1 file changed, 1 insertion(+) 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 2532a3e50..b356191ee 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 @@ -605,6 +605,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { throw FileNotFoundException("Folder creation failed: ${e.message}") } invalidateChildren(parentDocumentId) + documentRows.put(folder.uuid, DocumentRowBuilder.folderRow(folder)) return DocumentId.encodeFolder(folder.uuid) } From 01fb3f7445a0e00c28c2b9e75ab93e8e91baf0a2 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 27 Aug 2026 01:25:00 -0400 Subject: [PATCH 76/80] feat: add stabilize method to FileProviderDomainManager and integrate it in setCredentials --- ios/Internxt/FileProviderDomainManager.swift | 14 ++++++++++++++ ios/Internxt/InternxtAuthCredentialsModule.swift | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/ios/Internxt/FileProviderDomainManager.swift b/ios/Internxt/FileProviderDomainManager.swift index eaadd6e02..7018b1f85 100644 --- a/ios/Internxt/FileProviderDomainManager.swift +++ b/ios/Internxt/FileProviderDomainManager.swift @@ -21,6 +21,20 @@ enum FileProviderDomainManager { completion(isNotFound(removeError) ? nil : removeError) } } + + static func stabilize(completion: @escaping (Error?) -> Void) { + guard let manager = NSFileProviderManager(for: domain) else { + completion(NSError( + domain: "FileProviderDomainManager", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No NSFileProviderManager for domain \(domainIdentifier.rawValue)"] + )) + return + } + manager.waitForStabilization { error in + completion(error) + } + } static func signalEnumeration(completion: @escaping (Error?) -> Void) { signalEnumeration(for: .workingSet, completion: completion) diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift index 41d762585..16db344de 100644 --- a/ios/Internxt/InternxtAuthCredentialsModule.swift +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -30,6 +30,11 @@ class InternxtAuthCredentialsModule: NSObject { if let signalError = signalError { NSLog("InternxtAuthCredentialsModule: signalEnumeration failed (ignored): \(signalError.localizedDescription)") } + FileProviderDomainManager.stabilize { stabilizeError in + if let stabilizeError = stabilizeError { + NSLog("InternxtAuthCredentialsModule: stabilize failed (ignored): \(stabilizeError.localizedDescription)") + } + } resolver(nil) } } From 7490baf5825b6575246d79222134cb5564c282b5 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 27 Aug 2026 07:14:51 -0400 Subject: [PATCH 77/80] chore: downgrade version to 1.11.0 in build.gradle, strings.xml, Info.plist, Expo.plist, and package.json --- android/app/build.gradle | 2 +- android/app/src/main/res/values/strings.xml | 2 +- ios/Internxt/Info.plist | 2 +- ios/Internxt/Supporting/Expo.plist | 2 +- package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b694c38f3..12a9367fa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,7 +96,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 130 - versionName "1.11.1" + versionName "1.11.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "INTERNXT_CLIENT_NAME", "\"${packageJson.name}\"" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6607cacf5..b46a0461e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Internxt Internxt Drive automatic - 1.11.1 + 1.11.0 contain false Internxt uploads diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist index 39047cabe..c03a7a5af 100644 --- a/ios/Internxt/Info.plist +++ b/ios/Internxt/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.11.1 + 1.11.0 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist index 93495a42e..762fb8a6f 100644 --- a/ios/Internxt/Supporting/Expo.plist +++ b/ios/Internxt/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.11.1 + 1.11.0 EXUpdatesURL https://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2 diff --git a/package.json b/package.json index e0016ea3c..5332e8bff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "drive-mobile", - "version": "v1.11.1", + "version": "v1.11.0", "private": true, "license": "GNU", "scripts": { From c0201fdb490af1d679206d99b6483b55eb92c88d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 27 Aug 2026 10:39:58 -0400 Subject: [PATCH 78/80] fix: keep item metadata accurate after rename in system file managers --- .../documents/InternxtDocumentsProvider.kt | 12 +++++++++--- .../FileProviderExtension.swift | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) 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 b356191ee..e4adba21f 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 @@ -282,7 +282,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { DocumentId.Kind.FOLDER -> api.renameFolder(uuid, displayName) } notifyEncodedParent(parent) { encoded -> - patchRowDisplayName(encoded, documentId, displayName) + patchRowRenamed(encoded, documentId, displayName) } null } @@ -375,10 +375,16 @@ class InternxtDocumentsProvider : DocumentsProvider() { notifyChildren(parentDocumentId) } - private fun patchRowDisplayName(parentDocumentId: String, documentId: String, displayName: String) { + private fun patchRowRenamed(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) + if (idx >= 0) { + rows[idx] = rows[idx] + + mapOf( + Document.COLUMN_DISPLAY_NAME to displayName, + Document.COLUMN_LAST_MODIFIED to System.currentTimeMillis(), + ) + } } } diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift index a51d16575..67148c1af 100644 --- a/ios/InternxtFileProvider/FileProviderExtension.swift +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -222,9 +222,9 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { try await mutationService.move(decoded, toParentUuid: destinationFolderUuid) } progress.completedUnitCount = 1 - let modified = shouldRename - ? (FileProviderItem.renamed(from: item, newFilename: newFilename) ?? item) - : item + let modified = await Self.resolveModifiedItem( + item, decoded: decoded, shouldRename: shouldRename, mutationService: mutationService + ) completionHandler(modified, [], false, nil) } catch { completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) @@ -233,6 +233,19 @@ class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { return progress } + private static func resolveModifiedItem( + _ item: NSFileProviderItem, + decoded: (kind: DriveItemKind, uuid: String), + shouldRename: Bool, + mutationService: FileProviderMutationService + ) async -> NSFileProviderItem { + if let resolved = try? await mutationService.resolveItem(decoded, identifier: item.itemIdentifier) { + return resolved + } + guard shouldRename else { return item } + return FileProviderItem.renamed(from: item, newFilename: item.filename) ?? item + } + func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion _: NSFileProviderItemVersion, options _: NSFileProviderDeleteItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { guard let driveAPI = DriveAPIFactory.make(), let trashAPI = DriveAPIFactory.makeTrash() else { completionHandler(NSFileProviderError(.notAuthenticated)) From e37645e3b205f3c1486e5ead1a5cefa34bf5053b Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 28 Aug 2026 09:22:17 -0400 Subject: [PATCH 79/80] fix(android): make DocumentsProvider work through tree grants and keep file extensions - Implement isChildDocument (ancestry walk via cached rows, API fallback) so clients holding a folder grant (Use this folder, Material Files, galleries) no longer fail with "Document X is not a descendant of Y" / "file corrupted". - Build file display names as plainName + type so copies and web-uploaded files show their extension. - Rename keeps the file type immutable (as iOS/web do); renaming to the same base name is a no-op instead of a server 409, and the API call sends plainName only. --- .../cloud/documents/DocumentAncestry.kt | 19 ++++++ .../cloud/documents/DocumentNaming.kt | 16 ++++- .../cloud/documents/DocumentRowBuilder.kt | 8 ++- .../documents/InternxtDocumentsProvider.kt | 61 ++++++++++++++----- .../cloud/documents/api/InternxtApiClient.kt | 4 +- .../cloud/documents/DocumentAncestryTest.kt | 43 +++++++++++++ .../cloud/documents/DocumentNamingTest.kt | 60 ++++++++++++++++++ .../cloud/documents/DocumentRowBuilderTest.kt | 60 +++++++++++++----- .../documents/api/InternxtApiClientTest.kt | 4 +- 9 files changed, 237 insertions(+), 38 deletions(-) create mode 100644 android/app/src/main/java/com/internxt/cloud/documents/DocumentAncestry.kt create mode 100644 android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentAncestry.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentAncestry.kt new file mode 100644 index 000000000..5f3a45a8c --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentAncestry.kt @@ -0,0 +1,19 @@ +package com.internxt.cloud.documents + +internal object DocumentAncestry { + + private const val MAX_HOPS = 64 + + fun isDescendant(childUuid: String, parentUuid: String, parentOf: (String) -> String?): Boolean { + if (childUuid == parentUuid) return true + val visited = mutableSetOf(childUuid) + var current = childUuid + repeat(MAX_HOPS) { + val next = parentOf(current) ?: return false + if (next == parentUuid) return true + if (!visited.add(next)) return false + current = next + } + return false + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt index a779edefd..251354e2b 100644 --- a/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt +++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt @@ -29,6 +29,18 @@ object DocumentNaming { return if (hasExt) name.substring(0, dot) to name.substring(dot) else name to "" } - fun joinNameType(plainName: String, type: String?): String = - if (type.isNullOrBlank()) plainName else "$plainName.$type" + fun joinNameType(plainName: String, type: String?): String { + if (type.isNullOrBlank()) return plainName + val alreadySuffixed = plainName.endsWith(".$type", ignoreCase = true) + return if (alreadySuffixed) plainName else "$plainName.$type" + } + + fun extensionOf(plainName: String, type: String?): String? = + type?.takeIf { it.isNotBlank() } ?: splitNameExt(plainName).second.removePrefix(".").takeIf { it.isNotEmpty() } + + fun renameTarget(requestedDisplayName: String, currentType: String?): Pair? { + val newBase = splitNameExt(requestedDisplayName.trim()).first + if (newBase.isBlank()) return null + return newBase to joinNameType(newBase, currentType) + } } 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 b8c3e4463..cb1f5ca21 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 @@ -17,6 +17,8 @@ object DocumentRowBuilder { private const val FOLDER_FLAGS = FOLDER_FLAGS_BASIC or MUTATION_FLAGS private const val FILE_FLAGS = MUTATION_FLAGS + const val COLUMN_PARENT_UUID = "internxt_parent_uuid" + fun folderRow(folder: DriveFolder): Map = mapOf( Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFolder(folder.uuid), Document.COLUMN_MIME_TYPE to Document.MIME_TYPE_DIR, @@ -24,6 +26,7 @@ object DocumentRowBuilder { Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(folder.updatedAt), Document.COLUMN_FLAGS to FOLDER_FLAGS, Document.COLUMN_SIZE to null, + COLUMN_PARENT_UUID to folder.parentUuid, ) fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf( @@ -37,11 +40,12 @@ object DocumentRowBuilder { fun fileRow(file: DriveFile): Map = mapOf( 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_MIME_TYPE to MimeTypes.fromExtension(DocumentNaming.extensionOf(file.plainName, file.type)), + Document.COLUMN_DISPLAY_NAME to DocumentNaming.joinNameType(file.plainName, file.type), Document.COLUMN_LAST_MODIFIED to parseIsoToMillis(file.updatedAt), Document.COLUMN_FLAGS to FILE_FLAGS, Document.COLUMN_SIZE to file.size, + COLUMN_PARENT_UUID to file.folderUuid, ) internal fun parseIsoToMillis(iso: String?): Long? { 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 e4adba21f..8e9a7b350 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 @@ -145,17 +145,19 @@ class InternxtDocumentsProvider : DocumentsProvider() { return cursor } - val api = apiClient(op = "queryDocument") - val row = if (api == null) null else try { - runBlockingIo { fetchDocumentRow(api, decoded?.kind, uuid) } + val row = loadDocumentRow("queryDocument", decoded?.kind, uuid) + cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) + return cursor + } + + private fun loadDocumentRow(op: String, kind: DocumentId.Kind?, uuid: String): Map? { + val api = apiClient(op) ?: return null + return try { + runBlockingIo { fetchDocumentRow(api, kind, uuid) }?.also { documentRows.put(uuid, it) } } catch (e: InternxtApiException) { - Log.w(TAG, "queryDocument id=$id failed: ${e.javaClass.simpleName}: ${e.message}") + Log.w(TAG, "$op uuid=$uuid failed: ${e.javaClass.simpleName}: ${e.message}") null } - - row?.let { documentRows.put(uuid, it) } - cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) - return cursor } private fun fetchDocumentRow( @@ -169,6 +171,26 @@ class InternxtDocumentsProvider : DocumentsProvider() { ?: api.getFile(uuid)?.let(DocumentRowBuilder::fileRow) } + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { + if (parentDocumentId == documentId) return true + val parent = DocumentId.decode(parentDocumentId) + if (parent?.kind != DocumentId.Kind.FOLDER) return false + val child = if (DocumentId.isUploadToken(documentId)) { + pendingUploads[DocumentId.decodeUpload(documentId)] + ?.let { DocumentId.Decoded(DocumentId.Kind.FOLDER, it.parentUuid) } + } else { + DocumentId.decode(documentId) + } + if (child == null) return false + val rootUuid = authManager?.authenticatedRootUuid() + return DocumentAncestry.isDescendant(child.uuid, parent.uuid) { uuid -> + if (uuid == rootUuid) return@isDescendant null + val kind = if (uuid == child.uuid) child.kind else DocumentId.Kind.FOLDER + val row = documentRows[uuid] ?: loadDocumentRow("isChildDocument", kind, uuid) + row?.get(DocumentRowBuilder.COLUMN_PARENT_UUID) as? String + } + } + override fun queryChildDocuments( parentDocumentId: String?, projection: Array?, @@ -276,17 +298,28 @@ class InternxtDocumentsProvider : DocumentsProvider() { 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) + val (parent, newDisplayName) = when (kind) { + DocumentId.Kind.FILE -> renameFile(api, uuid, displayName) + DocumentId.Kind.FOLDER -> { + val parent = parentUuidOf(api, kind, uuid) + api.renameFolder(uuid, displayName) + parent to displayName + } } notifyEncodedParent(parent) { encoded -> - patchRowRenamed(encoded, documentId, displayName) + patchRowRenamed(encoded, documentId, newDisplayName) } null } + private fun renameFile(api: InternxtApiClient, uuid: String, displayName: String): Pair { + val file = api.getFile(uuid) ?: throw FileNotFoundException("Not found: $uuid") + val (newBase, newDisplayName) = DocumentNaming.renameTarget(displayName, file.type) + ?: throw FileNotFoundException("Invalid name: $displayName") + if (newBase != file.plainName) api.renameFile(uuid, newBase) + return file.folderUuid to newDisplayName + } + override fun moveDocument( sourceDocumentId: String, sourceParentDocumentId: String?, @@ -302,7 +335,7 @@ class InternxtDocumentsProvider : DocumentsProvider() { notifyChildren(it) } invalidateChildren(targetParentDocumentId) - null + sourceDocumentId } override fun deleteDocument(documentId: 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 a55103441..f44a9fbd3 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 @@ -73,8 +73,8 @@ class InternxtApiClient( return parseFolder(executeApiRequest(req)) } - fun renameFile(fileUuid: String, newName: String) { - val payload = JSONObject().put("plainName", newName) + fun renameFile(fileUuid: String, plainName: String) { + val payload = JSONObject().put("plainName", plainName) val req = driveRequest(driveUrl("files/$fileUuid/meta")) .put(payload.toString().toRequestBody(JSON)) .build() diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt new file mode 100644 index 000000000..b97770470 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt @@ -0,0 +1,43 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DocumentAncestryTest { + + private val tree = mapOf(PARENT to "root", CHILD to PARENT, "grandchild" to CHILD, "other" to "root") + + private fun isDescendant(child: String, parent: String, parentOf: (String) -> String? = tree::get) = + DocumentAncestry.isDescendant(child, parent, parentOf) + + @Test + fun `when child and parent are the same id, then it is a descendant`() { + assertTrue(isDescendant(CHILD, CHILD)) + } + + @Test + fun `when the document is a direct child, then it is a descendant`() { + assertTrue(isDescendant(CHILD, PARENT)) + } + + @Test + fun `when the document is a grandchild, then it is a descendant`() { + assertTrue(isDescendant("grandchild", PARENT)) + } + + @Test + fun `when the document lives under another branch, then it is not a descendant`() { + assertFalse(isDescendant("other", PARENT)) + } + + @Test + fun `when the parent chain has a cycle, then the walk stops and returns false`() { + assertFalse(isDescendant(CHILD, "root") { mapOf(CHILD to PARENT, PARENT to CHILD)[it] }) + } + + private companion object { + const val PARENT = "parent" + const val CHILD = "child" + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt index 0e8766e10..9af340e76 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt @@ -1,6 +1,7 @@ package com.internxt.cloud.documents import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class DocumentNamingTest { @@ -62,6 +63,65 @@ class DocumentNamingTest { assertEquals("report", DocumentNaming.joinNameType("report", "")) } + @Test + fun joinNameTypeDoesNotDuplicateExistingSuffix() { + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(REPORT_PDF, "pdf")) + } + + @Test + fun joinNameTypeTreatsExistingSuffixCaseInsensitively() { + assertEquals("report.PDF", DocumentNaming.joinNameType("report.PDF", "pdf")) + } + + @Test + fun joinNameTypeRoundTripsSplitNameExt() { + val (base, ext) = DocumentNaming.splitNameExt(REPORT_PDF) + + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(base, ext.removePrefix("."))) + } + + @Test + fun extensionOfPrefersType() { + assertEquals("pdf", DocumentNaming.extensionOf("report", "pdf")) + } + + @Test + fun extensionOfFallsBackToPlainNameWhenTypeMissing() { + assertEquals("pdf", DocumentNaming.extensionOf(REPORT_PDF, null)) + assertEquals("pdf", DocumentNaming.extensionOf(REPORT_PDF, "")) + } + + @Test + fun extensionOfIsNullWithoutAnyExtension() { + assertNull(DocumentNaming.extensionOf(FOLDER_NAME, null)) + } + + @Test + fun renameTargetWithExtensionRemovedKeepsBaseAndType() { + assertEquals("images-8" to "images-8.jpeg", DocumentNaming.renameTarget("images-8", "jpeg")) + } + + @Test + fun renameTargetWithNewBaseAndSameExtension() { + assertEquals("holiday" to "holiday.jpeg", DocumentNaming.renameTarget("holiday.jpeg", "jpeg")) + } + + @Test + fun renameTargetWithChangedExtensionKeepsCurrentType() { + assertEquals("holiday" to "holiday.jpeg", DocumentNaming.renameTarget("holiday.png", "jpeg")) + } + + @Test + fun renameTargetBlankBaseIsInvalid() { + assertNull(DocumentNaming.renameTarget(" ", "jpeg")) + assertNull(DocumentNaming.renameTarget("", null)) + } + + @Test + fun renameTargetWithoutTypeUsesBaseAsDisplayName() { + assertEquals("notes" to "notes", DocumentNaming.renameTarget("notes", null)) + } + companion object { private const val FOLDER_NAME = "My Folder" private const val REPORT_PDF = "report.pdf" 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 9f29b1e21..c5089f3bd 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 @@ -11,8 +11,22 @@ class DocumentRowBuilderTest { companion object { private const val UPDATED_AT = "2026-01-11T00:00:00.000Z" + private const val REPORT_PDF = "report.pdf" + private const val PDF_MIME = "application/pdf" } + private fun driveFile(plainName: String, type: String?) = DriveFile( + uuid = "file-uuid", + plainName = plainName, + type = type, + size = 0L, + bucket = null, + folderUuid = null, + createdAt = null, + updatedAt = null, + fileId = null, + ) + @Test fun folderRowFields() { val folder = DriveFolder( @@ -36,13 +50,14 @@ class DocumentRowBuilderTest { Document.FLAG_SUPPORTS_MOVE assertEquals(expectedFolderFlags, row[Document.COLUMN_FLAGS]) assertNull(row[Document.COLUMN_SIZE]) + assertEquals("parent-uuid", row[DocumentRowBuilder.COLUMN_PARENT_UUID]) } @Test fun fileRowFields() { val file = DriveFile( uuid = "file-uuid", - plainName = "report.pdf", + plainName = "report", type = "pdf", size = 102400L, bucket = "bucket-id", @@ -55,31 +70,44 @@ class DocumentRowBuilderTest { val row = DocumentRowBuilder.fileRow(file) 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(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) 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]) + assertEquals("parent-uuid", row[DocumentRowBuilder.COLUMN_PARENT_UUID]) } @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, - ) + fun fileRowJoinsPlainNameAndType() { + val row = DocumentRowBuilder.fileRow(driveFile("report", "pdf")) - val row = DocumentRowBuilder.fileRow(file) + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowDoesNotDuplicateExtensionAlreadyInPlainName() { + val row = DocumentRowBuilder.fileRow(driveFile(REPORT_PDF, "PDF")) + + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowWithNullTypeKeepsPlainNameAndDerivesMime() { + val row = DocumentRowBuilder.fileRow(driveFile(REPORT_PDF, null)) + + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowUnknownExtensionFallsBackToOctetStream() { + val row = DocumentRowBuilder.fileRow(driveFile("weird.xyz", "xyz")) assertEquals("application/octet-stream", row[Document.COLUMN_MIME_TYPE]) assertEquals("weird.xyz", row[Document.COLUMN_DISPLAY_NAME]) 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 96eab77bd..3b93b52f5 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 @@ -322,12 +322,12 @@ class InternxtApiClientTest { fun renameFilePutsPlainNameToMetaEndpoint() { enqueueJson("") - client.renameFile(FILE_UUID_1, "renamed.pdf") + client.renameFile(FILE_UUID_1, "renamed") 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")) + assertEquals("""{"plainName":"renamed"}""", recorded.body.readUtf8()) } @Test From af5f8600d8ede5c0490e1f91768afe608181c8b6 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 28 Aug 2026 09:27:20 -0400 Subject: [PATCH 80/80] refactor(tests): replace hardcoded parent UUID with constant in DocumentRowBuilderTest --- .../cloud/documents/DocumentNamingTest.kt | 26 +++++++++++-------- .../cloud/documents/DocumentRowBuilderTest.kt | 9 ++++--- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt index 9af340e76..7ec098a92 100644 --- a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt @@ -8,18 +8,18 @@ class DocumentNamingTest { @Test fun uniqueNameReturnsRequestedWhenNoCollision() { - assertEquals("Reports", DocumentNaming.uniqueName("Reports", setOf("Photos", "Music"))) + assertEquals(REPORTS, DocumentNaming.uniqueName(REPORTS, setOf("Photos", "Music"))) } @Test fun uniqueNameAppendsSuffixOnCollision() { - assertEquals("Reports (1)", DocumentNaming.uniqueName("Reports", setOf("Reports"))) + assertEquals("Reports (1)", DocumentNaming.uniqueName(REPORTS, setOf(REPORTS))) } @Test fun uniqueNameSkipsTakenSuffixes() { - val existing = setOf("Reports", "Reports (1)", "Reports (2)") - assertEquals("Reports (3)", DocumentNaming.uniqueName("Reports", existing)) + val existing = setOf(REPORTS, "Reports (1)", "Reports (2)") + assertEquals("Reports (3)", DocumentNaming.uniqueName(REPORTS, existing)) } @Test @@ -54,13 +54,13 @@ class DocumentNamingTest { @Test fun joinNameTypeAppendsType() { - assertEquals(REPORT_PDF, DocumentNaming.joinNameType("report", "pdf")) + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(REPORT, "pdf")) } @Test fun joinNameTypeWithoutTypeReturnsName() { - assertEquals("report", DocumentNaming.joinNameType("report", null)) - assertEquals("report", DocumentNaming.joinNameType("report", "")) + assertEquals(REPORT, DocumentNaming.joinNameType(REPORT, null)) + assertEquals(REPORT, DocumentNaming.joinNameType(REPORT, "")) } @Test @@ -82,7 +82,7 @@ class DocumentNamingTest { @Test fun extensionOfPrefersType() { - assertEquals("pdf", DocumentNaming.extensionOf("report", "pdf")) + assertEquals("pdf", DocumentNaming.extensionOf(REPORT, "pdf")) } @Test @@ -103,12 +103,12 @@ class DocumentNamingTest { @Test fun renameTargetWithNewBaseAndSameExtension() { - assertEquals("holiday" to "holiday.jpeg", DocumentNaming.renameTarget("holiday.jpeg", "jpeg")) + assertEquals("holiday" to HOLIDAY_JPEG, DocumentNaming.renameTarget(HOLIDAY_JPEG, "jpeg")) } @Test fun renameTargetWithChangedExtensionKeepsCurrentType() { - assertEquals("holiday" to "holiday.jpeg", DocumentNaming.renameTarget("holiday.png", "jpeg")) + assertEquals("holiday" to HOLIDAY_JPEG, DocumentNaming.renameTarget("holiday.png", "jpeg")) } @Test @@ -119,11 +119,15 @@ class DocumentNamingTest { @Test fun renameTargetWithoutTypeUsesBaseAsDisplayName() { - assertEquals("notes" to "notes", DocumentNaming.renameTarget("notes", null)) + assertEquals(NOTES to NOTES, DocumentNaming.renameTarget(NOTES, null)) } companion object { private const val FOLDER_NAME = "My Folder" + private const val HOLIDAY_JPEG = "holiday.jpeg" + private const val NOTES = "notes" + private const val REPORT = "report" + private const val REPORTS = "Reports" private const val REPORT_PDF = "report.pdf" } } 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 c5089f3bd..bb10b7517 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 @@ -13,6 +13,7 @@ class DocumentRowBuilderTest { private const val UPDATED_AT = "2026-01-11T00:00:00.000Z" private const val REPORT_PDF = "report.pdf" private const val PDF_MIME = "application/pdf" + private const val PARENT_UUID = "parent-uuid" } private fun driveFile(plainName: String, type: String?) = DriveFile( @@ -32,7 +33,7 @@ class DocumentRowBuilderTest { val folder = DriveFolder( uuid = "folder-uuid", plainName = "Documents", - parentUuid = "parent-uuid", + parentUuid = PARENT_UUID, bucket = null, createdAt = null, updatedAt = UPDATED_AT, @@ -50,7 +51,7 @@ class DocumentRowBuilderTest { Document.FLAG_SUPPORTS_MOVE assertEquals(expectedFolderFlags, row[Document.COLUMN_FLAGS]) assertNull(row[Document.COLUMN_SIZE]) - assertEquals("parent-uuid", row[DocumentRowBuilder.COLUMN_PARENT_UUID]) + assertEquals(PARENT_UUID, row[DocumentRowBuilder.COLUMN_PARENT_UUID]) } @Test @@ -61,7 +62,7 @@ class DocumentRowBuilderTest { type = "pdf", size = 102400L, bucket = "bucket-id", - folderUuid = "parent-uuid", + folderUuid = PARENT_UUID, createdAt = null, updatedAt = UPDATED_AT, fileId = "file-id-1", @@ -78,7 +79,7 @@ class DocumentRowBuilderTest { Document.FLAG_SUPPORTS_MOVE assertEquals(expectedFileFlags, row[Document.COLUMN_FLAGS]) assertEquals(102400L, row[Document.COLUMN_SIZE]) - assertEquals("parent-uuid", row[DocumentRowBuilder.COLUMN_PARENT_UUID]) + assertEquals(PARENT_UUID, row[DocumentRowBuilder.COLUMN_PARENT_UUID]) } @Test