feat: Implement file selection and gallery-as-file functionality in attachment UI

This commit is contained in:
2026-02-22 15:59:27 +05:00
parent ba7182abe6
commit 14ab10a1a0
10 changed files with 942 additions and 56 deletions

View File

@@ -8,6 +8,7 @@
<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.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

View File

@@ -326,7 +326,34 @@ fun ChatDetailScreen(
}
}
// 📄 File picker launcher
// <EFBFBD> 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)
}
}
}
}
// <20>📄 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()
},

View File

@@ -3202,6 +3202,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
// 💾 Сохраняем файл локально чтобы отправитель мог его открыть
try {
val app = getApplication<android.app.Application>()
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

View File

@@ -1,72 +1,265 @@
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
// ═══════════════════════════════════════════════════════════
// Telegramstyle "Select File" tab for the attach alert.
// Shows: Internal Storage, App folder, Gallery + Recent files.
// Supports directory browsing with backstack 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.
* Telegramstyle 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<Uri>) -> Unit,
onOpenSystemPicker: () -> Unit,
galleryMediaItems: List<MediaItem>,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
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 secondaryTextColor = Color(0xFF8E8E93)
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)
// ── Navigation state ──
var currentDir by remember { mutableStateOf<File?>(null) }
var currentTitle by remember { mutableStateOf("Select File") }
var history by remember { mutableStateOf(listOf<HistoryEntry>()) }
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<List<Long>>(emptyList()) }
// ── Recent files (loaded once) ──
var recentFiles by remember { mutableStateOf<List<FileEntry>>(emptyList()) }
LaunchedEffect(Unit) {
recentFiles = withContext(Dispatchers.IO) { loadRecentFiles(context) }
}
// ── Directory contents (loaded when currentDir changes) ──
var dirContents by remember { mutableStateOf<List<FileEntry>>(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)
) {
// ── 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.fillMaxWidth(),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
painter = TelegramIcons.File,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(72.dp)
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
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Send a File",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
},
onItemCheckClick = { item ->
gallerySelectedOrder = toggleGallerySelection(
gallerySelectedOrder, item.id, 10
)
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
},
onItemLongClick = { item ->
gallerySelectedOrder = toggleGallerySelection(
gallerySelectedOrder, item.id, 10
)
Spacer(modifier = Modifier.height(28.dp))
},
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)
// Send button when items selected
if (gallerySelectedOrder.isNotEmpty()) {
val count = gallerySelectedOrder.size
Button(
onClick = onOpenFilePicker,
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)
.padding(horizontal = 24.dp)
) {
Text(
text = "Choose File",
text = if (count == 1) "Send 1 file" else "Send $count files",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
@@ -75,3 +268,550 @@ internal fun AttachAlertFileLayout(
}
}
}
}
} 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
)
}
}
}
/** Rootlevel 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<FileEntry> {
val result = mutableListOf<File>()
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<File>, 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<FileEntry> {
val files = dir.listFiles() ?: return emptyList()
return files
.filter { !it.name.startsWith(".") }
.map { fileToEntry(context, it) }
.sortedWith(
compareByDescending<FileEntry> { 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<Long>,
itemId: Long,
maxSelection: Int
): List<Long> {
return if (currentOrder.contains(itemId)) {
currentOrder - itemId
} else {
if (currentOrder.size >= maxSelection) currentOrder
else currentOrder + itemId
}
}

View File

@@ -134,6 +134,8 @@ fun ChatAttachAlert(
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {},
onGalleryFilesSelected: (List<android.net.Uri>) -> 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()
)

View File

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

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Document / file icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M14,2H6c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Folder icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Gallery / image icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- SD card / internal storage icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,18c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,8H6V6h9v2z" />
</vector>