diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 41a220b..d6699f1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
index 669f9c7..d114486 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt
@@ -326,7 +326,34 @@ fun ChatDetailScreen(
}
}
- // π File picker launcher
+ // οΏ½οΈ Gallery-as-file launcher (sends images as compressed files, not as photos)
+ val galleryAsFileLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val fileName = MediaUtils.getFileName(context, uri)
+ val fileSize = MediaUtils.getFileSize(context, uri)
+
+ if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
+ android.widget.Toast.makeText(
+ context,
+ "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
+ return@launch
+ }
+
+ val base64 = MediaUtils.uriToBase64File(context, uri)
+ if (base64 != null) {
+ viewModel.sendFileMessage(base64, fileName, fileSize)
+ }
+ }
+ }
+ }
+
+ // οΏ½π File picker launcher
val filePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
@@ -2334,6 +2361,46 @@ fun ChatDetailScreen(
onOpenFilePicker = {
filePickerLauncher.launch("*/*")
},
+ onGalleryFilesSelected = { uris ->
+ showMediaPicker = false
+ inputFocusTrigger++
+ scope.launch {
+ for (uri in uris) {
+ val fileName = MediaUtils.getFileName(context, uri)
+ val fileSize = MediaUtils.getFileSize(context, uri)
+ if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
+ android.widget.Toast.makeText(
+ context,
+ "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
+ continue
+ }
+ val base64 = MediaUtils.uriToBase64File(context, uri)
+ if (base64 != null) {
+ viewModel.sendFileMessage(base64, fileName, fileSize)
+ }
+ }
+ }
+ },
+ onFileSelected = { uri, fileName, fileSize ->
+ showMediaPicker = false
+ inputFocusTrigger++
+ scope.launch {
+ if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) {
+ android.widget.Toast.makeText(
+ context,
+ "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)",
+ android.widget.Toast.LENGTH_LONG
+ ).show()
+ return@launch
+ }
+ val base64 = MediaUtils.uriToBase64File(context, uri)
+ if (base64 != null) {
+ viewModel.sendFileMessage(base64, fileName, fileSize)
+ }
+ }
+ },
onAvatarClick = {
viewModel.sendAvatarMessage()
},
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
index df6870f..c33f3ce 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt
@@ -3202,6 +3202,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
+ // πΎ Π‘ΠΎΡ
ΡΠ°Π½ΡΠ΅ΠΌ ΡΠ°ΠΉΠ» Π»ΠΎΠΊΠ°Π»ΡΠ½ΠΎ ΡΡΠΎΠ±Ρ ΠΎΡΠΏΡΠ°Π²ΠΈΡΠ΅Π»Ρ ΠΌΠΎΠ³ Π΅Π³ΠΎ ΠΎΡΠΊΡΡΡΡ
+ try {
+ val app = getApplication()
+ val downloadsDir = java.io.File(app.filesDir, "rosetta_downloads").apply { mkdirs() }
+ val localFile = java.io.File(downloadsDir, fileName)
+ if (!localFile.exists()) {
+ val base64Data = if (fileBase64.contains(",")) {
+ fileBase64.substringAfter(",")
+ } else {
+ fileBase64
+ }
+ val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
+ localFile.writeBytes(bytes)
+ }
+ } catch (_: Exception) {}
+
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt
index 5c54e8b..f376948 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt
@@ -1,77 +1,817 @@
package com.rosetta.messenger.ui.chats.attach
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import android.text.format.Formatter
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
-import androidx.compose.runtime.Composable
+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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.rosetta.messenger.ui.icons.TelegramIcons
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import com.rosetta.messenger.R
+import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Telegramβstyle "Select File" tab for the attach alert.
+// Shows: Internal Storage, App folder, Gallery + Recent files.
+// Supports directory browsing with backβstack navigation.
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+// ββ Color palette (matches Telegram attach icon colors) ββ
+
+private val StorageGreen = Color(0xFF4CAF50)
+private val FolderBlue = Color(0xFF2196F3)
+private val GalleryOrange = Color(0xFFFF9800)
+private val FileGray = Color(0xFF9E9E9E)
+
+// ββ Data models ββ
+
+private data class FileEntry(
+ val file: File,
+ val name: String,
+ val sizeFormatted: String,
+ val dateFormatted: String,
+ val isDirectory: Boolean,
+ val extension: String,
+ val isImage: Boolean
+)
+
+// ββ History entry for navigation stack ββ
+
+private data class HistoryEntry(
+ val dir: File,
+ val title: String
+)
/**
- * File/Document tab content for the attach alert.
- * Phase 1: Styled button that launches the system file picker.
+ * Telegramβstyle file browser tab.
+ *
+ * @param onFileSelected called with a [Uri] + file name + file size when user picks a file
+ * @param onGalleryFilesSelected called with list of media URIs to send as files
+ * @param onOpenSystemPicker fallback to system file picker
+ * @param galleryMediaItems pre-loaded media items from the ViewModel (already permission-checked)
+ * @param isDarkTheme current theme flag
*/
@Composable
internal fun AttachAlertFileLayout(
- onOpenFilePicker: () -> Unit,
+ onFileSelected: (Uri, String, Long) -> Unit,
+ onGalleryFilesSelected: (List) -> Unit,
+ onOpenSystemPicker: () -> Unit,
+ galleryMediaItems: List,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val secondaryTextColor = Color(0xFF8E8E93)
+ val context = LocalContext.current
+ val canBrowseStorage = remember {
+ android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R ||
+ Environment.isExternalStorageManager()
+ }
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val secondaryText = Color(0xFF8E8E93)
+ val dividerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA)
+ val headerColor = PrimaryBlue
+ val bgColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
+ val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
- Box(
- modifier = modifier.fillMaxWidth(),
- contentAlignment = Alignment.Center
+ // ββ Navigation state ββ
+ var currentDir by remember { mutableStateOf(null) }
+ var currentTitle by remember { mutableStateOf("Select File") }
+ var history by remember { mutableStateOf(listOf()) }
+ var searchQuery by remember { mutableStateOf("") }
+ var isSearching by remember { mutableStateOf(false) }
+
+ // ββ Gallery mode state ββ
+ var showGallery by remember { mutableStateOf(false) }
+ var gallerySelectedOrder by remember { mutableStateOf>(emptyList()) }
+
+ // ββ Recent files (loaded once) ββ
+ var recentFiles by remember { mutableStateOf>(emptyList()) }
+ LaunchedEffect(Unit) {
+ recentFiles = withContext(Dispatchers.IO) { loadRecentFiles(context) }
+ }
+
+ // ββ Directory contents (loaded when currentDir changes) ββ
+ var dirContents by remember { mutableStateOf>(emptyList()) }
+ LaunchedEffect(currentDir) {
+ dirContents = if (currentDir != null) {
+ withContext(Dispatchers.IO) { listDirectoryContents(context, currentDir!!) }
+ } else emptyList()
+ }
+
+ // ββ Search filter ββ
+ val displayedRecentFiles = if (searchQuery.isBlank()) recentFiles
+ else recentFiles.filter { it.name.contains(searchQuery, ignoreCase = true) }
+ val displayedDirContents = if (searchQuery.isBlank()) dirContents
+ else dirContents.filter { it.name.contains(searchQuery, ignoreCase = true) }
+
+ // ββ Back handler ββ
+ val isAtRoot = currentDir == null
+
+ fun navigateBack() {
+ if (history.isNotEmpty()) {
+ val prev = history.last()
+ history = history.dropLast(1)
+ currentDir = prev.dir
+ currentTitle = prev.title
+ } else {
+ currentDir = null
+ currentTitle = "Select File"
+ }
+ searchQuery = ""
+ isSearching = false
+ }
+
+ fun navigateToDir(dir: File) {
+ // Push current state
+ history = history + HistoryEntry(currentDir ?: Environment.getExternalStorageDirectory(), currentTitle)
+ currentDir = dir
+ currentTitle = dir.name
+ searchQuery = ""
+ isSearching = false
+ }
+
+ if (showGallery) {
+ BackHandler {
+ showGallery = false
+ gallerySelectedOrder = emptyList()
+ }
+ } else if (!isAtRoot) {
+ BackHandler { navigateBack() }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(bgColor)
) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.padding(32.dp)
- ) {
- Icon(
- painter = TelegramIcons.File,
- contentDescription = null,
- tint = PrimaryBlue,
- modifier = Modifier.size(72.dp)
- )
- Spacer(modifier = Modifier.height(20.dp))
- Text(
- text = "Send a File",
- color = textColor,
- fontSize = 20.sp,
- fontWeight = FontWeight.SemiBold
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Browse files on your device and share them in the chat",
- color = secondaryTextColor,
- fontSize = 14.sp,
- textAlign = TextAlign.Center
- )
- Spacer(modifier = Modifier.height(28.dp))
- Button(
- onClick = onOpenFilePicker,
- colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
- shape = RoundedCornerShape(12.dp),
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp)
- .padding(horizontal = 24.dp)
- ) {
- Text(
- text = "Choose File",
- color = Color.White,
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium
- )
+ // ββ Top bar ββ
+ val displayTitle = when {
+ showGallery -> if (gallerySelectedOrder.isNotEmpty())
+ "${gallerySelectedOrder.size} selected" else "Gallery"
+ else -> currentTitle
+ }
+ FilePickerTopBar(
+ title = displayTitle,
+ showBack = showGallery || !isAtRoot,
+ isSearching = isSearching,
+ searchQuery = searchQuery,
+ onBackClick = {
+ if (showGallery) {
+ showGallery = false
+ gallerySelectedOrder = emptyList()
+ } else {
+ navigateBack()
+ }
+ },
+ onSearchToggle = { isSearching = !isSearching; if (!isSearching) searchQuery = "" },
+ onSearchQueryChange = { searchQuery = it },
+ textColor = textColor,
+ bgColor = bgColor
+ )
+
+ Divider(thickness = 0.5.dp, color = dividerColor)
+
+ // ββ Content ββ
+ if (showGallery) {
+ // ββ Gallery mode: media grid for sending photos as files ββ
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (galleryMediaItems.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("No photos or videos", color = secondaryText, fontSize = 15.sp)
+ }
+ } else {
+ val gridState = rememberLazyGridState()
+ Column(modifier = Modifier.fillMaxSize()) {
+ MediaGrid(
+ mediaItems = galleryMediaItems,
+ selectedItemOrder = gallerySelectedOrder,
+ showCameraItem = false,
+ gridState = gridState,
+ onCameraClick = {},
+ onItemClick = { item, _ ->
+ gallerySelectedOrder = toggleGallerySelection(
+ gallerySelectedOrder, item.id, 10
+ )
+ },
+ onItemCheckClick = { item ->
+ gallerySelectedOrder = toggleGallerySelection(
+ gallerySelectedOrder, item.id, 10
+ )
+ },
+ onItemLongClick = { item ->
+ gallerySelectedOrder = toggleGallerySelection(
+ gallerySelectedOrder, item.id, 10
+ )
+ },
+ isDarkTheme = isDarkTheme,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Send button when items selected
+ if (gallerySelectedOrder.isNotEmpty()) {
+ val count = gallerySelectedOrder.size
+ Button(
+ onClick = {
+ val uris = gallerySelectedOrder.mapNotNull { id ->
+ galleryMediaItems.find { it.id == id }?.uri
+ }
+ if (uris.isNotEmpty()) {
+ onGalleryFilesSelected(uris)
+ }
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .height(48.dp)
+ ) {
+ Text(
+ text = if (count == 1) "Send 1 file" else "Send $count files",
+ color = Color.White,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+ }
+ } else if (isAtRoot) {
+ // Root screen
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ // Internal Storage
+ item(key = "root_storage") {
+ RootItemRow(
+ title = "Internal Storage",
+ subtitle = getStorageInfo(context),
+ iconRes = R.drawable.files_storage,
+ iconBg = StorageGreen,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ onClick = {
+ if (canBrowseStorage) {
+ navigateToDir(Environment.getExternalStorageDirectory())
+ } else {
+ onOpenSystemPicker()
+ }
+ }
+ )
+ }
+
+ // Rosetta folder (if exists)
+ item(key = "root_rosetta") {
+ val rosettaDir = File(
+ Environment.getExternalStorageDirectory(), "Download/Rosetta"
+ )
+ if (rosettaDir.exists() && rosettaDir.isDirectory) {
+ RootItemRow(
+ title = "Rosetta",
+ subtitle = "App folder",
+ iconRes = R.drawable.files_folder,
+ iconBg = FolderBlue,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ onClick = { navigateToDir(rosettaDir) }
+ )
+ }
+ }
+
+ // Gallery
+ item(key = "root_gallery") {
+ RootItemRow(
+ title = "Gallery",
+ subtitle = "Send images without compression",
+ iconRes = R.drawable.files_gallery,
+ iconBg = GalleryOrange,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ onClick = { showGallery = true }
+ )
+ }
+
+ // ββ Recent files section ββ
+ if (displayedRecentFiles.isNotEmpty()) {
+ item(key = "recent_divider") {
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .background(surfaceColor)
+ )
+ }
+ item(key = "recent_header") {
+ Text(
+ text = "Recent files",
+ color = headerColor,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 8.dp)
+ )
+ }
+ items(displayedRecentFiles, key = { "recent_${it.file.absolutePath}" }) { entry ->
+ FileItemRow(
+ item = entry,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ dividerColor = dividerColor,
+ isDarkTheme = isDarkTheme,
+ onClick = {
+ onFileSelected(
+ Uri.fromFile(entry.file),
+ entry.name,
+ entry.file.length()
+ )
+ }
+ )
+ }
+ } else if (searchQuery.isBlank()) {
+ item(key = "recent_empty_divider") {
+ Spacer(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .background(surfaceColor)
+ )
+ }
+ item(key = "recent_empty") {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "No recent files",
+ color = secondaryText,
+ fontSize = 14.sp
+ )
+ }
+ }
+ }
+ }
+ } else {
+ // Directory browser
+ if (displayedDirContents.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (searchQuery.isNotBlank()) "No results" else "Empty folder",
+ color = secondaryText,
+ fontSize = 15.sp
+ )
+ }
+ } else {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(displayedDirContents, key = { it.file.absolutePath }) { entry ->
+ if (entry.isDirectory) {
+ FolderItemRow(
+ item = entry,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ dividerColor = dividerColor,
+ onClick = { navigateToDir(entry.file) }
+ )
+ } else {
+ FileItemRow(
+ item = entry,
+ textColor = textColor,
+ secondaryText = secondaryText,
+ dividerColor = dividerColor,
+ isDarkTheme = isDarkTheme,
+ onClick = {
+ onFileSelected(
+ Uri.fromFile(entry.file),
+ entry.name,
+ entry.file.length()
+ )
+ }
+ )
+ }
+ }
+ }
}
}
}
}
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Composable building blocks
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+@Composable
+private fun FilePickerTopBar(
+ title: String,
+ showBack: Boolean,
+ isSearching: Boolean,
+ searchQuery: String,
+ onBackClick: () -> Unit,
+ onSearchToggle: () -> Unit,
+ onSearchQueryChange: (String) -> Unit,
+ textColor: Color,
+ bgColor: Color
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(bgColor)
+ .padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (showBack) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = "Back",
+ tint = textColor
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.width(12.dp))
+ }
+
+ if (isSearching) {
+ TextField(
+ value = searchQuery,
+ onValueChange = onSearchQueryChange,
+ placeholder = {
+ Text("Search", color = Color(0xFF8E8E93), fontSize = 16.sp)
+ },
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ cursorColor = PrimaryBlue,
+ focusedTextColor = textColor,
+ unfocusedTextColor = textColor
+ ),
+ modifier = Modifier.weight(1f)
+ )
+ } else {
+ Text(
+ text = title,
+ color = textColor,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ IconButton(onClick = onSearchToggle) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ tint = textColor
+ )
+ }
+ }
+}
+
+/** Rootβlevel special item row (Internal Storage, Rosetta, Gallery) */
+@Composable
+private fun RootItemRow(
+ title: String,
+ subtitle: String,
+ iconRes: Int,
+ iconBg: Color,
+ textColor: Color,
+ secondaryText: Color,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(CircleShape)
+ .background(iconBg),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ color = textColor,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = subtitle,
+ color = secondaryText,
+ fontSize = 13.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
+
+/** Folder row in directory browser */
+@Composable
+private fun FolderItemRow(
+ item: FileEntry,
+ textColor: Color,
+ secondaryText: Color,
+ dividerColor: Color,
+ onClick: () -> Unit
+) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(CircleShape)
+ .background(FolderBlue),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.files_folder),
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.name,
+ color = textColor,
+ fontSize = 16.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = "Folder",
+ color = secondaryText,
+ fontSize = 13.sp
+ )
+ }
+ }
+ Divider(
+ thickness = 0.5.dp,
+ color = dividerColor,
+ modifier = Modifier.padding(start = 74.dp)
+ )
+ }
+}
+
+/** File row with thumbnail/icon, name, size and date */
+@Composable
+private fun FileItemRow(
+ item: FileEntry,
+ textColor: Color,
+ secondaryText: Color,
+ dividerColor: Color,
+ isDarkTheme: Boolean,
+ onClick: () -> Unit
+) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (item.isImage) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(item.file)
+ .crossfade(true)
+ .size(84)
+ .build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(6.dp))
+ )
+ } else {
+ FileExtensionBadge(extension = item.extension)
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.name,
+ color = textColor,
+ fontSize = 16.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Row {
+ Text(text = item.sizeFormatted, color = secondaryText, fontSize = 13.sp)
+ if (item.dateFormatted.isNotEmpty()) {
+ Text(text = " Β· ${item.dateFormatted}", color = secondaryText, fontSize = 13.sp)
+ }
+ }
+ }
+ }
+ Divider(
+ thickness = 0.5.dp,
+ color = dividerColor,
+ modifier = Modifier.padding(start = 74.dp)
+ )
+ }
+}
+
+/** Colored extension badge (like Telegram's file type indicator) */
+@Composable
+private fun FileExtensionBadge(extension: String) {
+ val badgeColor = getExtensionColor(extension)
+ val displayExt = extension.uppercase().take(4)
+
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(badgeColor),
+ contentAlignment = Alignment.Center
+ ) {
+ if (displayExt.isNotEmpty()) {
+ Text(
+ text = displayExt,
+ color = Color.White,
+ fontSize = if (displayExt.length > 3) 9.sp else 11.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.files_document),
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+}
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Helper functions
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+private fun getExtensionColor(ext: String): Color = when (ext.lowercase()) {
+ "pdf" -> Color(0xFFE53935)
+ "doc", "docx" -> Color(0xFF1E88E5)
+ "xls", "xlsx" -> Color(0xFF43A047)
+ "ppt", "pptx" -> Color(0xFFFF7043)
+ "zip", "rar", "7z", "tar", "gz" -> Color(0xFF8E24AA)
+ "mp3", "wav", "flac", "aac", "ogg", "m4a" -> Color(0xFFEF6C00)
+ "mp4", "avi", "mkv", "mov", "webm" -> Color(0xFF5C6BC0)
+ "apk" -> Color(0xFF00897B)
+ "txt", "log" -> Color(0xFF546E7A)
+ "json", "xml", "html", "css", "js", "kt", "java" -> Color(0xFF00ACC1)
+ else -> FileGray
+}
+
+private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
+private val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.getDefault())
+
+private fun formatFileDate(lastModified: Long): String {
+ if (lastModified <= 0) return ""
+ return dateFormat.format(Date(lastModified))
+}
+
+private fun fileToEntry(context: Context, file: File): FileEntry {
+ val ext = file.extension.lowercase()
+ return FileEntry(
+ file = file,
+ name = file.name,
+ sizeFormatted = if (file.isDirectory) "" else Formatter.formatFileSize(context, file.length()),
+ dateFormatted = formatFileDate(file.lastModified()),
+ isDirectory = file.isDirectory,
+ extension = ext,
+ isImage = ext in imageExtensions
+ )
+}
+
+/** Load recent files from Downloads and common directories */
+private fun loadRecentFiles(context: Context): List {
+ val result = mutableListOf()
+
+ val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ if (downloads.exists() && downloads.isDirectory) {
+ scanDirRecursive(downloads, result, depth = 2)
+ }
+
+ val documents = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
+ if (documents.exists() && documents.isDirectory) {
+ scanDirRecursive(documents, result, depth = 2)
+ }
+
+ val rosettaDir = File(downloads, "Rosetta")
+ if (rosettaDir.exists() && rosettaDir.isDirectory) {
+ scanDirRecursive(rosettaDir, result, depth = 3)
+ }
+
+ return result
+ .filter { it.isFile && it.length() > 0 }
+ .sortedByDescending { it.lastModified() }
+ .take(50)
+ .map { fileToEntry(context, it) }
+}
+
+private fun scanDirRecursive(dir: File, result: MutableList, depth: Int) {
+ if (depth <= 0) return
+ val files = dir.listFiles() ?: return
+ for (f in files) {
+ if (f.name.startsWith(".")) continue
+ if (f.isFile) result.add(f)
+ else if (f.isDirectory && depth > 1) scanDirRecursive(f, result, depth - 1)
+ }
+}
+
+/** List contents of a directory (folders first, then files by name) */
+private fun listDirectoryContents(context: Context, dir: File): List {
+ val files = dir.listFiles() ?: return emptyList()
+ return files
+ .filter { !it.name.startsWith(".") }
+ .map { fileToEntry(context, it) }
+ .sortedWith(
+ compareByDescending { it.isDirectory }
+ .thenBy { it.name.lowercase() }
+ )
+}
+
+/** Get storage info string (e.g. "12.5 GB free of 64 GB") */
+private fun getStorageInfo(context: Context): String = try {
+ val stat = android.os.StatFs(Environment.getExternalStorageDirectory().path)
+ val free = stat.availableBytes
+ val total = stat.totalBytes
+ "${Formatter.formatFileSize(context, free)} free of ${Formatter.formatFileSize(context, total)}"
+} catch (_: Exception) {
+ "Internal storage"
+}
+
+/** Toggle selection for gallery-as-file mode */
+private fun toggleGallerySelection(
+ currentOrder: List,
+ itemId: Long,
+ maxSelection: Int
+): List {
+ return if (currentOrder.contains(itemId)) {
+ currentOrder - itemId
+ } else {
+ if (currentOrder.size >= maxSelection) currentOrder
+ else currentOrder + itemId
+ }
+}
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt
index b860807..0af1893 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt
@@ -134,6 +134,8 @@ fun ChatAttachAlert(
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {},
+ onGalleryFilesSelected: (List) -> Unit = {},
+ onFileSelected: (android.net.Uri, String, Long) -> Unit = { _, _, _ -> },
onAvatarClick: () -> Unit = {},
currentUserPublicKey: String = "",
maxSelection: Int = 10,
@@ -871,9 +873,16 @@ fun ChatAttachAlert(
modifier = Modifier.fillMaxSize()
)
AttachAlertTab.FILE -> AttachAlertFileLayout(
- onOpenFilePicker = {
+ onFileSelected = { uri, name, size ->
+ requestClose { onFileSelected(uri, name, size) }
+ },
+ onGalleryFilesSelected = { uris ->
+ requestClose { onGalleryFilesSelected(uris) }
+ },
+ onOpenSystemPicker = {
requestClose { onOpenFilePicker() }
},
+ galleryMediaItems = state.mediaItems,
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
)
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
index 251c410..a267a20 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt
@@ -1438,13 +1438,24 @@ fun FileAttachment(
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED
} else {
- DownloadStatus.DOWNLOADED
+ // ΠΠ»Ρ ΠΎΡΠΏΡΠ°Π²Π»Π΅Π½Π½ΡΡ
ΡΠ°ΠΉΠ»ΠΎΠ² Π±Π΅Π· ΡΠ΅Π³Π° β ΠΏΡΠΎΠ²Π΅ΡΡΠ΅ΠΌ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠΉ ΡΠ°ΠΉΠ»
+ if (savedFile.exists()) DownloadStatus.DOWNLOADED
+ else DownloadStatus.DOWNLOADED // blob Π΅ΡΡΡ Π² ΠΏΠ°ΠΌΡΡΠΈ
}
}
// ΠΡΠΊΡΡΡΡ ΡΠ°ΠΉΠ» ΡΠ΅ΡΠ΅Π· ΡΠΈΡΡΠ΅ΠΌΠ½ΠΎΠ΅ ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅
- val openFile: () -> Unit = {
+ val openFile: () -> Unit = openFile@{
try {
+ if (!savedFile.exists()) {
+ // Π€Π°ΠΉΠ» Π΅ΡΡ Π½Π΅ ΡΠΊΠ°ΡΠ°Π½ β ΠΏΠ΅ΡΠ΅ΠΊΠ»ΡΡΠ°Π΅ΠΌ ΡΡΠ°ΡΡΡ ΡΡΠΎΠ±Ρ UI ΠΏΠΎΠΊΠ°Π·Π°Π» ΠΊΠ½ΠΎΠΏΠΊΡ Π·Π°Π³ΡΡΠ·ΠΊΠΈ
+ if (downloadTag.isNotEmpty()) {
+ downloadStatus = DownloadStatus.NOT_DOWNLOADED
+ } else {
+ android.widget.Toast.makeText(context, "File not available", android.widget.Toast.LENGTH_SHORT).show()
+ }
+ return@openFile
+ }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
@@ -1458,7 +1469,9 @@ fun FileAttachment(
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
- } catch (_: Exception) {}
+ } catch (e: Exception) {
+ android.widget.Toast.makeText(context, "Cannot open file: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
+ }
}
val download: () -> Unit = {
diff --git a/app/src/main/res/drawable/files_document.xml b/app/src/main/res/drawable/files_document.xml
new file mode 100644
index 0000000..a545ed5
--- /dev/null
+++ b/app/src/main/res/drawable/files_document.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/files_folder.xml b/app/src/main/res/drawable/files_folder.xml
new file mode 100644
index 0000000..5e3bcd7
--- /dev/null
+++ b/app/src/main/res/drawable/files_folder.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/files_gallery.xml b/app/src/main/res/drawable/files_gallery.xml
new file mode 100644
index 0000000..0583036
--- /dev/null
+++ b/app/src/main/res/drawable/files_gallery.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/files_storage.xml b/app/src/main/res/drawable/files_storage.xml
new file mode 100644
index 0000000..3ac36b5
--- /dev/null
+++ b/app/src/main/res/drawable/files_storage.xml
@@ -0,0 +1,10 @@
+
+
+
+