Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-13 20:53:25 +03:00
Unverified
parent c6a9e32a48
commit d0a0f472eb
11 changed files with 498 additions and 9 deletions
+1
View File
@@ -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"
}
@@ -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
}
}