From f3addd8ccd31868489799a11b6b320de00412ea3 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:42:34 -0400 Subject: [PATCH 1/6] 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 b80b9e4c7..31e8eaf94 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 5c005400324a667433c1635465362410e2d8ab1a Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:49:18 -0400 Subject: [PATCH 2/6] 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 af3f02898636e429a8349b302cba7c81139459de Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Sun, 19 Apr 2026 23:53:01 -0400 Subject: [PATCH 3/6] 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 b3595d044fc6c0a9ba86893954c66067ac7d2ca7 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 23 Apr 2026 23:50:26 -0400 Subject: [PATCH 4/6] 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 31e8eaf94..506dd5263 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.9.0" 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 d1ed9a9f9d73ceafadf7db18f5f2e88aee0fe416 Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Thu, 23 Apr 2026 23:58:10 -0400 Subject: [PATCH 5/6] 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 f2670d5bdade6004ad420767ff00ebc30374469d Mon Sep 17 00:00:00 2001 From: Francis Terrero Date: Fri, 1 May 2026 00:50:36 -0400 Subject: [PATCH 6/6] 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() } }