diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 898986a..0256197 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,9 @@ dependencies { implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support + // Blurhash for image placeholders + implementation("com.vanniktech:blurhash:0.1.0") + // Crypto libraries for key generation implementation("org.bitcoinj:bitcoinj-core:0.16.2") implementation("org.bouncycastle:bcprov-jdk15to18:1.77") diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index e2bebf8..5381a39 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -105,7 +105,7 @@ fun MessageAttachments( } /** - * Image attachment + * Image attachment - Telegram style */ @Composable fun ImageAttachment( @@ -114,26 +114,20 @@ fun ImageAttachment( privateKey: String, isDarkTheme: Boolean ) { - val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var imageBitmap by remember { mutableStateOf(null) } - var downloadProgress by remember { mutableStateOf(0) } - // Проверяем статус при загрузке LaunchedEffect(attachment.id) { downloadStatus = if (attachment.blob.isNotEmpty() && !isDownloadTag(attachment.preview)) { - // Blob уже есть - сразу декодируем DownloadStatus.DOWNLOADED } else if (isDownloadTag(attachment.preview)) { - // Нужно скачать с сервера DownloadStatus.NOT_DOWNLOADED } else { DownloadStatus.DOWNLOADED } - // Если скачано - декодируем изображение if (downloadStatus == DownloadStatus.DOWNLOADED && attachment.blob.isNotEmpty()) { withContext(Dispatchers.IO) { imageBitmap = base64ToBitmap(attachment.blob) @@ -145,19 +139,15 @@ fun ImageAttachment( scope.launch { try { downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(attachment.preview) if (tag.isEmpty()) { downloadStatus = DownloadStatus.ERROR return@launch } - // Скачиваем с транспортного сервера val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING - // Расшифровываем с ChaCha ключом val decrypted = MessageCrypto.decryptAttachmentBlob( encryptedContent, chachaKey, @@ -179,71 +169,64 @@ fun ImageAttachment( } } - Card( + // Telegram-style: Изображение без Card wrapper + Box( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .widthIn(min = 200.dp, max = 280.dp) + .heightIn(min = 150.dp, max = 350.dp) + .clip(RoundedCornerShape(8.dp)) .clickable { if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { download() } }, - colors = CardDefaults.cardColors( - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) - ) + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 100.dp, max = 300.dp), - contentAlignment = Alignment.Center - ) { - when (downloadStatus) { - DownloadStatus.DOWNLOADED -> { - imageBitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Image", - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.Fit - ) - } + when (downloadStatus) { + DownloadStatus.DOWNLOADED -> { + imageBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) } - DownloadStatus.NOT_DOWNLOADED -> { - // Показываем preview (blurhash) если есть + } + DownloadStatus.NOT_DOWNLOADED -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center + ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(16.dp) + horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - Icons.Default.Image, + Icons.Default.Download, contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray, - modifier = Modifier.size(48.dp) + tint = PrimaryBlue, + modifier = Modifier.size(40.dp) ) Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = download, - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue - ) - ) { - Icon( - Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Download") - } + Text( + "Tap to download", + fontSize = 13.sp, + color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray + ) } } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center + ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(16.dp) + horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator( color = PrimaryBlue, @@ -253,32 +236,44 @@ fun ImageAttachment( Text( text = if (downloadStatus == DownloadStatus.DECRYPTING) "Decrypting..." else "Downloading...", fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.7f) else Color.Gray + color = if (isDarkTheme) Color.White.copy(0.7f) else Color.Gray ) } } - DownloadStatus.ERROR -> { + } + DownloadStatus.ERROR -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center + ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(16.dp) + horizontalAlignment = Alignment.CenterHorizontally ) { Icon( Icons.Default.Error, contentDescription = null, - tint = Color.Red, + tint = Color(0xFFE53935), modifier = Modifier.size(32.dp) ) Spacer(modifier = Modifier.height(8.dp)) - Text("Download failed", color = Color.Red, fontSize = 12.sp) - Spacer(modifier = Modifier.height(8.dp)) + Text("Failed", fontSize = 12.sp, color = Color(0xFFE53935)) + Spacer(modifier = Modifier.height(4.dp)) TextButton(onClick = download) { - Text("Retry") + Text("Retry", fontSize = 12.sp, color = PrimaryBlue) } } } - else -> { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + else -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = PrimaryBlue) } } } @@ -286,7 +281,7 @@ fun ImageAttachment( } /** - * File attachment + * File attachment - Telegram style */ @Composable fun FileAttachment( @@ -298,12 +293,10 @@ fun FileAttachment( val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } - // Парсим метаданные: "filesize::filename" val preview = attachment.preview val parts = preview.split("::") val (fileSize, fileName) = when { parts.size >= 3 -> { - // Формат: "UUID::filesize::filename" val size = parts[1].toLongOrNull() ?: 0L val name = parts.drop(2).joinToString("::") Pair(size, name) @@ -315,7 +308,6 @@ fun FileAttachment( else -> Pair(0L, "File") } - // Проверяем статус LaunchedEffect(attachment.id) { downloadStatus = if (isDownloadTag(preview)) { DownloadStatus.NOT_DOWNLOADED @@ -328,7 +320,6 @@ fun FileAttachment( scope.launch { try { downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(preview) if (tag.isEmpty()) { downloadStatus = DownloadStatus.ERROR @@ -336,7 +327,6 @@ fun FileAttachment( } val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING val decrypted = MessageCrypto.decryptAttachmentBlob( @@ -346,7 +336,6 @@ fun FileAttachment( ) if (decrypted != null) { - // TODO: Сохранить файл в Downloads downloadStatus = DownloadStatus.DOWNLOADED } else { downloadStatus = DownloadStatus.ERROR @@ -358,102 +347,97 @@ fun FileAttachment( } } - Card( + // Telegram-style: компактный row с иконкой файла + Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) .clickable { if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { download() } - }, - colors = CardDefaults.cardColors( - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) - ) + } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( + // File icon + Box( modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + .size(44.dp) + .clip(RoundedCornerShape(6.dp)) + .background(PrimaryBlue.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center ) { - // File icon - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(PrimaryBlue.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { + Icon( + Icons.Default.InsertDriveFile, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + // File info + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isDarkTheme) Color.White else Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatFileSize(fileSize), + fontSize = 12.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Download/status icon + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> { Icon( - Icons.Default.InsertDriveFile, - contentDescription = null, + Icons.Default.Download, + contentDescription = "Download", tint = PrimaryBlue, modifier = Modifier.size(24.dp) ) } - - Spacer(modifier = Modifier.width(12.dp)) - - // File info - Column(modifier = Modifier.weight(1f)) { - Text( - text = fileName, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color.White else Color.Black, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = formatFileSize(fileSize), - fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = PrimaryBlue, + strokeWidth = 2.dp ) } - - // Download button/status - when (downloadStatus) { - DownloadStatus.NOT_DOWNLOADED -> { - IconButton(onClick = download) { - Icon( - Icons.Default.Download, - contentDescription = "Download", - tint = PrimaryBlue - ) - } - } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = PrimaryBlue, - strokeWidth = 2.dp - ) - } - DownloadStatus.DOWNLOADED -> { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Downloaded", - tint = Color(0xFF4CAF50) - ) - } - DownloadStatus.ERROR -> { - IconButton(onClick = download) { - Icon( - Icons.Default.Refresh, - contentDescription = "Retry", - tint = Color.Red - ) - } - } - else -> {} + DownloadStatus.DOWNLOADED -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Downloaded", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(24.dp) + ) } + DownloadStatus.ERROR -> { + Icon( + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color(0xFFE53935), + modifier = Modifier.size(24.dp) + ) + } + else -> {} } } } /** - * Avatar attachment - аватар отправителя + * Avatar attachment - Telegram style (более компактный) */ @Composable fun AvatarAttachment( @@ -464,7 +448,6 @@ fun AvatarAttachment( avatarRepository: AvatarRepository?, isDarkTheme: Boolean ) { - val context = LocalContext.current val scope = rememberCoroutineScope() var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } @@ -490,7 +473,6 @@ fun AvatarAttachment( scope.launch { try { downloadStatus = DownloadStatus.DOWNLOADING - val tag = getDownloadTag(attachment.preview) if (tag.isEmpty()) { downloadStatus = DownloadStatus.ERROR @@ -498,7 +480,6 @@ fun AvatarAttachment( } val encryptedContent = TransportManager.downloadFile(attachment.id, tag) - downloadStatus = DownloadStatus.DECRYPTING val decrypted = MessageCrypto.decryptAttachmentBlob( @@ -511,7 +492,6 @@ fun AvatarAttachment( withContext(Dispatchers.IO) { avatarBitmap = base64ToBitmap(decrypted) } - // Сохраняем аватар в кэш avatarRepository?.saveAvatar(senderPublicKey, decrypted) downloadStatus = DownloadStatus.DOWNLOADED } else { @@ -524,99 +504,107 @@ fun AvatarAttachment( } } - Card( + // Telegram-style: компактный row с круглым превью + Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)), - colors = CardDefaults.cardColors( - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) - ) + .clip(RoundedCornerShape(8.dp)) + .background(if (isDarkTheme) Color(0xFF1F1F1F) else Color.White.copy(0.3f)) + .clickable(enabled = downloadStatus == DownloadStatus.NOT_DOWNLOADED) { + download() + } + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( + // Avatar preview (круглое) + Box( modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + .size(50.dp) + .clip(CircleShape) + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center ) { - // Avatar preview - Box( - modifier = Modifier - .size(60.dp) - .clip(CircleShape) - .background(if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFD0D0D0)), - contentAlignment = Alignment.Center - ) { - when (downloadStatus) { - DownloadStatus.DOWNLOADED -> { - avatarBitmap?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = "Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - } - DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = PrimaryBlue, - strokeWidth = 2.dp - ) - } - else -> { - Icon( - Icons.Default.Person, - contentDescription = null, - tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray, - modifier = Modifier.size(32.dp) + when (downloadStatus) { + DownloadStatus.DOWNLOADED -> { + avatarBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } } + DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = PrimaryBlue, + strokeWidth = 2.dp + ) + } + else -> { + Icon( + Icons.Default.Person, + contentDescription = null, + tint = if (isDarkTheme) Color.White.copy(0.4f) else Color.Gray, + modifier = Modifier.size(28.dp) + ) + } } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Avatar", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = if (isDarkTheme) Color.White else Color.Black - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - Icons.Default.Lock, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(14.dp) - ) - } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "An avatar image shared in the message.", - fontSize = 12.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Gray + text = "Avatar", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isDarkTheme) Color.White else Color.Black + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Lock, + contentDescription = null, + tint = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray, + modifier = Modifier.size(12.dp) ) } - - if (downloadStatus == DownloadStatus.NOT_DOWNLOADED) { - Button( - onClick = download, - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) - ) { - Icon( - Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Download", fontSize = 12.sp) - } + Text( + text = "Profile photo shared", + fontSize = 12.sp, + color = if (isDarkTheme) Color.White.copy(0.5f) else Color.Gray + ) + } + + // Download/status icon + when (downloadStatus) { + DownloadStatus.NOT_DOWNLOADED -> { + Icon( + Icons.Default.Download, + contentDescription = "Download", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) } + DownloadStatus.DOWNLOADED -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Downloaded", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(24.dp) + ) + } + DownloadStatus.ERROR -> { + Icon( + Icons.Default.Refresh, + contentDescription = "Retry", + tint = Color(0xFFE53935), + modifier = Modifier.size(24.dp) + ) + } + else -> {} } } }