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:
@@ -6,6 +6,10 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RosettaApplication"
|
android:name=".RosettaApplication"
|
||||||
@@ -34,6 +38,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- FileProvider for camera images -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Firebase Cloud Messaging Service -->
|
<!-- Firebase Cloud Messaging Service -->
|
||||||
<service
|
<service
|
||||||
android:name=".push.RosettaFirebaseMessagingService"
|
android:name=".push.RosettaFirebaseMessagingService"
|
||||||
|
|||||||
@@ -68,8 +68,15 @@ import com.rosetta.messenger.network.SearchUser
|
|||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.data.ForwardManager
|
import com.rosetta.messenger.data.ForwardManager
|
||||||
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
|
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
|
||||||
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
import androidx.compose.runtime.collectAsState
|
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.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -116,6 +123,9 @@ fun ChatDetailScreen(
|
|||||||
// 🔥 Emoji picker state
|
// 🔥 Emoji picker state
|
||||||
var showEmojiPicker by remember { mutableStateOf(false) }
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🔥 Media picker state (gallery bottom sheet)
|
||||||
|
var showMediaPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 🔥 List state с начальной позицией 0 (самое новое сообщение)
|
// 🔥 List state с начальной позицией 0 (самое новое сообщение)
|
||||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = 0)
|
val listState = rememberLazyListState(initialFirstVisibleItemIndex = 0)
|
||||||
|
|
||||||
@@ -163,6 +173,52 @@ fun ChatDetailScreen(
|
|||||||
// 📨 Forward: показывать ли выбор чата
|
// 📨 Forward: показывать ли выбор чата
|
||||||
var showForwardPicker by remember { mutableStateOf(false) }
|
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: список диалогов для выбора (загружаем из базы)
|
// 📨 Forward: список диалогов для выбора (загружаем из базы)
|
||||||
val chatsListViewModel: ChatsListViewModel = viewModel()
|
val chatsListViewModel: ChatsListViewModel = viewModel()
|
||||||
val dialogsList by chatsListViewModel.dialogs.collectAsState()
|
val dialogsList by chatsListViewModel.dialogs.collectAsState()
|
||||||
@@ -1357,7 +1413,13 @@ fun ChatDetailScreen(
|
|||||||
displayReplyMessages =
|
displayReplyMessages =
|
||||||
displayReplyMessages,
|
displayReplyMessages,
|
||||||
onReplyClick =
|
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
|
* 🔥 Используем 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,
|
focusRequester: FocusRequester? = null,
|
||||||
coordinator: KeyboardTransitionCoordinator,
|
coordinator: KeyboardTransitionCoordinator,
|
||||||
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
displayReplyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
|
||||||
onReplyClick: (String) -> Unit = {}
|
onReplyClick: (String) -> Unit = {},
|
||||||
|
onAttachClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
val hasReply = replyMessages.isNotEmpty()
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
@@ -360,7 +361,7 @@ fun MessageInputBar(
|
|||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { /* TODO: Attach file/image */ },
|
onClick = onAttachClick,
|
||||||
modifier = Modifier.size(40.dp)
|
modifier = Modifier.size(40.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
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)
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/src/main/res/xml/file_paths.xml
Normal file
14
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Cache directory for camera photos -->
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
|
||||||
|
<!-- External cache directory -->
|
||||||
|
<external-cache-path name="external_cache" path="." />
|
||||||
|
|
||||||
|
<!-- Files directory -->
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
|
||||||
|
<!-- External files directory -->
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user