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:
torlando-tech
2025-12-18 21:30:22 -05:00
parent 81f12c4dd9
commit 1202f19320
5 changed files with 269 additions and 2 deletions

View File

@@ -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() },
)
}
}
}

View File

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

View File

@@ -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)

View File

@@ -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.
*

View File

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