diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 691f05f..2864faa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,10 @@
+
+
+
+
+
+
+
+
+
(null) }
+
+ // 📷 Camera launcher
+ val cameraLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.TakePicture()
+ ) { success ->
+ if (success && cameraImageUri != null) {
+ scope.launch {
+ android.util.Log.d("ChatDetailScreen", "📷 Photo captured: $cameraImageUri")
+ // Конвертируем и отправляем
+ val base64 = MediaUtils.uriToBase64Image(context, cameraImageUri!!)
+ val blurhash = MediaUtils.generateBlurhash(context, cameraImageUri!!)
+ if (base64 != null) {
+ viewModel.sendImageMessage(base64, blurhash)
+ }
+ }
+ }
+ }
+
+ // 📄 File picker launcher
+ val filePickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri ->
+ if (uri != null) {
+ scope.launch {
+ android.util.Log.d("ChatDetailScreen", "📄 File selected: $uri")
+ val fileName = MediaUtils.getFileName(context, uri)
+ val fileSize = MediaUtils.getFileSize(context, uri)
+
+ // Проверяем размер файла
+ if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
+ android.util.Log.w("ChatDetailScreen", "📄 File too large: ${fileSize / 1024 / 1024}MB")
+ // TODO: Показать ошибку
+ return@launch
+ }
+
+ val base64 = MediaUtils.uriToBase64File(context, uri)
+ if (base64 != null) {
+ viewModel.sendFileMessage(base64, fileName, fileSize)
+ }
+ }
+ }
+ }
+
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
@@ -1357,7 +1413,13 @@ fun ChatDetailScreen(
displayReplyMessages =
displayReplyMessages,
onReplyClick =
- scrollToMessage
+ scrollToMessage,
+ onAttachClick = {
+ // Hide keyboard when opening media picker
+ keyboardController?.hide()
+ focusManager.clearFocus()
+ showMediaPicker = true
+ }
)
}
}
@@ -1898,4 +1960,54 @@ fun ChatDetailScreen(
}
)
}
+
+ // 📎 Media Picker BottomSheet (Telegram-style gallery)
+ MediaPickerBottomSheet(
+ isVisible = showMediaPicker,
+ onDismiss = { showMediaPicker = false },
+ isDarkTheme = isDarkTheme,
+ onMediaSelected = { selectedMedia ->
+ // 📸 Отправляем выбранные изображения
+ android.util.Log.d("ChatDetailScreen", "📸 Sending ${selectedMedia.size} media items")
+ scope.launch {
+ for (item in selectedMedia) {
+ if (item.isVideo) {
+ // TODO: Поддержка видео
+ android.util.Log.d("ChatDetailScreen", "📹 Video not supported yet: ${item.uri}")
+ } else {
+ // Изображение
+ val base64 = MediaUtils.uriToBase64Image(context, item.uri)
+ val blurhash = MediaUtils.generateBlurhash(context, item.uri)
+ if (base64 != null) {
+ viewModel.sendImageMessage(base64, blurhash)
+ // Небольшая задержка между отправками для правильного порядка
+ delay(100)
+ }
+ }
+ }
+ }
+ },
+ onOpenCamera = {
+ // 📷 Создаём временный файл для фото
+ try {
+ val photoFile = File.createTempFile(
+ "photo_${System.currentTimeMillis()}",
+ ".jpg",
+ context.cacheDir
+ )
+ cameraImageUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ photoFile
+ )
+ cameraLauncher.launch(cameraImageUri!!)
+ } catch (e: Exception) {
+ android.util.Log.e("ChatDetailScreen", "📷 Failed to create camera file", e)
+ }
+ },
+ onOpenFilePicker = {
+ // 📄 Открываем файловый пикер
+ filePickerLauncher.launch("*/*")
+ }
+ )
}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
index c7134cf..2767199 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
@@ -1279,6 +1279,255 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
+ /**
+ * 📸 Отправка сообщения с изображением
+ * @param imageBase64 Base64 изображения
+ * @param blurhash Blurhash preview изображения
+ * @param caption Подпись к изображению (опционально)
+ */
+ fun sendImageMessage(imageBase64: String, blurhash: String, caption: String = "") {
+ val recipient = opponentKey
+ val sender = myPublicKey
+ val privateKey = myPrivateKey
+
+ if (recipient == null || sender == null || privateKey == null) {
+ Log.e(TAG, "📸 Cannot send image: missing keys")
+ return
+ }
+ if (isSending) {
+ Log.w(TAG, "📸 Already sending message")
+ return
+ }
+
+ isSending = true
+
+ val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
+ val timestamp = System.currentTimeMillis()
+ val text = caption.trim()
+
+ // 1. 🚀 Optimistic UI - показываем сообщение с placeholder
+ val optimisticMessage = ChatMessage(
+ id = messageId,
+ text = text,
+ isOutgoing = true,
+ timestamp = Date(timestamp),
+ status = MessageStatus.SENDING,
+ attachments = listOf(
+ MessageAttachment(
+ id = "img_$timestamp",
+ type = AttachmentType.IMAGE,
+ preview = blurhash,
+ blob = imageBase64 // Для локального отображения
+ )
+ )
+ )
+ _messages.value = _messages.value + optimisticMessage
+ _inputText.value = ""
+
+ Log.d(TAG, "📸 Sending image message: id=$messageId")
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ // Шифрование текста
+ val encryptResult = MessageCrypto.encryptForSending(text, recipient)
+ val encryptedContent = encryptResult.ciphertext
+ val encryptedKey = encryptResult.encryptedKey
+ val plainKeyAndNonce = encryptResult.plainKeyAndNonce
+
+ val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
+
+ // Шифруем изображение с ChaCha ключом
+ val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
+
+ // Сохраняем оригинал для БД (зашифрованный приватным ключом)
+ val imageBlobForDatabase = CryptoManager.encryptWithPassword(imageBase64, privateKey)
+
+ val imageAttachment = MessageAttachment(
+ id = "img_$timestamp",
+ blob = encryptedImageBlob,
+ type = AttachmentType.IMAGE,
+ preview = blurhash
+ )
+
+ val packet = PacketMessage().apply {
+ fromPublicKey = sender
+ toPublicKey = recipient
+ content = encryptedContent
+ chachaKey = encryptedKey
+ this.timestamp = timestamp
+ this.privateKey = privateKeyHash
+ this.messageId = messageId
+ attachments = listOf(imageAttachment)
+ }
+
+ // Для Saved Messages не отправляем на сервер
+ val isSavedMessages = (sender == recipient)
+ if (!isSavedMessages) {
+ ProtocolManager.send(packet)
+ }
+
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+
+ // Сохраняем в БД с зашифрованным blob'ом
+ val attachmentsJson = JSONArray().apply {
+ put(JSONObject().apply {
+ put("id", imageAttachment.id)
+ put("type", AttachmentType.IMAGE.value)
+ put("preview", blurhash)
+ put("blob", imageBlobForDatabase)
+ })
+ }.toString()
+
+ saveMessageToDatabase(
+ messageId = messageId,
+ text = text,
+ encryptedContent = encryptedContent,
+ encryptedKey = encryptedKey,
+ timestamp = timestamp,
+ isFromMe = true,
+ delivered = if (isSavedMessages) 2 else 0,
+ attachmentsJson = attachmentsJson
+ )
+
+ saveDialog(if (text.isNotEmpty()) text else "photo", timestamp)
+ Log.d(TAG, "📸 ✅ Image message sent successfully")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "📸 ❌ Failed to send image message", e)
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+ } finally {
+ isSending = false
+ }
+ }
+ }
+
+ /**
+ * 📄 Отправка сообщения с файлом
+ * @param fileBase64 Base64 содержимого файла
+ * @param fileName Имя файла
+ * @param fileSize Размер файла в байтах
+ * @param caption Подпись к файлу (опционально)
+ */
+ fun sendFileMessage(fileBase64: String, fileName: String, fileSize: Long, caption: String = "") {
+ val recipient = opponentKey
+ val sender = myPublicKey
+ val privateKey = myPrivateKey
+
+ if (recipient == null || sender == null || privateKey == null) {
+ Log.e(TAG, "📄 Cannot send file: missing keys")
+ return
+ }
+ if (isSending) {
+ Log.w(TAG, "📄 Already sending message")
+ return
+ }
+
+ isSending = true
+
+ val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
+ val timestamp = System.currentTimeMillis()
+ val text = caption.trim()
+ val preview = "$fileSize::$fileName" // Format: "size::name" как в Desktop
+
+ // 1. 🚀 Optimistic UI
+ val optimisticMessage = ChatMessage(
+ id = messageId,
+ text = text,
+ isOutgoing = true,
+ timestamp = Date(timestamp),
+ status = MessageStatus.SENDING,
+ attachments = listOf(
+ MessageAttachment(
+ id = "file_$timestamp",
+ type = AttachmentType.FILE,
+ preview = preview,
+ blob = fileBase64
+ )
+ )
+ )
+ _messages.value = _messages.value + optimisticMessage
+ _inputText.value = ""
+
+ Log.d(TAG, "📄 Sending file message: $fileName ($fileSize bytes)")
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val encryptResult = MessageCrypto.encryptForSending(text, recipient)
+ val encryptedContent = encryptResult.ciphertext
+ val encryptedKey = encryptResult.encryptedKey
+ val plainKeyAndNonce = encryptResult.plainKeyAndNonce
+
+ val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
+
+ // Шифруем файл
+ val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce)
+ val fileBlobForDatabase = CryptoManager.encryptWithPassword(fileBase64, privateKey)
+
+ val fileAttachment = MessageAttachment(
+ id = "file_$timestamp",
+ blob = encryptedFileBlob,
+ type = AttachmentType.FILE,
+ preview = preview
+ )
+
+ val packet = PacketMessage().apply {
+ fromPublicKey = sender
+ toPublicKey = recipient
+ content = encryptedContent
+ chachaKey = encryptedKey
+ this.timestamp = timestamp
+ this.privateKey = privateKeyHash
+ this.messageId = messageId
+ attachments = listOf(fileAttachment)
+ }
+
+ val isSavedMessages = (sender == recipient)
+ if (!isSavedMessages) {
+ ProtocolManager.send(packet)
+ }
+
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+
+ val attachmentsJson = JSONArray().apply {
+ put(JSONObject().apply {
+ put("id", fileAttachment.id)
+ put("type", AttachmentType.FILE.value)
+ put("preview", preview)
+ put("blob", fileBlobForDatabase)
+ })
+ }.toString()
+
+ saveMessageToDatabase(
+ messageId = messageId,
+ text = text,
+ encryptedContent = encryptedContent,
+ encryptedKey = encryptedKey,
+ timestamp = timestamp,
+ isFromMe = true,
+ delivered = if (isSavedMessages) 2 else 0,
+ attachmentsJson = attachmentsJson
+ )
+
+ saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
+ Log.d(TAG, "📄 ✅ File message sent successfully")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "📄 ❌ Failed to send file message", e)
+ withContext(Dispatchers.Main) {
+ updateMessageStatus(messageId, MessageStatus.SENT)
+ }
+ } finally {
+ isSending = false
+ }
+ }
+ }
+
/**
* Сохранить диалог в базу данных
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt
new file mode 100644
index 0000000..fb44e65
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt
@@ -0,0 +1,714 @@
+package com.rosetta.messenger.ui.chats.components
+
+import android.Manifest
+import android.content.ContentUris
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import compose.icons.TablerIcons
+import compose.icons.tablericons.*
+import com.rosetta.messenger.ui.onboarding.PrimaryBlue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private const val TAG = "MediaPickerBottomSheet"
+
+/**
+ * Media item from gallery
+ */
+data class MediaItem(
+ val id: Long,
+ val uri: Uri,
+ val mimeType: String,
+ val duration: Long = 0, // For videos, in milliseconds
+ val dateModified: Long = 0
+) {
+ val isVideo: Boolean get() = mimeType.startsWith("video/")
+}
+
+/**
+ * Telegram-style media picker bottom sheet
+ * Shows gallery photos/videos in a grid with selection
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MediaPickerBottomSheet(
+ isVisible: Boolean,
+ onDismiss: () -> Unit,
+ isDarkTheme: Boolean,
+ onMediaSelected: (List) -> Unit,
+ onOpenCamera: () -> Unit = {},
+ onOpenFilePicker: () -> Unit = {},
+ maxSelection: Int = 10
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ // Media items from gallery
+ var mediaItems by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var hasPermission by remember { mutableStateOf(false) }
+
+ // Selected items
+ var selectedItems by remember { mutableStateOf>(emptySet()) }
+
+ // Permission launcher
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ hasPermission = permissions.values.all { it }
+ if (hasPermission) {
+ scope.launch {
+ mediaItems = loadMediaItems(context)
+ isLoading = false
+ }
+ }
+ }
+
+ // Check permission on show
+ LaunchedEffect(isVisible) {
+ if (isVisible) {
+ // Reset selection when opening
+ selectedItems = emptySet()
+
+ val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ arrayOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO
+ )
+ } else {
+ arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+
+ hasPermission = permissions.all {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ if (hasPermission) {
+ isLoading = true
+ mediaItems = loadMediaItems(context)
+ isLoading = false
+ } else {
+ permissionLauncher.launch(permissions)
+ }
+ }
+ }
+
+ // Colors
+ val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
+
+ if (isVisible) {
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ containerColor = backgroundColor,
+ dragHandle = {
+ // Telegram-style drag handle
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Box(
+ modifier = Modifier
+ .width(36.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(secondaryTextColor.copy(alpha = 0.3f))
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
+ windowInsets = WindowInsets(0)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.85f)
+ ) {
+ // Header with action buttons
+ MediaPickerHeader(
+ selectedCount = selectedItems.size,
+ onDismiss = onDismiss,
+ onSend = {
+ val selected = mediaItems.filter { it.id in selectedItems }
+ onMediaSelected(selected)
+ onDismiss()
+ },
+ isDarkTheme = isDarkTheme,
+ textColor = textColor
+ )
+
+ // Quick action buttons row (Camera, Gallery, File, Location, etc.)
+ QuickActionsRow(
+ isDarkTheme = isDarkTheme,
+ onCameraClick = {
+ onDismiss()
+ onOpenCamera()
+ },
+ onFileClick = {
+ onDismiss()
+ onOpenFilePicker()
+ }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Content
+ if (!hasPermission) {
+ // Permission request UI
+ PermissionRequestView(
+ isDarkTheme = isDarkTheme,
+ textColor = textColor,
+ secondaryTextColor = secondaryTextColor,
+ onRequestPermission = {
+ val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ arrayOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO
+ )
+ } else {
+ arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+ permissionLauncher.launch(permissions)
+ }
+ )
+ } else if (isLoading) {
+ // Loading indicator
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ color = PrimaryBlue,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+ } else if (mediaItems.isEmpty()) {
+ // Empty state
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ TablerIcons.Photo,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(64.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "No photos or videos",
+ color = secondaryTextColor,
+ fontSize = 16.sp
+ )
+ }
+ }
+ } else {
+ // Media grid
+ MediaGrid(
+ mediaItems = mediaItems,
+ selectedItems = selectedItems,
+ onItemClick = { item ->
+ selectedItems = if (item.id in selectedItems) {
+ selectedItems - item.id
+ } else if (selectedItems.size < maxSelection) {
+ selectedItems + item.id
+ } else {
+ selectedItems
+ }
+ },
+ onItemLongClick = { item ->
+ // TODO: Preview image
+ },
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ // Bottom safe area
+ Spacer(modifier = Modifier.navigationBarsPadding())
+ }
+ }
+ }
+}
+
+@Composable
+private fun MediaPickerHeader(
+ selectedCount: Int,
+ onDismiss: () -> Unit,
+ onSend: () -> Unit,
+ isDarkTheme: Boolean,
+ textColor: Color
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Title
+ Text(
+ text = if (selectedCount > 0) "$selectedCount selected" else "Gallery",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = textColor,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Send button (visible when items selected)
+ AnimatedVisibility(
+ visible = selectedCount > 0,
+ enter = fadeIn() + scaleIn(),
+ exit = fadeOut() + scaleOut()
+ ) {
+ Button(
+ onClick = onSend,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PrimaryBlue
+ ),
+ shape = RoundedCornerShape(20.dp),
+ contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp)
+ ) {
+ Icon(
+ TablerIcons.Send,
+ contentDescription = "Send",
+ modifier = Modifier.size(18.dp),
+ tint = Color.White
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "Send",
+ color = Color.White,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun QuickActionsRow(
+ isDarkTheme: Boolean,
+ onCameraClick: () -> Unit,
+ onFileClick: () -> Unit
+) {
+ val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
+ val iconColor = if (isDarkTheme) Color.White else Color.Black
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Camera button
+ QuickActionButton(
+ icon = TablerIcons.Camera,
+ label = "Camera",
+ backgroundColor = PrimaryBlue,
+ iconColor = Color.White,
+ onClick = onCameraClick
+ )
+
+ // File button
+ QuickActionButton(
+ icon = TablerIcons.File,
+ label = "File",
+ backgroundColor = buttonColor,
+ iconColor = iconColor,
+ onClick = onFileClick
+ )
+
+ // Location button (placeholder)
+ QuickActionButton(
+ icon = TablerIcons.MapPin,
+ label = "Location",
+ backgroundColor = buttonColor,
+ iconColor = iconColor,
+ onClick = { /* TODO */ }
+ )
+
+ // Contact button (placeholder)
+ QuickActionButton(
+ icon = TablerIcons.User,
+ label = "Contact",
+ backgroundColor = buttonColor,
+ iconColor = iconColor,
+ onClick = { /* TODO */ }
+ )
+ }
+}
+
+@Composable
+private fun QuickActionButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ backgroundColor: Color,
+ iconColor: Color,
+ onClick: () -> Unit
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = iconColor,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = label,
+ fontSize = 12.sp,
+ color = iconColor.copy(alpha = 0.8f)
+ )
+ }
+}
+
+@Composable
+private fun MediaGrid(
+ mediaItems: List,
+ selectedItems: Set,
+ onItemClick: (MediaItem) -> Unit,
+ onItemLongClick: (MediaItem) -> Unit,
+ isDarkTheme: Boolean,
+ modifier: Modifier = Modifier
+) {
+ val gridState = rememberLazyGridState()
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(3),
+ state = gridState,
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ items(
+ items = mediaItems,
+ key = { it.id }
+ ) { item ->
+ MediaGridItem(
+ item = item,
+ isSelected = item.id in selectedItems,
+ selectionIndex = if (item.id in selectedItems) {
+ selectedItems.toList().indexOf(item.id) + 1
+ } else 0,
+ onClick = { onItemClick(item) },
+ onLongClick = { onItemLongClick(item) },
+ isDarkTheme = isDarkTheme
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaGridItem(
+ item: MediaItem,
+ isSelected: Boolean,
+ selectionIndex: Int,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+ isDarkTheme: Boolean
+) {
+ val context = LocalContext.current
+
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(4.dp))
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onTap = { onClick() },
+ onLongPress = { onLongClick() }
+ )
+ }
+ ) {
+ // Thumbnail
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(item.uri)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+
+ // Video duration overlay
+ if (item.isVideo && item.duration > 0) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(4.dp)
+ .background(
+ Color.Black.copy(alpha = 0.6f),
+ RoundedCornerShape(4.dp)
+ )
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = formatDuration(item.duration),
+ color = Color.White,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+
+ // Selection overlay
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ if (isSelected) PrimaryBlue.copy(alpha = 0.3f) else Color.Transparent
+ )
+ )
+
+ // Selection checkbox
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(6.dp)
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(
+ if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.8f)
+ )
+ .border(
+ width = 2.dp,
+ color = if (isSelected) PrimaryBlue else Color.Gray.copy(alpha = 0.5f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isSelected) {
+ Text(
+ text = "$selectionIndex",
+ color = Color.White,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionRequestView(
+ isDarkTheme: Boolean,
+ textColor: Color,
+ secondaryTextColor: Color,
+ onRequestPermission: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ TablerIcons.PhotoOff,
+ contentDescription = null,
+ tint = secondaryTextColor,
+ modifier = Modifier.size(64.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Access to gallery",
+ color = textColor,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Allow access to your photos and videos to share them in chat",
+ color = secondaryTextColor,
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onRequestPermission,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PrimaryBlue
+ ),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text("Allow Access", color = Color.White)
+ }
+ }
+ }
+}
+
+/**
+ * Load media items from device gallery
+ */
+private suspend fun loadMediaItems(context: Context): List = withContext(Dispatchers.IO) {
+ val items = mutableListOf()
+
+ try {
+ // Query images
+ val imageProjection = arrayOf(
+ MediaStore.Images.Media._ID,
+ MediaStore.Images.Media.MIME_TYPE,
+ MediaStore.Images.Media.DATE_MODIFIED
+ )
+
+ val imageSortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC"
+
+ context.contentResolver.query(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ imageProjection,
+ null,
+ null,
+ imageSortOrder
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
+ val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
+ val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val mimeType = cursor.getString(mimeColumn) ?: "image/*"
+ val dateModified = cursor.getLong(dateColumn)
+
+ val uri = ContentUris.withAppendedId(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ id
+ )
+
+ items.add(MediaItem(
+ id = id,
+ uri = uri,
+ mimeType = mimeType,
+ dateModified = dateModified
+ ))
+ }
+ }
+
+ // Query videos
+ val videoProjection = arrayOf(
+ MediaStore.Video.Media._ID,
+ MediaStore.Video.Media.MIME_TYPE,
+ MediaStore.Video.Media.DURATION,
+ MediaStore.Video.Media.DATE_MODIFIED
+ )
+
+ val videoSortOrder = "${MediaStore.Video.Media.DATE_MODIFIED} DESC"
+
+ context.contentResolver.query(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ videoProjection,
+ null,
+ null,
+ videoSortOrder
+ )?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
+ val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE)
+ val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
+ val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val mimeType = cursor.getString(mimeColumn) ?: "video/*"
+ val duration = cursor.getLong(durationColumn)
+ val dateModified = cursor.getLong(dateColumn)
+
+ val uri = ContentUris.withAppendedId(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ id
+ )
+
+ // Use negative id to avoid collision with images
+ items.add(MediaItem(
+ id = -id,
+ uri = uri,
+ mimeType = mimeType,
+ duration = duration,
+ dateModified = dateModified
+ ))
+ }
+ }
+
+ // Sort all items by date
+ items.sortByDescending { it.dateModified }
+
+ Log.d(TAG, "📸 Loaded ${items.size} media items")
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Failed to load media items", e)
+ }
+
+ items
+}
+
+/**
+ * Format duration in milliseconds to MM:SS
+ */
+private fun formatDuration(durationMs: Long): String {
+ val totalSeconds = durationMs / 1000
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return "%d:%02d".format(minutes, seconds)
+}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
index ff4e37f..734da66 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt
@@ -73,7 +73,8 @@ fun MessageInputBar(
focusRequester: FocusRequester? = null,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List = emptyList(),
- onReplyClick: (String) -> Unit = {}
+ onReplyClick: (String) -> Unit = {},
+ onAttachClick: () -> Unit = {}
) {
val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current
@@ -360,7 +361,7 @@ fun MessageInputBar(
verticalAlignment = Alignment.Bottom
) {
IconButton(
- onClick = { /* TODO: Attach file/image */ },
+ onClick = onAttachClick,
modifier = Modifier.size(40.dp)
) {
Icon(
diff --git a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt
new file mode 100644
index 0000000..a08bcc0
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt
@@ -0,0 +1,219 @@
+package com.rosetta.messenger.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.Base64
+import android.util.Log
+import com.vanniktech.blurhash.BlurHash
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+
+private const val TAG = "MediaUtils"
+
+/**
+ * Утилиты для работы с медиафайлами
+ * Конвертация изображений в Base64, генерация blurhash
+ */
+object MediaUtils {
+
+ // Максимальный размер изображения для отправки (сжимаем большие изображения)
+ private const val MAX_IMAGE_SIZE = 1920
+ private const val IMAGE_QUALITY = 85
+
+ // Максимальный размер файла в МБ
+ const val MAX_FILE_SIZE_MB = 15
+
+ /**
+ * Конвертировать изображение из Uri в Base64 PNG
+ * Автоматически сжимает большие изображения
+ */
+ suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "📸 Converting image to Base64: $uri")
+
+ // Открываем InputStream
+ val inputStream: InputStream = context.contentResolver.openInputStream(uri)
+ ?: return@withContext null
+
+ // Декодируем изображение
+ val originalBitmap = BitmapFactory.decodeStream(inputStream)
+ inputStream.close()
+
+ if (originalBitmap == null) {
+ Log.e(TAG, "📸 Failed to decode image")
+ return@withContext null
+ }
+
+ Log.d(TAG, "📸 Original size: ${originalBitmap.width}x${originalBitmap.height}")
+
+ // Масштабируем если слишком большое
+ val scaledBitmap = scaleDownBitmap(originalBitmap, MAX_IMAGE_SIZE)
+ if (scaledBitmap != originalBitmap) {
+ originalBitmap.recycle()
+ }
+
+ Log.d(TAG, "📸 Scaled size: ${scaledBitmap.width}x${scaledBitmap.height}")
+
+ // Конвертируем в PNG Base64
+ val outputStream = ByteArrayOutputStream()
+ scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
+ val bytes = outputStream.toByteArray()
+
+ val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
+
+ scaledBitmap.recycle()
+
+ Log.d(TAG, "📸 ✅ Image converted to Base64, length: ${base64.length}")
+ base64
+ } catch (e: Exception) {
+ Log.e(TAG, "📸 ❌ Failed to convert image to Base64", e)
+ null
+ }
+ }
+
+ /**
+ * Генерировать Blurhash для изображения
+ */
+ suspend fun generateBlurhash(context: Context, uri: Uri): String = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "🎨 Generating blurhash for: $uri")
+
+ val inputStream = context.contentResolver.openInputStream(uri)
+ ?: return@withContext ""
+
+ // Декодируем в маленький размер для blurhash
+ val options = BitmapFactory.Options().apply {
+ inSampleSize = 8 // Уменьшаем в 8 раз для быстрого расчета
+ }
+ val bitmap = BitmapFactory.decodeStream(inputStream, null, options)
+ inputStream.close()
+
+ if (bitmap == null) {
+ Log.e(TAG, "🎨 Failed to decode image for blurhash")
+ return@withContext ""
+ }
+
+ // Генерируем blurhash
+ val blurhash = BlurHash.encode(bitmap, 4, 3)
+ bitmap.recycle()
+
+ Log.d(TAG, "🎨 ✅ Blurhash generated: $blurhash")
+ blurhash ?: ""
+ } catch (e: Exception) {
+ Log.e(TAG, "🎨 ❌ Failed to generate blurhash", e)
+ ""
+ }
+ }
+
+ /**
+ * Конвертировать файл из Uri в Base64
+ */
+ suspend fun uriToBase64File(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "📄 Converting file to Base64: $uri")
+
+ val inputStream = context.contentResolver.openInputStream(uri)
+ ?: return@withContext null
+
+ val bytes = inputStream.readBytes()
+ inputStream.close()
+
+ val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
+
+ Log.d(TAG, "📄 ✅ File converted to Base64, length: ${base64.length}")
+ base64
+ } catch (e: Exception) {
+ Log.e(TAG, "📄 ❌ Failed to convert file to Base64", e)
+ null
+ }
+ }
+
+ /**
+ * Получить размер файла из Uri
+ */
+ fun getFileSize(context: Context, uri: Uri): Long {
+ return try {
+ context.contentResolver.openInputStream(uri)?.use {
+ it.available().toLong()
+ } ?: 0L
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+
+ /**
+ * Получить имя файла из Uri
+ */
+ fun getFileName(context: Context, uri: Uri): String {
+ var name = "file"
+ try {
+ context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (nameIndex != -1 && cursor.moveToFirst()) {
+ name = cursor.getString(nameIndex) ?: "file"
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to get file name", e)
+ }
+ return name
+ }
+
+ /**
+ * Масштабировать bitmap до максимального размера
+ */
+ private fun scaleDownBitmap(bitmap: Bitmap, maxSize: Int): Bitmap {
+ val width = bitmap.width
+ val height = bitmap.height
+
+ if (width <= maxSize && height <= maxSize) {
+ return bitmap
+ }
+
+ val ratio = width.toFloat() / height.toFloat()
+ val newWidth: Int
+ val newHeight: Int
+
+ if (width > height) {
+ newWidth = maxSize
+ newHeight = (maxSize / ratio).toInt()
+ } else {
+ newHeight = maxSize
+ newWidth = (maxSize * ratio).toInt()
+ }
+
+ return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
+ }
+
+ /**
+ * Конвертировать Bitmap в Base64 PNG
+ */
+ fun bitmapToBase64(bitmap: Bitmap): String {
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
+ val bytes = outputStream.toByteArray()
+ return "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
+ }
+
+ /**
+ * Генерировать Blurhash из Bitmap
+ */
+ fun generateBlurhashFromBitmap(bitmap: Bitmap): String {
+ return try {
+ // Уменьшаем для быстрого расчета
+ val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 32, 32, true)
+ val hash = BlurHash.encode(scaledBitmap, 4, 3)
+ if (scaledBitmap != bitmap) {
+ scaledBitmap.recycle()
+ }
+ hash ?: ""
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to generate blurhash from bitmap", e)
+ ""
+ }
+ }
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..0d81cf9
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+