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>) { + rows.forEach { row -> uuidOf(row)?.let { rowsByUuid[it] = row } } + } + + fun evict(uuid: String) { + rowsByUuid.remove(uuid) + } + + fun evictAll(rows: List>) { + rows.forEach { row -> uuidOf(row)?.let(rowsByUuid::remove) } + } + + private fun uuidOf(row: Map): String? = + (row[Document.COLUMN_DOCUMENT_ID] as? String)?.let { DocumentId.decode(it)?.uuid ?: it } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt new file mode 100644 index 000000000..8e9a7b350 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/InternxtDocumentsProvider.kt @@ -0,0 +1,1094 @@ +package com.internxt.cloud.documents + +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.os.Handler +import android.os.HandlerThread +import android.os.OperationCanceledException +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsContract.Root +import android.provider.DocumentsProvider +import android.util.Log +import com.internxt.cloud.R +import com.internxt.cloud.documents.api.AuthConfig +import com.internxt.cloud.documents.api.InternxtApiClient +import com.internxt.cloud.documents.api.InternxtApiException +import com.internxt.cloud.documents.api.model.CreateFileEntry +import com.internxt.cloud.documents.api.model.FinishUploadShard +import com.internxt.cloud.documents.api.model.TrashItem +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadedPart +import com.internxt.cloud.documents.auth.InternxtAuthManager +import com.internxt.cloud.documents.cache.DocumentCache +import com.internxt.cloud.documents.crypto.FileKeyDeriver +import com.internxt.cloud.documents.crypto.awaitCryptoService +import com.internxt.cloud.documents.crypto.toHex +import com.internxt.cloud.documents.download.EncryptedFileDownloader +import com.internxt.cloud.documents.http.HttpClients +import com.internxt.cloud.documents.upload.EncryptedFileUploader +import com.internxt.cloud.documents.upload.PendingUpload +import com.internxt.cloud.documents.upload.UploadForegroundService +import com.rncrypto.util.CryptoService +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.security.SecureRandom +import java.time.Instant +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext + +class InternxtDocumentsProvider : DocumentsProvider() { + + private var authManager: InternxtAuthManager? = null + + private val loaderExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "InternxtDocsProvider-loader").apply { isDaemon = true } + } + + private val uploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val folderLoads = ConcurrentHashMap() + private val documentRows = DocumentRowCache() + private val pendingUploads = ConcurrentHashMap() + + @Volatile private var cachedRootBucket: String? = null + + private enum class LoadState { LOADING, DONE, ERROR } + + private class FolderLoad { + @Volatile var state: LoadState = LoadState.LOADING + @Volatile var errorMessage: String? = null + @Volatile var isRevalidating: Boolean = false + val rows = mutableListOf>() + } + private val itemKinds = ConcurrentHashMap() + private lateinit var closeHandler: Handler + + private enum class ItemKind { FILE, FOLDER } + + override fun onCreate(): Boolean { + val ctx = context ?: return false + authManager = InternxtAuthManager.create(ctx.applicationContext) ?: return false + // Process-scoped: ContentProvider has no shutdown hook, so the thread lives until the process dies. + closeHandler = Handler(HandlerThread("InternxtDocsClose").apply { start() }.looper) + activeInstance = this + return true + } + + override fun shutdown() { + if (activeInstance === this) activeInstance = null + uploadScope.cancel() + super.shutdown() + } + + override fun queryRoots(projection: Array?): Cursor { + val cursor = MatrixCursor(resolveRootProjection(projection)) + val ctx = context ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildRootsUri(AUTHORITY)) + + val rootUuid = authManager?.authenticatedRootUuid() + Log.d(TAG, "queryRoots: isLoggedIn=${authManager?.isLoggedIn()} rootUuid=$rootUuid") + if (rootUuid == null) return cursor + + cursor.newRow().apply { + add(Root.COLUMN_ROOT_ID, ROOT_ID) + add(Root.COLUMN_DOCUMENT_ID, DocumentId.encodeFolder(rootUuid)) + add(Root.COLUMN_TITLE, ctx.getString(R.string.documents_provider_label)) + authManager?.userEmail()?.let { add(Root.COLUMN_SUMMARY, it) } + add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD) + add(Root.COLUMN_ICON, R.mipmap.ic_launcher) + } + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val id = documentId ?: return cursor + cursor.setNotificationUri(ctx.contentResolver, DocumentsContract.buildDocumentUri(AUTHORITY, id)) + Log.d(TAG, "queryDocument id=$id") + + if (DocumentId.isUploadToken(id)) { + pendingUploads[DocumentId.decodeUpload(id)]?.let { pending -> + cursor.addDocumentRow(pendingUploadRow(id, pending)) + } + return cursor + } + + val decoded = DocumentId.decode(id) + val uuid = decoded?.uuid ?: id + + if (decoded?.kind == DocumentId.Kind.FOLDER && uuid == authManager?.authenticatedRootUuid()) { + cursor.addDocumentRow( + DocumentRowBuilder.folderRow(uuid, ctx.getString(R.string.documents_provider_label)) + ) + return cursor + } + + documentRows[uuid]?.let { cached -> + cursor.addDocumentRow(cached) + return cursor + } + + val row = loadDocumentRow("queryDocument", decoded?.kind, uuid) + cursor.addDocumentRow(row ?: DocumentRowBuilder.folderRow(uuid = uuid, displayName = uuid)) + return cursor + } + + private fun loadDocumentRow(op: String, kind: DocumentId.Kind?, uuid: String): Map? { + val api = apiClient(op) ?: return null + return try { + runBlockingIo { fetchDocumentRow(api, kind, uuid) }?.also { documentRows.put(uuid, it) } + } catch (e: InternxtApiException) { + Log.w(TAG, "$op uuid=$uuid failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + + private fun fetchDocumentRow( + api: InternxtApiClient, + kind: DocumentId.Kind?, + uuid: String, + ): Map? = when (kind) { + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.let(DocumentRowBuilder::folderRow) + DocumentId.Kind.FILE -> api.getFile(uuid)?.let(DocumentRowBuilder::fileRow) + null -> api.getFolder(uuid)?.let(DocumentRowBuilder::folderRow) + ?: api.getFile(uuid)?.let(DocumentRowBuilder::fileRow) + } + + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { + if (parentDocumentId == documentId) return true + val parent = DocumentId.decode(parentDocumentId) + if (parent?.kind != DocumentId.Kind.FOLDER) return false + val child = if (DocumentId.isUploadToken(documentId)) { + pendingUploads[DocumentId.decodeUpload(documentId)] + ?.let { DocumentId.Decoded(DocumentId.Kind.FOLDER, it.parentUuid) } + } else { + DocumentId.decode(documentId) + } + if (child == null) return false + val rootUuid = authManager?.authenticatedRootUuid() + return DocumentAncestry.isDescendant(child.uuid, parent.uuid) { uuid -> + if (uuid == rootUuid) return@isDescendant null + val kind = if (uuid == child.uuid) child.kind else DocumentId.Kind.FOLDER + val row = documentRows[uuid] ?: loadDocumentRow("isChildDocument", kind, uuid) + row?.get(DocumentRowBuilder.COLUMN_PARENT_UUID) as? String + } + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor { + val cursor = MatrixCursor(resolveDocumentProjection(projection)) + val ctx = context ?: return cursor + val parent = parentDocumentId ?: return cursor + val notifyUri = DocumentsContract.buildChildDocumentsUri(AUTHORITY, parent) + cursor.setNotificationUri(ctx.contentResolver, notifyUri) + Log.d(TAG, "queryChildDocuments parent=$parent") + + val decoded = DocumentId.decode(parent) + if (decoded?.kind == DocumentId.Kind.FILE) { + Log.w(TAG, "queryChildDocuments called with file id=$parent") + return cursor + } + val parentUuid = decoded?.uuid ?: parent + + val load = folderLoads.computeIfAbsent(parent) { + FolderLoad().also { startBackgroundLoad(parentUuid, it, notifyUri) } + } + + val snapshot: List> + val state: LoadState + val errorMessage: String? + synchronized(load) { + snapshot = load.rows.toList() + state = load.state + errorMessage = load.errorMessage + } + snapshot.forEach { cursor.addDocumentRow(it) } + + cursor.extras = Bundle().apply { + putBoolean(DocumentsContract.EXTRA_LOADING, state == LoadState.LOADING) + if (state == LoadState.ERROR && errorMessage != null) { + putString(DocumentsContract.EXTRA_ERROR, errorMessage) + } + } + return cursor + } + + private fun startBackgroundLoad(parent: String, load: FolderLoad, notifyUri: Uri) { + loaderExecutor.execute { + val api = apiClient(op = "queryChildDocuments[bg]") + if (api == null) { + finishLoad(load, notifyUri, LoadState.ERROR, NOT_AUTHENTICATED) + return@execute + } + try { + streamPages({ offset, size -> api.listFolderFolders(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.folderRow(it) }) + } + streamPages({ offset, size -> api.listFolderFiles(parent, offset, size) }) { page -> + appendRows(load, notifyUri, page.map { DocumentRowBuilder.fileRow(it) }) + } + finishLoad(load, notifyUri, LoadState.DONE, null) + Log.d(TAG, "queryChildDocuments parent=$parent loaded rows=${load.rows.size}") + } catch (e: InternxtApiException) { + Log.w(TAG, "queryChildDocuments parent=$parent failed: ${e.javaClass.simpleName}: ${e.message}") + finishLoad(load, notifyUri, LoadState.ERROR, e.message) + } + } + } + + private fun appendRows(load: FolderLoad, notifyUri: Uri, rows: List>) { + if (rows.isEmpty()) return + synchronized(load) { load.rows.addAll(rows) } + documentRows.putAll(rows) + context?.contentResolver?.notifyChange(notifyUri, null) + } + + private fun finishLoad(load: FolderLoad, notifyUri: Uri, state: LoadState, errorMessage: String?) { + synchronized(load) { + load.state = state + load.errorMessage = errorMessage + } + context?.contentResolver?.notifyChange(notifyUri, null) + } + + private fun apiClient(op: String): InternxtApiClient? { + val cfg = authManager?.loadAuthConfig() + if (cfg == null) { + Log.w(TAG, "$op: loadAuthConfig() returned null") + return null + } + return InternxtApiClient(cfg) + } + + private inline fun streamPages(fetch: (offset: Int, size: Int) -> List, onPage: (List) -> Unit) { + val pageSize = InternxtApiClient.DEFAULT_PAGE_SIZE + var offset = 0 + while (true) { + val page = fetch(offset, pageSize) + onPage(page) + if (page.size < pageSize) break + offset += pageSize + } + } + + private fun MatrixCursor.addDocumentRow(row: Map) { + val builder = newRow() + row.forEach { (column, value) -> builder.add(column, value) } + } + + override fun renameDocument(documentId: String, displayName: String): String? = + mutate("renameDocument", documentId) { api, kind, uuid -> + val (parent, newDisplayName) = when (kind) { + DocumentId.Kind.FILE -> renameFile(api, uuid, displayName) + DocumentId.Kind.FOLDER -> { + val parent = parentUuidOf(api, kind, uuid) + api.renameFolder(uuid, displayName) + parent to displayName + } + } + notifyEncodedParent(parent) { encoded -> + patchRowRenamed(encoded, documentId, newDisplayName) + } + null + } + + private fun renameFile(api: InternxtApiClient, uuid: String, displayName: String): Pair { + val file = api.getFile(uuid) ?: throw FileNotFoundException("Not found: $uuid") + val (newBase, newDisplayName) = DocumentNaming.renameTarget(displayName, file.type) + ?: throw FileNotFoundException("Invalid name: $displayName") + if (newBase != file.plainName) api.renameFile(uuid, newBase) + return file.folderUuid to newDisplayName + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + targetParentDocumentId: String, + ): String? = mutate("moveDocument", sourceDocumentId) { api, kind, uuid -> + val targetUuid = rawUuid(targetParentDocumentId) + when (kind) { + DocumentId.Kind.FILE -> api.moveFile(uuid, targetUuid) + DocumentId.Kind.FOLDER -> api.moveFolder(uuid, targetUuid) + } + sourceParentDocumentId?.let { + removeRow(it, sourceDocumentId) + notifyChildren(it) + } + invalidateChildren(targetParentDocumentId) + sourceDocumentId + } + + override fun deleteDocument(documentId: String) { + mutate("deleteDocument", documentId) { api, kind, uuid -> + val parent = parentUuidOf(api, kind, uuid) + api.sendToTrash(listOf(TrashItem(uuid, trashTypeOf(kind)))) + notifyEncodedParent(parent) { encoded -> + removeRow(encoded, documentId) + } + } + } + + private inline fun mutate( + op: String, + documentId: String, + block: (api: InternxtApiClient, kind: DocumentId.Kind, uuid: String) -> R, + ): R { + val api = apiClient(op) ?: throw FileNotFoundException("No auth") + val kind = resolveKind(api, documentId) ?: throw FileNotFoundException("Not found: $documentId") + val uuid = rawUuid(documentId) + return try { + val result = block(api, kind, uuid) + documentRows.evict(uuid) + result + } catch (e: InternxtApiException) { + Log.w(TAG, "$op $documentId failed: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException(e.message) + } + } + + private fun parentUuidOf(api: InternxtApiClient, kind: DocumentId.Kind, uuid: String): String? = + when (kind) { + DocumentId.Kind.FILE -> api.getFile(uuid)?.folderUuid + DocumentId.Kind.FOLDER -> api.getFolder(uuid)?.parentUuid + } + + private fun trashTypeOf(kind: DocumentId.Kind): TrashItem.Type = when (kind) { + DocumentId.Kind.FILE -> TrashItem.Type.FILE + DocumentId.Kind.FOLDER -> TrashItem.Type.FOLDER + } + + private inline fun notifyEncodedParent(rawParentUuid: String?, mutateCache: (encodedParent: String) -> Unit) { + rawParentUuid?.let { + val encoded = DocumentId.encodeFolder(it) + mutateCache(encoded) + notifyChildren(encoded) + } + } + + private fun rawUuid(documentId: String): String = + DocumentId.decode(documentId)?.uuid ?: documentId + + private fun resolveKind(api: InternxtApiClient, documentId: String): DocumentId.Kind? { + DocumentId.decode(documentId)?.kind?.let { return it } + return api.getFolder(documentId)?.let { DocumentId.Kind.FOLDER } + ?: api.getFile(documentId)?.let { DocumentId.Kind.FILE } + } + + private fun notifyChildren(parentDocumentId: String) { + context?.contentResolver?.notifyChange( + DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId), + null, + ) + } + + private fun invalidateChildren(parentDocumentId: String) { + folderLoads.remove(parentDocumentId)?.let { load -> + documentRows.evictAll(synchronized(load) { load.rows.toList() }) + } + notifyChildren(parentDocumentId) + } + + private fun patchRowRenamed(parentDocumentId: String, documentId: String, displayName: String) { + updateRows(parentDocumentId) { rows -> + val idx = rows.indexOfFirst { it[Document.COLUMN_DOCUMENT_ID] == documentId } + if (idx >= 0) { + rows[idx] = rows[idx] + + mapOf( + Document.COLUMN_DISPLAY_NAME to displayName, + Document.COLUMN_LAST_MODIFIED to System.currentTimeMillis(), + ) + } + } + } + + private fun removeRow(parentDocumentId: String, documentId: String) { + updateRows(parentDocumentId) { rows -> + rows.removeAll { it[Document.COLUMN_DOCUMENT_ID] == documentId } + } + } + + private inline fun updateRows( + parentDocumentId: String, + action: (MutableList>) -> Unit, + ) { + val load = folderLoads[parentDocumentId] ?: return + synchronized(load) { action(load.rows) } + } + + override fun refresh(uri: Uri, args: Bundle?, cancellationSignal: CancellationSignal?): Boolean { + val documentId = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { null } + Log.d(TAG, "refresh uri=$uri documentId=$documentId") + if (documentId == null) return false + revalidateChildren(documentId) + return true + } + + private fun revalidateChildren(parentDocumentId: String) { + val load = folderLoads[parentDocumentId] + if (load == null) { + notifyChildren(parentDocumentId) + return + } + synchronized(load) { + if (load.state == LoadState.LOADING || load.isRevalidating) return + load.isRevalidating = true + } + val parentUuid = rawUuid(parentDocumentId) + val notifyUri = DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId) + loaderExecutor.execute { revalidate(parentUuid, load, notifyUri) } + } + + private fun revalidate(parentUuid: String, load: FolderLoad, notifyUri: Uri) { + val api = apiClient(op = "refresh[bg]") + if (api == null) { + synchronized(load) { load.isRevalidating = false } + return + } + try { + val fresh = fetchAllRows(api, parentUuid) + documentRows.putAll(fresh) + synchronized(load) { + load.rows.clear() + load.rows.addAll(fresh) + load.state = LoadState.DONE + load.errorMessage = null + load.isRevalidating = false + } + context?.contentResolver?.notifyChange(notifyUri, null) + Log.d(TAG, "refresh parent=$parentUuid revalidated rows=${fresh.size}") + } catch (e: InternxtApiException) { + synchronized(load) { load.isRevalidating = false } + Log.w(TAG, "refresh parent=$parentUuid revalidation failed: ${e.javaClass.simpleName}: ${e.message}") + } + } + + private fun fetchAllRows(api: InternxtApiClient, parentUuid: String): List> { + val rows = mutableListOf>() + streamPages({ offset, size -> api.listFolderFolders(parentUuid, offset, size) }) { page -> + rows.addAll(page.map { DocumentRowBuilder.folderRow(it) }) + } + streamPages({ offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { page -> + rows.addAll(page.map { DocumentRowBuilder.fileRow(it) }) + } + return rows + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val ctx = context ?: throw FileNotFoundException("No context") + val id = documentId ?: throw FileNotFoundException("No document id") + val effectiveMode = mode ?: "r" + if (effectiveMode.contains('w')) { + return openForWrite(ctx, id, signal) + } + if (effectiveMode != "r") { + throw UnsupportedOperationException("Unsupported mode=$effectiveMode") + } + + val decoded = DocumentId.decode(id) + if (decoded?.kind != DocumentId.Kind.FILE) { + throw FileNotFoundException("openDocument requires a file id (got=$id)") + } + val fileUuid = decoded.uuid + + return try { + runBlockingIo { + signal?.setOnCancelListener { coroutineContext.cancel() } + openDocumentSuspending(ctx, id, fileUuid) + } + } catch (e: FileNotFoundException) { + throw e + } catch (e: CancellationException) { + throw FileNotFoundException("openDocument $id cancelled").apply { initCause(e) } + } catch (e: Exception) { + Log.w(TAG, "openDocument $id failed", e) + throw FileNotFoundException("openDocument failed: ${e.message}").apply { initCause(e) } + } + } + + private suspend fun openDocumentSuspending( + ctx: Context, + id: String, + fileUuid: String, + ): ParcelFileDescriptor { + val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException(NOT_AUTHENTICATED) + if (cfg.mnemonic.isBlank()) { + throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") + } + val api = InternxtApiClient(cfg) + val file = try { + requireFileMetadata(api, fileUuid) + } catch (e: FileNotFoundException) { + DocumentCache.existingCacheFor(ctx, id)?.let { return openCached(ctx, id, it) } + throw e + } + + val cacheFile = DocumentCache.cacheFileFor(ctx, id, file.updatedAt) + if (cacheFile.exists() && cacheFile.length() > 0) { + return openCached(ctx, id, cacheFile) + } + materializeIntoCache(ctx, id, api, cfg.mnemonic, file, cacheFile) + return openCached(ctx, id, cacheFile) + } + + private data class FileMetadata( + val bucket: String, + val fileId: String, + val updatedAt: String, + ) + + private fun requireFileMetadata(api: InternxtApiClient, fileUuid: String): FileMetadata { + val file = try { + api.getFile(fileUuid) ?: throw FileNotFoundException("File not found: $fileUuid") + } catch (e: InternxtApiException) { + throw FileNotFoundException("getFile $fileUuid failed: ${e.message}") + } + return FileMetadata( + bucket = file.bucket ?: throw FileNotFoundException("File $fileUuid has no bucket"), + fileId = file.fileId ?: throw FileNotFoundException("File $fileUuid has no fileId"), + updatedAt = file.updatedAt ?: throw FileNotFoundException("File $fileUuid has no updatedAt"), + ) + } + + private suspend fun materializeIntoCache( + ctx: Context, + id: String, + api: InternxtApiClient, + mnemonic: String, + file: FileMetadata, + cacheFile: File, + ) { + val (tempEnc, tempDec) = DocumentCache.tempPaths(ctx, id) + try { + val links = api.getDownloadLinks(file.bucket, file.fileId) + EncryptedFileDownloader.download(HttpClients.download, links.shards, tempEnc) + + val key = FileKeyDeriver.deriveFileKey(mnemonic, file.bucket, links.index) + val iv = FileKeyDeriver.deriveIv(links.index) + decryptFile(tempEnc, tempDec, key.toHex(), iv.toHex()) + + if (!tempDec.renameTo(cacheFile)) { + throw FileNotFoundException("Failed to promote temp file to cache for $id") + } + tempEnc.delete() + DocumentCache.pruneSiblings(ctx, id, cacheFile) + } catch (e: Exception) { + tempEnc.delete() + tempDec.delete() + throw when { + e is FileNotFoundException -> e + isCancellation(e) -> e + else -> + FileNotFoundException("openDocument $id failed: ${e.message}").apply { initCause(e) } + } + } + } + + private fun openCached(ctx: Context, id: String, cacheFile: File): ParcelFileDescriptor = + ParcelFileDescriptor.open( + cacheFile, + ParcelFileDescriptor.MODE_READ_ONLY, + closeHandler, + ) { DocumentCache.deleteTempsFor(ctx, id) } + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + val api = apiClient("createDocument") ?: throw FileNotFoundException(NOT_AUTHENTICATED) + val parentUuid = rawUuid(parentDocumentId) + try { + api.getFolder(parentUuid) ?: throw FileNotFoundException("Parent not found: $parentDocumentId") + } catch (e: InternxtApiException) { + throw FileNotFoundException("Parent lookup failed: ${e.message}") + } + + return if (mimeType == Document.MIME_TYPE_DIR) { + createFolderDocument(api, parentDocumentId, parentUuid, displayName) + } else { + createPendingFileDocument(api, parentUuid, mimeType, displayName) + } + } + + private fun createFolderDocument( + api: InternxtApiClient, + parentDocumentId: String, + parentUuid: String, + displayName: String, + ): String { + val resolvedName = uniqueFolderName(api, parentUuid, displayName) + val folder = try { + api.createFolder(parentUuid, resolvedName) + } catch (e: InternxtApiException) { + Log.w(TAG, "createDocument folder failed parent=$parentDocumentId: ${e.javaClass.simpleName}: ${e.message}") + throw FileNotFoundException("Folder creation failed: ${e.message}") + } + invalidateChildren(parentDocumentId) + documentRows.put(folder.uuid, DocumentRowBuilder.folderRow(folder)) + return DocumentId.encodeFolder(folder.uuid) + } + + private fun createPendingFileDocument( + api: InternxtApiClient, + parentUuid: String, + mimeType: String, + displayName: String, + ): String { + val normalized = collapseRedundantExtensions(displayName, mimeType) + val resolvedName = uniqueFileName(api, parentUuid, normalized) + evictStalePendingUploads() + val token = UUID.randomUUID().toString() + pendingUploads[token] = PendingUpload(parentUuid, resolvedName, mimeType) + return DocumentId.encodeUpload(token) + } + + private fun evictStalePendingUploads() { + val cutoff = System.currentTimeMillis() - PENDING_UPLOAD_TTL_MS + pendingUploads.entries.removeAll { it.value.createdAtMillis < cutoff } + } + + private fun collapseRedundantExtensions(displayName: String, mimeType: String): String { + val canonicalExt = android.webkit.MimeTypeMap.getSingleton() + .getExtensionFromMimeType(mimeType)?.lowercase() + if (canonicalExt.isNullOrBlank()) return displayName + val suffix = ".$canonicalExt" + var name = displayName + while (name.length > suffix.length && name.endsWith(suffix, ignoreCase = true)) { + val candidate = name.dropLast(suffix.length) + if (candidate.endsWith(suffix, ignoreCase = true)) { + name = candidate + } else { + break + } + } + return name + } + + private fun uniqueFileName(api: InternxtApiClient, parentUuid: String, requested: String): String = + uniqueChildName("uniqueFileName", requested, { offset, size -> api.listFolderFiles(parentUuid, offset, size) }) { + DocumentNaming.joinNameType(it.plainName, it.type) + } + + private fun uniqueFolderName(api: InternxtApiClient, parentUuid: String, requested: String): String = + uniqueChildName("uniqueFolderName", requested, { offset, size -> api.listFolderFolders(parentUuid, offset, size) }) { + it.plainName + } + + private inline fun uniqueChildName( + op: String, + requested: String, + listPage: (offset: Int, size: Int) -> List, + nameOf: (T) -> String, + ): String { + val existing = HashSet() + try { + streamPages(listPage) { page -> page.forEach { existing.add(nameOf(it)) } } + } catch (e: InternxtApiException) { + Log.w(TAG, "$op: listing failed, using requested name. ${e.message}") + return requested + } + return DocumentNaming.uniqueName(requested, existing) + } + + private fun openForWrite( + ctx: Context, + documentId: String, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + val token = DocumentId.decodeUpload(documentId) + ?: throw FileNotFoundException("Write only supported on pending uploads: $documentId") + val pending = pendingUploads[token] + ?: throw FileNotFoundException("Unknown upload token: $token") + + try { + val cfg = authManager?.loadAuthConfig() ?: throw FileNotFoundException(NOT_AUTHENTICATED) + if (cfg.mnemonic.isBlank()) { + throw FileNotFoundException("Stored credentials have no mnemonic; sign out and back in") + } + val pipe = ParcelFileDescriptor.createReliablePipe() + val readEnd = pipe[0] + val writeEnd = pipe[1] + val job: Job = uploadScope.launch { + runUpload(ctx, token, pending, cfg, readEnd) + } + signal?.setOnCancelListener { job.cancel() } + return writeEnd + } catch (t: Throwable) { + pendingUploads.remove(token) + throw t + } + } + + private suspend fun runUpload( + ctx: Context, + token: String, + pending: PendingUpload, + cfg: AuthConfig, + readEnd: ParcelFileDescriptor, + ) { + val temps = uploadTempsFor(ctx, token) + val uploadJob = coroutineContext[Job] + val uploadSignal = CancellationSignal() + uploadSignal.setOnCancelListener { uploadJob?.cancel() } + UploadForegroundService.start(ctx, token, pending.plainName, uploadSignal) + + var failure: Throwable? = null + try { + val api = InternxtApiClient(cfg) + val bucketId = resolveBucket(api, pending.parentUuid) + val crypto = prepareEncryption(cfg.mnemonic, bucketId) + val encrypted = encryptInputToTemp(token, temps, readEnd, crypto, uploadSignal) + val outcome = uploadEncryptedFile(token, api, temps.enc, bucketId, encrypted) + finalizeAndRecordFile(api, pending, bucketId, crypto.indexHex, encrypted.size, outcome) + invalidateChildren(DocumentId.encodeFolder(pending.parentUuid)) + } catch (t: Throwable) { + if (!isCancellation(t)) { + Log.w(TAG, "upload failed token=$token: ${t.javaClass.simpleName}: ${t.message}") + } + failure = t + } finally { + closePipeEnd(readEnd, failure) + deleteTempQuietly(temps.plain) + deleteTempQuietly(temps.enc) + pendingUploads.remove(token) + notifyServiceOfOutcome(ctx, token, failure) + } + } + + private fun isCancellation(t: Throwable): Boolean = + t is OperationCanceledException || t is CancellationException + + /** `tempEnc` holds the ciphertext PUT to bridge; `plain` holds the pipe's plaintext + * while we hand it to CryptoService (which only accepts file paths). */ + private data class UploadTemps(val plain: File, val enc: File) + + private fun uploadTempsFor(ctx: Context, token: String): UploadTemps { + val (enc, plain) = DocumentCache.tempPaths(ctx, token) + return UploadTemps(plain = plain, enc = enc) + } + + private fun resolveBucket(api: InternxtApiClient, parentUuid: String): String { + val parent = api.getFolder(parentUuid) + ?: throw IOException("Parent folder not found: $parentUuid") + // The API only populates `bucket` on the root folder; subfolders return null, + // so uploads into subfolders fall back to the user's (root) bucket. + return parent.bucket + ?: rootBucket(api) + ?: throw IOException("Parent folder $parentUuid has no bucket and root bucket is unavailable") + } + + private fun rootBucket(api: InternxtApiClient): String? { + cachedRootBucket?.let { return it } + val rootUuid = authManager?.authenticatedRootUuid() ?: return null + val bucket = api.getFolder(rootUuid)?.bucket + if (bucket != null) { + Log.d(TAG, "resolveBucket: using root bucket fallback rootUuid=$rootUuid") + cachedRootBucket = bucket + } + return bucket + } + + private fun prepareEncryption(mnemonic: String, bucketId: String): EncryptionContext { + val indexHex = ByteArray(32).also { SecureRandom().nextBytes(it) }.toHex() + return EncryptionContext( + key = FileKeyDeriver.deriveFileKey(mnemonic, bucketId, indexHex), + iv = FileKeyDeriver.deriveIv(indexHex), + indexHex = indexHex, + ) + } + + private suspend fun encryptInputToTemp( + token: String, + temps: UploadTemps, + readEnd: ParcelFileDescriptor, + crypto: EncryptionContext, + signal: CancellationSignal, + ): EncryptedFileUploader.Encrypted { + // Phase 1: drain the pipe into a plaintext file on disk — CryptoService is + // path-based, so we must materialize before encrypting. Cancellation + per-byte + // progress live here because this is the long-running streaming phase. + val onProgress = throttledProgress { bytes -> + UploadForegroundService.reportProgress( + token, UploadForegroundService.Phase.ENCRYPTING, bytes, 0L, + ) + } + drainPipeToFile(readEnd, temps.plain, signal, onProgress) + signal.throwIfCanceled() + // Phase 2: hand the plaintext file to the official rn-crypto CryptoService — same + // call the RN side makes from NetworkFacade.ts, identical AES-CTR pipeline. + return EncryptedFileUploader.encryptFile(temps.plain, temps.enc, crypto.key, crypto.iv) + } + + private fun drainPipeToFile( + readEnd: ParcelFileDescriptor, + target: File, + signal: CancellationSignal, + onProgress: (Long) -> Unit, + ) { + val buffer = ByteArray(EncryptedFileUploader.COPY_BUFFER_SIZE) + var written = 0L + // dup() so the InputStream owns its own FD; the original `readEnd` survives for + // the eventual close()/closeWithError() in `runUpload`'s finally. + readEnd.dup().use { dup -> + ParcelFileDescriptor.AutoCloseInputStream(dup).use { source -> + target.outputStream().use { sink -> + while (true) { + signal.throwIfCanceled() + val n = source.read(buffer) + if (n == -1) break + sink.write(buffer, 0, n) + written += n + onProgress(written) + } + sink.flush() + } + } + } + } + + private suspend fun uploadEncryptedFile( + token: String, + api: InternxtApiClient, + tempEnc: File, + bucketId: String, + encrypted: EncryptedFileUploader.Encrypted, + ): UploadOutcome { + val partsCount = if (encrypted.size >= MULTIPART_THRESHOLD) { + ((encrypted.size + MULTIPART_PART_SIZE - 1) / MULTIPART_PART_SIZE).toInt().coerceAtLeast(1) + } else 1 + + val slot = api.startUpload(bucketId, encrypted.size, partsCount).uploads.firstOrNull() + ?: throw IOException("Bridge /start returned no upload slot") + + val onProgress = throttledProgress { bytes -> + UploadForegroundService.reportProgress( + token, UploadForegroundService.Phase.UPLOADING, bytes, encrypted.size, + ) + } + + return when (slot) { + is UploadSlot.Single -> { + EncryptedFileUploader.uploadSingle( + HttpClients.upload, tempEnc, slot.url, onProgress, + ) + UploadOutcome.Single(slot.uuid, listOf(encrypted.wholeSha256Hex)) + } + is UploadSlot.Multipart -> { + val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, MULTIPART_PART_SIZE) + val parts = EncryptedFileUploader.uploadMultipart( + HttpClients.upload, tempEnc, slot.urls, MULTIPART_PART_SIZE, onProgress, + ) + UploadOutcome.Multipart(slot.uuid, partHashes, parts, slot.uploadId) + } + } + } + + private fun finalizeAndRecordFile( + api: InternxtApiClient, + pending: PendingUpload, + bucketId: String, + indexHex: String, + encryptedSize: Long, + outcome: UploadOutcome, + ) { + val hash = EncryptedFileUploader.computeShardHash(outcome.partHashes) + val shard = when (outcome) { + is UploadOutcome.Single -> FinishUploadShard(uuid = outcome.slotUuid, hash = hash) + is UploadOutcome.Multipart -> FinishUploadShard( + uuid = outcome.slotUuid, + hash = hash, + uploadId = outcome.uploadId, + parts = outcome.parts, + ) + } + val finish = api.finishUpload(bucketId, indexHex, listOf(shard)) + + val nowIso = Instant.now().toString() + val (basePlain, ext) = DocumentNaming.splitNameExt(pending.plainName) + api.createFileEntry( + CreateFileEntry( + fileId = finish.id, + type = ext.removePrefix("."), + size = encryptedSize, + plainName = basePlain, + bucket = bucketId, + folderUuid = pending.parentUuid, + modificationTime = nowIso, + creationTime = nowIso, + ) + ) + } + + private fun closePipeEnd(readEnd: ParcelFileDescriptor, failure: Throwable?) { + try { + if (failure != null) { + readEnd.closeWithError(failure.message ?: "Upload failed") + } else { + readEnd.close() + } + } catch (e: IOException) { + Log.w(TAG, "closing readEnd failed: ${e.message}") + } + } + + private fun deleteTempQuietly(tempEnc: File) { + if (!tempEnc.delete() && tempEnc.exists()) { + Log.w(TAG, "Failed to delete temp upload file: ${tempEnc.absolutePath}") + } + } + + private fun notifyServiceOfOutcome(ctx: Context, token: String, failure: Throwable?) { + when { + failure == null -> UploadForegroundService.complete(token) + isCancellation(failure) -> UploadForegroundService.complete(token) + else -> UploadForegroundService.fail(token, friendlyUploadError(ctx, failure)) + } + } + + private fun friendlyUploadError(ctx: Context, t: Throwable): String { + if (t is InternxtApiException.ApiError) { + val body = t.body.orEmpty() + if (t.code == 420 || body.contains("Max space used", ignoreCase = true)) { + return ctx.getString(R.string.upload_error_storage_full) + } + if (t.code == 413) return ctx.getString(R.string.upload_error_too_large) + if (t.code in 500..599) return ctx.getString(R.string.upload_error_server) + } + if (t is InternxtApiException.UnauthorizedException) { + return ctx.getString(R.string.upload_error_signed_out) + } + if (t is InternxtApiException.NetworkException) { + return ctx.getString(R.string.upload_error_network) + } + return ctx.getString(R.string.upload_error_generic) + } + + private fun throttledProgress(emit: (Long) -> Unit): (Long) -> Unit { + var lastEmittedMs = 0L + var lastEmittedBytes = -1L + return { bytes -> + val now = System.currentTimeMillis() + if (bytes != lastEmittedBytes && now - lastEmittedMs >= PROGRESS_THROTTLE_MS) { + lastEmittedMs = now + lastEmittedBytes = bytes + emit(bytes) + } + } + } + + private data class EncryptionContext( + val key: ByteArray, + val iv: ByteArray, + val indexHex: String, + ) + + private sealed class UploadOutcome { + abstract val slotUuid: String + abstract val partHashes: List + + data class Single( + override val slotUuid: String, + override val partHashes: List, + ) : UploadOutcome() + + data class Multipart( + override val slotUuid: String, + override val partHashes: List, + val parts: List, + val uploadId: String, + ) : UploadOutcome() + } + + private fun pendingUploadRow(documentId: String, pending: PendingUpload): Map = mapOf( + Document.COLUMN_DOCUMENT_ID to documentId, + Document.COLUMN_MIME_TYPE to pending.mimeType, + Document.COLUMN_DISPLAY_NAME to pending.plainName, + Document.COLUMN_LAST_MODIFIED to null, + Document.COLUMN_FLAGS to 0, + Document.COLUMN_SIZE to null, + ) + + private suspend fun decryptFile(src: File, dst: File, hexKey: String, hexIv: String) { + awaitCryptoService("Decryption failed") { cb -> + CryptoService.getInstance().decryptFile( + src.absolutePath, + dst.absolutePath, + hexKey, + hexIv, + /* runInBackground = */ false, + cb, + ) + } + } + + private fun resolveRootProjection(projection: Array?): Array = + projection ?: DEFAULT_ROOT_PROJECTION + + private fun resolveDocumentProjection(projection: Array?): Array = + projection ?: DEFAULT_DOCUMENT_PROJECTION + + companion object { + const val AUTHORITY = "com.internxt.cloud.documents" + private const val ROOT_ID = "internxt-root" + private const val TAG = "InternxtDocsProvider" + + @Volatile + private var activeInstance: InternxtDocumentsProvider? = null + + /** + * Entry point for the React Native app to signal that a folder's children changed + * (e.g. after uploading a file or creating a folder from the app UI). Reuses the same + * cache invalidation + notifyChange path as in-picker mutations. No-op if the provider + * is not currently alive in this process. + */ + fun signalParentChanged(folderUuid: String) { + if (folderUuid.isBlank()) return + activeInstance?.invalidateChildren(DocumentId.encodeFolder(folderUuid)) + } + + private const val MULTIPART_THRESHOLD = 100L * 1024L * 1024L + private const val MULTIPART_PART_SIZE = 30L * 1024L * 1024L + private const val PROGRESS_THROTTLE_MS = 300L + private const val PENDING_UPLOAD_TTL_MS = 60L * 60L * 1000L + private const val NOT_AUTHENTICATED = "Not authenticated" + + private val DEFAULT_ROOT_PROJECTION = arrayOf( + Root.COLUMN_ROOT_ID, + Root.COLUMN_FLAGS, + Root.COLUMN_ICON, + Root.COLUMN_TITLE, + Root.COLUMN_SUMMARY, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + Document.COLUMN_SIZE + ) + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt new file mode 100644 index 000000000..7fe17a7c8 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/MimeTypes.kt @@ -0,0 +1,45 @@ +package com.internxt.cloud.documents + +import android.webkit.MimeTypeMap + +object MimeTypes { + + const val DEFAULT = "application/octet-stream" + + private val TABLE = mapOf( + "pdf" to "application/pdf", + "png" to "image/png", + "jpg" to "image/jpeg", + "jpeg" to "image/jpeg", + "gif" to "image/gif", + "webp" to "image/webp", + "mp4" to "video/mp4", + "mov" to "video/quicktime", + "mp3" to "audio/mpeg", + "wav" to "audio/wav", + "txt" to "text/plain", + "csv" to "text/csv", + "json" to "application/json", + "xml" to "application/xml", + "html" to "text/html", + "zip" to "application/zip", + "doc" to "application/msword", + "docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" to "application/vnd.ms-excel", + "xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" to "application/vnd.ms-powerpoint", + "pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ) + + fun fromExtension(type: String?): String { + val key = type?.trim()?.lowercase().orEmpty() + if (key.isEmpty()) return DEFAULT + return TABLE[key] ?: lookupSystem(key) ?: DEFAULT + } + + private fun lookupSystem(extension: String): String? = try { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } catch (_: Throwable) { + null + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt new file mode 100644 index 000000000..52755fce9 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/AuthConfig.kt @@ -0,0 +1,13 @@ +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 mnemonic: 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..f44a9fbd3 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiClient.kt @@ -0,0 +1,316 @@ +package com.internxt.cloud.documents.api + +import com.internxt.cloud.documents.api.model.CreateFileEntry +import com.internxt.cloud.documents.api.model.DownloadLinks +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import com.internxt.cloud.documents.api.model.FinishUploadShard +import com.internxt.cloud.documents.api.model.Shard +import com.internxt.cloud.documents.api.model.TrashItem +import com.internxt.cloud.documents.api.model.UploadFinishResponse +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadStartResponse +import com.internxt.cloud.documents.crypto.HashUtil +import com.internxt.cloud.documents.http.HttpClients +import okhttp3.HttpUrl.Companion.toHttpUrl +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 + +class InternxtApiClient( + private val config: AuthConfig, + private val client: OkHttpClient = HttpClients.api +) { + + 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) + + fun getFolder(uuid: String): DriveFolder? = getMeta("folders/$uuid/meta", ::parseFolder) + + fun getFile(uuid: String): DriveFile? = getMeta("files/$uuid/meta", ::parseFile) + + private fun getMeta(path: String, parse: (JSONObject) -> T): T? = try { + parse(executeApiRequest(driveRequest(driveUrl(path)).get().build())) + } catch (_: InternxtApiException.NotFoundException) { + null + } + + private fun listChildren( + parentUuid: String, + kind: String, + 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, plainName: String) { + val payload = JSONObject().put("plainName", plainName) + val req = driveRequest(driveUrl("files/$fileUuid/meta")) + .put(payload.toString().toRequestBody(JSON)) + .build() + executeApiRequest(req) + } + + fun renameFolder(folderUuid: String, newName: String) { + val payload = JSONObject().put("plainName", newName) + val req = driveRequest(driveUrl("folders/$folderUuid/meta")) + .put(payload.toString().toRequestBody(JSON)) + .build() + 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 startUpload(bucketId: String, encryptedSize: Long, parts: Int = 1): UploadStartResponse { + require(parts >= 1) { "parts must be >= 1" } + val payload = JSONObject().put( + "uploads", + JSONArray().put( + JSONObject() + .put("index", 0) + .put("size", encryptedSize) + ) + ) + val url = bridgeUrl("v2/buckets/$bucketId/files/start") + .newBuilder() + .addQueryParameter("multiparts", parts.toString()) + .build() + val req = bridgeRequest(url) + .post(payload.toString().toRequestBody(JSON)) + .build() + return parseUploadStart(executeApiRequest(req)) + } + + fun finishUpload( + bucketId: String, + indexHex: String, + shards: List, + ): UploadFinishResponse { + require(shards.isNotEmpty()) { "shards cannot be empty" } + val shardsJson = JSONArray() + for (shard in shards) { + val obj = JSONObject() + .put("uuid", shard.uuid) + .put("hash", shard.hash) + if (shard.uploadId != null) obj.put("UploadId", shard.uploadId) + if (shard.parts != null) { + val parts = JSONArray() + shard.parts.sortedBy { it.partNumber }.forEach { part -> + parts.put( + JSONObject() + .put("PartNumber", part.partNumber) + .put("ETag", part.etag) + ) + } + obj.put("parts", parts) + } + shardsJson.put(obj) + } + val payload = JSONObject() + .put("index", indexHex) + .put("shards", shardsJson) + val req = bridgeRequest(bridgeUrl("v2/buckets/$bucketId/files/finish")) + .post(payload.toString().toRequestBody(JSON)) + .build() + val body = executeApiRequest(req) + val id = body.optStringOrNull("id") + ?: throw InternxtApiException.MalformedResponse("Bridge /finish missing 'id'") + return UploadFinishResponse(id = id, bucket = body.optStringOrNull("bucket")) + } + + fun createFileEntry(entry: CreateFileEntry): DriveFile { + val payload = JSONObject() + .put("fileId", entry.fileId) + .put("type", entry.type) + .put("size", entry.size) + .put("plainName", entry.plainName) + .put("bucket", entry.bucket) + .put("folderUuid", entry.folderUuid) + .put("encryptVersion", entry.encryptVersion) + entry.modificationTime?.let { payload.put("modificationTime", it) } + entry.creationTime?.let { payload.put("creationTime", it) } + val req = driveRequest(driveUrl("files")) + .post(payload.toString().toRequestBody(JSON)) + .build() + return parseFile(executeApiRequest(req)) + } + + private fun parseUploadStart(body: JSONObject): UploadStartResponse { + val uploadsJson = body.optJSONArray("uploads") + ?: throw InternxtApiException.MalformedResponse("Bridge /start missing 'uploads'") + return UploadStartResponse(uploads = uploadsJson.map(::parseUploadSlot)) + } + + private fun parseUploadSlot(obj: JSONObject): UploadSlot { + val index = obj.optInt("index") + val uuid = obj.requireString("uuid") + return when (val urlsArr = obj.optJSONArray("urls")) { + null -> UploadSlot.Single( + index = index, + uuid = uuid, + url = obj.requireString("url"), + ) + else -> UploadSlot.Multipart( + index = index, + uuid = uuid, + urls = urlsArr.toStringList(), + uploadId = obj.requireString("UploadId"), + ) + } + } + + fun getDownloadLinks(bucketId: String, fileId: String): DownloadLinks { + val url = bridgeUrl("buckets/$bucketId/files/$fileId/info") + val request = bridgeRequest(url).header("x-api-version", "2").get().build() + return parseDownloadLinks(executeApiRequest(request), bucketId, fileId) + } + + private fun parseDownloadLinks(body: JSONObject, bucketId: String, fileId: String): DownloadLinks { + val index = body.optStringOrNull("index") + ?: throw InternxtApiException.MalformedResponse("Bridge /info missing 'index'") + val version = body.optInt("version", 1) + if (version != 2) { + throw InternxtApiException.MalformedResponse("File version=$version is not supported (V2 only)") + } + val shardsJson = body.optJSONArray("shards") ?: JSONArray() + if (shardsJson.length() == 0) { + throw InternxtApiException.MalformedResponse("No shards returned for file $fileId") + } + val shards = shardsJson.map { obj -> + Shard( + index = obj.optInt("index"), + size = obj.optLongFlexible("size"), + hash = obj.optString("hash"), + url = obj.optString("url"), + ) + } + return DownloadLinks( + bucket = bucketId, + index = index, + size = body.optLongFlexible("size"), + version = version, + shards = shards, + ) + } + + 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() + return when (resp.code) { + in 200..299 -> if (bodyStr.isBlank()) JSONObject() else JSONObject(bodyStr) + 401 -> throw InternxtApiException.UnauthorizedException() + 404 -> throw InternxtApiException.NotFoundException() + else -> throw InternxtApiException.ApiError(resp.code, bodyStr) + } + } + } + + 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") + ) + + companion object { + const val DEFAULT_PAGE_SIZE = 50 + + private val JSON = "application/json; charset=utf-8".toMediaType() + } +} 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..346463ccd --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/InternxtApiException.kt @@ -0,0 +1,11 @@ +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) + class MalformedResponse(message: String) : InternxtApiException("Malformed response: $message") +} 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..e9fb0ebe1 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/JsonExtensions.kt @@ -0,0 +1,28 @@ +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.requireString( + key: String, + error: String = "Missing required field: $key", +): String = optStringOrNull(key) ?: throw InternxtApiException.MalformedResponse(error) + +internal fun JSONArray.toStringList(): List = List(length()) { getString(it) } + +internal fun JSONObject.optLongFlexible(key: String): Long = when (val v = opt(key)) { + is Number -> v.toLong() + is String -> v.toLongOrNull() ?: 0L + else -> 0L +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt new file mode 100644 index 000000000..f2d936744 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/CreateFileEntry.kt @@ -0,0 +1,15 @@ +package com.internxt.cloud.documents.api.model + +const val ENCRYPT_VERSION_AES03 = "03-aes" + +data class CreateFileEntry( + val fileId: String, + val type: String, + val size: Long, + val plainName: String, + val bucket: String, + val folderUuid: String, + val encryptVersion: String = ENCRYPT_VERSION_AES03, + val modificationTime: String? = null, + val creationTime: String? = null, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/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/FinishUploadShard.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt new file mode 100644 index 000000000..67255c84a --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/FinishUploadShard.kt @@ -0,0 +1,8 @@ +package com.internxt.cloud.documents.api.model + +data class FinishUploadShard( + val uuid: String, + val hash: String, + val uploadId: String? = null, + val parts: List? = null, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/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/api/model/UploadFinishResponse.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt new file mode 100644 index 000000000..b0217e9e6 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadFinishResponse.kt @@ -0,0 +1,6 @@ +package com.internxt.cloud.documents.api.model + +data class UploadFinishResponse( + val id: String, + val bucket: String?, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt new file mode 100644 index 000000000..4f2e1c56f --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadStartResponse.kt @@ -0,0 +1,23 @@ +package com.internxt.cloud.documents.api.model + +data class UploadStartResponse( + val uploads: List, +) + +sealed class UploadSlot { + abstract val index: Int + abstract val uuid: String + + data class Single( + override val index: Int, + override val uuid: String, + val url: String, + ) : UploadSlot() + + data class Multipart( + override val index: Int, + override val uuid: String, + val urls: List, + val uploadId: String, + ) : UploadSlot() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt new file mode 100644 index 000000000..55d2c6e81 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/api/model/UploadedPart.kt @@ -0,0 +1,6 @@ +package com.internxt.cloud.documents.api.model + +data class UploadedPart( + val partNumber: Int, + val etag: String, +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt new file mode 100644 index 000000000..2ca048e72 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/auth/InternxtAuthManager.kt @@ -0,0 +1,115 @@ +package com.internxt.cloud.documents.auth + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.internxt.cloud.BuildConfig +import com.internxt.cloud.documents.api.AuthConfig +import java.io.IOException +import java.security.GeneralSecurityException + +class InternxtAuthManager(private val prefs: SharedPreferences) { + + data class Credentials( + val bearerToken: String, + val userId: String, + val bridgeUser: String, + val mnemonic: String, + val rootFolderUuid: String, + val email: String?, + val driveBaseUrl: String, + val bridgeBaseUrl: String, + val desktopToken: String?, + ) + + fun isLoggedIn(): Boolean = + REQUIRED_KEYS.all { !prefs.getString(it, null).isNullOrBlank() } + + fun rootFolderUuid(): String? = prefs.getString(KEY_ROOT_FOLDER_UUID, null)?.takeIf { it.isNotBlank() } + + fun authenticatedRootUuid(): String? = if (isLoggedIn()) rootFolderUuid() else null + + fun userEmail(): String? = prefs.getString(KEY_EMAIL, null)?.takeIf { it.isNotBlank() } + + fun loadAuthConfig(): AuthConfig? { + if (!isLoggedIn()) return null + return AuthConfig( + driveBaseUrl = required(KEY_DRIVE_BASE_URL), + bridgeBaseUrl = required(KEY_BRIDGE_BASE_URL), + bearerToken = required(KEY_BEARER_TOKEN), + bridgeUser = required(KEY_BRIDGE_USER), + userId = required(KEY_USER_ID), + mnemonic = required(KEY_MNEMONIC), + clientName = BuildConfig.INTERNXT_CLIENT_NAME, + clientVersion = BuildConfig.INTERNXT_CLIENT_VERSION, + desktopToken = prefs.getString(KEY_DESKTOP_TOKEN, null)?.takeIf { it.isNotBlank() }, + ) + } + + private fun required(key: String): String = + prefs.getString(key, null) ?: error("$key missing after isLoggedIn() returned true") + + fun saveCredentials(creds: Credentials): Boolean = + prefs.edit() + .putString(KEY_BEARER_TOKEN, creds.bearerToken) + .putString(KEY_USER_ID, creds.userId) + .putString(KEY_BRIDGE_USER, creds.bridgeUser) + .putString(KEY_MNEMONIC, creds.mnemonic) + .putString(KEY_ROOT_FOLDER_UUID, creds.rootFolderUuid) + .putString(KEY_EMAIL, creds.email) + .putString(KEY_DRIVE_BASE_URL, creds.driveBaseUrl) + .putString(KEY_BRIDGE_BASE_URL, creds.bridgeBaseUrl) + .putString(KEY_DESKTOP_TOKEN, creds.desktopToken) + .commit() + + fun clear(): Boolean = prefs.edit().clear().commit() + + companion object { + private const val TAG = "InternxtAuthManager" + private const val PREFS_FILE = "internxt_documents_auth" + + private const val KEY_BEARER_TOKEN = "bearerToken" + private const val KEY_USER_ID = "userId" + private const val KEY_BRIDGE_USER = "bridgeUser" + private const val KEY_MNEMONIC = "mnemonic" + private const val KEY_ROOT_FOLDER_UUID = "rootFolderUuid" + private const val KEY_EMAIL = "email" + private const val KEY_DRIVE_BASE_URL = "driveBaseUrl" + private const val KEY_BRIDGE_BASE_URL = "bridgeBaseUrl" + private const val KEY_DESKTOP_TOKEN = "desktopToken" + + private val REQUIRED_KEYS = listOf( + KEY_BEARER_TOKEN, + KEY_USER_ID, + KEY_BRIDGE_USER, + KEY_MNEMONIC, + KEY_ROOT_FOLDER_UUID, + KEY_DRIVE_BASE_URL, + KEY_BRIDGE_BASE_URL, + ) + + fun create(context: Context): InternxtAuthManager? { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context, + PREFS_FILE, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + InternxtAuthManager(prefs) + } catch (e: GeneralSecurityException) { + Log.e(TAG, "Keystore unavailable, SAF auth disabled", e) + null + } catch (e: IOException) { + Log.e(TAG, "Could not open encrypted prefs, SAF auth disabled", e) + null + } + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt new file mode 100644 index 000000000..393b8b387 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/cache/DocumentCache.kt @@ -0,0 +1,50 @@ +package com.internxt.cloud.documents.cache + +import android.content.Context +import java.io.File + +object DocumentCache { + + private const val ROOT_DIR = "internxt_documents" + private const val CACHE_DIR = "cache" + private const val TMP_DIR = "tmp" + private const val DEC_SUFFIX = ".dec" + private const val ENC_SUFFIX = ".enc" + + fun cacheFileFor(context: Context, uuid: String, updatedAt: String): File = + File(cacheDir(context), "${uuid}_${slugFromUpdatedAt(updatedAt)}$DEC_SUFFIX") + + fun existingCacheFor(context: Context, uuid: String): File? = + cacheDir(context).listFiles() + ?.filter { it.name.startsWith("${uuid}_") && it.name.endsWith(DEC_SUFFIX) && it.length() > 0 } + ?.maxByOrNull { it.lastModified() } + + fun tempPaths(context: Context, uuid: String): Pair { + val dir = tmpDir(context) + val token = "${uuid}_${System.nanoTime()}" + return File(dir, "$token$ENC_SUFFIX") to File(dir, "$token$DEC_SUFFIX") + } + + fun pruneSiblings(context: Context, uuid: String, keep: File) { + deleteMatching(cacheDir(context)) { + it != keep && it.name.startsWith("${uuid}_") && it.name.endsWith(DEC_SUFFIX) + } + } + + fun deleteTempsFor(context: Context, uuid: String) { + deleteMatching(tmpDir(context)) { it.name.startsWith("${uuid}_") } + } + + private fun cacheDir(context: Context): File = + File(context.cacheDir, "$ROOT_DIR/$CACHE_DIR").apply { mkdirs() } + + private fun tmpDir(context: Context): File = + File(context.cacheDir, "$ROOT_DIR/$TMP_DIR").apply { mkdirs() } + + private inline fun deleteMatching(dir: File, predicate: (File) -> Boolean) { + dir.listFiles()?.forEach { if (predicate(it)) it.delete() } + } + + private fun slugFromUpdatedAt(updatedAt: String): String = + updatedAt.filter { it.isLetterOrDigit() }.ifEmpty { "0" } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt new file mode 100644 index 000000000..f29fd3b26 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/CryptoServiceAwait.kt @@ -0,0 +1,26 @@ +package com.internxt.cloud.documents.crypto + +import com.rncrypto.util.OnlyErrorCallback +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Suspends until `CryptoService.encryptFile` / `decryptFile` (callback-based) reports an + * outcome via [OnlyErrorCallback]. No thread is blocked waiting for the callback: the rn-crypto + * callback resumes the coroutine. Any non-null error is surfaced as [IOException] preserving the + * original cause. + */ +internal suspend inline fun awaitCryptoService( + failureMessage: String, + crossinline invoke: (OnlyErrorCallback) -> Unit, +) = suspendCancellableCoroutine { continuation -> + invoke { err -> + if (err != null) { + continuation.resumeWithException((err as? IOException) ?: IOException(failureMessage, err)) + } else { + continuation.resume(Unit) + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt new file mode 100644 index 000000000..1a54930da --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/FileKeyDeriver.kt @@ -0,0 +1,34 @@ +package com.internxt.cloud.documents.crypto + +import com.facebook.common.util.Hex +import com.rncrypto.util.CryptoService + +/** + * Derives the AES-CTR key/IV used to decrypt files from Internxt's network shards. + * Matches src/network/crypto.ts: generateFileKey. + * + * seed = PBKDF2(mnemonic, "mnemonic", 2048, 64) + * bucketKey = SHA-512( seed || bucketId ) + * fileKey = SHA-512( bucketKey[0..32] || index )[0..32] + * iv = index[0..16] + */ +object FileKeyDeriver { + + private const val PBKDF2_SALT = "mnemonic" + private const val PBKDF2_ROUNDS = 2048 + private const val SEED_LENGTH = 64 + private const val FILE_KEY_LENGTH = 32 + private const val IV_LENGTH = 16 + + fun deriveFileKey(mnemonic: String, bucketIdHex: String, indexHex: String): ByteArray { + val crypto = CryptoService.getInstance() + val seed = crypto.pbkdf2(mnemonic, PBKDF2_SALT.toByteArray(Charsets.UTF_8), PBKDF2_ROUNDS, SEED_LENGTH) + val bucketKey = crypto.sha512(listOf(seed, Hex.decodeHex(bucketIdHex))) + val fileKey = crypto.sha512(listOf(bucketKey.copyOf(FILE_KEY_LENGTH), Hex.decodeHex(indexHex))) + return fileKey.copyOf(FILE_KEY_LENGTH) + } + + fun deriveIv(indexHex: String): ByteArray = Hex.decodeHex(indexHex).copyOf(IV_LENGTH) +} + +fun ByteArray.toHex(): String = Hex.encodeHex(this, /* truncateAtFirstZero = */ false) 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/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt b/android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt new file mode 100644 index 000000000..f9ffca98f --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/crypto/Ripemd160.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.documents.crypto + +import org.bouncycastle.crypto.digests.RIPEMD160Digest + +object Ripemd160 { + + fun digest(input: ByteArray): ByteArray { + val md = RIPEMD160Digest() + md.update(input, 0, input.size) + val out = ByteArray(md.digestSize) + md.doFinal(out, 0) + return out + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt new file mode 100644 index 000000000..7a1334720 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/download/EncryptedFileDownloader.kt @@ -0,0 +1,64 @@ +package com.internxt.cloud.documents.download + +import com.internxt.cloud.documents.api.model.Shard +import com.internxt.cloud.documents.http.await +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext + +object EncryptedFileDownloader { + + private const val COPY_BUFFER_SIZE = 16 * 1024 + + suspend fun download( + client: OkHttpClient, + shards: List, + target: File, + ) { + require(shards.isNotEmpty()) { "No shards to download" } + prepareTarget(target) + + FileOutputStream(target, /* append = */ true).use { out -> + shards.sortedBy { it.index }.forEach { shard -> + coroutineContext.ensureActive() + downloadShard(client, shard, out) + } + out.flush() + } + } + + private fun prepareTarget(target: File) { + target.parentFile?.mkdirs() + if (target.exists() && !target.delete()) { + throw IOException("Failed to delete existing target file: ${target.absolutePath}") + } + } + + private suspend fun downloadShard( + client: OkHttpClient, + shard: Shard, + out: FileOutputStream, + ) { + val request = Request.Builder().url(shard.url).get().build() + client.newCall(request).await().use { response -> writeShardResponse(shard, response, out) } + } + + private fun writeShardResponse(shard: Shard, response: okhttp3.Response, out: FileOutputStream) { + if (!response.isSuccessful) { + throw IOException("Shard ${shard.index} HTTP ${response.code}") + } + val body = response.body ?: throw IOException("Shard ${shard.index} empty body") + val buffer = ByteArray(COPY_BUFFER_SIZE) + body.byteStream().use { input -> + var read = input.read(buffer) + while (read != -1) { + out.write(buffer, 0, read) + read = input.read(buffer) + } + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt new file mode 100644 index 000000000..6f4ff81fe --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/CallAwait.kt @@ -0,0 +1,30 @@ +package com.internxt.cloud.documents.http + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { response.closeQuietly() } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) + continuation.invokeOnCancellation { cancel() } +} + +private fun Response.closeQuietly() { + try { + close() + } catch (_: Throwable) { + // Intentionally empty: close() failures on an already-abandoned response are irrelevant + // and must not mask the coroutine's cancellation or original result. + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt new file mode 100644 index 000000000..4413d4328 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/http/HttpClients.kt @@ -0,0 +1,26 @@ +package com.internxt.cloud.documents.http + +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object HttpClients { + + val api: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .build() + } + + val download: OkHttpClient by lazy { largeTransferClient(writeTimeoutMinutes = 2L) } + val upload: OkHttpClient by lazy { largeTransferClient(writeTimeoutMinutes = 0L) } + + private fun largeTransferClient(writeTimeoutMinutes: Long): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.MINUTES) + .writeTimeout(writeTimeoutMinutes, TimeUnit.MINUTES) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt new file mode 100644 index 000000000..c2f24c3fe --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingModule.kt @@ -0,0 +1,39 @@ +package com.internxt.cloud.documents.signaling + +import android.util.Log +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.internxt.cloud.documents.InternxtDocumentsProvider + +class InternxtSignalingModule(ctx: ReactApplicationContext) : + ReactContextBaseJavaModule(ctx) { + + override fun getName() = MODULE_NAME + + @ReactMethod + fun notifyParentChanged(parentFolderUuid: String?, promise: Promise) { + val folderUuid = parentFolderUuid?.takeIf { it.isNotBlank() } ?: run { + promise.reject("E_INVALID_FOLDER", "parentFolderUuid must be a non-empty string") + return + } + signalParent(folderUuid, promise) + } + + private fun signalParent(folderUuid: String, promise: Promise) { + try { + InternxtDocumentsProvider.signalParentChanged(folderUuid) + promise.resolve(null) + } catch (e: Exception) { + // Best-effort signal: never let a failed refresh break the JS caller. + Log.w(TAG, "signalParent failed for $folderUuid", e) + promise.resolve(null) + } + } + + companion object { + const val MODULE_NAME = "InternxtSignalingModule" + private const val TAG = "InternxtSignaling" + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt new file mode 100644 index 000000000..0e66c3d06 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/signaling/InternxtSignalingPackage.kt @@ -0,0 +1,14 @@ +package com.internxt.cloud.documents.signaling + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class InternxtSignalingPackage : ReactPackage { + override fun createNativeModules(context: ReactApplicationContext): List = + listOf(InternxtSignalingModule(context)) + + override fun createViewManagers(context: ReactApplicationContext): List> = + emptyList() +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt new file mode 100644 index 000000000..b20a0dbdb --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/CancelUploadReceiver.kt @@ -0,0 +1,17 @@ +package com.internxt.cloud.documents.upload + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class CancelUploadReceiver : BroadcastReceiver() { + + override fun onReceive(ctx: Context, intent: Intent) { + val token = intent.getStringExtra(UploadForegroundService.EXTRA_TOKEN) ?: return + val forward = Intent(ctx, UploadForegroundService::class.java) + .setAction(UploadForegroundService.ACTION_CANCEL) + .putExtra(UploadForegroundService.EXTRA_TOKEN, token) + ContextCompat.startForegroundService(ctx, forward) + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt new file mode 100644 index 000000000..7d75e4be5 --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/EncryptedFileUploader.kt @@ -0,0 +1,234 @@ +package com.internxt.cloud.documents.upload + +import com.internxt.cloud.documents.api.model.UploadedPart +import com.internxt.cloud.documents.crypto.Ripemd160 +import com.internxt.cloud.documents.crypto.awaitCryptoService +import com.internxt.cloud.documents.crypto.toHex +import com.internxt.cloud.documents.http.await +import com.rncrypto.util.CryptoService +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.RandomAccessFile +import java.security.MessageDigest +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext + +object EncryptedFileUploader { + + internal const val COPY_BUFFER_SIZE = 16 * 1024 + private val OCTET_STREAM = "application/octet-stream".toMediaType() + + data class Encrypted( + val size: Long, + val wholeSha256Hex: String, + ) + + /** + * Encrypt [plain] → [tempEnc] using the official `@internxt/rn-crypto` CryptoService — + * the same code path the RN side calls into (NetworkFacade.ts -> encryptFile). The + * package does not return a digest, so SHA-256 over the ciphertext is computed here + * by streaming the produced file back through MessageDigest. + */ + suspend fun encryptFile(plain: File, tempEnc: File, key: ByteArray, iv: ByteArray): Encrypted { + prepareTarget(tempEnc) + awaitCryptoService("Encryption failed") { cb -> + CryptoService.getInstance().encryptFile( + plain.absolutePath, + tempEnc.absolutePath, + key.toHex(), + iv.toHex(), + /* runInBackground = */ false, + cb, + ) + } + return Encrypted(size = tempEnc.length(), wholeSha256Hex = computeSha256Hex(tempEnc)) + } + + private fun computeSha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(COPY_BUFFER_SIZE) + file.inputStream().use { input -> + while (true) { + val n = input.read(buffer) + if (n == -1) break + digest.update(buffer, 0, n) + } + } + return digest.digest().toHex() + } + + fun computePartSha256(tempEnc: File, partSize: Long): List { + require(partSize > 0) { "partSize must be > 0" } + val total = tempEnc.length() + if (total == 0L) return emptyList() + val hashes = mutableListOf() + val buffer = ByteArray(COPY_BUFFER_SIZE) + RandomAccessFile(tempEnc, "r").use { raf -> + var consumed = 0L + while (consumed < total) { + val partEnd = (consumed + partSize).coerceAtMost(total) + val digest = MessageDigest.getInstance("SHA-256") + var partRemaining = partEnd - consumed + while (partRemaining > 0) { + val toRead = partRemaining.coerceAtMost(buffer.size.toLong()).toInt() + val n = raf.read(buffer, 0, toRead) + if (n == -1) throw IOException("Unexpected EOF computing part hashes") + digest.update(buffer, 0, n) + partRemaining -= n + consumed += n + } + hashes.add(digest.digest().toHex()) + } + } + return hashes + } + + suspend fun uploadSingle( + client: OkHttpClient, + tempEnc: File, + url: String, + onProgress: ((Long) -> Unit)? = null, + ) { + val body = fileRangeBody(tempEnc, 0L, tempEnc.length(), onProgress, baseSent = 0L) + val request = Request.Builder().url(url).put(body).build() + runCall(client, request) { /* ETag not needed for single */ } + } + + suspend fun uploadMultipart( + client: OkHttpClient, + tempEnc: File, + urls: List, + partSize: Long, + onProgress: ((Long) -> Unit)? = null, + ): List { + require(urls.isNotEmpty()) { "urls cannot be empty" } + val total = tempEnc.length() + + val parts = ArrayList(urls.size) + var offset = 0L + urls.forEachIndexed { index, url -> + coroutineContext.ensureActive() + val length = (total - offset).coerceAtMost(partSize) + require(length > 0) { "Computed empty part for index $index" } + val body = fileRangeBody(tempEnc, offset, length, onProgress, baseSent = offset) + val request = Request.Builder().url(url).put(body).build() + val etag = runCall(client, request) { response -> + response.header("ETag") ?: response.header("Etag") + ?: throw IOException("Part ${index + 1} missing ETag in response") + } + parts.add(UploadedPart(partNumber = index + 1, etag = etag)) + offset += length + } + return parts + } + + /** + * `ripemd160(hex_decode(concat(sha256_hex_per_part)))`. For single-part upload, + * pass a 1-element list with the whole-file SHA-256. + */ + fun computeShardHash(partHashesHex: List): String { + require(partHashesHex.isNotEmpty()) { "no part hashes" } + val concatenated = partHashesHex.joinToString(separator = "") + return Ripemd160.digest(hexDecode(concatenated)).toHex() + } + + private suspend fun runCall( + client: OkHttpClient, + request: Request, + onResponse: (okhttp3.Response) -> T, + ): T = client.newCall(request).await().use { response -> + if (!response.isSuccessful) { + throw IOException("PUT failed HTTP ${response.code}") + } + onResponse(response) + } + + private fun fileRangeBody( + file: File, + offset: Long, + length: Long, + onProgress: ((Long) -> Unit)? = null, + baseSent: Long = 0L, + ): RequestBody = + object : RequestBody() { + override fun contentType() = OCTET_STREAM + override fun contentLength(): Long = length + override fun writeTo(sink: BufferedSink) { + RandomAccessFile(file, "r").use { raf -> + raf.seek(offset) + if (onProgress == null) { + val limited = LimitedInputStream(raf, length) + limited.source().use { sink.writeAll(it) } + } else { + val buffer = ByteArray(COPY_BUFFER_SIZE) + var remaining = length + var sent = 0L + while (remaining > 0) { + val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt() + val n = raf.read(buffer, 0, toRead) + if (n == -1) throw IOException("Unexpected EOF reading upload body") + sink.write(buffer, 0, n) + remaining -= n + sent += n + onProgress(baseSent + sent) + } + } + } + } + } + + private fun prepareTarget(target: File) { + target.parentFile?.mkdirs() + if (target.exists() && !target.delete()) { + throw IOException("Failed to delete existing temp file: ${target.absolutePath}") + } + } + + private fun hexDecode(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Invalid hex length" } + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + val hi = Character.digit(hex[i * 2], 16) + val lo = Character.digit(hex[i * 2 + 1], 16) + require(hi >= 0 && lo >= 0) { "Invalid hex char" } + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } + + private class LimitedInputStream( + private val raf: RandomAccessFile, + private var remaining: Long, + ) : InputStream() { + + override fun read(): Int { + if (remaining <= 0) return -1 + val b = raf.read() + if (b == -1) { + remaining = 0 + return -1 + } + remaining-- + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (remaining <= 0) return -1 + val toRead = remaining.coerceAtMost(len.toLong()).toInt() + val n = raf.read(b, off, toRead) + if (n == -1) { + remaining = 0 + return -1 + } + remaining -= n + return n + } + } +} diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt new file mode 100644 index 000000000..d52a68c7d --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/PendingUpload.kt @@ -0,0 +1,8 @@ +package com.internxt.cloud.documents.upload + +data class PendingUpload( + val parentUuid: String, + val plainName: String, + val mimeType: String, + val createdAtMillis: Long = System.currentTimeMillis(), +) diff --git a/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt b/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt new file mode 100644 index 000000000..1a30d83fc --- /dev/null +++ b/android/app/src/main/java/com/internxt/cloud/documents/upload/UploadForegroundService.kt @@ -0,0 +1,245 @@ +package com.internxt.cloud.documents.upload + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.CancellationSignal +import android.os.IBinder +import android.text.format.Formatter +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.internxt.cloud.R +import java.util.concurrent.ConcurrentHashMap + +class UploadForegroundService : Service() { + + private val notificationManager: NotificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + override fun onCreate() { + super.onCreate() + instance = this + ensureChannel(this) + cancelOrphanedNotifications() + } + + /** + * After process death, the static `uploads` map is empty but any in-flight foreground + * notification posted by a previous incarnation survives. Cancel anything in our channel + * that isn't backed by a live upload state so the tray doesn't show ghost progress bars. + */ + private fun cancelOrphanedNotifications() { + val live = uploads.values + .flatMap { listOf(it.notificationId, it.errorNotificationId) } + .toHashSet() + notificationManager.activeNotifications + .filter { it.notification.channelId == CHANNEL_ID && it.id !in live } + .forEach { notificationManager.cancel(it.id) } + } + + override fun onDestroy() { + if (instance === this) instance = null + isForeground = false + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + val token = intent?.getStringExtra(EXTRA_TOKEN) + when (action) { + ACTION_START -> { + val state = token?.let { uploads[it] } + if (state != null) { + intent.getStringExtra(EXTRA_DISPLAY_NAME)?.takeIf { it.isNotEmpty() } + ?.let { state.displayName = it } + postNotification(state, foregroundOnFirst = true) + } else { + ensureForeground() + maybeStop() + } + } + ACTION_CANCEL -> { + token?.let { signals[it]?.cancel() } + ensureForeground() + maybeStop() + } + else -> { + ensureForeground() + maybeStop() + } + } + return START_NOT_STICKY + } + + private fun ensureForeground() { + if (isForeground) return + val state = uploads.values.firstOrNull() ?: UploadState("placeholder", "") + postNotification(state, foregroundOnFirst = true) + } + + private fun postNotification(state: UploadState, foregroundOnFirst: Boolean) { + val notification = buildProgressNotification(state) + if (foregroundOnFirst && !isForeground) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + state.notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(state.notificationId, notification) + } + isForeground = true + } else { + notificationManager.notify(state.notificationId, notification) + } + } + + private fun postFailure(state: UploadState, message: String) { + // Use a distinct id so stopForeground(STOP_FOREGROUND_REMOVE) doesn't tear this down. + notificationManager.notify(state.errorNotificationId, buildErrorNotification(state, message)) + } + + private fun buildProgressNotification(state: UploadState): Notification { + val cancelPi = PendingIntent.getBroadcast( + this, + state.notificationId, + Intent(this, CancelUploadReceiver::class.java) + .setAction(ACTION_CANCEL) + .putExtra(EXTRA_TOKEN, state.token), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val title = when (state.phase) { + Phase.ENCRYPTING -> getString(R.string.upload_notification_encrypting, state.displayName) + Phase.UPLOADING -> getString(R.string.upload_notification_uploading, state.displayName) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(formatProgressText(state)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, getString(R.string.upload_notification_cancel), cancelPi) + + if (state.phase == Phase.UPLOADING && state.total > 0) { + val pct = ((state.bytes * 100L) / state.total).toInt().coerceIn(0, 100) + builder.setProgress(100, pct, false) + } else { + builder.setProgress(0, 0, true) + } + return builder.build() + } + + private fun buildErrorNotification(state: UploadState, message: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.upload_notification_failed, state.displayName)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setOngoing(false) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + } + + private fun formatProgressText(state: UploadState): String = when { + state.phase == Phase.UPLOADING && state.total > 0 -> + "${Formatter.formatShortFileSize(this, state.bytes)} / " + + Formatter.formatShortFileSize(this, state.total) + state.bytes > 0 -> Formatter.formatShortFileSize(this, state.bytes) + else -> "" + } + + private fun maybeStop() { + if (uploads.isEmpty()) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + isForeground = false + } + } + + enum class Phase { ENCRYPTING, UPLOADING } + + private class UploadState(val token: String, var displayName: String) { + @Volatile var bytes: Long = 0L + @Volatile var total: Long = 0L + @Volatile var phase: Phase = Phase.ENCRYPTING + val notificationId: Int = stableNotificationId(token) + val errorNotificationId: Int = stableNotificationId("err:$token") + } + + companion object { + const val CHANNEL_ID = "internxt_uploads" + + const val ACTION_START = "com.internxt.cloud.documents.upload.START" + const val ACTION_CANCEL = "com.internxt.cloud.documents.upload.CANCEL" + const val EXTRA_TOKEN = "token" + const val EXTRA_DISPLAY_NAME = "display_name" + + @Volatile private var instance: UploadForegroundService? = null + @Volatile private var isForeground = false + + private val signals = ConcurrentHashMap() + private val uploads = ConcurrentHashMap() + + fun start(ctx: Context, token: String, displayName: String, signal: CancellationSignal) { + signals[token] = signal + uploads.getOrPut(token) { UploadState(token, displayName) } + val intent = Intent(ctx, UploadForegroundService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_TOKEN, token) + .putExtra(EXTRA_DISPLAY_NAME, displayName) + ContextCompat.startForegroundService(ctx, intent) + } + + fun reportProgress(token: String, phase: Phase, bytes: Long, total: Long) { + val state = uploads[token] ?: return + state.phase = phase + state.bytes = bytes + state.total = total + instance?.postNotification(state, foregroundOnFirst = false) + } + + fun complete(token: String) { + val state = uploads.remove(token) ?: run { signals.remove(token); return } + signals.remove(token) + instance?.notificationManager?.cancel(state.notificationId) + instance?.maybeStop() + } + + fun fail(token: String, message: String) { + val state = uploads.remove(token) ?: run { signals.remove(token); return } + signals.remove(token) + instance?.postFailure(state, message) + instance?.maybeStop() + } + + private fun ensureChannel(ctx: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val name = ctx.getString(R.string.upload_notification_channel_name) + nm.createNotificationChannel( + NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW) + ) + } + + private fun stableNotificationId(token: String): Int { + val h = token.hashCode() + return if (h == Int.MIN_VALUE) 1 else (h and Int.MAX_VALUE).coerceAtLeast(1) + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f0d4ea285..b46a0461e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,19 @@ Internxt + Internxt Drive automatic - 1.10.2 + 1.11.0 contain false + Internxt uploads + Encrypting %1$s + Uploading %1$s + Upload failed: %1$s + Cancel + Your Internxt Drive is full. Free up space or upgrade your plan. + You\'ve been signed out. Open Internxt to sign in again. + This file is too large to upload. + Internxt is having trouble right now. Please try again in a moment. + No internet connection. The upload will not complete until you reconnect. + Couldn\'t upload the file. Please try again. \ No newline at end of file diff --git a/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt new file mode 100644 index 000000000..7fac8c704 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/BlockingIoTest.kt @@ -0,0 +1,33 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlockingIoTest { + + @Test + fun `when bridging from the caller thread, then the body runs on a different IO thread`() { + val callerThread = Thread.currentThread() + + val bodyThread = runBlockingIo { Thread.currentThread() } + + assertNotEquals( + "body must not run on the caller (binder) thread, otherwise blocking network trips StrictMode", + callerThread, + bodyThread, + ) + assertTrue( + "expected an IO dispatcher thread but ran on ${bodyThread.name}", + bodyThread.name.contains("DefaultDispatcher") || bodyThread.name.contains("IO"), + ) + } + + @Test + fun `when the body returns a value, then it is propagated to the caller`() { + val result = runBlockingIo { 42 } + + assertEquals(42, result) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt new file mode 100644 index 000000000..b97770470 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentAncestryTest.kt @@ -0,0 +1,43 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DocumentAncestryTest { + + private val tree = mapOf(PARENT to "root", CHILD to PARENT, "grandchild" to CHILD, "other" to "root") + + private fun isDescendant(child: String, parent: String, parentOf: (String) -> String? = tree::get) = + DocumentAncestry.isDescendant(child, parent, parentOf) + + @Test + fun `when child and parent are the same id, then it is a descendant`() { + assertTrue(isDescendant(CHILD, CHILD)) + } + + @Test + fun `when the document is a direct child, then it is a descendant`() { + assertTrue(isDescendant(CHILD, PARENT)) + } + + @Test + fun `when the document is a grandchild, then it is a descendant`() { + assertTrue(isDescendant("grandchild", PARENT)) + } + + @Test + fun `when the document lives under another branch, then it is not a descendant`() { + assertFalse(isDescendant("other", PARENT)) + } + + @Test + fun `when the parent chain has a cycle, then the walk stops and returns false`() { + assertFalse(isDescendant(CHILD, "root") { mapOf(CHILD to PARENT, PARENT to CHILD)[it] }) + } + + private companion object { + const val PARENT = "parent" + const val CHILD = "child" + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt new file mode 100644 index 000000000..7ec098a92 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentNamingTest.kt @@ -0,0 +1,133 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DocumentNamingTest { + + @Test + fun uniqueNameReturnsRequestedWhenNoCollision() { + assertEquals(REPORTS, DocumentNaming.uniqueName(REPORTS, setOf("Photos", "Music"))) + } + + @Test + fun uniqueNameAppendsSuffixOnCollision() { + assertEquals("Reports (1)", DocumentNaming.uniqueName(REPORTS, setOf(REPORTS))) + } + + @Test + fun uniqueNameSkipsTakenSuffixes() { + val existing = setOf(REPORTS, "Reports (1)", "Reports (2)") + assertEquals("Reports (3)", DocumentNaming.uniqueName(REPORTS, existing)) + } + + @Test + fun uniqueNamePreservesFileExtension() { + assertEquals("report (1).pdf", DocumentNaming.uniqueName(REPORT_PDF, setOf(REPORT_PDF))) + } + + @Test + fun uniqueNameForFolderHasNoExtension() { + assertEquals("$FOLDER_NAME (1)", DocumentNaming.uniqueName(FOLDER_NAME, setOf(FOLDER_NAME))) + } + + @Test + fun splitNameExtSplitsOnLastDot() { + assertEquals("archive.tar" to ".gz", DocumentNaming.splitNameExt("archive.tar.gz")) + } + + @Test + fun splitNameExtNoExtension() { + assertEquals(FOLDER_NAME to "", DocumentNaming.splitNameExt(FOLDER_NAME)) + } + + @Test + fun splitNameExtLeadingDotIsNotAnExtension() { + assertEquals(".gitignore" to "", DocumentNaming.splitNameExt(".gitignore")) + } + + @Test + fun splitNameExtTrailingDotIsNotAnExtension() { + assertEquals("name." to "", DocumentNaming.splitNameExt("name.")) + } + + @Test + fun joinNameTypeAppendsType() { + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(REPORT, "pdf")) + } + + @Test + fun joinNameTypeWithoutTypeReturnsName() { + assertEquals(REPORT, DocumentNaming.joinNameType(REPORT, null)) + assertEquals(REPORT, DocumentNaming.joinNameType(REPORT, "")) + } + + @Test + fun joinNameTypeDoesNotDuplicateExistingSuffix() { + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(REPORT_PDF, "pdf")) + } + + @Test + fun joinNameTypeTreatsExistingSuffixCaseInsensitively() { + assertEquals("report.PDF", DocumentNaming.joinNameType("report.PDF", "pdf")) + } + + @Test + fun joinNameTypeRoundTripsSplitNameExt() { + val (base, ext) = DocumentNaming.splitNameExt(REPORT_PDF) + + assertEquals(REPORT_PDF, DocumentNaming.joinNameType(base, ext.removePrefix("."))) + } + + @Test + fun extensionOfPrefersType() { + assertEquals("pdf", DocumentNaming.extensionOf(REPORT, "pdf")) + } + + @Test + fun extensionOfFallsBackToPlainNameWhenTypeMissing() { + assertEquals("pdf", DocumentNaming.extensionOf(REPORT_PDF, null)) + assertEquals("pdf", DocumentNaming.extensionOf(REPORT_PDF, "")) + } + + @Test + fun extensionOfIsNullWithoutAnyExtension() { + assertNull(DocumentNaming.extensionOf(FOLDER_NAME, null)) + } + + @Test + fun renameTargetWithExtensionRemovedKeepsBaseAndType() { + assertEquals("images-8" to "images-8.jpeg", DocumentNaming.renameTarget("images-8", "jpeg")) + } + + @Test + fun renameTargetWithNewBaseAndSameExtension() { + assertEquals("holiday" to HOLIDAY_JPEG, DocumentNaming.renameTarget(HOLIDAY_JPEG, "jpeg")) + } + + @Test + fun renameTargetWithChangedExtensionKeepsCurrentType() { + assertEquals("holiday" to HOLIDAY_JPEG, DocumentNaming.renameTarget("holiday.png", "jpeg")) + } + + @Test + fun renameTargetBlankBaseIsInvalid() { + assertNull(DocumentNaming.renameTarget(" ", "jpeg")) + assertNull(DocumentNaming.renameTarget("", null)) + } + + @Test + fun renameTargetWithoutTypeUsesBaseAsDisplayName() { + assertEquals(NOTES to NOTES, DocumentNaming.renameTarget(NOTES, null)) + } + + companion object { + private const val FOLDER_NAME = "My Folder" + private const val HOLIDAY_JPEG = "holiday.jpeg" + private const val NOTES = "notes" + private const val REPORT = "report" + private const val REPORTS = "Reports" + private const val REPORT_PDF = "report.pdf" + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt new file mode 100644 index 000000000..bb10b7517 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowBuilderTest.kt @@ -0,0 +1,147 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import com.internxt.cloud.documents.api.model.DriveFile +import com.internxt.cloud.documents.api.model.DriveFolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DocumentRowBuilderTest { + + companion object { + private const val UPDATED_AT = "2026-01-11T00:00:00.000Z" + private const val REPORT_PDF = "report.pdf" + private const val PDF_MIME = "application/pdf" + private const val PARENT_UUID = "parent-uuid" + } + + private fun driveFile(plainName: String, type: String?) = DriveFile( + uuid = "file-uuid", + plainName = plainName, + type = type, + size = 0L, + bucket = null, + folderUuid = null, + createdAt = null, + updatedAt = null, + fileId = null, + ) + + @Test + fun folderRowFields() { + val folder = DriveFolder( + uuid = "folder-uuid", + plainName = "Documents", + parentUuid = PARENT_UUID, + bucket = null, + createdAt = null, + updatedAt = UPDATED_AT, + ) + + val row = DocumentRowBuilder.folderRow(folder) + + assertEquals("f:folder-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Documents", row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + val expectedFolderFlags = Document.FLAG_DIR_SUPPORTS_CREATE or + Document.FLAG_SUPPORTS_RENAME or + Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE + assertEquals(expectedFolderFlags, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + assertEquals(PARENT_UUID, row[DocumentRowBuilder.COLUMN_PARENT_UUID]) + } + + @Test + fun fileRowFields() { + val file = DriveFile( + uuid = "file-uuid", + plainName = "report", + type = "pdf", + size = 102400L, + bucket = "bucket-id", + folderUuid = PARENT_UUID, + createdAt = null, + updatedAt = UPDATED_AT, + fileId = "file-id-1", + ) + + val row = DocumentRowBuilder.fileRow(file) + + assertEquals("d:file-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(1768089600000L, row[Document.COLUMN_LAST_MODIFIED]) + val expectedFileFlags = Document.FLAG_SUPPORTS_RENAME or + Document.FLAG_SUPPORTS_DELETE or + Document.FLAG_SUPPORTS_MOVE + assertEquals(expectedFileFlags, row[Document.COLUMN_FLAGS]) + assertEquals(102400L, row[Document.COLUMN_SIZE]) + assertEquals(PARENT_UUID, row[DocumentRowBuilder.COLUMN_PARENT_UUID]) + } + + @Test + fun fileRowJoinsPlainNameAndType() { + val row = DocumentRowBuilder.fileRow(driveFile("report", "pdf")) + + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowDoesNotDuplicateExtensionAlreadyInPlainName() { + val row = DocumentRowBuilder.fileRow(driveFile(REPORT_PDF, "PDF")) + + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowWithNullTypeKeepsPlainNameAndDerivesMime() { + val row = DocumentRowBuilder.fileRow(driveFile(REPORT_PDF, null)) + + assertEquals(REPORT_PDF, row[Document.COLUMN_DISPLAY_NAME]) + assertEquals(PDF_MIME, row[Document.COLUMN_MIME_TYPE]) + } + + @Test + fun fileRowUnknownExtensionFallsBackToOctetStream() { + val row = DocumentRowBuilder.fileRow(driveFile("weird.xyz", "xyz")) + + assertEquals("application/octet-stream", row[Document.COLUMN_MIME_TYPE]) + assertEquals("weird.xyz", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + } + + @Test + fun folderRowOverloadFields() { + val row = DocumentRowBuilder.folderRow("root-uuid", "Internxt Drive") + + assertEquals("f:root-uuid", row[Document.COLUMN_DOCUMENT_ID]) + assertEquals(Document.MIME_TYPE_DIR, row[Document.COLUMN_MIME_TYPE]) + assertEquals("Internxt Drive", row[Document.COLUMN_DISPLAY_NAME]) + assertNull(row[Document.COLUMN_LAST_MODIFIED]) + assertEquals(Document.FLAG_DIR_SUPPORTS_CREATE, row[Document.COLUMN_FLAGS]) + assertNull(row[Document.COLUMN_SIZE]) + } + + @Test + fun parseIsoToMillisHandlesValidInput() { + assertEquals(1768089600000L, DocumentRowBuilder.parseIsoToMillis(UPDATED_AT)) + } + + @Test + fun parseIsoToMillisHandlesNullAndBlank() { + assertNull(DocumentRowBuilder.parseIsoToMillis(null)) + assertNull(DocumentRowBuilder.parseIsoToMillis("")) + assertNull(DocumentRowBuilder.parseIsoToMillis(" ")) + } + + @Test + fun parseIsoToMillisHandlesMalformed() { + assertNull(DocumentRowBuilder.parseIsoToMillis("not-a-date")) + assertNull(DocumentRowBuilder.parseIsoToMillis("2026-99-99")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt new file mode 100644 index 000000000..4a05abfe3 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/DocumentRowCacheTest.kt @@ -0,0 +1,93 @@ +package com.internxt.cloud.documents + +import android.provider.DocumentsContract.Document +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class DocumentRowCacheTest { + + companion object { + private const val FOLDER_UUID = "folder-uuid" + private const val FILE_UUID = "file-uuid" + private const val FOLDER_DOC_ID = "f:$FOLDER_UUID" + private const val FILE_DOC_ID = "d:$FILE_UUID" + } + + private lateinit var cache: DocumentRowCache + + @Before + fun setUp() { + cache = DocumentRowCache() + } + + private fun row(documentId: String?, displayName: String = "name") = mapOf( + Document.COLUMN_DOCUMENT_ID to documentId, + Document.COLUMN_DISPLAY_NAME to displayName, + ) + + @Test + fun `when putAll receives listing rows, then they are retrievable by decoded uuid`() { + val folderRow = row(FOLDER_DOC_ID, "Documents") + val fileRow = row(FILE_DOC_ID, "report.pdf") + + cache.putAll(listOf(folderRow, fileRow)) + + assertEquals(folderRow, cache[FOLDER_UUID]) + assertEquals(fileRow, cache[FILE_UUID]) + } + + @Test + fun `when a row has an unprefixed document id, then it is keyed by the raw id`() { + val bareRow = row("bare-uuid") + + cache.putAll(listOf(bareRow)) + + assertEquals(bareRow, cache["bare-uuid"]) + } + + @Test + fun `when a row has no document id, then it is skipped`() { + cache.putAll(listOf(row(documentId = null))) + + assertNull(cache["null"]) + } + + @Test + fun `when put stores a row under a uuid, then a later put overwrites it`() { + cache.put(FILE_UUID, row(FILE_DOC_ID, "old.pdf")) + val renamed = row(FILE_DOC_ID, "new.pdf") + + cache.put(FILE_UUID, renamed) + + assertEquals(renamed, cache[FILE_UUID]) + } + + @Test + fun `when evict removes a uuid, then only that entry is gone`() { + cache.putAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID))) + + cache.evict(FILE_UUID) + + assertNull(cache[FILE_UUID]) + assertEquals(row(FOLDER_DOC_ID), cache[FOLDER_UUID]) + } + + @Test + fun `when evictAll receives stale rows, then their uuids are removed and others remain`() { + val staying = row("d:kept-uuid") + cache.putAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID), staying)) + + cache.evictAll(listOf(row(FILE_DOC_ID), row(FOLDER_DOC_ID))) + + assertNull(cache[FILE_UUID]) + assertNull(cache[FOLDER_UUID]) + assertEquals(staying, cache["kept-uuid"]) + } + + @Test + fun `when a uuid was never cached, then get returns null`() { + assertNull(cache["missing-uuid"]) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt new file mode 100644 index 000000000..4e9e063dc --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/MimeTypesTest.kt @@ -0,0 +1,52 @@ +package com.internxt.cloud.documents + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MimeTypesTest { + + companion object { + private const val APPLICATION_PDF = "application/pdf" + private const val IMAGE_JPEG = "image/jpeg" + } + + @Test + fun mapsKnownExtensions() { + assertEquals(APPLICATION_PDF, MimeTypes.fromExtension("pdf")) + assertEquals(IMAGE_JPEG, MimeTypes.fromExtension("jpg")) + assertEquals(IMAGE_JPEG, MimeTypes.fromExtension("jpeg")) + assertEquals("image/png", MimeTypes.fromExtension("png")) + assertEquals("video/mp4", MimeTypes.fromExtension("mp4")) + assertEquals("audio/mpeg", MimeTypes.fromExtension("mp3")) + assertEquals("application/zip", MimeTypes.fromExtension("zip")) + assertEquals( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + MimeTypes.fromExtension("xlsx") + ) + } + + @Test + fun isCaseInsensitive() { + assertEquals(APPLICATION_PDF, MimeTypes.fromExtension("PDF")) + assertEquals(IMAGE_JPEG, MimeTypes.fromExtension("JPG")) + assertEquals(IMAGE_JPEG, MimeTypes.fromExtension("Jpeg")) + } + + @Test + fun trimsWhitespace() { + assertEquals(APPLICATION_PDF, MimeTypes.fromExtension(" pdf ")) + } + + @Test + fun unknownExtensionFallsBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("xyz")) + assertEquals("application/octet-stream", MimeTypes.fromExtension("xyz")) + } + + @Test + fun nullAndBlankFallBackToOctetStream() { + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(null)) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension("")) + assertEquals(MimeTypes.DEFAULT, MimeTypes.fromExtension(" ")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt new file mode 100644 index 000000000..3b93b52f5 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientTest.kt @@ -0,0 +1,401 @@ +package com.internxt.cloud.documents.api + +import com.internxt.cloud.documents.api.model.TrashItem +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +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" + private const val FILE_UUID_1 = "file-uuid-1" + private const val FOLDER_UUID_1 = "folder-uuid-1" + private const val MISSING_UUID = "missing-uuid" + } + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + val base = server.url("/").toString().trimEnd('/') + client = InternxtApiClient( + AuthConfig( + driveBaseUrl = base, + bridgeBaseUrl = base, + bearerToken = "test-token", + bridgeUser = "user@example.com", + userId = "1234567890", + mnemonic = "test mnemonic phrase", + clientName = "drive-mobile", + clientVersion = "v1.9.0", + desktopToken = "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(BUCKET_ID, links.bucket) + assertEquals("idx", links.index) + assertEquals(1024L, links.size) + assertEquals(2, links.version) + assertEquals(1, links.shards.size) + assertEquals("https://shard/0", links.shards[0].url) + assertEquals(512L, links.shards[0].size) + + val infoRequest = server.takeRequest() + assertEquals("/buckets/$BUCKET_ID/files/file-id-1/info", infoRequest.path) + assertEquals("2", infoRequest.getHeader("x-api-version")) + + val expectedPass = "c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646" + val expectedAuth = "Basic " + java.util.Base64.getEncoder() + .encodeToString("user@example.com:$expectedPass".toByteArray(Charsets.UTF_8)) + assertEquals(expectedAuth, infoRequest.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 getFolderHitsMetaEndpointAndReturnsParsedFolder() { + enqueueJson("""{"uuid":"$FOLDER_UUID_1","plainName":"Documents"}""") + + val folder = client.getFolder(FOLDER_UUID_1) + + assertEquals(FOLDER_UUID_1, folder?.uuid) + assertEquals("Documents", folder?.plainName) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/folders/$FOLDER_UUID_1/meta", recorded.path) + } + + @Test + fun getFolderReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFolder(MISSING_UUID)) + } + + @Test + fun getFileHitsMetaEndpointAndReturnsParsedFile() { + enqueueJson("""{"uuid":"$FILE_UUID_1","plainName":"report.pdf","type":"pdf","size":102400}""") + + val file = client.getFile(FILE_UUID_1) + + assertEquals(FILE_UUID_1, file?.uuid) + assertEquals("report.pdf", file?.plainName) + assertEquals(102400L, file?.size) + + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertEquals("/files/$FILE_UUID_1/meta", recorded.path) + } + + @Test + fun getFileReturnsNullWhenNotFound() { + enqueueJson("", code = 404) + + assertNull(client.getFile(MISSING_UUID)) + } + + @Test + fun renameFilePutsPlainNameToMetaEndpoint() { + enqueueJson("") + + client.renameFile(FILE_UUID_1, "renamed") + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/files/$FILE_UUID_1/meta", recorded.path) + assertEquals("""{"plainName":"renamed"}""", recorded.body.readUtf8()) + } + + @Test + fun renameFolderPutsPlainNameToMetaEndpoint() { + enqueueJson("") + + client.renameFolder(FOLDER_UUID_1, "Renamed") + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/folders/$FOLDER_UUID_1/meta", recorded.path) + assertEquals("Renamed", JSONObject(recorded.body.readUtf8()).getString("plainName")) + } + + @Test + fun moveFilePatchesDestinationPayload() { + enqueueJson("""{"uuid":"$FILE_UUID_1","folderUuid":"$PARENT_UUID"}""") + + client.moveFile(FILE_UUID_1, PARENT_UUID) + + val recorded = server.takeRequest() + assertEquals("PATCH", recorded.method) + assertEquals("/files/$FILE_UUID_1", recorded.path) + assertEquals(PARENT_UUID, JSONObject(recorded.body.readUtf8()).getString("destinationFolder")) + } + + @Test + fun moveFolderPatchesDestinationPayload() { + enqueueJson("""{"uuid":"$FOLDER_UUID_1","parentUuid":"$PARENT_UUID"}""") + + client.moveFolder(FOLDER_UUID_1, PARENT_UUID) + + val recorded = server.takeRequest() + assertEquals("PATCH", recorded.method) + assertEquals("/folders/$FOLDER_UUID_1", recorded.path) + assertEquals(PARENT_UUID, JSONObject(recorded.body.readUtf8()).getString("destinationFolder")) + } + + @Test + fun sendToTrashPostsItemsPayload() { + enqueueJson("") + + client.sendToTrash( + listOf( + TrashItem(FILE_UUID_1, TrashItem.Type.FILE), + TrashItem(FOLDER_UUID_1, TrashItem.Type.FOLDER), + ) + ) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/storage/trash/add", recorded.path) + val items = JSONObject(recorded.body.readUtf8()).getJSONArray("items") + assertEquals(2, items.length()) + assertEquals(FILE_UUID_1, items.getJSONObject(0).getString("uuid")) + assertEquals("file", items.getJSONObject(0).getString("type")) + assertEquals(FOLDER_UUID_1, items.getJSONObject(1).getString("uuid")) + assertEquals("folder", items.getJSONObject(1).getString("type")) + } + + @Test + fun serverErrorSurfacesAsApiError() { + enqueueJson("""{"error":"boom"}""", code = 500) + + 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/InternxtApiClientUploadTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt new file mode 100644 index 000000000..1a2173dc5 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/api/InternxtApiClientUploadTest.kt @@ -0,0 +1,196 @@ +package com.internxt.cloud.documents.api + +import com.internxt.cloud.documents.api.model.CreateFileEntry +import com.internxt.cloud.documents.api.model.ENCRYPT_VERSION_AES03 +import com.internxt.cloud.documents.api.model.FinishUploadShard +import com.internxt.cloud.documents.api.model.UploadSlot +import com.internxt.cloud.documents.api.model.UploadedPart +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InternxtApiClientUploadTest { + + private lateinit var server: MockWebServer + private lateinit var client: InternxtApiClient + + companion object { + private const val BUCKET_ID = "bucket-123" + private const val PARENT_UUID = "parent-uuid-1" + private const val SLOT_UUID = "slot-uuid" + private const val BUCKET_FILE_ID = "bucket-file-id" + private const val TIMESTAMP = "2026-05-25T00:00:00Z" + } + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + val base = server.url("/").toString().trimEnd('/') + client = InternxtApiClient( + AuthConfig( + driveBaseUrl = base, + bridgeBaseUrl = base, + bearerToken = "test-token", + bridgeUser = "user@example.com", + userId = "1234567890", + mnemonic = "test mnemonic phrase", + clientName = "drive-mobile", + clientVersion = "v1.9.0", + desktopToken = null, + ) + ) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun enqueue(body: String, code: Int = 200) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun startUploadSinglePostsExpectedBodyAndParsesSlot() { + enqueue( + """{"uploads":[{"index":0,"uuid":"$SLOT_UUID","url":"https://shard/up"}]}""" + ) + + val response = client.startUpload(BUCKET_ID, encryptedSize = 4096, parts = 1) + + assertEquals(1, response.uploads.size) + val slot = response.uploads[0] + assertTrue("expected Single slot, got ${slot::class.simpleName}", slot is UploadSlot.Single) + slot as UploadSlot.Single + assertEquals(SLOT_UUID, slot.uuid) + assertEquals("https://shard/up", slot.url) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/v2/buckets/$BUCKET_ID/files/start?multiparts=1", recorded.path) + val sent = JSONObject(recorded.body.readUtf8()) + val uploads = sent.getJSONArray("uploads") + assertEquals(1, uploads.length()) + assertEquals(0, uploads.getJSONObject(0).getInt("index")) + assertEquals(4096L, uploads.getJSONObject(0).getLong("size")) + assertNotNull(recorded.getHeader("Authorization")) + assertEquals(true, recorded.getHeader("Authorization")!!.startsWith("Basic ")) + } + + @Test + fun startUploadMultipartPassesPartsCountAndParsesUrls() { + enqueue( + """{"uploads":[{"index":0,"uuid":"$SLOT_UUID","urls":["https://p/1","https://p/2","https://p/3"],"UploadId":"up-1"}]}""" + ) + + val response = client.startUpload(BUCKET_ID, encryptedSize = 300L * 1024L * 1024L, parts = 4) + + val slot = response.uploads.single() + assertTrue("expected Multipart slot, got ${slot::class.simpleName}", slot is UploadSlot.Multipart) + slot as UploadSlot.Multipart + assertEquals(listOf("https://p/1", "https://p/2", "https://p/3"), slot.urls) + assertEquals("up-1", slot.uploadId) + + val recorded = server.takeRequest() + assertEquals("/v2/buckets/$BUCKET_ID/files/start?multiparts=4", recorded.path) + } + + @Test + fun finishUploadSingleShardBody() { + enqueue("""{"id":"$BUCKET_FILE_ID","bucket":"$BUCKET_ID"}""") + + val finish = client.finishUpload( + BUCKET_ID, + indexHex = "ab".repeat(32), + shards = listOf(FinishUploadShard(uuid = SLOT_UUID, hash = "deadbeef")), + ) + + assertEquals(BUCKET_FILE_ID, finish.id) + assertEquals(BUCKET_ID, finish.bucket) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/v2/buckets/$BUCKET_ID/files/finish", recorded.path) + val sent = JSONObject(recorded.body.readUtf8()) + assertEquals("ab".repeat(32), sent.getString("index")) + val shards = sent.getJSONArray("shards") + assertEquals(1, shards.length()) + val shard = shards.getJSONObject(0) + assertEquals(SLOT_UUID, shard.getString("uuid")) + assertEquals("deadbeef", shard.getString("hash")) + assertEquals(false, shard.has("UploadId")) + assertEquals(false, shard.has("parts")) + } + + @Test + fun finishUploadMultipartIncludesUploadIdAndParts() { + enqueue("""{"id":"$BUCKET_FILE_ID"}""") + + client.finishUpload( + BUCKET_ID, + indexHex = "cd".repeat(32), + shards = listOf( + FinishUploadShard( + uuid = SLOT_UUID, + hash = "deadbeef", + uploadId = "up-1", + parts = listOf( + UploadedPart(partNumber = 2, etag = "etag-2"), + UploadedPart(partNumber = 1, etag = "etag-1"), + ), + ) + ), + ) + + val recorded = server.takeRequest() + val shard = JSONObject(recorded.body.readUtf8()).getJSONArray("shards").getJSONObject(0) + assertEquals("up-1", shard.getString("UploadId")) + val parts = shard.getJSONArray("parts") + assertEquals(2, parts.length()) + assertEquals(1, parts.getJSONObject(0).getInt("PartNumber")) + assertEquals("etag-1", parts.getJSONObject(0).getString("ETag")) + assertEquals(2, parts.getJSONObject(1).getInt("PartNumber")) + assertEquals("etag-2", parts.getJSONObject(1).getString("ETag")) + } + + @Test + fun createFileEntryPostsToDriveFilesWithBearer() { + enqueue("""{"uuid":"new-file-uuid","plainName":"report.pdf","type":"pdf","size":4096}""") + + val created = client.createFileEntry( + CreateFileEntry( + fileId = BUCKET_FILE_ID, + type = "pdf", + size = 4096L, + plainName = "report.pdf", + bucket = BUCKET_ID, + folderUuid = PARENT_UUID, + modificationTime = TIMESTAMP, + creationTime = TIMESTAMP, + ) + ) + + assertEquals("new-file-uuid", created.uuid) + + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("/files", recorded.path) + assertEquals("Bearer test-token", recorded.getHeader("Authorization")) + val sent = JSONObject(recorded.body.readUtf8()) + assertEquals(BUCKET_FILE_ID, sent.getString("fileId")) + assertEquals("pdf", sent.getString("type")) + assertEquals(4096L, sent.getLong("size")) + assertEquals("report.pdf", sent.getString("plainName")) + assertEquals(BUCKET_ID, sent.getString("bucket")) + assertEquals(PARENT_UUID, sent.getString("folderUuid")) + assertEquals(ENCRYPT_VERSION_AES03, sent.getString("encryptVersion")) + assertEquals(TIMESTAMP, sent.getString("modificationTime")) + assertEquals(TIMESTAMP, sent.getString("creationTime")) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/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/auth/InternxtAuthManagerTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt new file mode 100644 index 000000000..d698d6f3b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/auth/InternxtAuthManagerTest.kt @@ -0,0 +1,169 @@ +package com.internxt.cloud.documents.auth + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class InternxtAuthManagerTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var manager: InternxtAuthManager + + companion object { + private const val USER_EMAIL = "user@example.com" + private val FULL_CREDS = InternxtAuthManager.Credentials( + bearerToken = "bearer-xyz", + userId = "user-1", + bridgeUser = USER_EMAIL, + mnemonic = "test mnemonic phrase", + rootFolderUuid = "root-uuid", + email = USER_EMAIL, + driveBaseUrl = "https://drive.test/api", + bridgeBaseUrl = "https://bridge.test", + desktopToken = "desktop-tok", + ) + } + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + } + + @Test + fun isLoggedInFalseWhenPrefsEmpty() { + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun isLoggedInTrueAfterSavingFullCredentials() { + manager.saveCredentials(FULL_CREDS) + + assertTrue(manager.isLoggedIn()) + assertEquals("root-uuid", manager.rootFolderUuid()) + assertEquals(USER_EMAIL, manager.userEmail()) + } + + @Test + fun loadAuthConfigReturnsExpectedFields() { + manager.saveCredentials(FULL_CREDS) + + val config = manager.loadAuthConfig()!! + assertEquals("https://drive.test/api", config.driveBaseUrl) + assertEquals("https://bridge.test", config.bridgeBaseUrl) + assertEquals("bearer-xyz", config.bearerToken) + assertEquals(USER_EMAIL, config.bridgeUser) + assertEquals("user-1", config.userId) + assertEquals("desktop-tok", config.desktopToken) + } + + @Test + fun loadAuthConfigOmitsDesktopTokenWhenBlank() { + manager.saveCredentials(FULL_CREDS.copy(desktopToken = null)) + + val config = manager.loadAuthConfig()!! + assertNull(config.desktopToken) + } + + @Test + fun isLoggedInFalseWhenAnyRequiredFieldMissing() { + val requiredMissing = listOf( + FULL_CREDS.copy(bearerToken = ""), + FULL_CREDS.copy(userId = ""), + FULL_CREDS.copy(bridgeUser = ""), + FULL_CREDS.copy(rootFolderUuid = ""), + FULL_CREDS.copy(driveBaseUrl = ""), + FULL_CREDS.copy(bridgeBaseUrl = ""), + ) + for (creds in requiredMissing) { + prefs = FakeSharedPreferences() + manager = InternxtAuthManager(prefs) + manager.saveCredentials(creds) + + assertFalse("should be logged out when field blank: $creds", manager.isLoggedIn()) + assertNull(manager.loadAuthConfig()) + } + } + + @Test + fun clearRemovesAllCredentials() { + manager.saveCredentials(FULL_CREDS) + assertTrue(manager.isLoggedIn()) + + manager.clear() + + assertFalse(manager.isLoggedIn()) + assertNull(manager.rootFolderUuid()) + assertNull(manager.userEmail()) + assertNull(manager.loadAuthConfig()) + } + + @Test + fun savingOverwritesPreviousCredentials() { + manager.saveCredentials(FULL_CREDS) + manager.saveCredentials(FULL_CREDS.copy(bearerToken = "new-token", rootFolderUuid = "new-root")) + + assertEquals("new-token", manager.loadAuthConfig()!!.bearerToken) + assertEquals("new-root", manager.rootFolderUuid()) + } +} + +private class FakeSharedPreferences : SharedPreferences { + private val store = HashMap() + + override fun getAll(): MutableMap = HashMap(store) + override fun getString(key: String?, defValue: String?): String? = + store[key] as? String ?: defValue + + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + @Suppress("UNCHECKED_CAST") (store[key] as? MutableSet) ?: defValues + + override fun getInt(key: String?, defValue: Int): Int = (store[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (store[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (store[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (store[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = store.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun edit(): SharedPreferences.Editor = FakeEditor(store) + + private class FakeEditor(private val store: HashMap) : SharedPreferences.Editor { + private val pending = HashMap() + private val removed = HashSet() + private var clearPending = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, values: MutableSet?) = apply { pending[key] = values } + override fun putInt(key: String, value: Int) = apply { pending[key] = value } + override fun putLong(key: String, value: Long) = apply { pending[key] = value } + override fun putFloat(key: String, value: Float) = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value } + override fun remove(key: String) = apply { removed.add(key) } + override fun clear() = apply { clearPending = true } + + override fun commit(): Boolean { + applyChanges() + return true + } + + override fun apply() { + applyChanges() + } + + private fun applyChanges() { + if (clearPending) store.clear() + for (k in removed) store.remove(k) + for ((k, v) in pending) { + if (v == null) store.remove(k) else store[k] = v + } + } + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt new file mode 100644 index 000000000..95038f5c9 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/CryptoServiceAwaitTest.kt @@ -0,0 +1,54 @@ +package com.internxt.cloud.documents.crypto + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.IOException + +class CryptoServiceAwaitTest { + + @Test + fun `when the callback reports no error, then it resumes without throwing`() = runTest { + awaitCryptoService("should not surface") { cb -> cb.onComplete(null) } + } + + @Test + fun `when the callback reports an IOException, then it surfaces it as an IOException`() = runTest { + val original = IOException("boom") + + val thrown = runCatching { + awaitCryptoService(CRYPTO_FAILED_MESSAGE) { cb -> cb.onComplete(original) } + }.exceptionOrNull() + + val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") + assertEquals("boom", io.message) + } + + @Test + fun `when the callback reports a non-IO error, then it wraps it in an IOException keeping the cause`() = runTest { + val original = IllegalStateException("nope") + + val thrown = runCatching { + awaitCryptoService(CRYPTO_FAILED_MESSAGE) { cb -> cb.onComplete(original) } + }.exceptionOrNull() + + val io = thrown as? IOException ?: return@runTest fail("expected IOException but got $thrown") + assertEquals(CRYPTO_FAILED_MESSAGE, io.message) + assertTrue("original cause not preserved in chain", io.hasCauseWithMessage("nope")) + } + + private fun Throwable.hasCauseWithMessage(message: String): Boolean { + var current: Throwable? = cause + while (current != null) { + if (current.message == message) return true + current = current.cause + } + return false + } + + companion object { + private const val CRYPTO_FAILED_MESSAGE = "crypto failed" + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt new file mode 100644 index 000000000..5d14888fa --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/crypto/FileKeyDeriverTest.kt @@ -0,0 +1,31 @@ +package com.internxt.cloud.documents.crypto + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FileKeyDeriverTest { + + @Test + fun deriveFileKeyMatchesDriveWebForCanonicalTriple() { + val fileKey = FileKeyDeriver.deriveFileKey(MNEMONIC, BUCKET_ID, INDEX_HEX) + assertEquals(EXPECTED_FILE_KEY_HEX, fileKey.toHex().lowercase()) + } + + @Test + fun deriveIvMatchesFirst16BytesOfIndex() { + val iv = FileKeyDeriver.deriveIv(INDEX_HEX) + assertEquals(EXPECTED_IV_HEX, iv.toHex().lowercase()) + } + + companion object { + private const val MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private const val BUCKET_ID = "a1b2c3d4e5f6a1b2c1d2e3f4a5b6c7d8" + private const val INDEX_HEX = + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + + private const val EXPECTED_FILE_KEY_HEX = + "186473ae7deaa32c1b5b9b1c7708c2c29098f24664ece11abfff56839c630fbb" + private const val EXPECTED_IV_HEX = "abcdef1234567890abcdef1234567890" + } +} 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) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt new file mode 100644 index 000000000..be34ab61b --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/download/EncryptedFileDownloaderTest.kt @@ -0,0 +1,92 @@ +package com.internxt.cloud.documents.download + +import com.internxt.cloud.documents.api.model.Shard +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.coroutines.cancellation.CancellationException + +class EncryptedFileDownloaderTest { + + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var tempDir: File + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + client = OkHttpClient() + tempDir = Files.createTempDirectory("downloader-test").toFile() + } + + @After + fun tearDown() { + server.shutdown() + tempDir.deleteRecursively() + } + + private fun shard(index: Int, path: String, size: Int): Shard = + Shard(index = index, size = size.toLong(), hash = "h$index", url = server.url(path).toString()) + + @Test + fun `when shards are served out of order, then bytes are written in index order`() = runTest { + val part0 = ByteArray(2_000) { it.toByte() } + val part1 = ByteArray(1_500) { (it + 7).toByte() } + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(part0))) + server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(part1))) + val target = File(tempDir, "out.bin") + + val shards = listOf(shard(1, "/p1", part1.size), shard(0, "/p0", part0.size)) + EncryptedFileDownloader.download(client, shards, target) + + assertArrayEquals(part0 + part1, target.readBytes()) + } + + @Test + fun `when the coroutine is cancelled mid transfer, then it raises cancellation not a network error`() { + val requestReceived = CompletableDeferred() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requestReceived.complete(Unit) + Thread.sleep(2_000) + return MockResponse().setResponseCode(200).setBody("late") + } + } + val target = File(tempDir, "cancel.bin") + val shards = listOf(shard(0, "/slow", 4)) + val thrown = CompletableDeferred() + + runBlocking { + val job = launch(Dispatchers.IO) { + try { + EncryptedFileDownloader.download(client, shards, target) + } catch (t: Throwable) { + thrown.complete(t) + throw t + } + } + requestReceived.await() + job.cancel() + job.join() + } + + assertTrue( + "expected cancellation but got ${thrown.getCompleted().javaClass.name}", + thrown.getCompleted() is CancellationException, + ) + } +} diff --git a/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt new file mode 100644 index 000000000..2fcbee3b5 --- /dev/null +++ b/android/app/src/test/java/com/internxt/cloud/documents/upload/EncryptedFileUploaderTest.kt @@ -0,0 +1,184 @@ +package com.internxt.cloud.documents.upload + +import com.internxt.cloud.documents.crypto.Ripemd160 +import com.internxt.cloud.documents.crypto.toHex +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.cancellation.CancellationException + +/** + * Encryption itself is exercised by the `@internxt/rn-crypto` package's own test suite + * (`EncryptFileRepositoryTest`) — we don't re-verify the cipher here. These tests cover + * the upload + hashing logic, building the encrypted input with a control AES-CTR call + * so we know exactly what bytes the uploader is meant to PUT. + */ +class EncryptedFileUploaderTest { + + companion object { + private const val SHA_256 = "SHA-256" + } + + private lateinit var server: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var tempDir: File + + private val key = ByteArray(32) { it.toByte() } + private val iv = ByteArray(16) { (it + 1).toByte() } + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + client = OkHttpClient() + tempDir = Files.createTempDirectory("uploader-test").toFile() + } + + @After + fun tearDown() { + server.shutdown() + tempDir.deleteRecursively() + } + + private fun controlEncrypt(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(plain) + } + + private fun writeEncrypted(name: String, plain: ByteArray): File = + File(tempDir, name).apply { writeBytes(controlEncrypt(plain)) } + + @Test + fun uploadSinglePutsTempFileContentsAndReportsCorrectHash() = runTest { + val plain = ByteArray(8_000) { it.toByte() } + val tempEnc = writeEncrypted("single.enc", plain) + val encBytes = tempEnc.readBytes() + val sha = MessageDigest.getInstance(SHA_256).digest(encBytes) + + server.enqueue(MockResponse().setResponseCode(200)) + val url = server.url("/upload").toString() + EncryptedFileUploader.uploadSingle(client, tempEnc, url) + + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + assertEquals("/upload", recorded.path) + assertArrayEquals(encBytes, recorded.body.readByteArray()) + + val computed = EncryptedFileUploader.computeShardHash(listOf(sha.toHex())) + val expected = Ripemd160.digest(sha).toHex() + assertEquals(expected, computed) + } + + @Test + fun uploadMultipartCollectsEtagsAndPartHashesMatchSlices() = runTest { + val partSize = 4_000L + val plain = ByteArray(13_000) { it.toByte() } + val tempEnc = writeEncrypted("multi.enc", plain) + val totalSize = tempEnc.length() + + val urls = (1..4).map { idx -> + server.enqueue(MockResponse().setResponseCode(200).setHeader("ETag", "etag-$idx")) + server.url("/part-$idx").toString() + } + + val parts = EncryptedFileUploader.uploadMultipart( + client = client, + tempEnc = tempEnc, + urls = urls, + partSize = partSize, + ) + + assertEquals(listOf(1, 2, 3, 4), parts.map { it.partNumber }) + assertEquals(listOf("etag-1", "etag-2", "etag-3", "etag-4"), parts.map { it.etag }) + + val sentParts = (1..4).map { server.takeRequest() } + var offset = 0 + sentParts.forEachIndexed { i, req: RecordedRequest -> + val expectedLength = ((offset + partSize).coerceAtMost(totalSize) - offset).toInt() + val body = req.body.readByteArray() + assertEquals("part ${i + 1} length", expectedLength, body.size) + assertArrayEquals( + "part ${i + 1} bytes", + tempEnc.readBytes().copyOfRange(offset, offset + expectedLength), + body, + ) + offset += expectedLength + } + + // hash parity with the RN multipart formula + val partHashes = EncryptedFileUploader.computePartSha256(tempEnc, partSize) + val bytes = tempEnc.readBytes() + val expected = partHashes.mapIndexed { i, h -> + val start = (i * partSize).toInt() + val end = (start + partSize.toInt()).coerceAtMost(bytes.size) + assertEquals(MessageDigest.getInstance(SHA_256).digest(bytes.copyOfRange(start, end)).toHex(), h) + h + } + val computed = EncryptedFileUploader.computeShardHash(expected) + val expectedHash = Ripemd160.digest(hexDecode(expected.joinToString(""))).toHex() + assertEquals(expectedHash, computed) + } + + @Test + fun `when the upload coroutine is cancelled mid PUT, then it raises cancellation not a failure`() { + val requestReceived = CompletableDeferred() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requestReceived.complete(Unit) + Thread.sleep(2_000) + return MockResponse().setResponseCode(200) + } + } + val tempEnc = writeEncrypted("cancel.enc", ByteArray(8_000) { it.toByte() }) + val url = server.url("/slow").toString() + val thrown = CompletableDeferred() + + runBlocking { + val job = launch(Dispatchers.IO) { + try { + EncryptedFileUploader.uploadSingle(client, tempEnc, url) + } catch (t: Throwable) { + thrown.complete(t) + throw t + } + } + requestReceived.await() + job.cancel() + job.join() + } + + assertTrue( + "expected cancellation but got ${thrown.getCompleted().javaClass.name}", + thrown.getCompleted() is CancellationException, + ) + } + + private fun hexDecode(hex: String): ByteArray { + val out = ByteArray(hex.length / 2) + for (i in out.indices) { + val hi = Character.digit(hex[i * 2], 16) + val lo = Character.digit(hex[i * 2 + 1], 16) + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } +} diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index b35200d7a..f148391a4 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -13,20 +13,61 @@ 1A3E7803EDA342F39787DC8F /* PHAssetExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */; }; 2F8E878CDCAC7D5C07B5C670 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 512CF1042062583CD79DC618 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = DE1D47552F87AB4000E14783 /* InternxtSwiftCore */; }; + 517593AA71C926C43E076321 /* FileProviderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47632F87A94000E14783 /* FileProviderExtension.swift */; }; 5EECD9AA8FE2941A3CB8B8EF /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */; }; 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; + AB12CD34EF56AB78CD90EF12 /* InternxtFileProvider.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = D330686328BD4286AB778B88 /* PHAssetExportModule.m */; }; A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */; }; A1B2C3D4E5F601234567890B /* PHBurstExportModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */; }; - AC90D7AE08E1AB3023417F70 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */; }; + AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */; }; + AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */; }; + AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */; }; + AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; + AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */; }; + AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */; }; + AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */; }; + AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */; }; + AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */; }; + AB1D47962F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; + ABEEE0748E6ACDC97FD4A52E /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + CEF3BECD880147688CACD8B5 /* FileProviderEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */; }; DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */; }; DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; }; + E969AA12C7375D21048F65E7 /* libPods-InternxtFileProvider.a in Frameworks */ = {isa = PBXBuildFile; fileRef = B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + F603918B0E78918549FB9494 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; + FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */; }; + FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */; }; + FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */ = {isa = PBXBuildFile; productRef = FB1D480412F87A94000E14783 /* InternxtSwiftCore */; }; + FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */; }; + FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1D47652F87A94000E14783 /* FileProviderItem.swift */; }; + FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; + FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */; }; + FB1D481512F87A94000E14783 /* SharedAuthKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */; }; + FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010002F90000000E14783 /* FileProviderModule.swift */; }; + FF2010012F90000000E14783 /* FileProviderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1010012F90000000E14783 /* FileProviderModule.m */; }; + FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010022F90000000E14783 /* SyncAnchorStore.swift */; }; + FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */; }; + FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + DE1D474D2F87A94000E14783 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DE1D47402F87A94000E14783; + remoteInfo = InternxtFileProvider; + }; DEA227B339134920AF95B704 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; @@ -43,6 +84,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + AB12CD34EF56AB78CD90EF12 /* InternxtFileProvider.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -72,15 +114,37 @@ 8519E68C15D0C6CCDC034276 /* Pods-InternxtShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.release.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.release.xcconfig"; sourceTree = ""; }; 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHAssetExportModule.swift; path = Internxt/PHAssetExportModule.swift; sourceTree = ""; }; + 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtFileProvider.release.xcconfig"; path = "Target Support Files/Pods-InternxtFileProvider/Pods-InternxtFileProvider.release.xcconfig"; sourceTree = ""; }; A1B2C3D4E5F6012345678901 /* PHBurstExportModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PHBurstExportModule.swift; path = Internxt/PHBurstExportModule.swift; sourceTree = ""; }; A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHBurstExportModule.m; path = Internxt/PHBurstExportModule.m; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; + AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SharedAuthKeychain.swift; path = Internxt/SharedAuthKeychain.swift; sourceTree = ""; }; + AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderDomainManager.swift; path = Internxt/FileProviderDomainManager.swift; sourceTree = ""; }; + AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = InternxtAuthCredentialsModule.swift; path = Internxt/InternxtAuthCredentialsModule.swift; sourceTree = ""; }; + AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = InternxtAuthCredentialsModule.m; path = Internxt/InternxtAuthCredentialsModule.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; + AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItemIdentifier.swift; sourceTree = ""; }; + AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveAPIFactory.swift; sourceTree = ""; }; + AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFacadeFactory.swift; sourceTree = ""; }; + AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainCredentials.swift; sourceTree = ""; }; + AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapper.swift; sourceTree = ""; }; + AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderUploadService.swift; sourceTree = ""; }; + AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDownloadService.swift; sourceTree = ""; }; + AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationService.swift; sourceTree = ""; }; + AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtFileProvider.debug.xcconfig"; path = "Target Support Files/Pods-InternxtFileProvider/Pods-InternxtFileProvider.debug.xcconfig"; sourceTree = ""; }; + B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtFileProvider.a"; sourceTree = BUILT_PRODUCTS_DIR; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; }; D330686328BD4286AB778B88 /* PHAssetExportModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PHAssetExportModule.m; path = Internxt/PHAssetExportModule.m; sourceTree = ""; }; D83881814DD0A20237F63249 /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; }; DAE15E8DB4DF4E048E04B0E0 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = InternxtFileProvider.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DE1D47632F87A94000E14783 /* FileProviderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderExtension.swift; sourceTree = ""; }; + DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderEnumerator.swift; sourceTree = ""; }; + DE1D47652F87A94000E14783 /* FileProviderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderItem.swift; sourceTree = ""; }; + DE1D47662F87A94000E14783 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InternxtFileProvider.entitlements; sourceTree = ""; }; + DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = InternxtFileProvider.plist; sourceTree = ""; }; DE74E3972FACD7205D98C2BD /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupPendingShareModule.m; path = Internxt/AppGroupPendingShareModule.m; sourceTree = ""; }; DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupPendingShareModule.swift; path = Internxt/AppGroupPendingShareModule.swift; sourceTree = ""; }; @@ -88,6 +152,13 @@ F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; }; F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderErrorMapperTests.swift; sourceTree = ""; }; + FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InternxtFileProviderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderMutationTests.swift; sourceTree = ""; }; + FF1010002F90000000E14783 /* FileProviderModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FileProviderModule.swift; path = Internxt/FileProviderModule.swift; sourceTree = ""; }; + FF1010012F90000000E14783 /* FileProviderModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FileProviderModule.m; path = Internxt/FileProviderModule.m; sourceTree = ""; }; + FF1010022F90000000E14783 /* SyncAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStore.swift; sourceTree = ""; }; + FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAnchorStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,7 +166,16 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - AC90D7AE08E1AB3023417F70 /* libPods-Internxt.a in Frameworks */, + ABEEE0748E6ACDC97FD4A52E /* libPods-Internxt.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2A04AF4D95A14473303BAABA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 512CF1042062583CD79DC618 /* InternxtSwiftCore in Frameworks */, + E969AA12C7375D21048F65E7 /* libPods-InternxtFileProvider.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -107,6 +187,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FB1D480612F87A94000E14783 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB1D480312F87A94000E14783 /* InternxtSwiftCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -115,6 +203,12 @@ children = ( DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */, DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */, + AA0100002F87A94000E14783 /* SharedAuthKeychain.swift */, + AA0100022F87A94000E14783 /* FileProviderDomainManager.swift */, + AA0100042F87A94000E14783 /* InternxtAuthCredentialsModule.swift */, + AA0100062F87A94000E14783 /* InternxtAuthCredentialsModule.m */, + FF1010002F90000000E14783 /* FileProviderModule.swift */, + FF1010012F90000000E14783 /* FileProviderModule.m */, D330686328BD4286AB778B88 /* PHAssetExportModule.m */, 9949B2A33B5D4CA2BD828B67 /* PHAssetExportModule.swift */, A1B2C3D4E5F6012345678902 /* PHBurstExportModule.m */, @@ -136,6 +230,7 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */, F2CA5C712E32AED3D806BBC6 /* libPods-Internxt.a */, 92F299358DE5ADDA8D13CA27 /* libPods-InternxtShareExtension.a */, + B795FB0D0D82BE9A80DF7EBF /* libPods-InternxtFileProvider.a */, ); name = Frameworks; sourceTree = ""; @@ -165,6 +260,8 @@ D83881814DD0A20237F63249 /* Pods-Internxt.release.xcconfig */, 7C701A830CDF1F4D18966C60 /* Pods-InternxtShareExtension.debug.xcconfig */, 8519E68C15D0C6CCDC034276 /* Pods-InternxtShareExtension.release.xcconfig */, + AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */, + 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -181,6 +278,8 @@ children = ( 13B07FAE1A68108700A75B9A /* Internxt */, 832341AE1AAA6A7D00B99B32 /* Libraries */, + DE1D47442F87A94000E14783 /* InternxtFileProvider */, + FB1D480712F87A94000E14783 /* InternxtFileProviderTests */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 7151A997BCA04521BD294E7B /* InternxtShareExtension */, @@ -197,6 +296,8 @@ children = ( 13B07F961A680F5B00A75B9A /* Internxt.app */, 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */, + DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */, + FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */, ); name = Products; sourceTree = ""; @@ -227,6 +328,38 @@ name = InternxtShareExtension; sourceTree = ""; }; + DE1D47442F87A94000E14783 /* InternxtFileProvider */ = { + isa = PBXGroup; + children = ( + DE1D47632F87A94000E14783 /* FileProviderExtension.swift */, + FF1010022F90000000E14783 /* SyncAnchorStore.swift */, + DE1D47642F87A94000E14783 /* FileProviderEnumerator.swift */, + DE1D47652F87A94000E14783 /* FileProviderItem.swift */, + AB1D47912F87A94000E14783 /* FileProviderErrorMapper.swift */, + AB1D47932F87A94000E14783 /* FileProviderUploadService.swift */, + AB1D47952F87A94000E14783 /* FileProviderDownloadService.swift */, + AB1D47972F87A94000E14783 /* FileProviderMutationService.swift */, + AB1D47722F87A94000E14783 /* FileProviderItemIdentifier.swift */, + AB1D47732F87A94000E14783 /* DriveAPIFactory.swift */, + AB1D47822F87A94000E14783 /* NetworkFacadeFactory.swift */, + AB1D47832F87A94000E14783 /* SharedKeychainCredentials.swift */, + DE1D47662F87A94000E14783 /* Info.plist */, + DE1D47672F87A94000E14783 /* InternxtFileProvider.entitlements */, + DE1D47682F87A94000E14783 /* InternxtFileProvider.plist */, + ); + path = InternxtFileProvider; + sourceTree = ""; + }; + FB1D480712F87A94000E14783 /* InternxtFileProviderTests */ = { + isa = PBXGroup; + children = ( + FB1D480112F87A94000E14783 /* FileProviderErrorMapperTests.swift */, + FB1D481112F87A94000E14783 /* FileProviderMutationTests.swift */, + FF1010032F90000000E14783 /* SyncAnchorStoreTests.swift */, + ); + path = InternxtFileProviderTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -249,6 +382,7 @@ ); dependencies = ( 03ED76FE58CE4782913172E7 /* PBXTargetDependency */, + DE1D474E2F87A94000E14783 /* PBXTargetDependency */, ); name = Internxt; productName = Internxt; @@ -277,16 +411,56 @@ productReference = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + DE1D47402F87A94000E14783 /* InternxtFileProvider */ = { + isa = PBXNativeTarget; + buildConfigurationList = DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */; + buildPhases = ( + 74E905CB0B70CDBE606791CD /* [CP] Check Pods Manifest.lock */, + DE1D473D2F87A94000E14783 /* Sources */, + DE1D473F2F87A94000E14783 /* Resources */, + 2A04AF4D95A14473303BAABA /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = InternxtFileProvider; + packageProductDependencies = ( + DE1D47552F87AB4000E14783 /* InternxtSwiftCore */, + ); + productName = InternxtFileProvider; + productReference = DE1D47412F87A94000E14783 /* InternxtFileProvider.appex */; + productType = "com.apple.product-type.app-extension"; + }; + FB1D480812F87A94000E14783 /* InternxtFileProviderTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FB1D480912F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProviderTests" */; + buildPhases = ( + FB1D480A12F87A94000E14783 /* Sources */, + FB1D480612F87A94000E14783 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = InternxtFileProviderTests; + packageProductDependencies = ( + FB1D480412F87A94000E14783 /* InternxtSwiftCore */, + ); + productName = InternxtFileProviderTests; + productReference = FB1D480512F87A94000E14783 /* InternxtFileProviderTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { - DevelopmentTeam = JR4S3SY396; LastSwiftMigration = 1250; ProvisioningStyle = Automatic; }; @@ -295,6 +469,12 @@ LastSwiftMigration = 1250; ProvisioningStyle = Automatic; }; + DE1D47402F87A94000E14783 = { + CreatedOnToolsVersion = 26.4; + }; + FB1D480812F87A94000E14783 = { + CreatedOnToolsVersion = 26.4; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Internxt" */; @@ -306,12 +486,17 @@ Base, ); mainGroup = 83CBB9F61A601CBA00E9B192; + packageReferences = ( + DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */, + ); productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* Internxt */, 573EC8782D084646801534FA /* InternxtShareExtension */, + DE1D47402F87A94000E14783 /* InternxtFileProvider */, + FB1D480812F87A94000E14783 /* InternxtFileProviderTests */, ); }; /* End PBXProject section */ @@ -335,6 +520,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DE1D473F2F87A94000E14783 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -445,6 +637,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n"; showEnvVarsInLog = 0; }; + 74E905CB0B70CDBE606791CD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-InternxtFileProvider-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -604,6 +818,14 @@ 1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */, DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */, DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */, + AA0100012F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, + AA0100032F87A94000E14783 /* FileProviderDomainManager.swift in Sources */, + AA0100052F87A94000E14783 /* InternxtAuthCredentialsModule.swift in Sources */, + AA0100072F87A94000E14783 /* InternxtAuthCredentialsModule.m in Sources */, + FF2010002F90000000E14783 /* FileProviderModule.swift in Sources */, + FF2010012F90000000E14783 /* FileProviderModule.m in Sources */, + FF2010032F90000000E14783 /* SyncAnchorStore.swift in Sources */, + FF2010062F90000000E14783 /* FileProviderItemIdentifier.swift in Sources */, 1A3E7803EDA342F39787DC8F /* PHAssetExportModule.swift in Sources */, 9DB4D633CA40422A956585FD /* PHAssetExportModule.m in Sources */, A1B2C3D4E5F601234567890A /* PHBurstExportModule.swift in Sources */, @@ -620,6 +842,42 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DE1D473D2F87A94000E14783 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF2010022F90000000E14783 /* SyncAnchorStore.swift in Sources */, + AB1D47902F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, + AB1D47922F87A94000E14783 /* FileProviderUploadService.swift in Sources */, + AB1D47942F87A94000E14783 /* FileProviderDownloadService.swift in Sources */, + AB1D47962F87A94000E14783 /* FileProviderMutationService.swift in Sources */, + AB1D47702F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, + AB1D47712F87A94000E14783 /* DriveAPIFactory.swift in Sources */, + AB1D47802F87A94000E14783 /* NetworkFacadeFactory.swift in Sources */, + AB1D47812F87A94000E14783 /* SharedKeychainCredentials.swift in Sources */, + AA0100082F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, + F603918B0E78918549FB9494 /* FileProviderItem.swift in Sources */, + 517593AA71C926C43E076321 /* FileProviderExtension.swift in Sources */, + CEF3BECD880147688CACD8B5 /* FileProviderEnumerator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FB1D480A12F87A94000E14783 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB1D480012F87A94000E14783 /* FileProviderErrorMapperTests.swift in Sources */, + FB1D480212F87A94000E14783 /* FileProviderErrorMapper.swift in Sources */, + FB1D481012F87A94000E14783 /* FileProviderMutationTests.swift in Sources */, + FF2010042F90000000E14783 /* SyncAnchorStore.swift in Sources */, + FF2010052F90000000E14783 /* SyncAnchorStoreTests.swift in Sources */, + FB1D481212F87A94000E14783 /* FileProviderItem.swift in Sources */, + FB1D481312F87A94000E14783 /* FileProviderItemIdentifier.swift in Sources */, + FB1D481412F87A94000E14783 /* FileProviderMutationService.swift in Sources */, + FB1D481512F87A94000E14783 /* SharedAuthKeychain.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -628,6 +886,11 @@ target = 573EC8782D084646801534FA /* InternxtShareExtension */; targetProxy = DEA227B339134920AF95B704 /* PBXContainerItemProxy */; }; + DE1D474E2F87A94000E14783 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DE1D47402F87A94000E14783 /* InternxtFileProvider */; + targetProxy = DE1D474D2F87A94000E14783 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -878,6 +1141,154 @@ }; name = Release; }; + DE1D47502F87A94000E14783 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AEF5ED5D913A5F0C6A1779E7 /* Pods-InternxtFileProvider.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = InternxtFileProvider/InternxtFileProvider.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = JR4S3SY396; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = InternxtFileProvider/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = InternxtFileProvider; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.10.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DE1D47512F87A94000E14783 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9EBA538C165E183B083F129B /* Pods-InternxtFileProvider.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = InternxtFileProvider/InternxtFileProvider.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = JR4S3SY396; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = InternxtFileProvider/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = InternxtFileProvider; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.10.0; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProvider; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FB1D480B12F87A94000E14783 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProviderTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FB1D480C12F87A94000E14783 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks.InternxtFileProviderTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -908,7 +1319,49 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DE1D47532F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProvider" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DE1D47502F87A94000E14783 /* Debug */, + DE1D47512F87A94000E14783 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FB1D480912F87A94000E14783 /* Build configuration list for PBXNativeTarget "InternxtFileProviderTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FB1D480B12F87A94000E14783 /* Debug */, + FB1D480C12F87A94000E14783 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/internxt/swift-core"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DE1D47552F87AB4000E14783 /* InternxtSwiftCore */ = { + isa = XCSwiftPackageProductDependency; + package = DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */; + productName = InternxtSwiftCore; + }; + FB1D480412F87A94000E14783 /* InternxtSwiftCore */ = { + isa = XCSwiftPackageProductDependency; + package = DE1D47542F87AB4000E14783 /* XCRemoteSwiftPackageReference "swift-core" */; + productName = InternxtSwiftCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; } diff --git a/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme new file mode 100644 index 000000000..dbf6fd211 --- /dev/null +++ b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProvider.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme new file mode 100644 index 000000000..5492a35d3 --- /dev/null +++ b/ios/Internxt.xcodeproj/xcshareddata/xcschemes/InternxtFileProviderTests.xcscheme @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..2e8b82375 --- /dev/null +++ b/ios/Internxt.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "f8d6cbc56ef3d7b4288ed213a329a2171e922e22cab926463add5c4d1f99c732", + "pins" : [ + { + "identity" : "idzswiftcommoncrypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/iosdevzone/IDZSwiftCommonCrypto.git", + "state" : { + "revision" : "47f9747d12137cd59d783ab376e3ccab7206319b", + "version" : "0.16.1" + } + }, + { + "identity" : "swift-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/internxt/swift-core", + "state" : { + "branch" : "main", + "revision" : "ab849424b8e12641207184fe3695de0c69a0de80" + } + } + ], + "version" : 3 +} diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift index 574040176..f733c7a96 100644 --- a/ios/Internxt/AppDelegate.swift +++ b/ios/Internxt/AppDelegate.swift @@ -1,7 +1,6 @@ import Expo import React import ReactAppDependencyProvider -import Security @UIApplicationMain public class AppDelegate: ExpoAppDelegate { @@ -44,81 +43,7 @@ public class AppDelegate: ExpoAppDelegate { // MARK: - App Group auth sync private func syncAuthStatusToAppGroup() { - guard let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String - else { return } - - let isAuthenticated = privateKeychainItemExists(key: "photosToken") - - if isAuthenticated { - copyToSharedKeychain(privateKey: "photosToken", sharedKey: "shared_photosToken", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_mnemonic", sharedKey: "shared_mnemonic", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_rootFolderId", sharedKey: "shared_rootFolderId", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_bucket", sharedKey: "shared_bucket", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_bridgeUser", sharedKey: "shared_bridgeUser", accessGroup: sharedGroup) - copyToSharedKeychain(privateKey: "xUser_userId", sharedKey: "shared_userId", accessGroup: sharedGroup) - } else { - deleteFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_mnemonic", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_rootFolderId", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_bucket", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_bridgeUser", accessGroup: sharedGroup) - deleteFromSharedKeychain(key: "shared_userId", accessGroup: sharedGroup) - } - - if privateKeychainItemExists(key: "themePreference") { - copyToSharedKeychain(privateKey: "themePreference", sharedKey: "shared_themePreference", accessGroup: sharedGroup) - } else { - deleteFromSharedKeychain(key: "shared_themePreference", accessGroup: sharedGroup) - } - } - - private func privateKeychainItemExists(key: String) -> Bool { - return readFromPrivateKeychain(key: key) != nil - } - - private func copyToSharedKeychain(privateKey: String, sharedKey: String, accessGroup: String) { - guard let data = readFromPrivateKeychain(key: privateKey) else { return } - writeToSharedKeychain(data: data, key: sharedKey, accessGroup: accessGroup) - } - - private func readFromPrivateKeychain(key: String) -> Data? { - var result: AnyObject? - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { return nil } - return data - } - - private func writeToSharedKeychain(data: Data, key: String, accessGroup: String) { - deleteFromSharedKeychain(key: key, accessGroup: accessGroup) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecAttrAccessGroup as String: accessGroup, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, - kSecValueData as String: data, - ] - SecItemAdd(query as CFDictionary, nil) - } - - private func deleteFromSharedKeychain(key: String, accessGroup: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "app:no-auth", - kSecAttrGeneric as String: Data(key.utf8), - kSecAttrAccount as String: Data(key.utf8), - kSecAttrAccessGroup as String: accessGroup, - ] - SecItemDelete(query as CFDictionary) + SharedAuthKeychain.syncFromPrivateKeychain() } // Linking API diff --git a/ios/Internxt/FileProviderDomainManager.swift b/ios/Internxt/FileProviderDomainManager.swift new file mode 100644 index 000000000..7018b1f85 --- /dev/null +++ b/ios/Internxt/FileProviderDomainManager.swift @@ -0,0 +1,69 @@ +import FileProvider +import Foundation + +@available(iOS 16.0, *) +enum FileProviderDomainManager { + static let domainIdentifier = NSFileProviderDomainIdentifier("com.internxt.drive") + static let displayName = "Internxt Drive" + + private static var domain: NSFileProviderDomain { + NSFileProviderDomain(identifier: domainIdentifier, displayName: displayName) + } + + static func registerDomain(completion: @escaping (Error?) -> Void) { + NSFileProviderManager.add(domain) { error in + completion(isAlreadyExists(error) ? nil : error) + } + } + + static func unregisterDomain(completion: @escaping (Error?) -> Void) { + NSFileProviderManager.removeAllDomains { removeError in + completion(isNotFound(removeError) ? nil : removeError) + } + } + + static func stabilize(completion: @escaping (Error?) -> Void) { + guard let manager = NSFileProviderManager(for: domain) else { + completion(NSError( + domain: "FileProviderDomainManager", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No NSFileProviderManager for domain \(domainIdentifier.rawValue)"] + )) + return + } + manager.waitForStabilization { error in + completion(error) + } + } + + static func signalEnumeration(completion: @escaping (Error?) -> Void) { + signalEnumeration(for: .workingSet, completion: completion) + } + + static func signalEnumeration( + for container: NSFileProviderItemIdentifier, + completion: @escaping (Error?) -> Void + ) { + guard let manager = NSFileProviderManager(for: domain) else { + completion(NSError( + domain: "FileProviderDomainManager", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No NSFileProviderManager for domain \(domainIdentifier.rawValue)"] + )) + return + } + manager.signalEnumerator(for: container) { error in + completion(error) + } + } + + private static func isAlreadyExists(_ error: Error?) -> Bool { + guard let error = error as NSError? else { return false } + return error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError + } + + private static func isNotFound(_ error: Error?) -> Bool { + guard let error = error as NSError? else { return false } + return error.domain == NSCocoaErrorDomain && error.code == NSFileNoSuchFileError + } +} diff --git a/ios/Internxt/FileProviderModule.m b/ios/Internxt/FileProviderModule.m new file mode 100644 index 000000000..ac7e5c3f0 --- /dev/null +++ b/ios/Internxt/FileProviderModule.m @@ -0,0 +1,7 @@ +#import + +@interface RCT_EXTERN_MODULE(InternxtSignalingModule, NSObject) +RCT_EXTERN_METHOD(notifyParentChanged:(NSString *)parentFolderUuid + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +@end diff --git a/ios/Internxt/FileProviderModule.swift b/ios/Internxt/FileProviderModule.swift new file mode 100644 index 000000000..993a3e7db --- /dev/null +++ b/ios/Internxt/FileProviderModule.swift @@ -0,0 +1,35 @@ +import FileProvider +import Foundation +import React + +@objc(InternxtSignalingModule) +class FileProviderModule: NSObject { + + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc func notifyParentChanged( + _ parentFolderUuid: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter _: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + resolver(nil) + return + } + + let store = SyncAnchorStore() + _ = store?.recordChange(parentUuid: parentFolderUuid) + + signalParent(parentFolderUuid, resolver: resolver) + } + + @available(iOS 16.0, *) + private func signalParent(_ parentFolderUuid: String, resolver: @escaping RCTPromiseResolveBlock) { + let container = FileProviderItemID.parentIdentifier(folderUuid: parentFolderUuid) + FileProviderDomainManager.signalEnumeration(for: container) { _ in + FileProviderDomainManager.signalEnumeration { _ in + resolver(nil) + } + } + } +} diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist index 941e5c8da..c03a7a5af 100644 --- a/ios/Internxt/Info.plist +++ b/ios/Internxt/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.10.2 + 1.11.0 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/Internxt/InternxtAuthCredentialsModule.m b/ios/Internxt/InternxtAuthCredentialsModule.m new file mode 100644 index 000000000..cf2088f40 --- /dev/null +++ b/ios/Internxt/InternxtAuthCredentialsModule.m @@ -0,0 +1,9 @@ +#import + +@interface RCT_EXTERN_MODULE(InternxtAuthCredentialsModule, NSObject) +RCT_EXTERN_METHOD(setCredentials:(NSDictionary *)creds + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(clearCredentials:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) +@end diff --git a/ios/Internxt/InternxtAuthCredentialsModule.swift b/ios/Internxt/InternxtAuthCredentialsModule.swift new file mode 100644 index 000000000..16db344de --- /dev/null +++ b/ios/Internxt/InternxtAuthCredentialsModule.swift @@ -0,0 +1,77 @@ +import Foundation +import React + +/// Bridges the RN auth lifecycle to iOS. On login the host app writes the auth +/// credentials into the shared Keychain (read by the File Provider extension) and +/// registers the File Provider domain; on logout it clears them and removes the +/// domain. iOS counterpart of the Android `InternxtAuthCredentialsModule` +/// (`setCredentials` + `notifyChange(roots)` / `clearCredentials` + `notifyChange(roots)`). +@objc(InternxtAuthCredentialsModule) +class InternxtAuthCredentialsModule: NSObject { + + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc func setCredentials( + _ creds: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + writeSharedCredentials(creds) + guard #available(iOS 16.0, *) else { + resolver(nil) + return + } + FileProviderDomainManager.registerDomain { error in + if let error = error { + rejecter("E_REGISTER_DOMAIN", error.localizedDescription, error as NSError) + return + } + FileProviderDomainManager.signalEnumeration { signalError in + if let signalError = signalError { + NSLog("InternxtAuthCredentialsModule: signalEnumeration failed (ignored): \(signalError.localizedDescription)") + } + FileProviderDomainManager.stabilize { stabilizeError in + if let stabilizeError = stabilizeError { + NSLog("InternxtAuthCredentialsModule: stabilize failed (ignored): \(stabilizeError.localizedDescription)") + } + } + resolver(nil) + } + } + } + + @objc func clearCredentials( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + SharedAuthKeychain.clearAll() + resolver(nil) + return + } + + FileProviderDomainManager.unregisterDomain { error in + SharedAuthKeychain.clearAll() + if let error = error { + rejecter("E_UNREGISTER_DOMAIN", error.localizedDescription, error as NSError) + return + } + resolver(nil) + } + } + + private func writeSharedCredentials(_ creds: NSDictionary) { + writeIfPresent(creds["bearerToken"], to: SharedAuthKeychain.photosTokenKey) + writeIfPresent(creds["mnemonic"], to: SharedAuthKeychain.mnemonicKey) + writeIfPresent(creds["rootFolderUuid"], to: SharedAuthKeychain.rootFolderIdKey) + writeIfPresent(creds["bridgeUser"], to: SharedAuthKeychain.bridgeUserKey) + writeIfPresent(creds["userId"], to: SharedAuthKeychain.userIdKey) + writeIfPresent(creds["driveBaseUrl"], to: SharedAuthKeychain.driveBaseUrlKey) + writeIfPresent(creds["bridgeBaseUrl"], to: SharedAuthKeychain.bridgeBaseUrlKey) + } + + private func writeIfPresent(_ value: Any?, to sharedKey: String) { + guard let value = value as? String, !value.isEmpty else { return } + SharedAuthKeychain.write(value, for: sharedKey) + } +} diff --git a/ios/Internxt/SharedAuthKeychain.swift b/ios/Internxt/SharedAuthKeychain.swift new file mode 100644 index 000000000..f9e12bdb8 --- /dev/null +++ b/ios/Internxt/SharedAuthKeychain.swift @@ -0,0 +1,148 @@ +import Foundation +import Security + +/// Single source of truth for the shared Keychain access group used to hand off +/// auth credentials from the host app to the share extension and the File +/// Provider extension. Extracted from `AppDelegate` so the native module and the +/// app-lifecycle sync write to the exact same items. +enum SharedAuthKeychain { + static let service = "app:no-auth" + + static let photosTokenKey = "shared_photosToken" + static let mnemonicKey = "shared_mnemonic" + static let rootFolderIdKey = "shared_rootFolderId" + static let bucketKey = "shared_bucket" + static let bridgeUserKey = "shared_bridgeUser" + static let userIdKey = "shared_userId" + static let driveBaseUrlKey = "shared_driveBaseUrl" + static let bridgeBaseUrlKey = "shared_bridgeBaseUrl" + static let themePreferenceKey = "shared_themePreference" + + static let allKeys = [ + photosTokenKey, mnemonicKey, rootFolderIdKey, bucketKey, bridgeUserKey, userIdKey, + driveBaseUrlKey, bridgeBaseUrlKey, + ] + + static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String + } + + static func read(_ sharedKey: String) -> Data? { + guard let accessGroup = accessGroup else { return nil } + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return data + } + + static func write(_ value: Data, for sharedKey: String) { + guard let accessGroup = accessGroup else { return } + delete(sharedKey) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + // These credentials are read by the File Provider extension in the + // background with no UI, so a user-authentication gate (SecAccessControl / + // .userPresence) is intentionally NOT used — it would block every + // background read. `AfterFirstUnlock` keeps the item readable once the + // device has been unlocked after boot while still protecting it before + // first unlock at boot. + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: value, + ] + SecItemAdd(query as CFDictionary, nil) + } + + static func write(_ value: String, for sharedKey: String) { + write(Data(value.utf8), for: sharedKey) + } + + static func delete(_ sharedKey: String) { + guard let accessGroup = accessGroup else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(sharedKey.utf8), + kSecAttrAccount as String: Data(sharedKey.utf8), + kSecAttrAccessGroup as String: accessGroup, + ] + SecItemDelete(query as CFDictionary) + } + + static func clearAll() { + allKeys.forEach(delete) + } + + static func readPrivate(_ privateKey: String) -> Data? { + var result: AnyObject? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrGeneric as String: Data(privateKey.utf8), + kSecAttrAccount as String: Data(privateKey.utf8), + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return data + } + + static func syncFromPrivateKeychain() { + guard accessGroup != nil else { return } + + syncThemePreference() + + let isAuthenticated = readPrivate("photosToken") != nil + guard isAuthenticated else { + clearAll() + return + } + + copyFromPrivate(privateKey: "photosToken", sharedKey: photosTokenKey) + copyFromPrivate(privateKey: "xUser_mnemonic", sharedKey: mnemonicKey, isJSONEncoded: true) + copyFromPrivate(privateKey: "xUser_rootFolderId", sharedKey: rootFolderIdKey, isJSONEncoded: true) + copyFromPrivate(privateKey: "xUser_bucket", sharedKey: bucketKey) + copyFromPrivate(privateKey: "xUser_bridgeUser", sharedKey: bridgeUserKey, isJSONEncoded: true) + copyFromPrivate(privateKey: "xUser_userId", sharedKey: userIdKey, isJSONEncoded: true) + } + + static func syncThemePreference() { + guard accessGroup != nil else { return } + if let data = readPrivate("themePreference") { + write(data, for: themePreferenceKey) + } else { + delete(themePreferenceKey) + } + } + + private static func copyFromPrivate(privateKey: String, sharedKey: String, isJSONEncoded: Bool = false) { + guard let data = readPrivate(privateKey) else { return } + write(isJSONEncoded ? jsonDecoded(data) : data, for: sharedKey) + } + + private static func jsonDecoded(_ data: Data) -> Data { + guard let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + return data + } + if let string = object as? String { + return Data(string.utf8) + } + if let number = object as? NSNumber { + return Data(number.stringValue.utf8) + } + return data + } +} diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist index 778050ce9..762fb8a6f 100644 --- a/ios/Internxt/Supporting/Expo.plist +++ b/ios/Internxt/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.10.2 + 1.11.0 EXUpdatesURL https://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2 diff --git a/ios/InternxtFileProvider/DriveAPIFactory.swift b/ios/InternxtFileProvider/DriveAPIFactory.swift new file mode 100644 index 000000000..dc498248e --- /dev/null +++ b/ios/InternxtFileProvider/DriveAPIFactory.swift @@ -0,0 +1,32 @@ +import Foundation +import InternxtSwiftCore + +enum DriveAPIFactory { + static func make() -> DriveAPI? { + guard let authToken = SharedKeychainCredentials.string(SharedAuthKeychain.photosTokenKey), + let baseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.driveBaseUrlKey) else { + return nil + } + + return DriveAPI( + baseUrl: baseUrl, + authToken: authToken, + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion + ) + } + + static func makeTrash() -> TrashAPI? { + guard let authToken = SharedKeychainCredentials.string(SharedAuthKeychain.photosTokenKey), + let baseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.driveBaseUrlKey) else { + return nil + } + + return TrashAPI( + baseUrl: baseUrl, + authToken: authToken, + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion + ) + } +} diff --git a/ios/InternxtFileProvider/FileProviderDownloadService.swift b/ios/InternxtFileProvider/FileProviderDownloadService.swift new file mode 100644 index 000000000..b2af83292 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderDownloadService.swift @@ -0,0 +1,37 @@ +// +// FileProviderDownloadService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderDownloadService { + let driveAPI: DriveAPI + let networkFacade: NetworkFacade + + func fetchContents( + uuid: String, + progressHandler: @escaping (Double) -> Void + ) async throws -> (url: URL, meta: GetFileMetaByIdResponse) { + let encryptedTmp = Self.temporaryFileURL() + let plainTmp = Self.temporaryFileURL() + defer { try? FileManager.default.removeItem(at: encryptedTmp) } + + let meta = try await driveAPI.getFileMetaByUuid(uuid: uuid) + _ = try await networkFacade.downloadFile( + bucketId: meta.bucket, + fileId: meta.fileId, + encryptedFileDestination: encryptedTmp, + destinationURL: plainTmp, + progressHandler: progressHandler + ) + return (plainTmp, meta) + } + + static func temporaryFileURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} diff --git a/ios/InternxtFileProvider/FileProviderEnumerator.swift b/ios/InternxtFileProvider/FileProviderEnumerator.swift new file mode 100644 index 000000000..b65ac2634 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderEnumerator.swift @@ -0,0 +1,252 @@ +// +// FileProviderEnumerator.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +class FileProviderEnumerator: NSObject, NSFileProviderEnumerator { + + private static let pageSize = 50 + private static let order = "ASC" + private static let folderContainerAnchor = NSFileProviderSyncAnchor(SyncAnchorStore.encode(0)) + + private enum Phase: String { + case folders + case files + } + + private struct Cursor { + let phase: Phase + let offset: Int + + static let initial = Cursor(phase: .folders, offset: 0) + + func encoded() -> NSFileProviderPage { + NSFileProviderPage("\(phase.rawValue):\(offset)".data(using: .utf8)!) + } + + static func decode(_ page: NSFileProviderPage) -> Cursor { + guard let raw = String(data: page.rawValue, encoding: .utf8), + let separatorIndex = raw.firstIndex(of: ":"), + let phase = Phase(rawValue: String(raw[raw.startIndex.. Void) { + completionHandler(isWorkingSet ? workingSetAnchor() : Self.folderContainerAnchor) + } + + func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + guard isWorkingSet else { + observer.finishEnumeratingChanges(upTo: Self.folderContainerAnchor, moreComing: false) + return + } + enumerateWorkingSetChanges(for: observer, from: anchor) + } + + private func enumerateWorkingSetChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + let incoming = SyncAnchorStore.decode(anchor.rawValue) ?? 0 + let current = workingSetAnchor() + + guard let store = syncAnchorStore else { + observer.finishEnumeratingChanges(upTo: NSFileProviderSyncAnchor(SyncAnchorStore.encode(incoming)), moreComing: false) + return + } + + let parents = store.changedParents(after: incoming) + + guard !parents.isEmpty, let driveAPI = DriveAPIFactory.make() else { + observer.finishEnumeratingChanges(upTo: current, moreComing: false) + return + } + + Task { + do { + for parentUuid in parents { + try await diffFolder(parentUuid, store: store, driveAPI: driveAPI, observer: observer) + } + observer.finishEnumeratingChanges(upTo: current, moreComing: false) + } catch { + observer.finishEnumeratingChanges( + upTo: NSFileProviderSyncAnchor(SyncAnchorStore.encode(incoming)), + moreComing: false + ) + } + } + } + + private func diffFolder( + _ parentUuid: String, + store: SyncAnchorStore, + driveAPI: DriveAPI, + observer: NSFileProviderChangeObserver + ) async throws { + let parent = FileProviderItemID.parentIdentifier(folderUuid: parentUuid) + let folderUuid = FileProviderItemID.folderUuid(for: parent) ?? parentUuid + + let currentItems = try await currentChildren(of: folderUuid, parent: parent, driveAPI: driveAPI) + let currentIds = currentItems.map { $0.itemIdentifier.rawValue } + let prevIds = store.snapshot(forFolderUuid: parentUuid) + let deletedIds = Set(prevIds).subtracting(currentIds) + + observer.didUpdate(currentItems) + if !deletedIds.isEmpty { + observer.didDeleteItems(withIdentifiers: deletedIds.map { NSFileProviderItemIdentifier($0) }) + } + store.saveSnapshot(currentIds, forFolderUuid: parentUuid) + } + + private func currentChildren( + of folderUuid: String, + parent: NSFileProviderItemIdentifier, + driveAPI: DriveAPI + ) async throws -> [FileProviderItem] { + var items: [FileProviderItem] = [] + try await forEachPage { offset in + let response = try await driveAPI.getFolderFolders( + folderUuid: folderUuid, offset: offset, limit: Self.pageSize, order: Self.order + ) + items.append(contentsOf: response.folders.map { FileProviderItem(folder: $0, parent: parent) }) + return response.folders.count + } + try await forEachPage { offset in + let response = try await driveAPI.getFolderFilesV2( + folderUuid: folderUuid, offset: offset, limit: Self.pageSize, order: Self.order + ) + items.append(contentsOf: response.files.map { FileProviderItem(file: $0, parent: parent) }) + return response.files.count + } + return items + } + + private func forEachPage(_ fetchPage: (_ offset: Int) async throws -> Int) async throws { + var offset = 0 + while true { + let count = try await fetchPage(offset) + if count < Self.pageSize { break } + offset += Self.pageSize + } + } + + private func workingSetAnchor() -> NSFileProviderSyncAnchor { + guard let store = syncAnchorStore else { + return NSFileProviderSyncAnchor(SyncAnchorStore.encode(0)) + } + return NSFileProviderSyncAnchor(store.currentData) + } + + private func notAuthenticatedError() -> Error { + NSFileProviderError(.notAuthenticated) + } + + private func enumerationError(from error: Error) -> Error { + if let apiError = error as? APIClientError, apiError.statusCode == 401 { + return notAuthenticatedError() + } + return error + } +} diff --git a/ios/InternxtFileProvider/FileProviderErrorMapper.swift b/ios/InternxtFileProvider/FileProviderErrorMapper.swift new file mode 100644 index 000000000..6e2dba37d --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderErrorMapper.swift @@ -0,0 +1,91 @@ +// +// FileProviderErrorMapper.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +enum FileProviderErrorMapper { + private static let offlineURLErrorCodes: Set = [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotConnectToHost, + .dataNotAllowed, + .cannotFindHost + ] + + private static let offlineErrorCodes: Set = [ + .networkNoConnection, + .networkConnectionLost, + .networkTimeout, + .networkCannotConnect + ] + + static func lookupError(from error: Error) -> Error { + if let alreadyMapped = error as? NSFileProviderError { + return alreadyMapped + } + if isUnauthorized(error) { + return NSFileProviderError(.notAuthenticated) + } + if isOffline(error) { + return NSFileProviderError(.serverUnreachable) + } + if isNameCollision(error) { + return NSFileProviderError(.filenameCollision) + } + return NSFileProviderError(.noSuchItem) + } + + static func isNameCollision(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if let cause = enriched.cause { + return isNameCollision(cause) + } + return false + } + if let apiError = error as? APIClientError { + return apiError.statusCode == 409 + } + return false + } + + static func isUnauthorized(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if enriched.code == .apiUnauthorized { + return true + } + if let cause = enriched.cause { + return isUnauthorized(cause) + } + return false + } + if let apiError = error as? APIClientError { + return apiError.statusCode == 401 + } + return false + } + + static func isOffline(_ error: Error) -> Bool { + if let enriched = error as? EnrichedError { + if offlineErrorCodes.contains(enriched.code) { + return true + } + if let cause = enriched.cause { + return isOffline(cause) + } + return false + } + if let urlError = error as? URLError { + return offlineURLErrorCodes.contains(urlError.code) + } + if let apiError = error as? APIClientError { + return apiError.statusCode <= 0 + } + return false + } +} diff --git a/ios/InternxtFileProvider/FileProviderExtension.swift b/ios/InternxtFileProvider/FileProviderExtension.swift new file mode 100644 index 000000000..67148c1af --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderExtension.swift @@ -0,0 +1,277 @@ +// +// FileProviderExtension.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore +import UniformTypeIdentifiers + +class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension { + private let rootDisplayName: String + + required init(domain: NSFileProviderDomain) { + self.rootDisplayName = domain.displayName + super.init() + } + + func invalidate() { + // Nothing to cancel: operations run in unretained Tasks that complete on + // their own, and the system ignores their callbacks after invalidation. + } + + func item(for identifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress { + if identifier == .rootContainer { + let rootItem = FileProviderItem.root(displayName: rootDisplayName) + completionHandler(rootItem, nil) + return Progress() + } + + guard let decoded = FileProviderItemID.decode(identifier) else { + completionHandler(nil, NSFileProviderError(.noSuchItem)) + return Progress() + } + + guard let driveAPI = DriveAPIFactory.make() else { + completionHandler(nil, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + let mutationService = FileProviderMutationService(driveAPI: driveAPI) + let progress = Progress(totalUnitCount: 1) + Task { + do { + let item = try await mutationService.resolveItem(decoded, identifier: identifier) + progress.completedUnitCount = 1 + completionHandler(item, nil) + } catch { + completionHandler(nil, FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier, version _: NSFileProviderItemVersion?, request _: NSFileProviderRequest, completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) -> Progress { + guard let decoded = FileProviderItemID.decode(itemIdentifier), decoded.kind == .file else { + completionHandler(nil, nil, NSFileProviderError(.noSuchItem)) + return Progress() + } + + guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { + completionHandler(nil, nil, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + let downloadService = FileProviderDownloadService(driveAPI: driveAPI, networkFacade: networkFacade) + let progress = Progress(totalUnitCount: 100) + Task { + do { + let result = try await downloadService.fetchContents(uuid: decoded.uuid) { fraction in + progress.completedUnitCount = Int64(fraction * 100) + } + let item = FileProviderItem(fileMeta: result.meta, identifier: itemIdentifier) + completionHandler(result.url, item, nil) + } catch { + completionHandler(nil, nil, FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + func createItem(basedOn itemTemplate: NSFileProviderItem, fields _: NSFileProviderItemFields, contents url: URL?, options _: NSFileProviderCreateItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + guard let driveAPI = DriveAPIFactory.make(), let networkFacade = NetworkFacadeFactory.make() else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard let parentUuid = FileProviderItemID.folderUuid(for: itemTemplate.parentItemIdentifier) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + + let parentIdentifier = itemTemplate.parentItemIdentifier + let uploadService = FileProviderUploadService(driveAPI: driveAPI, networkFacade: networkFacade) + + let isFolder = itemTemplate.contentType?.conforms(to: .folder) == true + + if isFolder { + return createFolder( + name: itemTemplate.filename, + parentUuid: parentUuid, + parentIdentifier: parentIdentifier, + uploadService: uploadService, + completionHandler: completionHandler + ) + } + + guard let contentsURL = url else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + + return uploadFile( + filename: itemTemplate.filename, + contentsURL: contentsURL, + parentUuid: parentUuid, + parentIdentifier: parentIdentifier, + uploadService: uploadService, + completionHandler: completionHandler + ) + } + + private func createFolder( + name: String, + parentUuid: String, + parentIdentifier: NSFileProviderItemIdentifier, + uploadService: FileProviderUploadService, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void + ) -> Progress { + let progress = Progress(totalUnitCount: 1) + Task { + do { + let response = try await uploadService.createFolder(name: name, parentUuid: parentUuid) + progress.completedUnitCount = 1 + let item = FileProviderItem(folder: response, parent: parentIdentifier) + completionHandler(item, [], false, nil) + } catch { + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + private func uploadFile( + filename: String, + contentsURL: URL, + parentUuid: String, + parentIdentifier: NSFileProviderItemIdentifier, + uploadService: FileProviderUploadService, + completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void + ) -> Progress { + let progress = Progress(totalUnitCount: 100) + Task { + let encryptedTmp = FileProviderDownloadService.temporaryFileURL() + defer { try? FileManager.default.removeItem(at: encryptedTmp) } + + do { + let result = try await uploadService.uploadFile( + filename: filename, + contentsURL: contentsURL, + parentUuid: parentUuid, + encryptedOutput: encryptedTmp, + progressHandler: { fraction in + progress.completedUnitCount = Int64(fraction * 100) + } + ) + + switch result { + case .created(let created): + let item = FileProviderItem(file: created, parent: parentIdentifier) + completionHandler(item, [], false, nil) + case .notAuthenticated: + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + case .noSuchItem: + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + } + } catch { + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + func modifyItem(_ item: NSFileProviderItem, baseVersion _: NSFileProviderItemVersion, changedFields: NSFileProviderItemFields, contents _: URL?, options _: NSFileProviderModifyItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) -> Progress { + let shouldRename = changedFields.contains(.filename) + let shouldMove = changedFields.contains(.parentItemIdentifier) + + guard shouldRename || shouldMove else { + completionHandler(item, [], false, nil) + return Progress() + } + + guard let driveAPI = DriveAPIFactory.make() else { + completionHandler(nil, [], false, NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard let decoded = FileProviderItemID.decode(item.itemIdentifier) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + + var destinationFolderUuid: String? + if shouldMove { + guard let resolved = FileProviderItemID.folderUuid(for: item.parentItemIdentifier) else { + completionHandler(nil, [], false, NSFileProviderError(.noSuchItem)) + return Progress() + } + destinationFolderUuid = resolved + } + + let mutationService = FileProviderMutationService(driveAPI: driveAPI) + let newFilename = item.filename + let progress = Progress(totalUnitCount: 1) + Task { + do { + if shouldRename { + try await mutationService.rename(decoded, to: newFilename) + } + if let destinationFolderUuid { + try await mutationService.move(decoded, toParentUuid: destinationFolderUuid) + } + progress.completedUnitCount = 1 + let modified = await Self.resolveModifiedItem( + item, decoded: decoded, shouldRename: shouldRename, mutationService: mutationService + ) + completionHandler(modified, [], false, nil) + } catch { + completionHandler(nil, [], false, FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + private static func resolveModifiedItem( + _ item: NSFileProviderItem, + decoded: (kind: DriveItemKind, uuid: String), + shouldRename: Bool, + mutationService: FileProviderMutationService + ) async -> NSFileProviderItem { + if let resolved = try? await mutationService.resolveItem(decoded, identifier: item.itemIdentifier) { + return resolved + } + guard shouldRename else { return item } + return FileProviderItem.renamed(from: item, newFilename: item.filename) ?? item + } + + func deleteItem(identifier: NSFileProviderItemIdentifier, baseVersion _: NSFileProviderItemVersion, options _: NSFileProviderDeleteItemOptions = [], request _: NSFileProviderRequest, completionHandler: @escaping (Error?) -> Void) -> Progress { + guard let driveAPI = DriveAPIFactory.make(), let trashAPI = DriveAPIFactory.makeTrash() else { + completionHandler(NSFileProviderError(.notAuthenticated)) + return Progress() + } + + guard let decoded = FileProviderItemID.decode(identifier) else { + completionHandler(NSFileProviderError(.noSuchItem)) + return Progress() + } + + let mutationService = FileProviderMutationService(driveAPI: driveAPI, trashAPI: trashAPI) + let progress = Progress(totalUnitCount: 1) + Task { + do { + try await mutationService.trash(decoded) + progress.completedUnitCount = 1 + completionHandler(nil) + } catch { + completionHandler(FileProviderErrorMapper.lookupError(from: error)) + } + } + return progress + } + + func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest) throws -> NSFileProviderEnumerator { + return FileProviderEnumerator(enumeratedItemIdentifier: containerItemIdentifier) + } +} diff --git a/ios/InternxtFileProvider/FileProviderItem.swift b/ios/InternxtFileProvider/FileProviderItem.swift new file mode 100644 index 000000000..03bd5120e --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderItem.swift @@ -0,0 +1,254 @@ +// +// FileProviderItem.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore +import UniformTypeIdentifiers + +class FileProviderItem: NSObject, NSFileProviderItem { + + private let identifier: NSFileProviderItemIdentifier + private let parent: NSFileProviderItemIdentifier + private let name: String + private let kind: DriveItemKind + private let fileExtension: String? + private let updatedAt: String? + private let createdAt: String? + private let sizeInBytes: String? + + struct Metadata { + var fileExtension: String? = nil + var createdAt: String? = nil + var updatedAt: String? = nil + var sizeInBytes: String? = nil + } + + private init( + identifier: NSFileProviderItemIdentifier, + parent: NSFileProviderItemIdentifier, + name: String, + kind: DriveItemKind, + metadata: Metadata + ) { + self.identifier = identifier + self.parent = parent + self.name = name + self.kind = kind + self.fileExtension = metadata.fileExtension + self.createdAt = metadata.createdAt + self.updatedAt = metadata.updatedAt + self.sizeInBytes = metadata.sizeInBytes + } + + convenience init(folder: GetFolderFoldersResult, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.folder, uuid: folder.uuid ?? ""), + parent: parent, + name: folder.plainName ?? folder.name, + kind: .folder, + metadata: Metadata(createdAt: folder.createdAt, updatedAt: folder.updatedAt) + ) + } + + convenience init(file: GetFolderFilesResultV2, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.file, uuid: file.uuid), + parent: parent, + name: file.plainName ?? file.name ?? file.uuid, + kind: .file, + metadata: Metadata( + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) + ) + } + + convenience init(folder: CreateFolderResponseNew, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.folder, uuid: folder.uuid), + parent: parent, + name: folder.plainName ?? folder.name, + kind: .folder, + metadata: Metadata(createdAt: folder.createdAt, updatedAt: folder.updatedAt) + ) + } + + convenience init(file: CreateFileResponseNew, parent: NSFileProviderItemIdentifier) { + self.init( + identifier: FileProviderItemID.encode(.file, uuid: file.uuid), + parent: parent, + name: file.plain_name, + kind: .file, + metadata: Metadata( + fileExtension: file.type, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + sizeInBytes: file.size + ) + ) + } + + static func renamed(from item: NSFileProviderItem, newFilename: String) -> FileProviderItem? { + guard let decoded = FileProviderItemID.decode(item.itemIdentifier) else { return nil } + let (baseName, newExtension) = splitNameExtension(newFilename, kind: decoded.kind) + return FileProviderItem( + identifier: item.itemIdentifier, + parent: item.parentItemIdentifier, + name: baseName, + kind: decoded.kind, + metadata: Metadata( + fileExtension: newExtension, + createdAt: iso8601String(from: item.creationDate ?? nil), + updatedAt: iso8601String(from: item.contentModificationDate ?? nil), + sizeInBytes: (item.documentSize ?? nil)?.stringValue + ) + ) + } + + private static func iso8601String(from date: Date?) -> String? { + guard let date = date else { return nil } + return iso8601Formatter.string(from: date) + } + + static func splitNameExtension(_ filename: String, kind: DriveItemKind) -> (base: String, fileExtension: String?) { + guard kind == .file else { return (filename, nil) } + let url = URL(fileURLWithPath: filename) + let fileExtension = url.pathExtension + let base = url.deletingPathExtension().lastPathComponent + if fileExtension.isEmpty || base.isEmpty { + return (filename, nil) + } + return (base, fileExtension) + } + + static func root(displayName: String) -> FileProviderItem { + FileProviderItem( + identifier: .rootContainer, + parent: .rootContainer, + name: displayName, + kind: .folder, + metadata: Metadata() + ) + } + + convenience init(folderMeta: GetFolderMetaByIdResponse, identifier: NSFileProviderItemIdentifier) { + self.init( + identifier: identifier, + parent: FileProviderItemID.parentIdentifier(folderUuid: folderMeta.parentUuid), + name: folderMeta.plainName ?? folderMeta.name ?? identifier.rawValue, + kind: .folder, + metadata: Metadata(createdAt: folderMeta.createdAt, updatedAt: folderMeta.updatedAt) + ) + } + + convenience init(fileMeta: GetFileMetaByIdResponse, identifier: NSFileProviderItemIdentifier) { + self.init( + identifier: identifier, + parent: FileProviderItemID.parentIdentifier(folderUuid: fileMeta.folderUuid), + name: fileMeta.plainName ?? fileMeta.name, + kind: .file, + metadata: Metadata( + fileExtension: fileMeta.type, + createdAt: fileMeta.createdAt, + updatedAt: fileMeta.updatedAt, + sizeInBytes: fileMeta.size + ) + ) + } + + var itemIdentifier: NSFileProviderItemIdentifier { + identifier + } + + var parentItemIdentifier: NSFileProviderItemIdentifier { + parent + } + + var capabilities: NSFileProviderItemCapabilities { + switch kind { + case .folder: + return [.allowsReading, .allowsContentEnumerating, .allowsAddingSubItems, .allowsRenaming, .allowsReparenting, .allowsDeleting] + case .file: + return [.allowsReading, .allowsRenaming, .allowsReparenting, .allowsDeleting] + } + } + + var itemVersion: NSFileProviderItemVersion { + let version = Data((updatedAt ?? identifier.rawValue).utf8) + return NSFileProviderItemVersion(contentVersion: version, metadataVersion: version) + } + + var filename: String { + guard kind == .file, let fileExtension = fileExtension, !fileExtension.isEmpty else { + return name + } + return "\(name).\(fileExtension)" + } + + var contentType: UTType { + guard kind == .file else { return .folder } + if let fileExtension = fileExtension, !fileExtension.isEmpty, + let type = UTType(filenameExtension: fileExtension) { + return type + } + return .data + } + + var documentSize: NSNumber? { + guard kind == .file, let sizeInBytes = sizeInBytes, + let bytes = Int64(sizeInBytes) else { + return nil + } + return NSNumber(value: bytes) + } + + private var isFolder: Bool { + kind == .folder + } + + var isUploaded: Bool { + true + } + + var isDownloaded: Bool { + isFolder + } + + var isMostRecentVersionDownloaded: Bool { + isFolder + } + + var creationDate: Date? { + Self.parseDate(createdAt) + } + + var contentModificationDate: Date? { + Self.parseDate(updatedAt) + } + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601FormatterNoFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static func parseDate(_ value: String?) -> Date? { + guard let value = value, !value.isEmpty else { return nil } + if let date = iso8601Formatter.date(from: value) { + return date + } + return iso8601FormatterNoFractionalSeconds.date(from: value) + } +} diff --git a/ios/InternxtFileProvider/FileProviderItemIdentifier.swift b/ios/InternxtFileProvider/FileProviderItemIdentifier.swift new file mode 100644 index 000000000..d7536c623 --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderItemIdentifier.swift @@ -0,0 +1,49 @@ +import FileProvider + +enum DriveItemKind: String { + case folder = "f" + case file = "d" +} + +enum FileProviderItemID { + private static let separator: Character = ":" + + static func encode(_ kind: DriveItemKind, uuid: String) -> NSFileProviderItemIdentifier { + NSFileProviderItemIdentifier("\(kind.rawValue)\(separator)\(uuid)") + } + + static func decode(_ identifier: NSFileProviderItemIdentifier) -> (kind: DriveItemKind, uuid: String)? { + let raw = identifier.rawValue + guard let separatorIndex = raw.firstIndex(of: separator) else { return nil } + let prefix = String(raw[raw.startIndex.. Bool { + if container == .rootContainer { return true } + guard let decoded = decode(container) else { return false } + return decoded.kind == .folder + } + + static func folderUuid(for container: NSFileProviderItemIdentifier) -> String? { + if container == .rootContainer { + return rootFolderUuid() + } + guard let decoded = decode(container), decoded.kind == .folder else { return nil } + return decoded.uuid + } + + static func rootFolderUuid() -> String? { + guard let data = SharedAuthKeychain.read(SharedAuthKeychain.rootFolderIdKey) else { return nil } + let uuid = String(decoding: data, as: UTF8.self) + return uuid.isEmpty ? nil : uuid + } + + static func parentIdentifier(folderUuid: String?) -> NSFileProviderItemIdentifier { + guard let folderUuid = folderUuid, !folderUuid.isEmpty else { return .rootContainer } + if folderUuid == rootFolderUuid() { return .rootContainer } + return encode(.folder, uuid: folderUuid) + } +} diff --git a/ios/InternxtFileProvider/FileProviderMutationService.swift b/ios/InternxtFileProvider/FileProviderMutationService.swift new file mode 100644 index 000000000..94569416b --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderMutationService.swift @@ -0,0 +1,71 @@ +// +// FileProviderMutationService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderMutationService { + let driveAPI: DriveAPI + var trashAPI: TrashAPI? = nil + + func resolveItem( + _ decoded: (kind: DriveItemKind, uuid: String), + identifier: NSFileProviderItemIdentifier + ) async throws -> FileProviderItem { + switch decoded.kind { + case .folder: + let meta = try await driveAPI.getFolderMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(folderMeta: meta, identifier: identifier) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + return FileProviderItem(fileMeta: meta, identifier: identifier) + } + } + + func rename( + _ decoded: (kind: DriveItemKind, uuid: String), + to newFilename: String + ) async throws { + switch decoded.kind { + case .folder: + _ = try await driveAPI.updateFolderNew(folderUuid: decoded.uuid, folderName: newFilename) + case .file: + let meta = try await driveAPI.getFileMetaByUuid(uuid: decoded.uuid) + let (baseName, _) = FileProviderItem.splitNameExtension(newFilename, kind: .file) + _ = try await driveAPI.updateFileNew(uuid: decoded.uuid, bucketId: meta.bucket, newFilename: baseName) + } + } + + func move( + _ decoded: (kind: DriveItemKind, uuid: String), + toParentUuid destinationFolderUuid: String + ) async throws { + switch decoded.kind { + case .folder: + _ = try await driveAPI.moveFolderNew(uuid: decoded.uuid, destinationFolder: destinationFolderUuid) + case .file: + _ = try await driveAPI.moveFileNew(uuid: decoded.uuid, destinationFolder: destinationFolderUuid) + } + } + + func trash(_ decoded: (kind: DriveItemKind, uuid: String)) async throws { + guard let trashAPI else { throw NSFileProviderError(.notAuthenticated) } + let didTrash = try await trashAPI.trashItemsByUuid(itemsToTrash: Self.trashItems(for: decoded)) + try Self.validateTrashOutcome(didTrash) + } + + static func validateTrashOutcome(_ didTrash: Bool) throws { + guard didTrash else { throw NSFileProviderError(.serverUnreachable) } + } + + static func trashItems( + for decoded: (kind: DriveItemKind, uuid: String) + ) -> [ItemToTrashV2] { + let type: ItemToTrashType = decoded.kind == .folder ? .Folder : .File + return [ItemToTrashV2(uuid: decoded.uuid, type: type)] + } +} diff --git a/ios/InternxtFileProvider/FileProviderUploadService.swift b/ios/InternxtFileProvider/FileProviderUploadService.swift new file mode 100644 index 000000000..5b7b7431e --- /dev/null +++ b/ios/InternxtFileProvider/FileProviderUploadService.swift @@ -0,0 +1,86 @@ +// +// FileProviderUploadService.swift +// InternxtFileProvider +// +// Created by Ramon Candel on 9/4/26. +// + +import FileProvider +import InternxtSwiftCore + +struct FileProviderUploadService { + let driveAPI: DriveAPI + let networkFacade: NetworkFacade + + func createFolder( + name: String, + parentUuid: String + ) async throws -> CreateFolderResponseNew { + try await driveAPI.createFolderNew(parentFolderUuid: parentUuid, folderName: name) + } + + func uploadFile( + filename: String, + contentsURL: URL, + parentUuid: String, + encryptedOutput: URL, + progressHandler: @escaping (Double) -> Void + ) async throws -> UploadResult { + let meta = try await driveAPI.getFolderMetaByUuid(uuid: parentUuid) + guard let bucket = try await resolveBucket(parentMeta: meta) else { + return .notAuthenticated + } + guard let input = InputStream(url: contentsURL) else { + return .noSuchItem + } + + let fileSize = Self.fileSize(of: contentsURL) + let finish = try await networkFacade.uploadFile( + input: input, + encryptedOutput: encryptedOutput, + fileSize: fileSize, + bucketId: bucket, + progressHandler: progressHandler + ) + + let (baseName, fileExtension) = Self.splitNameExtension(filename) + let created = try await driveAPI.createFileNew(createFile: CreateFileDataNew( + fileId: finish.id, + type: fileExtension, + bucket: bucket, + size: fileSize, + folderId: meta.id, + name: baseName, + plainName: baseName, + folderUuid: parentUuid + )) + + return .created(created) + } + + private func resolveBucket(parentMeta: GetFolderMetaByIdResponse) async throws -> String? { + if let bucket = parentMeta.bucket { + return bucket + } + guard let rootUuid = FileProviderItemID.rootFolderUuid() else { + return nil + } + let rootMeta = try await driveAPI.getFolderMetaByUuid(uuid: rootUuid) + return rootMeta.bucket + } + + private static func fileSize(of url: URL) -> Int { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize ?? 0 + } + + private static func splitNameExtension(_ filename: String) -> (base: String, fileExtension: String?) { + FileProviderItem.splitNameExtension(filename, kind: .file) + } + + enum UploadResult { + case created(CreateFileResponseNew) + case notAuthenticated + case noSuchItem + } +} diff --git a/ios/InternxtFileProvider/Info.plist b/ios/InternxtFileProvider/Info.plist new file mode 100644 index 000000000..b11be0274 --- /dev/null +++ b/ios/InternxtFileProvider/Info.plist @@ -0,0 +1,21 @@ + + + + + NSExtension + + NSExtensionFileProviderDocumentGroup + group.com.internxt.snacks + NSExtensionFileProviderSupportsEnumeration + + NSExtensionPointIdentifier + com.apple.fileprovider-nonui + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).FileProviderExtension + + RCTNewArchEnabled + + SharedKeychainGroup + $(AppIdentifierPrefix)group.com.internxt.snacks + + diff --git a/ios/InternxtFileProvider/InternxtFileProvider.entitlements b/ios/InternxtFileProvider/InternxtFileProvider.entitlements new file mode 100644 index 000000000..2b5a4e963 --- /dev/null +++ b/ios/InternxtFileProvider/InternxtFileProvider.entitlements @@ -0,0 +1,15 @@ + + + + + com.apple.security.application-groups + + group.com.internxt.snacks + + keychain-access-groups + + $(AppIdentifierPrefix)group.com.internxt.snacks + + + + diff --git a/ios/InternxtFileProvider/InternxtFileProvider.plist b/ios/InternxtFileProvider/InternxtFileProvider.plist new file mode 100644 index 000000000..7a62a22e9 --- /dev/null +++ b/ios/InternxtFileProvider/InternxtFileProvider.plist @@ -0,0 +1,12 @@ + + + + + + diff --git a/ios/InternxtFileProvider/NetworkFacadeFactory.swift b/ios/InternxtFileProvider/NetworkFacadeFactory.swift new file mode 100644 index 000000000..d67c88af2 --- /dev/null +++ b/ios/InternxtFileProvider/NetworkFacadeFactory.swift @@ -0,0 +1,35 @@ +import Foundation +import CryptoKit +import InternxtSwiftCore + +enum NetworkFacadeFactory { + static func make() -> NetworkFacade? { + guard let mnemonic = SharedKeychainCredentials.string(SharedAuthKeychain.mnemonicKey) else { + return nil + } + guard let bridgeBaseUrl = SharedKeychainCredentials.string(SharedAuthKeychain.bridgeBaseUrlKey) else { + return nil + } + guard let bridgeUser = SharedKeychainCredentials.string(SharedAuthKeychain.bridgeUserKey) else { + return nil + } + guard let userId = SharedKeychainCredentials.string(SharedAuthKeychain.userIdKey) else { + return nil + } + + let bridgePass = deriveBridgePass(userId: userId) + let basicAuthToken = Data("\(bridgeUser):\(bridgePass)".utf8).base64EncodedString() + let networkAPI = NetworkAPI( + baseUrl: bridgeBaseUrl, + basicAuthToken: basicAuthToken, + clientName: SharedKeychainCredentials.clientName, + clientVersion: SharedKeychainCredentials.clientVersion + ) + return NetworkFacade(mnemonic: mnemonic, networkAPI: networkAPI) + } + + private static func deriveBridgePass(userId: String) -> String { + let digest = SHA256.hash(data: Data(userId.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/ios/InternxtFileProvider/SharedKeychainCredentials.swift b/ios/InternxtFileProvider/SharedKeychainCredentials.swift new file mode 100644 index 000000000..7597682d5 --- /dev/null +++ b/ios/InternxtFileProvider/SharedKeychainCredentials.swift @@ -0,0 +1,21 @@ +import Foundation + +enum SharedKeychainCredentials { + static func string(_ key: String) -> String? { + guard let data = SharedAuthKeychain.read(key) else { return nil } + let value = String(decoding: data, as: UTF8.self) + return value.isEmpty ? nil : value + } + + static var clientName: String { + bundleString("CFBundleName") ?? "drive-mobile" + } + + static var clientVersion: String { + bundleString("CFBundleShortVersionString") ?? "0.0.0" + } + + private static func bundleString(_ key: String) -> String? { + Bundle(for: FileProviderExtension.self).object(forInfoDictionaryKey: key) as? String + } +} diff --git a/ios/InternxtFileProvider/SyncAnchorStore.swift b/ios/InternxtFileProvider/SyncAnchorStore.swift new file mode 100644 index 000000000..db1cbff4e --- /dev/null +++ b/ios/InternxtFileProvider/SyncAnchorStore.swift @@ -0,0 +1,126 @@ +import Foundation + +struct SyncAnchorStore { + static let appGroupIdentifier = "group.com.internxt.snacks" + + private static let counterFileName = "InternxtFileProviderSyncAnchorCounter" + private static let changesFileName = "InternxtFileProviderPendingChanges" + private static let snapshotsFileName = "InternxtFileProviderFolderSnapshots" + private static let maxEntries = 50 + private static let maxTrackedFolders = 50 + + private let counterURL: URL + private let changesURL: URL + private let snapshotsURL: URL + + init?(appGroupIdentifier: String = SyncAnchorStore.appGroupIdentifier) { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { return nil } + self.counterURL = container.appendingPathComponent(Self.counterFileName) + self.changesURL = container.appendingPathComponent(Self.changesFileName) + self.snapshotsURL = container.appendingPathComponent(Self.snapshotsFileName) + } + + init(directory: URL) { + self.counterURL = directory.appendingPathComponent(Self.counterFileName) + self.changesURL = directory.appendingPathComponent(Self.changesFileName) + self.snapshotsURL = directory.appendingPathComponent(Self.snapshotsFileName) + } + + var currentValue: UInt64 { + guard let data = try? Data(contentsOf: counterURL), + let value = Self.decode(data) else { + return 0 + } + return value + } + + var currentData: Data { + Self.encode(currentValue) + } + + @discardableResult + func recordChange(parentUuid: String) -> UInt64 { + let next = currentValue &+ 1 + try? Self.encode(next).write(to: counterURL, options: .atomic) + + var map = readChanges() + map[parentUuid] = next + writeChanges(boundedChanges(map)) + + return next + } + + func changedParents(after incoming: UInt64) -> [String] { + readChanges() + .filter { $0.value > incoming } + .sorted { $0.value < $1.value } + .map { $0.key } + } + + func snapshot(forFolderUuid folderUuid: String) -> [String] { + readSnapshots()[folderUuid] ?? [] + } + + func saveSnapshot(_ identifiers: [String], forFolderUuid folderUuid: String) { + var snapshots = readSnapshots() + snapshots[folderUuid] = identifiers + writeSnapshots(boundedSnapshots(snapshots, keeping: folderUuid)) + } + + static func encode(_ value: UInt64) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } + + static func decode(_ data: Data) -> UInt64? { + guard data.count == MemoryLayout.size else { return nil } + return data.withUnsafeBytes { $0.load(as: UInt64.self).bigEndian } + } + + private func readChanges() -> [String: UInt64] { + guard let data = try? Data(contentsOf: changesURL), + let map = try? JSONDecoder().decode([String: UInt64].self, from: data) else { + return [:] + } + return map + } + + private func writeChanges(_ map: [String: UInt64]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: changesURL, options: .atomic) + } + + private func boundedChanges(_ map: [String: UInt64]) -> [String: UInt64] { + guard map.count > Self.maxEntries else { return map } + let mostRecent = map.sorted { $0.value > $1.value }.prefix(Self.maxEntries) + return Dictionary(uniqueKeysWithValues: mostRecent.map { ($0.key, $0.value) }) + } + + private func readSnapshots() -> [String: [String]] { + guard let data = try? Data(contentsOf: snapshotsURL), + let map = try? JSONDecoder().decode([String: [String]].self, from: data) else { + return [:] + } + return map + } + + private func writeSnapshots(_ map: [String: [String]]) { + guard let data = try? JSONEncoder().encode(map) else { return } + try? data.write(to: snapshotsURL, options: .atomic) + } + + // Simple eviction: when over the cap, keep the folder just written and an + // arbitrary subset of the rest. The snapshot is only an optimization for + // detecting removals; a missing snapshot just means "no removals reported", + // never incorrect state. + private func boundedSnapshots(_ map: [String: [String]], keeping folderUuid: String) -> [String: [String]] { + guard map.count > Self.maxTrackedFolders else { return map } + var trimmed = map + for key in trimmed.keys where key != folderUuid { + if trimmed.count <= Self.maxTrackedFolders { break } + trimmed.removeValue(forKey: key) + } + return trimmed + } +} diff --git a/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift new file mode 100644 index 000000000..c54b4bfa0 --- /dev/null +++ b/ios/InternxtFileProviderTests/FileProviderErrorMapperTests.swift @@ -0,0 +1,103 @@ +// +// FileProviderErrorMapperTests.swift +// InternxtFileProviderTests +// + +import XCTest +import FileProvider +import InternxtSwiftCore + +final class FileProviderErrorMapperTests: XCTestCase { + + private func enriched(_ code: ErrorCode, cause: Error? = nil) -> EnrichedError { + EnrichedError(code: code, step: .downloadGetInfo, cause: cause) + } + + private func apiError(statusCode: Int) -> APIClientError { + APIClientError(statusCode: statusCode, message: "test") + } + + private func nsError(from error: Error) -> NSError { + FileProviderErrorMapper.lookupError(from: error) as NSError + } + + private func assertCode( + _ error: Error, + _ expected: NSFileProviderError.Code, + file: StaticString = #filePath, + line: UInt = #line + ) { + let mapped = nsError(from: error) + XCTAssertEqual(mapped.domain, NSFileProviderErrorDomain, file: file, line: line) + XCTAssertEqual(mapped.code, expected.rawValue, file: file, line: line) + } + + func testWhenEnrichedApiUnauthorizedThenNotAuthenticated() { + assertCode(enriched(.apiUnauthorized), .notAuthenticated) + } + + func testWhenApiClientError401ThenNotAuthenticated() { + assertCode(apiError(statusCode: 401), .notAuthenticated) + } + + func testWhenEnrichedCauseIsUnauthorizedThenNotAuthenticated() { + let nested = enriched(.downloadInfoFailed, cause: apiError(statusCode: 401)) + assertCode(nested, .notAuthenticated) + } + + func testWhenDeeplyNestedCauseIsUnauthorizedThenNotAuthenticated() { + let leaf = enriched(.apiUnauthorized) + let middle = enriched(.downloadMirrorsFailed, cause: leaf) + let root = enriched(.downloadFailed, cause: middle) + assertCode(root, .notAuthenticated) + } + + func testWhenEnrichedNetworkCodesThenServerUnreachable() { + let offlineCodes: [ErrorCode] = [ + .networkNoConnection, + .networkConnectionLost, + .networkTimeout, + .networkCannotConnect + ] + for code in offlineCodes { + assertCode(enriched(code), .serverUnreachable) + } + } + + func testWhenApiClientErrorStatusCodeZeroOrNegativeThenServerUnreachable() { + for statusCode in [0, -1, -2] { + assertCode(apiError(statusCode: statusCode), .serverUnreachable) + } + } + + func testWhenURLErrorOfflineCodesThenServerUnreachable() { + let offlineURLCodes: [URLError.Code] = [ + .notConnectedToInternet, + .networkConnectionLost, + .timedOut, + .cannotConnectToHost, + .dataNotAllowed, + .cannotFindHost + ] + for code in offlineURLCodes { + assertCode(URLError(code), .serverUnreachable) + } + } + + func testWhenEnrichedCauseIsOfflineURLErrorThenServerUnreachable() { + let nested = enriched(.downloadInfoFailed, cause: URLError(.notConnectedToInternet)) + assertCode(nested, .serverUnreachable) + } + + func testWhenUnknownEnrichedCodeWithoutCauseThenNoSuchItem() { + assertCode(enriched(.apiNotFound), .noSuchItem) + } + + func testWhenApiClientErrorNonAuthPositiveStatusThenNoSuchItem() { + assertCode(apiError(statusCode: 500), .noSuchItem) + } + + func testWhenUnrelatedErrorThenNoSuchItem() { + assertCode(URLError(.badURL), .noSuchItem) + } +} diff --git a/ios/InternxtFileProviderTests/FileProviderMutationTests.swift b/ios/InternxtFileProviderTests/FileProviderMutationTests.swift new file mode 100644 index 000000000..87ecc16c9 --- /dev/null +++ b/ios/InternxtFileProviderTests/FileProviderMutationTests.swift @@ -0,0 +1,147 @@ +// +// FileProviderMutationTests.swift +// InternxtFileProviderTests +// + +import XCTest +import FileProvider +import InternxtSwiftCore + +private final class StubItem: NSObject, NSFileProviderItem { + let itemIdentifier: NSFileProviderItemIdentifier + let parentItemIdentifier: NSFileProviderItemIdentifier + let filename: String + + init( + identifier: NSFileProviderItemIdentifier, + parent: NSFileProviderItemIdentifier, + filename: String + ) { + self.itemIdentifier = identifier + self.parentItemIdentifier = parent + self.filename = filename + } +} + +final class FileProviderMutationTests: XCTestCase { + + private let fileIdentifier = FileProviderItemID.encode(.file, uuid: "file-uuid") + private let folderIdentifier = FileProviderItemID.encode(.folder, uuid: "folder-uuid") + private let destinationIdentifier = FileProviderItemID.encode(.folder, uuid: "destination-uuid") + + private func decodedFile() -> (kind: DriveItemKind, uuid: String) { + (kind: .file, uuid: "file-uuid") + } + + private func decodedFolder() -> (kind: DriveItemKind, uuid: String) { + (kind: .folder, uuid: "folder-uuid") + } + + private func apiError(statusCode: Int) -> APIClientError { + APIClientError(statusCode: statusCode, message: "test") + } + + private func mappedCode(from error: Error) -> Int { + (FileProviderErrorMapper.lookupError(from: error) as NSError).code + } + + func testWhenItemIsFileThenCapabilitiesAllowReparentingAndDeletingButNotTrashing() { + let source = StubItem(identifier: fileIdentifier, parent: .rootContainer, filename: "report.pdf") + + let item = FileProviderItem.renamed(from: source, newFilename: "report.pdf") + + let capabilities = item?.capabilities ?? [] + XCTAssertTrue(capabilities.contains(.allowsReparenting)) + XCTAssertTrue(capabilities.contains(.allowsDeleting)) + XCTAssertFalse(capabilities.contains(.allowsTrashing)) + } + + func testWhenItemIsFolderThenCapabilitiesAllowReparentingAndDeletingButNotTrashing() { + let source = StubItem(identifier: folderIdentifier, parent: .rootContainer, filename: "Docs") + + let item = FileProviderItem.renamed(from: source, newFilename: "Docs") + + let capabilities = item?.capabilities ?? [] + XCTAssertTrue(capabilities.contains(.allowsReparenting)) + XCTAssertTrue(capabilities.contains(.allowsDeleting)) + XCTAssertFalse(capabilities.contains(.allowsTrashing)) + } + + func testWhenRenamingAFileThenItKeepsTheBaseNameWithoutExtension() { + let source = StubItem(identifier: fileIdentifier, parent: .rootContainer, filename: "old.pdf") + + let item = FileProviderItem.renamed(from: source, newFilename: "renamed.pdf") + + XCTAssertEqual(item?.filename, "renamed.pdf") + } + + func testWhenRenamingAFileThenSplitProducesTheBaseNameOnly() { + let split = FileProviderItem.splitNameExtension("renamed.pdf", kind: .file) + + XCTAssertEqual(split.base, "renamed") + } + + func testWhenRenamingAFolderThenItUsesTheFullName() { + let split = FileProviderItem.splitNameExtension("My Folder", kind: .folder) + + XCTAssertEqual(split.base, "My Folder") + } + + func testWhenAMoveSucceedsThenTheReturnedItemKeepsItsIdentifierAndDestinationParent() { + let moved = StubItem(identifier: fileIdentifier, parent: destinationIdentifier, filename: "report.pdf") + + let item = FileProviderItem.renamed(from: moved, newFilename: "report.pdf") + + XCTAssertEqual(item?.itemIdentifier, fileIdentifier) + XCTAssertEqual(item?.parentItemIdentifier, destinationIdentifier) + } + + func testWhenTheDestinationParentIdentifierIsInvalidThenFolderUuidIsNil() { + let invalidParent = NSFileProviderItemIdentifier("not-a-valid-id") + + let resolved = FileProviderItemID.folderUuid(for: invalidParent) + + XCTAssertNil(resolved) + } + + func testWhenDeletingAFileThenTrashItemsUseTheFileType() { + let items = FileProviderMutationService.trashItems(for: decodedFile()) + + XCTAssertEqual(items.first?.type, ItemToTrashType.File.rawValue) + } + + func testWhenDeletingAFolderThenTrashItemsUseTheFolderType() { + let items = FileProviderMutationService.trashItems(for: decodedFolder()) + + XCTAssertEqual(items.first?.type, ItemToTrashType.Folder.rawValue) + } + + func testWhenDeletingAnItemThenTrashItemCarriesItsUuid() { + let items = FileProviderMutationService.trashItems(for: decodedFile()) + + XCTAssertEqual(items.first?.uuid, "file-uuid") + } + + func testWhenTheMoveApiThrowsA409ConflictThenFilenameCollision() { + let code = mappedCode(from: apiError(statusCode: 409)) + + XCTAssertEqual(code, NSFileProviderError.Code.filenameCollision.rawValue) + } + + func testWhenTheTrashBackendReturnsFalseThenTheOutcomeThrows() { + XCTAssertThrowsError(try FileProviderMutationService.validateTrashOutcome(false)) + } + + func testWhenTheTrashBackendReturnsFalseThenTheErrorMapsToServerUnreachable() { + do { + try FileProviderMutationService.validateTrashOutcome(false) + XCTFail("expected validateTrashOutcome to throw on false") + } catch { + XCTAssertEqual(mappedCode(from: error), NSFileProviderError.Code.serverUnreachable.rawValue) + } + } + + func testWhenTheTrashBackendReturnsTrueThenTheOutcomeDoesNotThrow() { + XCTAssertNoThrow(try FileProviderMutationService.validateTrashOutcome(true)) + } +} diff --git a/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift new file mode 100644 index 000000000..7a5da5d7d --- /dev/null +++ b/ios/InternxtFileProviderTests/SyncAnchorStoreTests.swift @@ -0,0 +1,141 @@ +// +// SyncAnchorStoreTests.swift +// InternxtFileProviderTests +// + +import XCTest + +final class SyncAnchorStoreTests: XCTestCase { + + private var directory: URL! + + override func setUp() { + super.setUp() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("SyncAnchorStoreTests.\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: directory) + directory = nil + super.tearDown() + } + + func testWhenChangeRecordedThenCurrentValueAdvancesByOne() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentValue + + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.currentValue, before + 1) + } + + func testWhenChangeRecordedThenCurrentDataDiffersFromBefore() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentData + + store.recordChange(parentUuid: "parent-a") + + XCTAssertNotEqual(store.currentData, before) + } + + func testWhenReadFromSeparateInstanceThenSeesPersistedChange() { + SyncAnchorStore(directory: directory).recordChange(parentUuid: "parent-a") + + let reader = SyncAnchorStore(directory: directory) + + XCTAssertEqual(reader.currentValue, 1) + } + + func testWhenChangeRecordedThenChangedParentsAfterPreviousAnchorIncludesIt() { + let store = SyncAnchorStore(directory: directory) + let before = store.currentValue + + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: before), ["parent-a"]) + } + + func testWhenAnchorIsAtRecordedValueThenChangedParentsExcludesIt() { + let store = SyncAnchorStore(directory: directory) + let recordedAt = store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: recordedAt), []) + } + + func testWhenMultipleParentsRecordedThenChangedParentsReturnsOnlyNewerOnesInOrder() { + let store = SyncAnchorStore(directory: directory) + let afterFirst = store.recordChange(parentUuid: "parent-a") + store.recordChange(parentUuid: "parent-b") + store.recordChange(parentUuid: "parent-c") + + XCTAssertEqual(store.changedParents(after: afterFirst), ["parent-b", "parent-c"]) + } + + func testWhenSameParentRecordedTwiceThenItUsesTheLatestAnchorValue() { + let store = SyncAnchorStore(directory: directory) + store.recordChange(parentUuid: "parent-a") + let afterSecond = store.recordChange(parentUuid: "parent-b") + store.recordChange(parentUuid: "parent-a") + + XCTAssertEqual(store.changedParents(after: afterSecond), ["parent-a"]) + } + + func testWhenNoSnapshotSavedThenSnapshotIsEmpty() { + let store = SyncAnchorStore(directory: directory) + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), []) + } + + func testWhenSnapshotSavedThenSnapshotRoundTrips() { + let store = SyncAnchorStore(directory: directory) + + store.saveSnapshot(["d:file-1", "f:folder-1"], forFolderUuid: "folder-a") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-1", "f:folder-1"]) + } + + func testWhenSnapshotSavedFromSeparateInstanceThenItIsPersisted() { + SyncAnchorStore(directory: directory).saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") + + let reader = SyncAnchorStore(directory: directory) + + XCTAssertEqual(reader.snapshot(forFolderUuid: "folder-a"), ["d:file-1"]) + } + + func testWhenSnapshotSavedForDifferentFoldersThenTheyAreIndependent() { + let store = SyncAnchorStore(directory: directory) + + store.saveSnapshot(["d:file-1"], forFolderUuid: "folder-a") + store.saveSnapshot(["d:file-2"], forFolderUuid: "folder-b") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-1"]) + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-b"), ["d:file-2"]) + } + + func testWhenSnapshotSavedAgainThenItReplacesThePrevious() { + let store = SyncAnchorStore(directory: directory) + store.saveSnapshot(["d:file-1", "d:file-2"], forFolderUuid: "folder-a") + + store.saveSnapshot(["d:file-2"], forFolderUuid: "folder-a") + + XCTAssertEqual(store.snapshot(forFolderUuid: "folder-a"), ["d:file-2"]) + } + + func testWhenEncodingThenDecodingThenRoundTripIsStable() { + let value: UInt64 = 42 + + let decoded = SyncAnchorStore.decode(SyncAnchorStore.encode(value)) + + XCTAssertEqual(decoded, value) + } + + func testWhenDataLengthIsWrongThenDecodeReturnsNil() { + let malformed = Data([0x01, 0x02]) + + let decoded = SyncAnchorStore.decode(malformed) + + XCTAssertNil(decoded) + } +} diff --git a/ios/Podfile b/ios/Podfile index a90164094..c1638c650 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -68,6 +68,17 @@ target 'Internxt' do end end + # IDZSwiftCommonCrypto is built twice (rn-crypto pod + InternxtSwiftCore SPM + # dependency); both run ExtractAppIntentsMetadata into the same archive path. + # Neither defines App Intents, so skip extraction for the Pods copy. + installer.pods_project.targets.each do |target| + if target.name == 'IDZSwiftCommonCrypto' + target.build_configurations.each do |config| + config.build_settings['LM_SKIP_METADATA_EXTRACTION'] = 'YES' + end + end + end + # Fix: fmt/glog consteval incompatibility with Xcode 26 / Apple Clang 17. # consteval in C++20 is strictly enforced; forcing c++17 disables it entirely # in fmt (FMT_CONSTEVAL becomes a no-op when __cpp_consteval is not defined). @@ -94,6 +105,10 @@ target 'Internxt' do end end +target 'InternxtFileProvider' do + # No use_expo_modules! — InternxtSwiftCore comes via SPM +end + target 'InternxtShareExtension' do exclude = ["expo-updates", "expo-splash-screen", "expo-dev-client", "react-native-reanimated", "react-native-screens", "react-native-safe-area-context", "react-native-gesture-handler", "react-native-video", "react-native-webview", "react-native-svg", "@shopify/flash-list", "react-native-pdf", "jail-monkey"] use_expo_modules!(exclude: exclude) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3b558c57e..a1921ade7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3002,6 +3002,6 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a -PODFILE CHECKSUM: ad998c6337cfc81591a69440b19d65a401345467 +PODFILE CHECKSUM: 7c28a9e3ba87af9d136b3e7f68ff1b1c7b7abf2c COCOAPODS: 1.16.2 diff --git a/package.json b/package.json index 41aa2c033..5332e8bff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "drive-mobile", - "version": "v1.10.2", + "version": "v1.11.0", "private": true, "license": "GNU", "scripts": { diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx index bf96c27c6..30c7c2b05 100644 --- a/src/components/modals/AddModal/index.tsx +++ b/src/components/modals/AddModal/index.tsx @@ -55,7 +55,7 @@ import { isValidFilename } from '../../../helpers'; import useGetColor from '../../../hooks/useColor'; import network from '../../../network'; import analytics, { DriveAnalyticsEvent } from '../../../services/AnalyticsService'; -import { constants } from '../../../services/AppService'; +import appService, { constants } from '../../../services/AppService'; import { uploadQueueService } from '../../../services/drive/file/uploadQueue.service'; import { createUploadingFiles, @@ -153,6 +153,17 @@ function AddModal(): JSX.Element { } } + // Android 13+ requires runtime permission for POST_NOTIFICATIONS. + // Fire-and-forget: a denial only hides the progress UI, the upload itself still runs. + if (appService.isAndroidApiAtLeast(33)) { + await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, { + title: 'Notifications Permission', + message: 'Internxt needs to show notifications to display upload progress', + buttonNegative: strings.buttons.cancel, + buttonPositive: strings.buttons.grant, + }); + } + const createdFileEntry = await uploadAndCreateFileEntry( destPath, name, diff --git a/src/components/modals/DriveItemInfoModal/index.tsx b/src/components/modals/DriveItemInfoModal/index.tsx index 95aa76d33..c0c6410ed 100644 --- a/src/components/modals/DriveItemInfoModal/index.tsx +++ b/src/components/modals/DriveItemInfoModal/index.tsx @@ -12,6 +12,7 @@ import { logger } from '@internxt-mobile/services/common'; import { time } from '@internxt-mobile/services/common/time'; import drive from '@internxt-mobile/services/drive'; import { driveLocalDB } from '@internxt-mobile/services/drive/database'; +import { notifyParentChanged } from '@internxt-mobile/services/native/InternxtSignalingModule'; import { Abortable } from '@internxt-mobile/types/index'; import * as driveUseCases from '@internxt-mobile/useCases/drive'; import { @@ -109,6 +110,13 @@ function DriveItemInfoModal(): JSX.Element { () => handleUndoMoveToTrash(), ); + if (success) { + const parentFolderUuid = isFolder ? item.parentUuid : item.folderUuid; + if (parentFolderUuid) { + void notifyParentChanged(parentFolderUuid); + } + } + if (success && dbItem?.id) { await driveLocalDB.deleteItem({ id: dbItem.id }); } diff --git a/src/components/modals/DriveRenameModal/index.tsx b/src/components/modals/DriveRenameModal/index.tsx index b108523b6..1135049d1 100644 --- a/src/components/modals/DriveRenameModal/index.tsx +++ b/src/components/modals/DriveRenameModal/index.tsx @@ -11,6 +11,7 @@ import strings from '../../../../assets/lang/strings'; import useGetColor from '../../../hooks/useColor'; import { logger } from '../../../services/common'; import errorService from '../../../services/ErrorService'; +import { notifyParentChanged } from '../../../services/native/InternxtSignalingModule'; import notificationsService from '../../../services/NotificationsService'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { driveActions } from '../../../store/slices/drive'; @@ -63,6 +64,8 @@ function RenameModal(): JSX.Element { await drive.file.updateMetaData(focusedItem.uuid, trimmedNewName); } + void notifyParentChanged(driveCtx.focusedFolder.uuid); + notificationsService.show({ text1: strings.messages.renamedSuccessfully, type: NotificationType.Success, diff --git a/src/services/AppService.ts b/src/services/AppService.ts index 697d19773..90ed0ab81 100644 --- a/src/services/AppService.ts +++ b/src/services/AppService.ts @@ -73,6 +73,10 @@ class AppService { public get isIOS() { return Platform.OS === 'ios'; } + + public isAndroidApiAtLeast(api: number): boolean { + return this.isAndroid && (Platform.Version as number) >= api; + } } const appService = new AppService(); diff --git a/src/services/drive/file/driveFile.service.ts b/src/services/drive/file/driveFile.service.ts index 80035b94b..1260b1fb0 100644 --- a/src/services/drive/file/driveFile.service.ts +++ b/src/services/drive/file/driveFile.service.ts @@ -16,6 +16,7 @@ import uuid from 'react-native-uuid'; import { getEnvironmentConfigFromUser } from 'src/lib/network'; import * as networkDownload from 'src/network/download'; import network from '../../../network'; +import { notifyParentChanged } from '../../native/InternxtSignalingModule'; import { uploadService } from '../../common/network/upload/upload.service'; import { DRIVE_THUMBNAILS_DIRECTORY } from '../constants'; import { driveFileCache } from './driveFileCache.service'; @@ -107,7 +108,9 @@ class DriveFileService { fileUuid: string; destinationFolderUuid: string; }): Promise { - return this.sdk.storageV2.moveFileByUuid(fileUuid, { destinationFolder: destinationFolderUuid }); + const moved = await this.sdk.storageV2.moveFileByUuid(fileUuid, { destinationFolder: destinationFolderUuid }); + void notifyParentChanged(destinationFolderUuid); + return moved; } public getSortFunction({ diff --git a/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts new file mode 100644 index 000000000..0a3ab85ba --- /dev/null +++ b/src/services/drive/file/utils/uploadFileUtils.signaling.spec.ts @@ -0,0 +1,86 @@ +import { Action } from 'redux'; +import { Dispatch } from 'react'; +import { UploadingFile } from '../../../../types/drive/operations'; + +const mockNotifyParentChanged = jest.fn().mockResolvedValue(undefined); + +// Sibling modules not exercised by `uploadSingleFile` but pulling in heavy native/network +// chains at import time — stubbed so the unit under test loads in isolation. +jest.mock('./checkDuplicatedFiles', () => ({ checkDuplicatedFiles: jest.fn() })); +jest.mock('./prepareFilesToUpload', () => ({ prepareFilesToUpload: jest.fn() })); +jest.mock('../../../common/network/upload/upload.service', () => ({ + uploadService: { createFileEntry: jest.fn(), uploadFile: jest.fn() }, +})); + +jest.mock('../../../../store/slices/drive', () => ({ + driveActions: { + uploadFileStart: jest.fn(), + uploadFileFailed: jest.fn(), + uploadFileFinished: jest.fn(), + }, +})); + +jest.mock('../../../native/InternxtSignalingModule', () => ({ + notifyParentChanged: (...args: unknown[]) => mockNotifyParentChanged(...args), +})); + +jest.mock('../../../ErrorService', () => ({ + __esModule: true, + default: { reportError: jest.fn(), castError: (e: Error) => e }, +})); + +jest.mock('../../../AnalyticsService', () => ({ + __esModule: true, + default: { track: jest.fn() }, + DriveAnalyticsEvent: {}, +})); + +jest.mock('../../../common', () => ({ + logger: { error: jest.fn(), info: jest.fn() }, +})); + +import { uploadSingleFile } from './uploadFileUtils'; + +const buildUploadingFile = (overrides: Partial = {}): UploadingFile => ({ + id: 1, + uuid: 'file-uuid', + uri: 'file:///tmp/file.txt', + name: 'file.txt', + type: 'txt', + parentId: 10, + parentUuid: 'destination-folder-uuid', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + size: 1024, + progress: 0, + uploaded: false, + ...overrides, +}); + +describe('uploadSingleFile — picker signaling', () => { + const dispatch = jest.fn() as unknown as Dispatch; + + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + }); + + test('when a file upload succeeds, then it signals the destination folder uuid', async () => { + const file = buildUploadingFile({ parentUuid: 'destination-folder-uuid' }); + const uploadFile = jest.fn().mockResolvedValue(undefined); + const uploadSuccess = jest.fn(); + + await uploadSingleFile(file, dispatch, uploadFile, uploadSuccess); + + expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-folder-uuid'); + }); + + test('when the file upload fails, then it does not signal the file picker', async () => { + const file = buildUploadingFile(); + const uploadFile = jest.fn().mockRejectedValue(new Error('network down')); + const uploadSuccess = jest.fn(); + + await expect(uploadSingleFile(file, dispatch, uploadFile, uploadSuccess)).rejects.toThrow('network down'); + + expect(mockNotifyParentChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/drive/file/utils/uploadFileUtils.ts b/src/services/drive/file/utils/uploadFileUtils.ts index 1e574b2c4..70c6852c6 100644 --- a/src/services/drive/file/utils/uploadFileUtils.ts +++ b/src/services/drive/file/utils/uploadFileUtils.ts @@ -18,6 +18,7 @@ import { uiActions } from '../../../../store/slices/ui'; import analyticsService, { DriveAnalyticsEvent } from '../../../AnalyticsService'; import { logger } from '../../../common'; import { uploadService } from '../../../common/network/upload/upload.service'; +import { notifyParentChanged } from '../../../native/InternxtSignalingModule'; import { EmptyFileNotAllowedError, isEmptyFilePlanError } from './emptyFileErrors'; import { FileSizeExceededError, isFileSizeExceededError } from './fileSizeErrors'; import { BucketNotFoundError } from './upload.errors'; @@ -228,6 +229,7 @@ export async function uploadSingleFile( await uploadFile(file, 'document'); } uploadSuccess(file); + void notifyParentChanged(file.parentUuid); } catch (e) { if (e instanceof EmptyFileNotAllowedError) { dispatch(uiActions.setShowEmptyFileNotAllowedModal(true)); diff --git a/src/services/drive/folder/driveFolder.service.signaling.spec.ts b/src/services/drive/folder/driveFolder.service.signaling.spec.ts new file mode 100644 index 000000000..7b87298d1 --- /dev/null +++ b/src/services/drive/folder/driveFolder.service.signaling.spec.ts @@ -0,0 +1,57 @@ +const mockNotifyParentChanged = jest.fn().mockResolvedValue(undefined); +const mockCreateFolderByUuid = jest.fn(); +const mockMoveFolderByUuid = jest.fn(); + +jest.mock('../../native/InternxtSignalingModule', () => ({ + notifyParentChanged: (...args: unknown[]) => mockNotifyParentChanged(...args), +})); + +jest.mock('@internxt-mobile/services/common', () => ({ + SdkManager: { + getInstance: () => ({ + storageV2: { + createFolderByUuid: (...args: unknown[]) => mockCreateFolderByUuid(...args), + moveFolderByUuid: (...args: unknown[]) => mockMoveFolderByUuid(...args), + }, + }), + }, +})); + +import { driveFolderService } from './driveFolder.service'; + +describe('driveFolderService.createFolder — picker signaling', () => { + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + mockCreateFolderByUuid.mockReset(); + }); + + test('when a folder is created, then it signals the parent folder uuid', async () => { + mockCreateFolderByUuid.mockReturnValue([Promise.resolve({ uuid: 'new-folder', name: 'docs' })]); + + await driveFolderService.createFolder('parent-folder-uuid', 'docs'); + + expect(mockNotifyParentChanged).toHaveBeenCalledWith('parent-folder-uuid'); + }); + + test('when the SDK returns no result, then it rejects and does not signal', async () => { + mockCreateFolderByUuid.mockReturnValue(undefined); + + await expect(driveFolderService.createFolder('parent-folder-uuid', 'docs')).rejects.toBeDefined(); + expect(mockNotifyParentChanged).not.toHaveBeenCalled(); + }); +}); + +describe('driveFolderService.moveFolder — picker signaling', () => { + beforeEach(() => { + mockNotifyParentChanged.mockClear(); + mockMoveFolderByUuid.mockReset(); + }); + + test('when a folder is moved, then it signals the destination folder uuid', async () => { + mockMoveFolderByUuid.mockResolvedValue({ uuid: 'folder-uuid' }); + + await driveFolderService.moveFolder({ folderUuid: 'folder-uuid', destinationFolderUuid: 'destination-uuid' }); + + expect(mockNotifyParentChanged).toHaveBeenCalledWith('destination-uuid'); + }); +}); diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts index 5facefcf3..c1e9e4f14 100644 --- a/src/services/drive/folder/driveFolder.service.ts +++ b/src/services/drive/folder/driveFolder.service.ts @@ -5,6 +5,7 @@ import { FolderAncestor as SdkFolderAncestor } from '@internxt/sdk/dist/drive/st import { getHeaders } from '../../../helpers/headers'; import { ModifiedFolder } from '../../../types/drive/folder'; import { constants } from '../../AppService'; +import { notifyParentChanged } from '../../native/InternxtSignalingModule'; export type FolderAncestor = SdkFolderAncestor & { parentUuid: string | null }; @@ -32,7 +33,12 @@ class DriveFolderService { parentFolderUuid: parentFolderId, plainName: folderName, }); - return sdkResult ? sdkResult[0] : Promise.reject('createFolder Sdk method did not return a valid result'); + if (!sdkResult) { + throw new Error('Sdk method did not return a valid result'); + } + const folder = await sdkResult[0]; + void notifyParentChanged(parentFolderId); + return folder; } public async checkDuplicatedFolders(parentFolderUuid: string, folderNamesList: string[]) { @@ -46,7 +52,9 @@ class DriveFolderService { folderUuid: string; destinationFolderUuid: string; }) { - return this.sdk.storageV2.moveFolderByUuid(folderUuid, { destinationFolder: destinationFolderUuid }); + const moved = await this.sdk.storageV2.moveFolderByUuid(folderUuid, { destinationFolder: destinationFolderUuid }); + void notifyParentChanged(destinationFolderUuid); + return moved; } public async updateMetaData(folderUuid: string, newName: string): Promise { diff --git a/src/services/native/InternxtAuthCredentialsModule.spec.ts b/src/services/native/InternxtAuthCredentialsModule.spec.ts new file mode 100644 index 000000000..deaa58732 --- /dev/null +++ b/src/services/native/InternxtAuthCredentialsModule.spec.ts @@ -0,0 +1,91 @@ +type Wrapper = typeof import('./InternxtAuthCredentialsModule'); +type Credentials = import('./InternxtAuthCredentialsModule').InternxtAuthCredentials; + +const credentials: Credentials = { + bearerToken: 'token-abc', + userId: 'user-1', + bridgeUser: 'bridge@internxt.com', + mnemonic: 'pretty cloud secret words', + rootFolderUuid: 'root-uuid-123', + email: 'user@internxt.com', + driveBaseUrl: 'https://drive.example', + bridgeBaseUrl: 'https://bridge.example', + desktopToken: 'desktop-token', +}; + +const loadWrapper = (platformOS: 'android' | 'ios', nativeModule: unknown): Wrapper => { + let wrapper!: Wrapper; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: platformOS }, + NativeModules: nativeModule === undefined ? {} : { InternxtAuthCredentialsModule: nativeModule }, + })); + wrapper = require('./InternxtAuthCredentialsModule'); + }); + return wrapper; +}; + +const arrangePresentNative = (platformOS: 'android' | 'ios') => { + const setCredentials = jest.fn().mockResolvedValue(undefined); + const clearCredentials = jest.fn().mockResolvedValue(undefined); + const wrapper = loadWrapper(platformOS, { setCredentials, clearCredentials }); + return { wrapper, setCredentials, clearCredentials }; +}; + +describe('InternxtAuthCredentialsModule wrapper', () => { + afterEach(() => { + jest.resetModules(); + }); + + test('when on iOS with the module present, then setCredentials delegates with the credentials', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials).toHaveBeenCalledWith(credentials); + }); + + test('when on iOS with the module present, then setCredentials forwards driveBaseUrl to the native module', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials.mock.calls[0][0]).toMatchObject({ driveBaseUrl: 'https://drive.example' }); + }); + + test('when on iOS with the module present, then setCredentials forwards bridgeBaseUrl to the native module', async () => { + const { wrapper, setCredentials } = arrangePresentNative('ios'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials.mock.calls[0][0]).toMatchObject({ bridgeBaseUrl: 'https://bridge.example' }); + }); + + test('when on iOS with the module present, then clearCredentials delegates to the native module', async () => { + const { wrapper, clearCredentials } = arrangePresentNative('ios'); + + await wrapper.clearCredentials(); + + expect(clearCredentials).toHaveBeenCalledTimes(1); + }); + + test('when on Android with the module present, then setCredentials delegates with the credentials', async () => { + const { wrapper, setCredentials } = arrangePresentNative('android'); + + await wrapper.setCredentials(credentials); + + expect(setCredentials).toHaveBeenCalledWith(credentials); + }); + + test('when the native module is absent, then setCredentials resolves without throwing', async () => { + const wrapper = loadWrapper('ios', undefined); + + await expect(wrapper.setCredentials(credentials)).resolves.toBeUndefined(); + }); + + test('when the native module is absent, then clearCredentials resolves without throwing', async () => { + const wrapper = loadWrapper('ios', undefined); + + await expect(wrapper.clearCredentials()).resolves.toBeUndefined(); + }); +}); diff --git a/src/services/native/InternxtAuthCredentialsModule.ts b/src/services/native/InternxtAuthCredentialsModule.ts new file mode 100644 index 000000000..aaa62bab5 --- /dev/null +++ b/src/services/native/InternxtAuthCredentialsModule.ts @@ -0,0 +1,31 @@ +import { NativeModules, Platform } from 'react-native'; + +export interface InternxtAuthCredentials { + bearerToken: string; + userId: string; + bridgeUser: string; + mnemonic: string; + rootFolderUuid: string; + email?: string | null; + driveBaseUrl: string; + bridgeBaseUrl: string; + desktopToken?: string | null; +} + +interface NativeBridge { + setCredentials(creds: InternxtAuthCredentials): Promise; + clearCredentials(): Promise; +} + +const bridge: NativeBridge | undefined = + Platform.OS === 'android' || Platform.OS === 'ios' ? NativeModules.InternxtAuthCredentialsModule : undefined; + +export async function setCredentials(creds: InternxtAuthCredentials): Promise { + if (!bridge) return; + await bridge.setCredentials(creds); +} + +export async function clearCredentials(): Promise { + if (!bridge) return; + await bridge.clearCredentials(); +} diff --git a/src/services/native/InternxtSignalingModule.spec.ts b/src/services/native/InternxtSignalingModule.spec.ts new file mode 100644 index 000000000..69370aa53 --- /dev/null +++ b/src/services/native/InternxtSignalingModule.spec.ts @@ -0,0 +1,70 @@ +const mockLoggerWarn = jest.fn(); + +jest.mock('../common', () => ({ + logger: { info: jest.fn(), warn: mockLoggerWarn, error: jest.fn() }, +})); + +type Wrapper = typeof import('./InternxtSignalingModule'); + +const loadWrapper = (nativeModule: unknown): { wrapper: Wrapper } => { + let wrapper!: Wrapper; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + NativeModules: nativeModule === undefined ? {} : { InternxtSignalingModule: nativeModule }, + })); + wrapper = require('./InternxtSignalingModule'); + }); + return { wrapper }; +}; + +const arrangePresentNative = () => { + const native = jest.fn().mockResolvedValue(undefined); + const { wrapper } = loadWrapper({ notifyParentChanged: native }); + return { wrapper, native }; +}; + +describe('InternxtSignalingModule wrapper', () => { + afterEach(() => { + jest.resetModules(); + mockLoggerWarn.mockReset(); + }); + + test('when the native bridge is present with a valid uuid, then it invokes the native module with that uuid', async () => { + const { wrapper, native } = arrangePresentNative(); + + await wrapper.notifyParentChanged('folder-uuid-123'); + + expect(native).toHaveBeenCalledWith('folder-uuid-123'); + }); + + test('when the uuid is empty, then it resolves without invoking the native module', async () => { + const { wrapper, native } = arrangePresentNative(); + + await expect(wrapper.notifyParentChanged('')).resolves.toBeUndefined(); + + expect(native).not.toHaveBeenCalled(); + }); + + test('when the uuid is not a string, then it resolves without invoking the native module', async () => { + const { wrapper, native } = arrangePresentNative(); + + await expect(wrapper.notifyParentChanged(undefined as unknown as string)).resolves.toBeUndefined(); + + expect(native).not.toHaveBeenCalled(); + }); + + test('when the native module is absent, then it resolves without throwing', async () => { + const { wrapper } = loadWrapper(undefined); + + await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); + }); + + test('when the native module rejects, then it resolves without throwing', async () => { + const { wrapper, native } = arrangePresentNative(); + native.mockRejectedValueOnce(new Error('E_INVALID_FOLDER')); + + await expect(wrapper.notifyParentChanged('folder-uuid-123')).resolves.toBeUndefined(); + + expect(mockLoggerWarn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/native/InternxtSignalingModule.ts b/src/services/native/InternxtSignalingModule.ts new file mode 100644 index 000000000..548be1986 --- /dev/null +++ b/src/services/native/InternxtSignalingModule.ts @@ -0,0 +1,27 @@ +import { NativeModules } from 'react-native'; +import { logger } from '../common'; + +interface NativeBridge { + notifyParentChanged(parentFolderUuid: string): Promise; +} + +const bridge: NativeBridge | undefined = NativeModules.InternxtSignalingModule; + +/** + * Signals the native file browser that a folder's children changed after a mutation originated + * in the React Native app (upload, create, move, rename, trash, restore): the Android SAF + * DocumentsProvider re-queries the parent; the iOS File Provider re-evaluates the parent against + * its child snapshot, so add/rename/move/trash all refresh Files.app live through a single call. + * + * Best-effort and cross-platform: no-op when the native module is unavailable, never throws to + * the caller and never invokes the native side with a malformed uuid. + */ +export async function notifyParentChanged(parentFolderUuid: string): Promise { + if (!bridge) return; + if (typeof parentFolderUuid !== 'string' || parentFolderUuid.length === 0) return; + try { + await bridge.notifyParentChanged(parentFolderUuid); + } catch (error) { + logger.warn('InternxtSignalingModule.notifyParentChanged failed', error); + } +} diff --git a/src/store/slices/auth/auth.syncNativeCredentials.spec.ts b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts new file mode 100644 index 000000000..51217d054 --- /dev/null +++ b/src/store/slices/auth/auth.syncNativeCredentials.spec.ts @@ -0,0 +1,110 @@ +jest.mock('../../../services/native/InternxtAuthCredentialsModule', () => ({ + setCredentials: jest.fn().mockResolvedValue(undefined), + clearCredentials: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + BaseLogger: jest.fn().mockImplementation(() => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })), + imageService: {}, + PROFILE_PICTURE_CACHE_KEY: 'profile-picture', + SdkManager: { init: jest.fn(), setApiSecurity: jest.fn(), getInstance: jest.fn() }, +})); + +jest.mock('../drive', () => ({ driveActions: { resetState: jest.fn(() => ({ type: 'drive/resetState' })) } })); +jest.mock('../ui', () => ({ uiActions: { resetState: jest.fn(() => ({ type: 'ui/resetState' })) } })); + +jest.mock('@internxt-mobile/services/drive', () => ({ + __esModule: true, + default: { clear: jest.fn().mockResolvedValue(undefined) }, +})); + +jest.mock('src/services/ErrorService', () => ({ __esModule: true, default: { reportError: jest.fn() } })); + +jest.mock('../../../services/AppService', () => { + const appConstants = { + DRIVE_NEW_API_URL: 'https://drive', + BRIDGE_URL: 'https://bridge', + CLOUDFLARE_TOKEN: 'cf', + CRYPTO_SECRET: 'crypto-secret', + CRYPTO_SECRET2: 'crypto-secret-2', + }; + return { __esModule: true, default: { constants: appConstants }, constants: appConstants }; +}); + +jest.mock('../../../services/AsyncStorageService', () => ({ + __esModule: true, + default: { + saveItem: jest.fn().mockResolvedValue(undefined), + getItem: jest.fn().mockResolvedValue('current-photos-token'), + deleteItem: jest.fn().mockResolvedValue(undefined), + clearStorage: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('../../../services/AuthService', () => ({ + __esModule: true, + default: { + emitLoginEvent: jest.fn(), + emitLogoutEvent: jest.fn(), + signout: jest.fn().mockResolvedValue(undefined), + refreshAuthToken: jest.fn(), + getAuthCredentials: jest.fn(), + }, +})); + +jest.mock('../../../services/NotificationsService', () => ({ __esModule: true, default: {} })); +jest.mock('../../../services/UserService', () => ({ __esModule: true, default: {} })); + +import authService from '../../../services/AuthService'; +import { clearCredentials, setCredentials } from '../../../services/native/InternxtAuthCredentialsModule'; +import { refreshTokensThunk, signInThunk, signOutThunk } from './index'; + +const setCredentialsMock = setCredentials as jest.Mock; +const clearCredentialsMock = clearCredentials as jest.Mock; +const refreshAuthTokenMock = authService.refreshAuthToken as jest.Mock; +const getAuthCredentialsMock = authService.getAuthCredentials as jest.Mock; + +const user = { + userId: 'user-1', + bridgeUser: 'bridge@internxt.com', + mnemonic: 'pretty cloud secret words', + rootFolderUuid: 'root-uuid-123', + email: 'user@internxt.com', +} as never; + +type DispatchableThunk = (dispatch: jest.Mock, getState: jest.Mock, extra: undefined) => Promise; + +const runThunk = (action: unknown) => + (action as DispatchableThunk)(jest.fn(), jest.fn(), undefined); + +describe('auth thunks native credentials handoff', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('when the login completes, then the wrapper receives the new token', async () => { + await runThunk(signInThunk({ user, token: 'access-token', newToken: 'login-new-token' })); + + expect(setCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'login-new-token', rootFolderUuid: 'root-uuid-123' }), + ); + }); + + it('when the token is refreshed in the foreground, then the wrapper receives the refreshed token', async () => { + refreshAuthTokenMock.mockResolvedValue({ token: 'access-2', newToken: 'refreshed-new-token' }); + getAuthCredentialsMock.mockResolvedValue({ + credentials: { accessToken: 'access-2', photosToken: 'refreshed-new-token', user }, + }); + + await runThunk(refreshTokensThunk()); + + expect(setCredentialsMock).toHaveBeenCalledWith(expect.objectContaining({ bearerToken: 'refreshed-new-token' })); + }); + + it('when the logout completes, then clearCredentials is invoked', async () => { + await runThunk(signOutThunk({ reason: 'manual' })); + + expect(clearCredentialsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/slices/auth/index.ts b/src/store/slices/auth/index.ts index ed35f395c..777daab0c 100644 --- a/src/store/slices/auth/index.ts +++ b/src/store/slices/auth/index.ts @@ -8,8 +8,10 @@ import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; import errorService from 'src/services/ErrorService'; import { RootState } from '../..'; import strings from '../../../../assets/lang/strings'; +import appService from '../../../services/AppService'; import asyncStorageService from '../../../services/AsyncStorageService'; import authService from '../../../services/AuthService'; +import { clearCredentials, setCredentials } from '../../../services/native/InternxtAuthCredentialsModule'; import notificationsService from '../../../services/NotificationsService'; import { default as userService } from '../../../services/UserService'; import { AsyncStorageKey, NotificationType } from '../../../types'; @@ -34,6 +36,34 @@ const initialState: AuthState = { sessionPassword: undefined, }; +async function ensureRootFolderUuid(user: UserSettings): Promise { + if (user.rootFolderUuid || !user.root_folder_id) return user; + const meta = await SdkManager.getInstance().storageV2.getFolderMetaById(user.root_folder_id); + return { ...user, rootFolderUuid: meta.uuid }; +} + +async function syncNativeCredentials(token: string, user: UserSettings): Promise { + if (!user.rootFolderUuid) { + errorService.reportError(new Error('syncNativeCredentials: missing rootFolderUuid')); + return; + } + try { + await setCredentials({ + bearerToken: token, + userId: user.userId, + bridgeUser: user.bridgeUser, + mnemonic: user.mnemonic, + rootFolderUuid: user.rootFolderUuid, + email: user.email, + driveBaseUrl: appService.constants.DRIVE_NEW_API_URL, + bridgeBaseUrl: appService.constants.BRIDGE_URL, + desktopToken: appService.constants.CLOUDFLARE_TOKEN, + }); + } catch (err) { + errorService.reportError(err); + } +} + export const initializeThunk = createAsyncThunk( 'auth/initialize', async (_, { dispatch }) => { @@ -100,7 +130,6 @@ export const signInThunk = createAsyncThunk< { user: UserSettings; token: string; newToken: string }, { state: RootState } >('auth/signIn', async (payload, { dispatch }) => { - const userToSave = payload.user; SdkManager.init({ token: payload.token, newToken: payload.newToken, @@ -112,11 +141,16 @@ export const signInThunk = createAsyncThunk< newToken: payload.newToken, }); + const userToSave = await ensureRootFolderUuid(payload.user); + await asyncStorageService.saveItem(AsyncStorageKey.Token, payload.token); await asyncStorageService.saveItem(AsyncStorageKey.PhotosToken, payload.newToken); // Photos access token await asyncStorageService.saveItem(AsyncStorageKey.User, JSON.stringify(userToSave)); // Reset this, in case we logged out during the pull process await asyncStorageService.deleteItem(AsyncStorageKey.LastPhotoPulledDate); + + await syncNativeCredentials(payload.newToken, userToSave); + dispatch( authActions.setSignInData({ token: payload.token, @@ -156,6 +190,8 @@ export const refreshTokensThunk = createAsyncThunk