mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<AttachmentUploadProgress>(extraBufferCapacity = 64)
|
||||
}
|
||||
|
||||
actual object AttachmentUploadQueue {
|
||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = AttachmentUploadEvents.flow
|
||||
|
||||
actual fun enqueue(job: AttachmentUploadJob) {
|
||||
AttachmentUploadEvents.flow.tryEmit(
|
||||
AttachmentUploadProgress.Pending(
|
||||
jobId = job.jobId,
|
||||
filename = job.filename
|
||||
)
|
||||
)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<DmAttachmentUploadWorker>()
|
||||
.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"
|
||||
}
|
||||
|
||||
+30
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SendDmFile> = emptyList(),
|
||||
uploadedFileIds: List<String> = 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.
|
||||
*/
|
||||
|
||||
@@ -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<AttachmentUploadProgress>
|
||||
|
||||
fun enqueue(job: AttachmentUploadJob)
|
||||
|
||||
fun cancel(jobId: String)
|
||||
}
|
||||
|
||||
@@ -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<SendDmFile> = emptyList()
|
||||
@SerialName("transport_files") val transportFiles: List<SendDmFile> = emptyList(),
|
||||
@SerialName("uploaded_file_ids") val uploadedFileIds: List<String> = 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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AttachmentUploadProgress>(extraBufferCapacity = 64)
|
||||
actual val progressFlow: SharedFlow<AttachmentUploadProgress> = _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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user