Fix image display, implement expressive file upload

This commit is contained in:
2026-04-02 15:42:03 +03:00
Unverified
parent 849262a79d
commit ff1ef8872c
3 changed files with 179 additions and 115 deletions
@@ -2,6 +2,8 @@ package ru.fromchat.api
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import java.io.File
import org.json.JSONObject
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -17,12 +19,18 @@ import com.pr0gramm3r101.utils.settings.settings
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.runBlocking
import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.crypto.transport.TransportCrypto
private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024 private const val INLINE_UPLOAD_THRESHOLD_BYTES = 512 * 1024
private const val DEFAULT_CHUNK_SIZE = 262_144 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 { private object AttachmentUploadEvents {
val flow = MutableSharedFlow<AttachmentUploadProgress>(extraBufferCapacity = 64) val flow = MutableSharedFlow<AttachmentUploadProgress>(extraBufferCapacity = 64)
} }
@@ -68,7 +76,12 @@ actual object AttachmentUploadQueue {
} }
actual fun cancel(jobId: String) { 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" private fun uniqueWorkName(jobId: String): String = "dm-attachment-upload-$jobId"
@@ -101,29 +114,49 @@ class DmAttachmentUploadWorker(
return runCatching { return runCatching {
emitProgress(jobId, 0) emitProgress(jobId, 0)
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 transportKey = ApiClient.getTransportPublicKey()
val (msgCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret( val (freshCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
plaintext = plaintext, plaintext = plaintext,
transportPublicKeyB64 = transportKey.publicKeyB64 transportPublicKeyB64 = transportKey.publicKeyB64
) )
try { try {
val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() } val bytes = applicationContext.contentResolver.openInputStream(Uri.parse(fileUri))?.use { it.readBytes() }
?: error("Failed to read file from URI") ?: error("Failed to read file from URI")
val encryptedBlob = TransportCrypto.encryptFileForTransport( val blob = TransportCrypto.encryptFileForTransport(
fileBytes = bytes, fileBytes = bytes,
transportPublicKeyB64 = transportKey.publicKeyB64, transportPublicKeyB64 = transportKey.publicKeyB64,
ephemeralSecretKey = ephemeralSecret ephemeralSecretKey = ephemeralSecret
) )
encryptedBlob = blob
msgCipher = freshCipher
savePreparedTransportAndBlob(applicationContext, jobId, encryptedBlob, msgCipher)
} finally {
ephemeralSecret.fill(0)
}
}
if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) { if (encryptedBlob.size <= INLINE_UPLOAD_THRESHOLD_BYTES) {
sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) sendInline(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
} else { } else {
sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher) sendResumable(jobId, recipientId, plaintext, filename, encryptedBlob, msgCipher)
} }
} finally {
ephemeralSecret.fill(0)
}
clearPreparedTransportAndBlob(applicationContext, jobId)
clearResumableState(jobId) clearResumableState(jobId)
AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId)) AttachmentUploadEvents.flow.tryEmit(AttachmentUploadProgress.Success(jobId))
Result.success() Result.success()
@@ -216,5 +249,46 @@ class DmAttachmentUploadWorker(
} }
private fun uploadIdKey(jobId: String): String = "dm_upload_id_$jobId" 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<ByteArray, TransportCiphertext>? {
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), "")
}
} }
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile 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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.WavyProgressIndicatorDefaults import androidx.compose.material3.WavyProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -47,14 +49,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color 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.graphicsLayer
import androidx.compose.ui.graphics.withSaveLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
@@ -111,31 +108,24 @@ fun AttachmentPreview(
else -> false else -> false
} }
val isFile = file != null && !isImage
val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank() val isImageWithThumb = file != null && isImage && dmEnvelope != null && !fileThumbnail.isNullOrBlank()
val isPendingImage = pendingFileUri != null && isImage val isPendingImage = pendingFileUri != null && isImage
val isPendingFile = pendingFileUri != null && !isImage val isPendingFile = pendingFileUri != null && !isImage
when { when {
isFile -> { (file != null && !isImage) || isPendingFile -> {
FileIconContent( ExpressiveFileAttachmentRow(
filename = file.name, filename = file?.name
?: pendingFilename?.takeIf { it.isNotBlank() }
?: pendingFileUri?.substringAfterLast("/")
?.substringBefore("?")
?.takeIf { it.isNotBlank() }
?: "File",
sizeBytes = fileSizeBytes, sizeBytes = fileSizeBytes,
onClick = onFileClick, onClick = if (file != null) onFileClick else null,
isAuthor = isAuthor, isAuthor = isAuthor,
isUploading = false, isUploading = isPendingFile && isUploading,
uploadProgress = null, uploadProgress = if (isPendingFile) uploadProgress else null,
modifier = modifier
)
}
isPendingFile -> {
FileIconContent(
filename = pendingFilename ?: "File",
sizeBytes = null,
onClick = null,
isAuthor = isAuthor,
isUploading = isUploading,
uploadProgress = uploadProgress,
modifier = modifier modifier = modifier
) )
} }
@@ -357,7 +347,9 @@ private fun UploadingImageOverlay(
@Composable @Composable
private fun ExpressiveUploadIndicator( private fun ExpressiveUploadIndicator(
uploadProgress: Int?, uploadProgress: Int?,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
indicatorColor: Color? = null,
trackColorOverride: Color? = null
) { ) {
val clampedProgress = uploadProgress?.coerceIn(0, 100) val clampedProgress = uploadProgress?.coerceIn(0, 100)
val indeterminate = clampedProgress == null || clampedProgress == 0 val indeterminate = clampedProgress == null || clampedProgress == 0
@@ -377,15 +369,15 @@ private fun ExpressiveUploadIndicator(
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "uploadProgress" label = "uploadProgress"
) )
val primary = MaterialTheme.colorScheme.primary val primary = indicatorColor ?: MaterialTheme.colorScheme.primary
val trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f) val trackColor = trackColorOverride
?: MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f)
val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude val defaultIndicatorAmplitude = WavyProgressIndicatorDefaults.indicatorAmplitude
val ringModifier = Modifier.size(56.dp)
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
if (indeterminate) { if (indeterminate) {
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
modifier = ringModifier, modifier = Modifier.fillMaxSize(),
color = primary, color = primary,
trackColor = trackColor, trackColor = trackColor,
amplitude = animatedWaveStrength amplitude = animatedWaveStrength
@@ -393,7 +385,7 @@ private fun ExpressiveUploadIndicator(
} else { } else {
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = ringModifier, modifier = Modifier.fillMaxSize(),
color = primary, color = primary,
trackColor = trackColor, trackColor = trackColor,
amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) } amplitude = { p -> animatedWaveStrength * defaultIndicatorAmplitude(p) }
@@ -658,101 +650,95 @@ private fun formatFileSize(bytes: Long): String {
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun FileIconContent( private fun ExpressiveFileAttachmentRow(
filename: String, filename: String,
sizeBytes: Long?, sizeBytes: Long?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
isAuthor: Boolean, isAuthor: Boolean,
isUploading: Boolean = false, isUploading: Boolean,
uploadProgress: Int? = null, uploadProgress: Int?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
val circleBackground = if (isAuthor) Color.White else MaterialTheme.colorScheme.primary val supportingColor = if (isAuthor) {
val iconTint = if (isAuthor) MaterialTheme.colorScheme.primary else Color.White Color.White.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val leadingSize = 48.dp
Row( Row(
modifier = modifier modifier = modifier
.widthIn(max = 240.dp) .widthIn(max = 268.dp)
.padding(vertical = 8.dp, horizontal = 4.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), .then(
verticalAlignment = Alignment.CenterVertically if (onClick != null && !isUploading) {
) { Modifier.clickable(onClick = onClick)
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()
}
}
} else { } else {
Modifier Modifier
.background(circleBackground, RoundedCornerShape(20.dp))
} }
), ),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier.size(leadingSize),
contentAlignment = Alignment.Center
) {
if (isUploading) {
ExpressiveUploadIndicator(
uploadProgress = uploadProgress,
modifier = Modifier.size(leadingSize),
indicatorColor = if (isAuthor) Color.White else null,
trackColorOverride = if (isAuthor) {
Color.White.copy(alpha = 0.28f)
} else {
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 contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Download, imageVector = Icons.Rounded.Download,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp), modifier = Modifier.size(26.dp),
tint = if (isAuthor) Color.White else iconTint tint = if (isAuthor) {
) Color.White
}
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)
)
} else { } else {
IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) MaterialTheme.colorScheme.onSecondaryContainer
}
)
} }
} }
} }
} }
Column( Column(
modifier = Modifier.padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Text(
text = filename.take(70) + if (filename.length > 70) "" else "", text = filename.take(70) + if (filename.length > 70) "" else "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleSmall,
color = contentColor, color = headlineColor,
maxLines = 2 maxLines = 2
) )
if (sizeBytes != null) { if (sizeBytes != null) {
Text( Text(
text = formatFileSize(sizeBytes), text = formatFileSize(sizeBytes),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
fontSize = 12.sp, color = supportingColor
color = contentColor.copy(alpha = 0.8f)
) )
} }
} }
@@ -312,7 +312,11 @@ fun MessageItem(
} else { } else {
message.pendingFileAspectRatio 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, messageId = if (pendingImageFile != null && isPendingImage) message.id else null,
fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null, fileIndex = if (pendingImageFile != null && isPendingImage) 0 else null,
onFileClick = null, onFileClick = null,
@@ -343,7 +347,7 @@ fun MessageItem(
) )
} }
message.files?.forEachIndexed { index, file -> 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 isImage = isImageFilename(file.name)
val imageKey = if (isImage) "img_${message.id}_$index" else null val imageKey = if (isImage) "img_${message.id}_$index" else null
val isFirstImage = index == 0 && isImage val isFirstImage = index == 0 && isImage