mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix image display, implement expressive file upload
This commit is contained in:
@@ -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<AttachmentUploadProgress>(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 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 (msgCipher, ephemeralSecret) = TransportCrypto.encryptWithTransportKeyWithEphemeralSecret(
|
||||
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 encryptedBlob = TransportCrypto.encryptFileForTransport(
|
||||
val blob = TransportCrypto.encryptFileForTransport(
|
||||
fileBytes = bytes,
|
||||
transportPublicKeyB64 = transportKey.publicKeyB64,
|
||||
ephemeralSecretKey = ephemeralSecret
|
||||
)
|
||||
encryptedBlob = blob
|
||||
msgCipher = freshCipher
|
||||
savePreparedTransportAndBlob(applicationContext, jobId, encryptedBlob, msgCipher)
|
||||
} 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)
|
||||
}
|
||||
} finally {
|
||||
ephemeralSecret.fill(0)
|
||||
}
|
||||
|
||||
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<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.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
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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)
|
||||
)
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = if (isAuthor) {
|
||||
Color.White
|
||||
} else {
|
||||
IndefiniteCircularProgress(modifier = Modifier.size(28.dp))
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user