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 @@ + + + + + + + + + + + + + +