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 index 89e2c4e..14580c6 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/AttachmentUploadQueue.android.kt @@ -2,6 +2,8 @@ package ru.fromchat.api import android.content.Context import android.net.Uri +import java.io.File +import org.json.JSONObject import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker @@ -17,12 +19,18 @@ import com.pr0gramm3r101.utils.settings.settings import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCrypto private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 private const val DEFAULT_CHUNK_SIZE = 262_144 +private fun dmBlobCacheFile(context: Context, jobId: String): File = + File(context.cacheDir, "dm_resumable_$jobId.blob") + +private fun dmTransportCipherPrefsKey(jobId: String): String = "dm_upload_transport_cipher_$jobId" + private object AttachmentUploadEvents { val flow = MutableSharedFlow(extraBufferCapacity = 64) } @@ -68,7 +76,12 @@ actual object AttachmentUploadQueue { } actual fun cancel(jobId: String) { - WorkManager.getInstance(UtilsLibrary.context).cancelUniqueWork(uniqueWorkName(jobId)) + val ctx = UtilsLibrary.context + dmBlobCacheFile(ctx, jobId).delete() + runBlocking { + settings.putString(dmTransportCipherPrefsKey(jobId), "") + } + WorkManager.getInstance(ctx).cancelUniqueWork(uniqueWorkName(jobId)) } private fun uniqueWorkName(jobId: String): String = "dm-attachment-upload-$jobId" @@ -101,29 +114,49 @@ class DmAttachmentUploadWorker( return runCatching { emitProgress(jobId, 0) - val transportKey = ApiClient.getTransportPublicKey() - val (msgCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( - plaintext = plaintext, - transportPublicKeyB64 = transportKey.publicKeyB64 - ) - try { - val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } - ?: error("Failed to read file from URI") - val encryptedBlob = TransportCrypto.encryptFileForTransport( - fileBytes = bytes, - transportPublicKeyB64 = transportKey.publicKeyB64, - ephemeralSecretKey = ephemeralSecret - ) - if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { - sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) - } else { - sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) + val prepared = loadPreparedTransportAndBlob(applicationContext, jobId) + val encryptedBlob: ByteArray + val msgCipher: TransportCiphertext + + if (prepared != null) { + encryptedBlob = prepared.first + msgCipher = prepared.second + } else { + val staleUploadId = settings.getString(uploadIdKey(jobId), "").ifBlank { null } + if (staleUploadId != null) { + runCatching { ApiClient.abortDmUpload(staleUploadId) } + settings.putString(uploadIdKey(jobId), "") + } + + val transportKey = ApiClient.getTransportPublicKey() + val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( + plaintext = plaintext, + transportPublicKeyB64 = transportKey.publicKeyB64 + ) + try { + val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } + ?: error("Failed to read file from URI") + val blob = TransportCrypto.encryptFileForTransport( + fileBytes = bytes, + transportPublicKeyB64 = transportKey.publicKeyB64, + ephemeralSecretKey = ephemeralSecret + ) + encryptedBlob = blob + msgCipher = freshCipher + savePreparedTransportAndBlob(applicationContext, jobId, encryptedBlob, msgCipher) + } finally { + ephemeralSecret.fill(0) } - } finally { - ephemeralSecret.fill(0) } + if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { + sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) + } else { + sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) + } + + clearPreparedTransportAndBlob(applicationContext, jobId) clearResumableState(jobId) AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId)) Result.success() @@ -216,5 +249,46 @@ class DmAttachmentUploadWorker( } private fun uploadIdKey(jobId: String): String = "dm_upload_id_$jobId" + + private suspend fun savePreparedTransportAndBlob( + context: Context, + jobId: String, + blob: ByteArray, + cipher: TransportCiphertext + ) { + val f = dmBlobCacheFile(context, jobId) + f.outputStream().use { it.write(blob) } + val json = JSONObject().apply { + put("clientPublicKeyB64", cipher.clientPublicKeyB64) + put("nonceB64", cipher.nonceB64) + put("ciphertextB64", cipher.ciphertextB64) + } + settings.putString(dmTransportCipherPrefsKey(jobId), json.toString()) + } + + private suspend fun loadPreparedTransportAndBlob( + context: Context, + jobId: String + ): Pair? { + val f = dmBlobCacheFile(context, jobId) + val raw = settings.getString(dmTransportCipherPrefsKey(jobId), "").ifBlank { return null } + if (!f.isFile || f.length() == 0L) return null + return try { + val o = JSONObject(raw) + val cipher = TransportCiphertext( + clientPublicKeyB64 = o.getString("clientPublicKeyB64"), + nonceB64 = o.getString("nonceB64"), + ciphertextB64 = o.getString("ciphertextB64") + ) + f.readBytes() to cipher + } catch (_: Exception) { + null + } + } + + private suspend fun clearPreparedTransportAndBlob(context: Context, jobId: String) { + dmBlobCacheFile(context, jobId).delete() + settings.putString(dmTransportCipherPrefsKey(jobId), "") + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 3b12d87..dfe1bd5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile @@ -33,6 +34,7 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.WavyProgressIndicatorDefaults import androidx.compose.material3.Text @@ -47,14 +49,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.withSaveLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot @@ -111,31 +108,24 @@ fun AttachmentPreview( else -> false } - val isFile = file != null && !isImage val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() val isPendingImage = pendingFileUri != null && isImage val isPendingFile = pendingFileUri != null && !isImage when { - isFile -> { - FileIconContent( - filename = file.name, + (file != null && !isImage) || isPendingFile -> { + ExpressiveFileAttachmentRow( + filename = file?.name + ?: pendingFilename?.takeIf { it.isNotBlank() } + ?: pendingFileUri?.substringAfterLast("/") + ?.substringBefore("?") + ?.takeIf { it.isNotBlank() } + ?: "File", sizeBytes = fileSizeBytes, - onClick = onFileClick, + onClick = if (file != null) onFileClick else null, isAuthor = isAuthor, - isUploading = false, - uploadProgress = null, - modifier = modifier - ) - } - isPendingFile -> { - FileIconContent( - filename = pendingFilename ?: "File", - sizeBytes = null, - onClick = null, - isAuthor = isAuthor, - isUploading = isUploading, - uploadProgress = uploadProgress, + isUploading = isPendingFile && isUploading, + uploadProgress = if (isPendingFile) uploadProgress else null, modifier = modifier ) } @@ -357,7 +347,9 @@ private fun UploadingImageOverlay( @Composable private fun ExpressiveUploadIndicator( uploadProgress: Int?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + indicatorColor: Color? = null, + trackColorOverride: Color? = null ) { val clampedProgress = uploadProgress?.coerceIn(0, 100) val indeterminate = clampedProgress == null || clampedProgress == 0 @@ -377,15 +369,15 @@ private fun ExpressiveUploadIndicator( animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "uploadProgress" ) - val primary = MaterialTheme.colorScheme.primary - val trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f) + val primary = indicatorColor ?: MaterialTheme.colorScheme.primary + val trackColor = trackColorOverride + ?: MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f) val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude - val ringModifier = Modifier.size(56.dp) Box(modifier = modifier, contentAlignment = Alignment.Center) { if (indeterminate) { CircularWavyProgressIndicator( - modifier = ringModifier, + modifier = Modifier.fillMaxSize(), color = primary, trackColor = trackColor, amplitude = animatedWaveStrength @@ -393,7 +385,7 @@ private fun ExpressiveUploadIndicator( } else { CircularWavyProgressIndicator( progress = { animatedProgress }, - modifier = ringModifier, + modifier = Modifier.fillMaxSize(), color = primary, trackColor = trackColor, amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) } @@ -658,101 +650,95 @@ private fun formatFileSize(bytes: Long): String { } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun FileIconContent( +private fun ExpressiveFileAttachmentRow( filename: String, sizeBytes: Long?, onClick: (() -> Unit)?, isAuthor: Boolean, - isUploading: Boolean = false, - uploadProgress: Int? = null, + isUploading: Boolean, + uploadProgress: Int?, modifier: Modifier = Modifier ) { - val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface - val circleBackground = if (isAuthor) Color.White else MaterialTheme.colorScheme.primary - val iconTint = if (isAuthor) MaterialTheme.colorScheme.primary else Color.White + val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface + val supportingColor = if (isAuthor) { + Color.White.copy(alpha = 0.78f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val leadingSize = 48.dp Row( modifier = modifier - .widthIn(max = 240.dp) - .padding(vertical = 8.dp, horizontal = 4.dp) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.size(40.dp), - contentAlignment = Alignment.Center - ) { - Box(modifier = Modifier.size(40.dp).then( - if (isAuthor) { - Modifier - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - drawCircle( - color = circleBackground, - radius = size.minDimension / 2f, - center = center - ) - drawContext.canvas.withSaveLayer( - bounds = Rect(0f, 0f, size.width, size.height), - paint = Paint().apply { blendMode = BlendMode.DstOut } - ) { - drawContent() - } - } + .widthIn(max = 268.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) + .then( + if (onClick != null && !isUploading) { + Modifier.clickable(onClick = onClick) } else { Modifier - .background(circleBackground, RoundedCornerShape(20.dp)) } ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier.size(leadingSize), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Rounded.Download, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = if (isAuthor) Color.White else iconTint - ) - } if (isUploading) { - Box( - modifier = Modifier - .size(40.dp) - .align(Alignment.Center) - .background( - MaterialTheme.colorScheme.surface.copy(alpha = 0.6f), - RoundedCornerShape(20.dp) - ), - contentAlignment = Alignment.Center - ) { - if (uploadProgress != null) { - DeterminateCircularProgress( - progress = uploadProgress, - modifier = Modifier.size(28.dp) - ) + ExpressiveUploadIndicator( + uploadProgress = uploadProgress, + modifier = Modifier.size(leadingSize), + indicatorColor = if (isAuthor) Color.White else null, + trackColorOverride = if (isAuthor) { + Color.White.copy(alpha = 0.28f) } else { - IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) + null + } + ) + } else { + Surface( + shape = CircleShape, + color = if (isAuthor) { + Color.White.copy(alpha = 0.22f) + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + modifier = Modifier.size(leadingSize) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Download, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = if (isAuthor) { + Color.White + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + ) } } } } Column( - modifier = Modifier.padding(start = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = filename.take(70) + if (filename.length > 70) "…" else "", - style = MaterialTheme.typography.bodyMedium, - color = contentColor, + style = MaterialTheme.typography.titleSmall, + color = headlineColor, maxLines = 2 ) if (sizeBytes != null) { Text( text = formatFileSize(sizeBytes), - style = MaterialTheme.typography.labelSmall, - fontSize = 12.sp, - color = contentColor.copy(alpha = 0.8f) + style = MaterialTheme.typography.labelMedium, + color = supportingColor ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 14a2922..b107349 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -312,7 +312,11 @@ fun MessageItem( } else { message.pendingFileAspectRatio }, - fileSizeBytes = if (pendingImageFile != null) message.fileSizes?.firstOrNull() else null, + fileSizeBytes = when { + pendingImageFile != null -> message.fileSizes?.firstOrNull() + !isPendingImage -> message.fileSizes?.firstOrNull() + else -> null + }, messageId = if (pendingImageFile != null && isPendingImage) message.id else null, fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, onFileClick = null, @@ -343,7 +347,7 @@ fun MessageItem( ) } message.files?.forEachIndexed { index, file -> - if (hasPendingServerImage && index == 0) return@forEachIndexed + if (message.pendingFileUri != null && index == 0) return@forEachIndexed val isImage = isImageFilename(file.name) val imageKey = if (isImage) "img_${message.id}_$index" else null val isFirstImage = index == 0 && isImage