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 b80b9e4c7..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 { @@ -195,4 +199,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..239ccdb50 --- /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, + 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 new file mode 100644 index 000000000..6d3f17758 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -0,0 +1,198 @@ +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 = executeApiRequest(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(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)) + .build() + return parseFile(executeApiRequest(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(executeApiRequest(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(executeApiRequest(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(executeApiRequest(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() + executeApiRequest(req) + } + + 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) + } + + 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 executeApiRequest(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() + .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 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/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/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..b13d5947b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt @@ -0,0 +1,276 @@ +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.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.json.JSONObject + +class InternxtApiClientTest { + + private lateinit var server: MockWebServer + private lateinit var client: InternxtApiClient + + 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 + 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", + clientName = "drive-mobile", + clientVersion = "v1.9.0", + desktopToken = "desktop-token-xyz" + ) + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun enqueueJson(body: String, code: Int = 200) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun listFolderFilesParsesResponseFields() { + enqueueJson( + """ + { + "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", + "fileId": "file-id-1" + } + ] + } + """.trimIndent() + ) + + val files = client.listFolderFiles(PARENT_UUID) + + 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() { + enqueueJson("""{"files":[]}""") + + 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", + 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 unauthorizedResponseSurfacesAsUnauthorized() { + enqueueJson("", code = 401) + + assertThrows(InternxtApiException.UnauthorizedException::class.java) { + client.listFolderFiles(PARENT_UUID) + } + } + + @Test + fun notFoundResponseSurfacesAsNotFound() { + enqueueJson("", code = 404) + + assertThrows(InternxtApiException.NotFoundException::class.java) { + client.listFolderFiles("missing-uuid") + } + } + + @Test + fun socketDisconnectSurfacesAsNetworkError() { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)) + + assertThrows(InternxtApiException.NetworkException::class.java) { + client.listFolderFiles(PARENT_UUID) + } + } + + @Test + fun getDownloadLinksUsesBasicAuthWithDerivedBridgePass() { + 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") + + assertEquals(1, links.shards.size) + assertEquals("https://shard/0", links.shards[0].url) + assertEquals(512L, links.shards[0].size) + + val recorded = server.takeRequest() + val expectedPass = "c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" + val expectedAuth = "Basic " + java.util.Base64.getEncoder() + .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_NAME","parentUuid":"$PARENT_UUID"}""") + + val created = client.createFolder(PARENT_UUID, NEW_FOLDER_NAME) + + assertEquals("new-folder-uuid", created.uuid) + 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_NAME, 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")) + } +} 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) + } +}