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 c20f0e5dd..12a9367fa 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.
@@ -93,10 +95,12 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 129
- versionName "1.10.2"
+ versionCode 130
+ versionName "1.11.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 {
@@ -192,4 +196,14 @@ dependencies {
} else {
implementation jscFlavor
}
+
+ 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/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..b444ff764 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -13,6 +13,9 @@
+
+
+
@@ -59,5 +62,24 @@
+
+
+
+
+
+
+
\ No newline at end of file
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..20bc7423f 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,9 @@ 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 com.internxt.cloud.documents.signaling.InternxtSignalingPackage
+
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
@@ -24,6 +27,8 @@ class MainApplication : Application(), ReactApplication {
override fun getPackages(): List =
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/auth/InternxtAuthCredentialsModule.kt b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt
new file mode 100644
index 000000000..ced6fbe84
--- /dev/null
+++ b/android/app/src/main/java/com/internxt/cloud/auth/InternxtAuthCredentialsModule.kt
@@ -0,0 +1,86 @@
+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) {
+ val manager = authManager ?: run {
+ promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable")
+ return
+ }
+ try {
+ val creds = InternxtAuthManager.Credentials(
+ 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"),
+ bridgeBaseUrl = map.requireString("bridgeBaseUrl"),
+ desktopToken = map.optString("desktopToken"),
+ )
+ if (!manager.saveCredentials(creds)) {
+ promise.reject("E_SAVE_CREDENTIALS", "Failed to persist credentials")
+ return
+ }
+ 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) {
+ val manager = authManager ?: run {
+ promise.reject("E_AUTH_UNAVAILABLE", "Encrypted credential storage unavailable")
+ return
+ }
+ try {
+ if (!manager.clear()) {
+ promise.reject("E_CLEAR_CREDENTIALS", "Failed to clear credentials")
+ return
+ }
+ 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/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/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/DocumentId.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt
new file mode 100644
index 000000000..84f8b7586
--- /dev/null
+++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentId.kt
@@ -0,0 +1,26 @@
+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:"
+ 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))
+ 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/DocumentNaming.kt b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt
new file mode 100644
index 000000000..251354e2b
--- /dev/null
+++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentNaming.kt
@@ -0,0 +1,46 @@
+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()) 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
new file mode 100644
index 000000000..cb1f5ca21
--- /dev/null
+++ b/android/app/src/main/java/com/internxt/cloud/documents/DocumentRowBuilder.kt
@@ -0,0 +1,59 @@
+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 MUTATION_FLAGS =
+ Document.FLAG_SUPPORTS_RENAME or
+ Document.FLAG_SUPPORTS_DELETE or
+ Document.FLAG_SUPPORTS_MOVE
+
+ 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
+
+ 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,
+ 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,
+ COLUMN_PARENT_UUID to folder.parentUuid,
+ )
+
+ fun folderRow(uuid: String, displayName: String, lastModified: Long? = null): Map = mapOf(
+ 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,
+ Document.COLUMN_FLAGS to FOLDER_FLAGS_BASIC,
+ Document.COLUMN_SIZE to null,
+ )
+
+ fun fileRow(file: DriveFile): Map = mapOf(
+ Document.COLUMN_DOCUMENT_ID to DocumentId.encodeFile(file.uuid),
+ 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? {
+ 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/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