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 {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
// NaCl box implementation for transport encryption (Android/JVM only)
|
// NaCl box implementation for transport encryption (Android/JVM only)
|
||||||
implementation("org.purejava:tweetnacl-java:1.1.3")
|
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
|
package ru.fromchat.crypto.transport
|
||||||
|
|
||||||
|
import com.iwebpp.crypto.TweetNaclFast
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import com.iwebpp.crypto.TweetNaclFast
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
import ru.fromchat.crypto.IdentityKeyManager
|
||||||
|
|
||||||
actual object TransportCrypto {
|
actual object TransportCrypto {
|
||||||
private val random = SecureRandom()
|
private val random = SecureRandom()
|
||||||
@@ -13,8 +14,6 @@ actual object TransportCrypto {
|
|||||||
plaintext: String,
|
plaintext: String,
|
||||||
transportPublicKeyB64: String
|
transportPublicKeyB64: String
|
||||||
): TransportCiphertext = withContext(Dispatchers.Default) {
|
): TransportCiphertext = withContext(Dispatchers.Default) {
|
||||||
val messageBytes = plaintext.encodeToByteArray()
|
|
||||||
|
|
||||||
// Decode server-provided transport public key
|
// Decode server-provided transport public key
|
||||||
val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64)
|
val transportPublicKey = Base64.getDecoder().decode(transportPublicKeyB64)
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ actual object TransportCrypto {
|
|||||||
val nonce = ByteArray(TweetNaclFast.Box.nonceLength)
|
val nonce = ByteArray(TweetNaclFast.Box.nonceLength)
|
||||||
random.nextBytes(nonce)
|
random.nextBytes(nonce)
|
||||||
|
|
||||||
val ciphertext = box.box(messageBytes, nonce)
|
val ciphertext = box.box(plaintext.encodeToByteArray(), nonce)
|
||||||
|
|
||||||
val encoder = Base64.getEncoder()
|
val encoder = Base64.getEncoder()
|
||||||
TransportCiphertext(
|
TransportCiphertext(
|
||||||
@@ -37,5 +36,32 @@ actual object TransportCrypto {
|
|||||||
ciphertextB64 = encoder.encodeToString(ciphertext)
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.pr0gramm3r101.utils.crypto.Base64
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.SendDmFile
|
||||||
import ru.fromchat.crypto.IdentityKeyManager
|
import ru.fromchat.crypto.IdentityKeyManager
|
||||||
import ru.fromchat.crypto.decryptEnvelope
|
import ru.fromchat.crypto.decryptEnvelope
|
||||||
|
import ru.fromchat.crypto.transport.TransportCrypto
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -228,6 +231,44 @@ actual fun DebugApiScreen() {
|
|||||||
Text(text = "Send DM")
|
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(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
thickness = DividerDefaults.Thickness,
|
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.WebSockets
|
||||||
import io.ktor.client.plugins.websocket.pingInterval
|
import io.ktor.client.plugins.websocket.pingInterval
|
||||||
import io.ktor.client.request.bearerAuth
|
import io.ktor.client.request.bearerAuth
|
||||||
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.patch
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
@@ -229,7 +231,7 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
private suspend fun getTransportPublicKey(): TransportKeyResponse =
|
suspend fun getTransportPublicKey(): TransportKeyResponse =
|
||||||
http
|
http
|
||||||
.get("${Config.apiBaseUrl}/dm/key/transport/public") {
|
.get("${Config.apiBaseUrl}/dm/key/transport/public") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
@@ -242,7 +244,9 @@ object ApiClient {
|
|||||||
suspend fun sendDm(
|
suspend fun sendDm(
|
||||||
recipientId: Int,
|
recipientId: Int,
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
replyToId: Int? = null
|
replyToId: Int? = null,
|
||||||
|
transportFiles: List<SendDmFile> = emptyList(),
|
||||||
|
uploadedFileIds: List<String> = emptyList()
|
||||||
) {
|
) {
|
||||||
val keys = IdentityKeyManager.getCurrentKeys()
|
val keys = IdentityKeyManager.getCurrentKeys()
|
||||||
?: IdentityKeyManager.restoreFromLocal()
|
?: IdentityKeyManager.restoreFromLocal()
|
||||||
@@ -267,7 +271,8 @@ object ApiClient {
|
|||||||
senderPublicKeyB64 = senderPublicKeyB64,
|
senderPublicKeyB64 = senderPublicKeyB64,
|
||||||
recipientPublicKeyB64 = recipientPublicKey,
|
recipientPublicKeyB64 = recipientPublicKey,
|
||||||
replyToId = replyToId,
|
replyToId = replyToId,
|
||||||
transportFiles = emptyList()
|
transportFiles = transportFiles,
|
||||||
|
uploadedFileIds = uploadedFileIds
|
||||||
)
|
)
|
||||||
|
|
||||||
http.post("${Config.apiBaseUrl}/dm/send") {
|
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.
|
* 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("sender_public_key_b64") val senderPublicKeyB64: String,
|
||||||
@SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String,
|
@SerialName("recipient_public_key_b64") val recipientPublicKeyB64: String,
|
||||||
@SerialName("reply_to_id") val replyToId: Int? = null,
|
@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
|
@Serializable
|
||||||
@@ -236,6 +237,47 @@ data class TransportKeyResponse(
|
|||||||
@SerialName("created_at") val createdAt: Double? = null
|
@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
|
// Batched updates message
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UpdateItem(
|
data class UpdateItem(
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ data class TransportCiphertext(
|
|||||||
/**
|
/**
|
||||||
* Platform-specific NaCl box-compatible transport crypto.
|
* 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 {
|
expect object TransportCrypto {
|
||||||
suspend fun encryptWithTransportKey(
|
suspend fun encryptWithTransportKey(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
transportPublicKeyB64: String
|
transportPublicKeyB64: String
|
||||||
): TransportCiphertext
|
): 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 com.pr0gramm3r101.utils.crypto.Base64
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.crypto.IdentityKeyManager
|
||||||
|
|
||||||
actual object TransportCrypto {
|
actual object TransportCrypto {
|
||||||
actual suspend fun encryptWithTransportKey(
|
actual suspend fun encryptWithTransportKey(
|
||||||
@@ -32,4 +33,33 @@ actual object TransportCrypto {
|
|||||||
ciphertextB64 = Base64.encode(ciphertext.toByteArray())
|
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"
|
composeMaterial3 = "1.10.0-alpha05"
|
||||||
composeComponents = "1.10.0"
|
composeComponents = "1.10.0"
|
||||||
playServicesBase = "18.10.0"
|
playServicesBase = "18.10.0"
|
||||||
|
androidxWork = "2.10.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
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-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
play-services-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" }
|
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-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", 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" }
|
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
||||||
|
|||||||
Reference in New Issue
Block a user