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:
@@ -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("*/*")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
219
app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt
Normal file
219
app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt
Normal 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)
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user