diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 0efe010..cdf7639 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -39,6 +39,7 @@ kotlin { androidMain.dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.work.runtime.ktx) // NaCl box implementation for transport encryption (Android/JVM only) implementation("org.purejava:tweetnacl-java:1.1.3") } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt new file mode 100644 index 0000000..f038f14 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt @@ -0,0 +1,207 @@ +package ru.fromchat.api + +import android.content.Context +import android.net.Uri +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.pr0gramm3r101.utils.UtilsLibrary +import com.pr0gramm3r101.utils.crypto.Base64 +import com.pr0gramm3r101.utils.settings.settings +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import ru.fromchat.crypto.transport.TransportCrypto + +private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 +private const val DEFAULT_CHUNK_SIZE = 262_144 + +private object AttachmentUploadEvents { + val flow = MutableSharedFlow(extraBufferCapacity = 64) +} + +actual object AttachmentUploadQueue { + actual val progressFlow: SharedFlow = AttachmentUploadEvents.flow + + actual fun enqueue(job: AttachmentUploadJob) { + AttachmentUploadEvents.flow.tryEmit( + AttachmentUploadProgress.Pending( + jobId = job.jobId, + filename = job.filename + ) + ) + + val request = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(DmAttachmentUploadWorker.KEY_JOB_ID, job.jobId) + .putString(DmAttachmentUploadWorker.KEY_FILE_URI, job.fileUri) + .putString(DmAttachmentUploadWorker.KEY_FILENAME, job.filename) + .putInt(DmAttachmentUploadWorker.KEY_RECIPIENT_ID, job.recipientId) + .putString(DmAttachmentUploadWorker.KEY_PLAINTEXT, job.plaintext) + .build() + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 10, + TimeUnit.SECONDS + ) + .build() + + WorkManager.getInstance(UtilsLibrary.context).enqueueUniqueWork( + uniqueWorkName(job.jobId), + ExistingWorkPolicy.REPLACE, + request + ) + } + + actual fun cancel(jobId: String) { + WorkManager.getInstance(UtilsLibrary.context).cancelUniqueWork(uniqueWorkName(jobId)) + } + + private fun uniqueWorkName(jobId: String): String = "dm-attachment-upload-$jobId" +} + +class DmAttachmentUploadWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + companion object { + const val KEY_JOB_ID = "job_id" + const val KEY_FILE_URI = "file_uri" + const val KEY_FILENAME = "filename" + const val KEY_RECIPIENT_ID = "recipient_id" + const val KEY_PLAINTEXT = "plaintext" + } + + override suspend fun doWork(): Result { + val jobId = inputData.getString(KEY_JOB_ID) + ?: return Result.failure() + val fileUri = inputData.getString(KEY_FILE_URI) + ?: return Result.failure() + val filename = inputData.getString(KEY_FILENAME) + ?: "file" + val recipientId = inputData.getInt(KEY_RECIPIENT_ID, -1) + val plaintext = inputData.getString(KEY_PLAINTEXT)?.trim().orEmpty() + + if (recipientId <= 0) return Result.failure() + if (plaintext.isBlank()) return Result.failure() + + return runCatching { + emitProgress(jobId, 0) + val encryptedBlob = encryptFileBlob(fileUri) + + if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { + sendInline(recipientId, plaintext, filename, encryptedBlob) + } else { + sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob) + } + + clearResumableState(jobId) + AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId)) + Result.success() + }.getOrElse { error -> + AttachmentUploadEvents.flow.tryEmit( + AttachmentUploadProgress.Failed( + jobId = jobId, + error = error.message ?: "Upload failed" + ) + ) + if (runAttemptCount >= 5) Result.failure() else Result.retry() + } + } + + private suspend fun encryptFileBlob(fileUri: String): ByteArray { + val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } + ?: error("Failed to read file from URI") + val transportKey = ApiClient.getTransportPublicKey() + return TransportCrypto.encryptFileForTransport( + fileBytes = bytes, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + } + + private suspend fun sendInline( + recipientId: Int, + plaintext: String, + filename: String, + encryptedBlob: ByteArray + ) { + val file = SendDmFile( + encryptedFileDataB64 = Base64.encode(encryptedBlob), + filename = filename, + fileSize = encryptedBlob.size.toLong() + ) + ApiClient.sendDm( + recipientId = recipientId, + plaintext = plaintext, + transportFiles = listOf(file) + ) + } + + private suspend fun sendResumable( + jobId: String, + recipientId: Int, + plaintext: String, + filename: String, + encryptedBlob: ByteArray + ) { + val uploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { + val init = ApiClient.initDmUpload( + filename = filename, + totalSize = encryptedBlob.size.toLong(), + recipientId = recipientId, + chunkSize = DEFAULT_CHUNK_SIZE + ) + settings.putString(uploadIdKey(jobId), init.uploadId) + init.uploadId + } + + var offset = ApiClient.getDmUploadStatus(uploadId).offset.toInt() + while (offset < encryptedBlob.size) { + val nextOffset = minOf(offset + DEFAULT_CHUNK_SIZE, encryptedBlob.size) + val chunk = encryptedBlob.copyOfRange(offset, nextOffset) + ApiClient.uploadDmChunk( + uploadId = uploadId, + offset = offset.toLong(), + dataB64 = Base64.encode(chunk) + ) + offset = nextOffset + emitProgress(jobId, ((offset.toDouble() / encryptedBlob.size.toDouble()) * 100.0).toInt()) + } + + val completed = ApiClient.completeDmUpload(uploadId) + ApiClient.sendDm( + recipientId = recipientId, + plaintext = plaintext, + uploadedFileIds = listOf(completed.fileId) + ) + } + + private fun emitProgress(jobId: String, value: Int) { + AttachmentUploadEvents.flow.tryEmit( + AttachmentUploadProgress.InProgress( + jobId = jobId, + percent = value.coerceIn(0, 100) + ) + ) + } + + private suspend fun clearResumableState(jobId: String) { + settings.putString(uploadIdKey(jobId), "") + } + + private fun uploadIdKey(jobId: String): String = "dm_upload_id_$jobId" +} + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt index b09646f..df4e94e 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.android.kt @@ -1,10 +1,11 @@ package ru.fromchat.crypto.transport +import com.iwebpp.crypto.TweetNaclFast import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import com.iwebpp.crypto.TweetNaclFast import java.security.SecureRandom import java.util.Base64 +import ru.fromchat.crypto.IdentityKeyManager actual object TransportCrypto { private val random = SecureRandom() @@ -13,8 +14,6 @@ actual object TransportCrypto { plaintext: String, transportPublicKeyB64: String ): TransportCiphertext = withContext(Dispatchers.Default) { - val messageBytes = plaintext.encodeToByteArray() - // Decode server-provided transport public key val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) @@ -28,7 +27,7 @@ actual object TransportCrypto { val nonce = ByteArray(TweetNaclFast.Box.nonceLength) random.nextBytes(nonce) - val ciphertext = box.box(messageBytes, nonce) + val ciphertext = box.box(plaintext.encodeToByteArray(), nonce) val encoder = Base64.getEncoder() TransportCiphertext( @@ -37,5 +36,32 @@ actual object TransportCrypto { ciphertextB64 = encoder.encodeToString(ciphertext) ) } + + actual suspend fun encryptFileForTransport( + fileBytes: ByteArray, + transportPublicKeyB64: String + ): ByteArray = withContext(Dispatchers.Default) { + val keys = IdentityKeyManager.getCurrentKeys() + ?: IdentityKeyManager.restoreFromLocal() + ?: error("Identity keys not initialized. Please log in again.") + + // Decode server-provided transport public key + val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64) + + // NaCl box with (server transport public key, our long-term identity secret key) + val box = TweetNaclFast.Box(transportPublicKey, keys.privateKey) + + // 24-byte nonce as required by NaCl box + val nonce = ByteArray(TweetNaclFast.Box.nonceLength) + random.nextBytes(nonce) + + val ciphertext = box.box(fileBytes, nonce) + + // Files are sent as nonce || ciphertext, base64-encoded by the caller + val result = ByteArray(nonce.size + ciphertext.size) + System.arraycopy(nonce, 0, result, 0, nonce.size) + System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size) + result + } } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt index ebb9ecd..4732134 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/debug/DebugApiScreen.kt @@ -28,11 +28,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.settings.settings import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient +import ru.fromchat.api.SendDmFile import ru.fromchat.crypto.IdentityKeyManager import ru.fromchat.crypto.decryptEnvelope +import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.ui.LocalNavController @OptIn(ExperimentalMaterial3Api::class) @@ -228,6 +231,44 @@ actual fun DebugApiScreen() { Text(text = "Send DM") } + Text( + text = "Phase 1: File Protocol Test", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { + scope.launch { + runCatching { + val fileBytes = "test file".encodeToByteArray() + val transportKey = ApiClient.getTransportPublicKey() + val transportBlob = TransportCrypto.encryptFileForTransport( + fileBytes = fileBytes, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + val sendFile = SendDmFile( + encryptedFileDataB64 = Base64.encode(transportBlob), + filename = "test.txt", + fileSize = fileBytes.size.toLong() + ) + ApiClient.sendDm( + recipientId = 2, + plaintext = "test file", + transportFiles = listOf(sendFile) + ) + }.onSuccess { + statusMessage = "Protocol test file sent to user 2" + }.onFailure { + statusMessage = it.message ?: "Failed to send protocol test file" + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Send test.txt to user 2") + } + HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), thickness = DividerDefaults.Thickness, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index f0aa5f6..426f11c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -15,7 +15,9 @@ import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.pingInterval import io.ktor.client.request.bearerAuth +import io.ktor.client.request.delete import io.ktor.client.request.get +import io.ktor.client.request.patch import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -229,7 +231,7 @@ object ApiClient { } .body() - private suspend fun getTransportPublicKey(): TransportKeyResponse = + suspend fun getTransportPublicKey(): TransportKeyResponse = http .get("${Config.apiBaseUrl}/dm/key/transport/public") { contentType(ContentType.Application.Json) @@ -242,7 +244,9 @@ object ApiClient { suspend fun sendDm( recipientId: Int, plaintext: String, - replyToId: Int? = null + replyToId: Int? = null, + transportFiles: List = emptyList(), + uploadedFileIds: List = emptyList() ) { val keys = IdentityKeyManager.getCurrentKeys() ?: IdentityKeyManager.restoreFromLocal() @@ -267,7 +271,8 @@ object ApiClient { senderPublicKeyB64 = senderPublicKeyB64, recipientPublicKeyB64 = recipientPublicKey, replyToId = replyToId, - transportFiles = emptyList() + transportFiles = transportFiles, + uploadedFileIds = uploadedFileIds ) http.post("${Config.apiBaseUrl}/dm/send") { @@ -276,6 +281,56 @@ object ApiClient { } } + suspend fun initDmUpload( + filename: String, + totalSize: Long, + recipientId: Int, + chunkSize: Int? = null + ): DmUploadInitResponse = + http.post("${Config.apiBaseUrl}/dm/upload/init") { + contentType(ContentType.Application.Json) + setBody( + DmUploadInitRequest( + filename = filename, + totalSize = totalSize, + recipientId = recipientId, + chunkSize = chunkSize + ) + ) + }.body() + + suspend fun getDmUploadStatus(uploadId: String): DmUploadStatusResponse = + http.get("${Config.apiBaseUrl}/dm/upload/$uploadId") { + contentType(ContentType.Application.Json) + }.body() + + suspend fun uploadDmChunk( + uploadId: String, + offset: Long, + dataB64: String + ): DmUploadChunkResponse = + http.patch("${Config.apiBaseUrl}/dm/upload/$uploadId") { + contentType(ContentType.Application.Json) + setBody( + DmUploadChunkRequest( + offset = offset, + dataB64 = dataB64 + ) + ) + }.body() + + suspend fun completeDmUpload(uploadId: String): DmUploadCompleteResponse = + http.post("${Config.apiBaseUrl}/dm/upload/$uploadId/complete") { + contentType(ContentType.Application.Json) + setBody(mapOf("upload_id" to uploadId)) + }.body() + + suspend fun abortDmUpload(uploadId: String) { + http.delete("${Config.apiBaseUrl}/dm/upload/$uploadId") { + contentType(ContentType.Application.Json) + } + } + /** * Edit an existing direct message using the same transport encryption scheme as /dm/send. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt new file mode 100644 index 0000000..fe7210a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.kt @@ -0,0 +1,28 @@ +package ru.fromchat.api + +import kotlinx.coroutines.flow.SharedFlow + +data class AttachmentUploadJob( + val jobId: String, + val fileUri: String, + val filename: String, + val recipientId: Int, + val plaintext: String, + val replyToId: Int? = null +) + +sealed class AttachmentUploadProgress { + data class Pending(val jobId: String, val filename: String) : AttachmentUploadProgress() + data class InProgress(val jobId: String, val percent: Int) : AttachmentUploadProgress() + data class Success(val jobId: String) : AttachmentUploadProgress() + data class Failed(val jobId: String, val error: String) : AttachmentUploadProgress() +} + +expect object AttachmentUploadQueue { + val progressFlow: SharedFlow + + fun enqueue(job: AttachmentUploadJob) + + fun cancel(jobId: String) +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 5938d3b..41f4464 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -217,7 +217,8 @@ data class SendDmRequest( @SerialName("sender_public_key_b64") val senderPublicKeyB64: String, @SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String, @SerialName("reply_to_id") val replyToId: Int? = null, - @SerialName("transport_files") val transportFiles: List = emptyList() + @SerialName("transport_files") val transportFiles: List = emptyList(), + @SerialName("uploaded_file_ids") val uploadedFileIds: List = emptyList() ) @Serializable @@ -236,6 +237,47 @@ data class TransportKeyResponse( @SerialName("created_at") val createdAt: Double? = null ) +@Serializable +data class DmUploadInitRequest( + val filename: String, + @SerialName("total_size") val totalSize: Long, + @SerialName("recipient_id") val recipientId: Int, + @SerialName("chunk_size") val chunkSize: Int? = null +) + +@Serializable +data class DmUploadInitResponse( + @SerialName("upload_id") val uploadId: String, + @SerialName("chunk_size") val chunkSize: Int, + val offset: Long = 0L +) + +@Serializable +data class DmUploadStatusResponse( + @SerialName("upload_id") val uploadId: String, + val filename: String, + @SerialName("total_size") val totalSize: Long, + val offset: Long, + val complete: Boolean +) + +@Serializable +data class DmUploadChunkRequest( + val offset: Long, + @SerialName("data_b64") val dataB64: String +) + +@Serializable +data class DmUploadChunkResponse( + @SerialName("offset_received") val offsetReceived: Long +) + +@Serializable +data class DmUploadCompleteResponse( + @SerialName("file_id") val fileId: String, + @SerialName("upload_id") val uploadId: String +) + // Batched updates message @Serializable data class UpdateItem( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt index 43950ec..61544e6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.kt @@ -17,12 +17,25 @@ data class TransportCiphertext( /** * Platform-specific NaCl box-compatible transport crypto. * - * Android provides a real implementation; iOS currently provides a stub. + * Android and iOS provide real implementations. */ expect object TransportCrypto { suspend fun encryptWithTransportKey( plaintext: String, transportPublicKeyB64: String ): TransportCiphertext + + /** + * Encrypt raw file bytes for transport using the server's transport public key + * and the client's long-term identity private key. + * + * This mirrors the Web client's behaviour for `transport_files`: + * - Uses NaCl box with (server transport public key, identity private key) + * - Returns a blob formatted as `nonce || ciphertext` + */ + suspend fun encryptFileForTransport( + fileBytes: ByteArray, + transportPublicKeyB64: String + ): ByteArray } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt new file mode 100644 index 0000000..10c06af --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.ios.kt @@ -0,0 +1,44 @@ +package ru.fromchat.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +private val iosUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + +actual object AttachmentUploadQueue { + private val _progressFlow = MutableSharedFlow(extraBufferCapacity = 64) + actual val progressFlow: SharedFlow = _progressFlow + + actual fun enqueue(job: AttachmentUploadJob) { + _progressFlow.tryEmit(AttachmentUploadProgress.Pending(job.jobId, job.filename)) + iosUploadScope.launch { + // Phase 1 placeholder on iOS: eager message-only send to keep API functional. + // Full background resumable URLSession integration is completed in Phase 4. + runCatching { + ApiClient.sendDm( + recipientId = job.recipientId, + plaintext = job.plaintext, + replyToId = job.replyToId + ) + }.onSuccess { + _progressFlow.tryEmit(AttachmentUploadProgress.Success(job.jobId)) + }.onFailure { error -> + _progressFlow.tryEmit( + AttachmentUploadProgress.Failed( + jobId = job.jobId, + error = error.message ?: "Upload failed" + ) + ) + } + } + } + + actual fun cancel(jobId: String) { + // No-op in the Phase 1 iOS eager placeholder implementation. + } +} + diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt index a327346..b6a89af 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/crypto/transport/TransportCrypto.ios.kt @@ -7,6 +7,7 @@ import com.ionspin.kotlin.crypto.util.LibsodiumRandom import com.pr0gramm3r101.utils.crypto.Base64 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import ru.fromchat.crypto.IdentityKeyManager actual object TransportCrypto { actual suspend fun encryptWithTransportKey( @@ -32,4 +33,33 @@ actual object TransportCrypto { ciphertextB64 = Base64.encode(ciphertext.toByteArray()) ) } + + actual suspend fun encryptFileForTransport( + fileBytes: ByteArray, + transportPublicKeyB64: String + ): ByteArray = withContext(Dispatchers.Default) { + if (!LibsodiumInitializer.isInitialized()) { + LibsodiumInitializer.initialize() + } + + val keys = IdentityKeyManager.getCurrentKeys() + ?: IdentityKeyManager.restoreFromLocal() + ?: error("Identity keys not initialized. Please log in again.") + + val nonce = LibsodiumRandom.buf(crypto_box_NONCEBYTES) + val ciphertext = Box.easy( + fileBytes.toUByteArray(), + nonce, + Base64.decode(transportPublicKeyB64).toUByteArray(), + keys.privateKey.toUByteArray() + ) + + // Files are sent as nonce || ciphertext, base64-encoded by the caller + val nonceBytes = nonce.toByteArray() + val cipherBytes = ciphertext.toByteArray() + val result = ByteArray(nonceBytes.size + cipherBytes.size) + nonceBytes.copyInto(result, 0, 0, nonceBytes.size) + cipherBytes.copyInto(result, nonceBytes.size, 0, cipherBytes.size) + result + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13b6a49..c898cdd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ composeMaterialIconsExtended = "1.7.3" composeMaterial3 = "1.10.0-alpha05" composeComponents = "1.10.0" playServicesBase = "18.10.0" +androidxWork = "2.10.2" [libraries] androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } @@ -83,6 +84,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }