mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
feat: add "Open With" bottom sheet for file attachments
When tapping a received file attachment, shows a bottom sheet with: - "Open with..." - opens file in external app via Intent chooser - "Save to device" - existing save to file picker flow Changes: - Add FileAttachmentOptionsSheet composable for the bottom sheet UI - Add getFileAttachmentUri() to ViewModel for creating FileProvider URIs - Add loadFileAttachmentMetadata() helper for extracting filename/MIME type - Update file_paths.xml with attachments directory for FileProvider - Update MessagingScreen to show bottom sheet on file tap 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package com.lxmf.messenger.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Bottom sheet that shows options for a received file attachment.
|
||||
*
|
||||
* Provides two options:
|
||||
* - Open with: Opens the file using an external app via Intent chooser
|
||||
* - Save to device: Saves the file to a user-selected location
|
||||
*
|
||||
* @param filename The name of the file to display
|
||||
* @param onOpenWith Callback when "Open with..." is selected
|
||||
* @param onSaveToDevice Callback when "Save to device" is selected
|
||||
* @param onDismiss Callback when the sheet is dismissed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun FileAttachmentOptionsSheet(
|
||||
filename: String,
|
||||
onOpenWith: () -> Unit,
|
||||
onSaveToDevice: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
modifier = Modifier.systemBarsPadding(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Filename header
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Open with option
|
||||
ListItem(
|
||||
headlineContent = { Text("Open with...") },
|
||||
supportingContent = { Text("Open in another app") },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onOpenWith() },
|
||||
)
|
||||
|
||||
// Save to device option
|
||||
ListItem(
|
||||
headlineContent = { Text("Save to device") },
|
||||
supportingContent = { Text("Save to a folder on your device") },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onSaveToDevice() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,57 @@ fun loadFileAttachmentData(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for file attachment metadata.
|
||||
*/
|
||||
data class FileAttachmentInfo(
|
||||
val filename: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Load file attachment metadata (filename and MIME type) by index.
|
||||
*
|
||||
* @param fieldsJson The message's fields JSON containing file attachment data
|
||||
* @param index The index of the file attachment
|
||||
* @return FileAttachmentInfo or null if not found
|
||||
*/
|
||||
fun loadFileAttachmentMetadata(
|
||||
fieldsJson: String?,
|
||||
index: Int,
|
||||
): FileAttachmentInfo? {
|
||||
if (fieldsJson == null) return null
|
||||
|
||||
return try {
|
||||
val fields = JSONObject(fieldsJson)
|
||||
val field5 = fields.opt("5") ?: return null
|
||||
|
||||
val attachmentsArray: JSONArray =
|
||||
when {
|
||||
field5 is JSONObject && field5.has(FILE_REF_KEY) -> {
|
||||
val filePath = field5.getString(FILE_REF_KEY)
|
||||
val diskData = loadAttachmentFromDisk(filePath) ?: return null
|
||||
JSONArray(diskData)
|
||||
}
|
||||
field5 is JSONArray -> field5
|
||||
else -> return null
|
||||
}
|
||||
|
||||
if (index < 0 || index >= attachmentsArray.length()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val attachment = attachmentsArray.getJSONObject(index)
|
||||
val filename = attachment.optString("filename", "unknown")
|
||||
val mimeType = FileUtils.getMimeTypeFromFilename(filename)
|
||||
|
||||
FileAttachmentInfo(filename = filename, mimeType = mimeType)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load file attachment metadata at index $index", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently convert a hex string to byte array.
|
||||
* Uses direct array allocation and character arithmetic instead of
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lxmf.messenger.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -91,6 +92,7 @@ import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.lxmf.messenger.service.SyncResult
|
||||
import com.lxmf.messenger.ui.components.FileAttachmentCard
|
||||
import com.lxmf.messenger.ui.components.FileAttachmentOptionsSheet
|
||||
import com.lxmf.messenger.ui.components.FileAttachmentPreviewRow
|
||||
import com.lxmf.messenger.ui.components.StarToggleButton
|
||||
import com.lxmf.messenger.ui.theme.MeshConnected
|
||||
@@ -220,6 +222,10 @@ fun MessagingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// State for file attachment options bottom sheet
|
||||
var showFileOptionsSheet by remember { mutableStateOf(false) }
|
||||
var selectedFileInfo by remember { mutableStateOf<Triple<String, Int, String>?>(null) }
|
||||
|
||||
// State for saving received file attachments
|
||||
var pendingFileSave by remember { mutableStateOf<Triple<String, Int, String>?>(null) }
|
||||
|
||||
@@ -490,8 +496,8 @@ fun MessagingScreen(
|
||||
onViewDetails = onViewMessageDetails,
|
||||
onRetry = { viewModel.retryFailedMessage(message.id) },
|
||||
onFileAttachmentTap = { messageId, fileIndex, filename ->
|
||||
pendingFileSave = Triple(messageId, fileIndex, filename)
|
||||
fileSaveLauncher.launch(filename)
|
||||
selectedFileInfo = Triple(messageId, fileIndex, filename)
|
||||
showFileOptionsSheet = true
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -528,6 +534,47 @@ fun MessagingScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File attachment options bottom sheet
|
||||
if (showFileOptionsSheet && selectedFileInfo != null) {
|
||||
val (messageId, fileIndex, filename) = selectedFileInfo!!
|
||||
FileAttachmentOptionsSheet(
|
||||
filename = filename,
|
||||
onOpenWith = {
|
||||
showFileOptionsSheet = false
|
||||
scope.launch {
|
||||
val result = viewModel.getFileAttachmentUri(context, messageId, fileIndex)
|
||||
if (result != null) {
|
||||
val (uri, mimeType) = result
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
context.startActivity(Intent.createChooser(intent, null))
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "No app found to open this file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Failed to load file", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveToDevice = {
|
||||
showFileOptionsSheet = false
|
||||
pendingFileSave = Triple(messageId, fileIndex, filename)
|
||||
fileSaveLauncher.launch(filename)
|
||||
},
|
||||
onDismiss = {
|
||||
showFileOptionsSheet = false
|
||||
selectedFileInfo = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.lxmf.messenger.viewmodel
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
@@ -18,6 +19,7 @@ import com.lxmf.messenger.ui.model.ImageCache
|
||||
import com.lxmf.messenger.ui.model.MessageUi
|
||||
import com.lxmf.messenger.ui.model.decodeAndCacheImage
|
||||
import com.lxmf.messenger.ui.model.loadFileAttachmentData
|
||||
import com.lxmf.messenger.ui.model.loadFileAttachmentMetadata
|
||||
import com.lxmf.messenger.ui.model.toMessageUi
|
||||
import com.lxmf.messenger.util.FileAttachment
|
||||
import com.lxmf.messenger.util.FileUtils
|
||||
@@ -43,6 +45,7 @@ import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import com.lxmf.messenger.data.repository.Message as DataMessage
|
||||
@@ -614,6 +617,71 @@ class MessagingViewModel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a FileProvider URI for a received file attachment.
|
||||
*
|
||||
* Creates a temporary file in the attachments directory and returns a content URI
|
||||
* that can be shared with external apps via Intent.ACTION_VIEW.
|
||||
*
|
||||
* @param context Android context for file operations
|
||||
* @param messageId The message ID containing the file attachment
|
||||
* @param fileIndex The index of the file attachment in the message's field 5
|
||||
* @return Pair of (Uri, mimeType) or null if the file cannot be accessed
|
||||
*/
|
||||
suspend fun getFileAttachmentUri(
|
||||
context: Context,
|
||||
messageId: String,
|
||||
fileIndex: Int,
|
||||
): Pair<Uri, String>? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Get the message from the database
|
||||
val messageEntity = conversationRepository.getMessageById(messageId)
|
||||
if (messageEntity == null) {
|
||||
Log.e(TAG, "Message not found: $messageId")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Get file metadata (filename, MIME type)
|
||||
val metadata = loadFileAttachmentMetadata(messageEntity.fieldsJson, fileIndex)
|
||||
if (metadata == null) {
|
||||
Log.e(TAG, "Could not load file metadata for message $messageId index $fileIndex")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Load the file data
|
||||
val fileData = loadFileAttachmentData(messageEntity.fieldsJson, fileIndex)
|
||||
if (fileData == null) {
|
||||
Log.e(TAG, "Could not load file data for message $messageId index $fileIndex")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
// Create attachments directory if needed
|
||||
val attachmentsDir = File(context.filesDir, "attachments")
|
||||
if (!attachmentsDir.exists()) {
|
||||
attachmentsDir.mkdirs()
|
||||
}
|
||||
|
||||
// Write to temp file with original filename
|
||||
val tempFile = File(attachmentsDir, metadata.filename)
|
||||
tempFile.writeBytes(fileData)
|
||||
Log.d(TAG, "Created temp file for sharing: ${tempFile.absolutePath}")
|
||||
|
||||
// Get FileProvider URI
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
tempFile,
|
||||
)
|
||||
|
||||
Pair(uri, metadata.mimeType)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get file attachment URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image attachment asynchronously.
|
||||
*
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Cache directory for sharing identity files -->
|
||||
<cache-path name="identity_files" path="." />
|
||||
<!-- Files directory for sharing received attachments -->
|
||||
<files-path name="attachments" path="attachments/" />
|
||||
</paths>
|
||||
|
||||
Reference in New Issue
Block a user