feat: Implement media picker and camera functionality

- Added permissions for CAMERA, READ_EXTERNAL_STORAGE, READ_MEDIA_IMAGES, and READ_MEDIA_VIDEO in AndroidManifest.xml.
- Introduced MediaPickerBottomSheet for selecting images and videos from the gallery.
- Implemented camera functionality to capture images and send them in chat.
- Created MediaUtils for handling image and file conversions to Base64.
- Updated ChatDetailScreen to handle media selection and sending images/files.
- Enhanced ChatViewModel with methods to send image and file messages.
- Added file_paths.xml for FileProvider configuration.
This commit is contained in:
k1ngsterr1
2026-01-25 19:19:31 +05:00
parent 89746c5bbd
commit 636cd9f3b8
7 changed files with 1327 additions and 3 deletions

View File

@@ -68,8 +68,15 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
import com.rosetta.messenger.utils.MediaUtils
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.compose.runtime.collectAsState
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import java.io.File
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
@@ -116,6 +123,9 @@ fun ChatDetailScreen(
// 🔥 Emoji picker state
var showEmojiPicker by remember { mutableStateOf(false) }
// 🔥 Media picker state (gallery bottom sheet)
var showMediaPicker by remember { mutableStateOf(false) }
// 🔥 List state с начальной позицией 0 (самое новое сообщение)
val listState = rememberLazyListState(initialFirstVisibleItemIndex = 0)
@@ -162,6 +172,52 @@ fun ChatDetailScreen(
// 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) }
// 📷 Camera: URI для сохранения фото
var cameraImageUri by remember { mutableStateOf<Uri?>(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("*/*")
}
)
}

View File

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

View File

@@ -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<MediaItem>) -> Unit,
onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {},
maxSelection: Int = 10
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Media items from gallery
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var hasPermission by remember { mutableStateOf(false) }
// Selected items
var selectedItems by remember { mutableStateOf<Set<Long>>(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<MediaItem>,
selectedItems: Set<Long>,
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<MediaItem> = withContext(Dispatchers.IO) {
val items = mutableListOf<MediaItem>()
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)
}

View File

@@ -73,7 +73,8 @@ fun MessageInputBar(
focusRequester: FocusRequester? = null,
coordinator: KeyboardTransitionCoordinator,
displayReplyMessages: List<ChatViewModel.ReplyMessage> = 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(

View File

@@ -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)
""
}
}
}