RFC-026: File Attachments
RFC-026: File Attachments
Document Information
- RFC ID: RFC-026
- Related PRD: FEAT-026 (File Attachments)
- Related Architecture: RFC-000 (Overall Architecture)
- Related RFC: RFC-001 (Chat Interaction), RFC-016 (Chat Input Redesign)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
OneClaw currently supports text-only chat messages. All three supported AI providers (OpenAI, Anthropic, Gemini) support multimodal input – at minimum images, with Gemini also supporting video. Users need the ability to send images, videos, and files alongside text messages to take full advantage of these provider capabilities.
The current ApiMessage.User class only holds a content: String, and the Message domain model has no attachment fields. This RFC designs the full stack of changes needed: data model, file storage, UI components, API adapter extensions, and media viewing.
Goals
- Add an attachment button to
ChatInputwith a bottom sheet picker (Photo, Video, Camera, File) - Design an
Attachmentdata model and Room table with FK to messages - Implement file storage at
files/attachments/{sessionId}/with thumbnail generation - Display attachment previews in the chat input before sending
- Display attachments inline in message bubbles (images, video thumbnails, file cards)
- Implement full-screen image viewer with zoom/pan
- Open videos and files via system Intent
- Extend
ApiMessage.Userto support multimodal content parts - Extend all three API adapters to format attachments in provider-specific formats
- Handle unsupported attachment types with user notification
Non-Goals
- Video recording from camera
- Image editing, cropping, or annotation
- Drag-and-drop or clipboard paste for attachments
- Audio recording or voice messages
- Inline PDF viewer or document preview
- Cloud storage integration
- AI-generated image rendering (text-to-image models)
- Attachment compression settings UI
Technical Design
Architecture Overview
┌──────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ ChatScreen │
│ ├── ChatMessageList │
│ │ └── MessageBubble │
│ │ └── AttachmentDisplay [NEW] │
│ │ ├── ImageAttachment (clickable -> ImageViewer) │
│ │ ├── VideoAttachment (clickable -> system player) │
│ │ └── FileAttachment (clickable -> system app) │
│ │ │
│ ├── ChatInput [MODIFIED] │
│ │ ├── AttachmentPreviewRow [NEW] │
│ │ ├── BasicTextField (unchanged) │
│ │ └── ActionRow │
│ │ ├── SkillButton (unchanged) │
│ │ ├── AttachButton [NEW] ("+") │
│ │ ├── Spacer │
│ │ ├── StopButton (unchanged) │
│ │ └── SendButton (unchanged) │
│ │ │
│ ├── AttachmentPickerSheet [NEW] (ModalBottomSheet) │
│ └── ImageViewerDialog [NEW] (full-screen overlay) │
│ │
│ ChatViewModel [MODIFIED] │
│ ├── pendingAttachments: StateFlow<List<PendingAttachment>> │
│ ├── addAttachment(uri) / removeAttachment(id) │
│ └── sendMessage() -- includes attachments │
│ │
├──────────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ │
│ Attachment [NEW] (core/model/) │
│ AttachmentType [NEW] (core/model/) │
│ AttachmentRepository [NEW] (core/repository/) │
│ SaveAttachmentUseCase [NEW] (feature/chat/usecase/) │
│ ProviderCapability [NEW] (core/model/) │
│ │
├──────────────────────────────────────────────────────────────────┤
│ Data Layer │
│ │
│ AttachmentEntity [NEW] (data/local/entity/) │
│ AttachmentDao [NEW] (data/local/dao/) │
│ AttachmentMapper [NEW] (data/local/mapper/) │
│ AttachmentRepositoryImpl [NEW] (data/repository/) │
│ AttachmentFileManager [NEW] (data/local/) │
│ AppDatabase [MODIFIED] -- add AttachmentEntity + migration │
│ │
│ ApiMessage.User [MODIFIED] -- multimodal content parts │
│ OpenAiAdapter [MODIFIED] -- image content parts │
│ AnthropicAdapter [MODIFIED] -- image/PDF content parts │
│ GeminiAdapter [MODIFIED] -- image/video content parts │
│ │
└──────────────────────────────────────────────────────────────────┘
Core Components
New:
Attachment– Domain model for attachment metadataAttachmentEntity+AttachmentDao– Room persistenceAttachmentRepository/AttachmentRepositoryImpl– Data accessAttachmentFileManager– File copy, thumbnail generation, cleanupAttachmentDisplay– Composable for rendering attachments in message bubblesAttachmentPreviewRow– Composable for pre-send attachment previewsAttachmentPickerSheet– Bottom sheet with picker optionsImageViewerDialog– Full-screen image viewer with zoom/panSaveAttachmentUseCase– Orchestrates file copy + DB save
Modified:
ChatInput– Add attachment button, preview rowChatViewModel– Manage pending attachments, include in sendChatScreen– Wire attachment UI, media viewerChatUiState/ChatMessageItem– Add attachment dataApiMessage.User– Support multimodal contentOpenAiAdapter/AnthropicAdapter/GeminiAdapter– Multimodal formattingSendMessageUseCase– Include attachments in API messageAppDatabase– Migration to addattachmentstableMessageMapper– Map attachments alongside messagesDatabaseModule– ProvideAttachmentDaoRepositoryModule– ProvideAttachmentRepository
Detailed Design
Directory Structure (New & Changed Files)
app/src/main/kotlin/com/oneclaw/shadow/
├── core/
│ ├── model/
│ │ ├── Attachment.kt # NEW
│ │ └── Message.kt # unchanged
│ └── repository/
│ ├── AttachmentRepository.kt # NEW
│ └── MessageRepository.kt # unchanged
├── data/
│ ├── local/
│ │ ├── dao/
│ │ │ ├── AttachmentDao.kt # NEW
│ │ │ └── MessageDao.kt # unchanged
│ │ ├── entity/
│ │ │ ├── AttachmentEntity.kt # NEW
│ │ │ └── MessageEntity.kt # unchanged
│ │ ├── mapper/
│ │ │ ├── AttachmentMapper.kt # NEW
│ │ │ └── MessageMapper.kt # unchanged
│ │ ├── db/
│ │ │ └── AppDatabase.kt # MODIFIED (add entity + migration)
│ │ └── AttachmentFileManager.kt # NEW
│ ├── remote/
│ │ └── adapter/
│ │ ├── ApiMessage.kt # MODIFIED
│ │ ├── OpenAiAdapter.kt # MODIFIED
│ │ ├── AnthropicAdapter.kt # MODIFIED
│ │ └── GeminiAdapter.kt # MODIFIED
│ └── repository/
│ └── AttachmentRepositoryImpl.kt # NEW
├── feature/
│ └── chat/
│ ├── ChatScreen.kt # MODIFIED
│ ├── ChatViewModel.kt # MODIFIED
│ ├── ChatUiState.kt # MODIFIED
│ ├── components/
│ │ ├── AttachmentDisplay.kt # NEW
│ │ ├── AttachmentPreviewRow.kt # NEW
│ │ ├── AttachmentPickerSheet.kt # NEW
│ │ └── ImageViewerDialog.kt # NEW
│ └── usecase/
│ ├── SaveAttachmentUseCase.kt # NEW
│ └── SendMessageUseCase.kt # MODIFIED
└── di/
├── DatabaseModule.kt # MODIFIED
├── RepositoryModule.kt # MODIFIED
└── FeatureModule.kt # MODIFIED
app/src/test/kotlin/com/oneclaw/shadow/
├── data/
│ ├── local/
│ │ └── AttachmentFileManagerTest.kt # NEW
│ └── repository/
│ └── AttachmentRepositoryImplTest.kt # NEW
└── feature/
└── chat/
└── usecase/
└── SaveAttachmentUseCaseTest.kt # NEW
Data Model
Attachment Domain Model
/**
* Located in: core/model/Attachment.kt
*/
data class Attachment(
val id: String,
val messageId: String,
val type: AttachmentType,
val fileName: String,
val mimeType: String,
val fileSize: Long,
val filePath: String,
val thumbnailPath: String?,
val width: Int?,
val height: Int?,
val durationMs: Long?,
val createdAt: Long
)
enum class AttachmentType {
IMAGE,
VIDEO,
FILE
}
Attachment Room Entity
/**
* Located in: data/local/entity/AttachmentEntity.kt
*/
@Entity(
tableName = "attachments",
foreignKeys = [
ForeignKey(
entity = MessageEntity::class,
parentColumns = ["id"],
childColumns = ["message_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["message_id"])]
)
data class AttachmentEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "message_id")
val messageId: String,
@ColumnInfo(name = "type")
val type: String, // "IMAGE", "VIDEO", "FILE"
@ColumnInfo(name = "file_name")
val fileName: String,
@ColumnInfo(name = "mime_type")
val mimeType: String,
@ColumnInfo(name = "file_size")
val fileSize: Long,
@ColumnInfo(name = "file_path")
val filePath: String,
@ColumnInfo(name = "thumbnail_path")
val thumbnailPath: String?,
@ColumnInfo(name = "width")
val width: Int?,
@ColumnInfo(name = "height")
val height: Int?,
@ColumnInfo(name = "duration_ms")
val durationMs: Long?,
@ColumnInfo(name = "created_at")
val createdAt: Long
)
AttachmentDao
/**
* Located in: data/local/dao/AttachmentDao.kt
*/
@Dao
interface AttachmentDao {
@Query("SELECT * FROM attachments WHERE message_id = :messageId ORDER BY created_at ASC")
fun getAttachmentsForMessage(messageId: String): Flow<List<AttachmentEntity>>
@Query("SELECT * FROM attachments WHERE message_id IN (:messageIds) ORDER BY created_at ASC")
suspend fun getAttachmentsForMessages(messageIds: List<String>): List<AttachmentEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAttachment(attachment: AttachmentEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAttachments(attachments: List<AttachmentEntity>)
@Delete
suspend fun deleteAttachment(attachment: AttachmentEntity)
@Query("DELETE FROM attachments WHERE message_id = :messageId")
suspend fun deleteAttachmentsForMessage(messageId: String)
}
AttachmentMapper
/**
* Located in: data/local/mapper/AttachmentMapper.kt
*/
fun AttachmentEntity.toDomain(): Attachment = Attachment(
id = id,
messageId = messageId,
type = AttachmentType.valueOf(type),
fileName = fileName,
mimeType = mimeType,
fileSize = fileSize,
filePath = filePath,
thumbnailPath = thumbnailPath,
width = width,
height = height,
durationMs = durationMs,
createdAt = createdAt
)
fun Attachment.toEntity(): AttachmentEntity = AttachmentEntity(
id = id,
messageId = messageId,
type = type.name,
fileName = fileName,
mimeType = mimeType,
fileSize = fileSize,
filePath = filePath,
thumbnailPath = thumbnailPath,
width = width,
height = height,
durationMs = durationMs,
createdAt = createdAt
)
Database Migration
/**
* In AppDatabase.kt -- add migration
*/
val MIGRATION_X_Y = object : Migration(X, Y) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS attachments (
id TEXT NOT NULL PRIMARY KEY,
message_id TEXT NOT NULL,
type TEXT NOT NULL,
file_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_path TEXT NOT NULL,
thumbnail_path TEXT,
width INTEGER,
height INTEGER,
duration_ms INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS index_attachments_message_id ON attachments(message_id)")
}
}
Repository Layer
AttachmentRepository Interface
/**
* Located in: core/repository/AttachmentRepository.kt
*/
interface AttachmentRepository {
fun getAttachmentsForMessage(messageId: String): Flow<List<Attachment>>
suspend fun getAttachmentsForMessages(messageIds: List<String>): List<Attachment>
suspend fun addAttachment(attachment: Attachment)
suspend fun addAttachments(attachments: List<Attachment>)
suspend fun deleteAttachment(id: String)
suspend fun deleteAttachmentsForMessage(messageId: String)
}
AttachmentRepositoryImpl
/**
* Located in: data/repository/AttachmentRepositoryImpl.kt
*/
class AttachmentRepositoryImpl(
private val attachmentDao: AttachmentDao
) : AttachmentRepository {
override fun getAttachmentsForMessage(messageId: String): Flow<List<Attachment>> {
return attachmentDao.getAttachmentsForMessage(messageId)
.map { entities -> entities.map { it.toDomain() } }
}
override suspend fun getAttachmentsForMessages(messageIds: List<String>): List<Attachment> {
return attachmentDao.getAttachmentsForMessages(messageIds)
.map { it.toDomain() }
}
override suspend fun addAttachment(attachment: Attachment) {
attachmentDao.insertAttachment(attachment.toEntity())
}
override suspend fun addAttachments(attachments: List<Attachment>) {
attachmentDao.insertAttachments(attachments.map { it.toEntity() })
}
override suspend fun deleteAttachment(id: String) {
// Note: File cleanup handled by AttachmentFileManager
attachmentDao.deleteAttachment(
AttachmentEntity(id = id, messageId = "", type = "", fileName = "",
mimeType = "", fileSize = 0, filePath = "", thumbnailPath = null,
width = null, height = null, durationMs = null, createdAt = 0)
)
}
override suspend fun deleteAttachmentsForMessage(messageId: String) {
attachmentDao.deleteAttachmentsForMessage(messageId)
}
}
File Management
AttachmentFileManager
/**
* Located in: data/local/AttachmentFileManager.kt
*
* Handles file operations for attachments: copy from URI,
* generate thumbnails, delete files, and clean up session directories.
*/
class AttachmentFileManager(
private val context: Context
) {
companion object {
private const val ATTACHMENTS_DIR = "attachments"
private const val THUMBS_DIR = "thumbs"
private const val THUMB_SIZE = 256
private const val MAX_FILE_SIZE = 20L * 1024 * 1024 // 20MB
}
/**
* Copy a file from a content URI to internal storage.
* Returns the Attachment metadata (without messageId, which is set later).
*/
suspend fun copyFromUri(
uri: Uri,
sessionId: String
): AppResult<PendingAttachment> = withContext(Dispatchers.IO) {
try {
val contentResolver = context.contentResolver
// Get file metadata
val fileName = getFileName(contentResolver, uri)
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val fileSize = getFileSize(contentResolver, uri)
// Validate file size
if (fileSize > MAX_FILE_SIZE) {
return@withContext AppResult.Error(
"File is too large (${fileSize / 1024 / 1024}MB). Maximum is 20MB."
)
}
// Determine attachment type
val type = when {
mimeType.startsWith("image/") -> AttachmentType.IMAGE
mimeType.startsWith("video/") -> AttachmentType.VIDEO
else -> AttachmentType.FILE
}
// Create directories
val sessionDir = getSessionDir(sessionId)
sessionDir.mkdirs()
// Generate UUID filename
val id = UUID.randomUUID().toString()
val extension = MimeTypeMap.getSingleton()
.getExtensionFromMimeType(mimeType) ?: fileName.substringAfterLast('.', "bin")
val destFile = File(sessionDir, "$id.$extension")
// Copy file
contentResolver.openInputStream(uri)?.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: return@withContext AppResult.Error("Cannot open file")
// Generate thumbnail
val thumbnailPath = when (type) {
AttachmentType.IMAGE -> generateImageThumbnail(destFile, sessionId, id)
AttachmentType.VIDEO -> generateVideoThumbnail(uri, sessionId, id)
AttachmentType.FILE -> null
}
// Get image/video dimensions
val dimensions = when (type) {
AttachmentType.IMAGE -> getImageDimensions(destFile)
AttachmentType.VIDEO -> getVideoDimensions(uri)
AttachmentType.FILE -> null
}
// Get video duration
val durationMs = if (type == AttachmentType.VIDEO) {
getVideoDuration(uri)
} else null
AppResult.Success(
PendingAttachment(
id = id,
type = type,
fileName = fileName,
mimeType = mimeType,
fileSize = fileSize,
filePath = destFile.absolutePath,
thumbnailPath = thumbnailPath,
width = dimensions?.first,
height = dimensions?.second,
durationMs = durationMs
)
)
} catch (e: Exception) {
AppResult.Error("Failed to save attachment: ${e.message}")
}
}
/**
* Copy a camera-captured photo to internal storage.
*/
suspend fun copyFromCameraFile(
file: File,
sessionId: String
): AppResult<PendingAttachment> = withContext(Dispatchers.IO) {
// Similar to copyFromUri but reads from a File directly
// ...implementation follows same pattern...
}
/**
* Delete all attachment files for a session.
*/
suspend fun deleteSessionAttachments(sessionId: String) = withContext(Dispatchers.IO) {
val sessionDir = getSessionDir(sessionId)
if (sessionDir.exists()) {
sessionDir.deleteRecursively()
}
}
/**
* Read a file as base64-encoded string for API transmission.
*/
suspend fun readAsBase64(filePath: String): String = withContext(Dispatchers.IO) {
val file = File(filePath)
val bytes = file.readBytes()
Base64.encodeToString(bytes, Base64.NO_WRAP)
}
// --- Private helpers ---
private fun getSessionDir(sessionId: String): File {
return File(context.filesDir, "$ATTACHMENTS_DIR/$sessionId")
}
private fun getThumbsDir(sessionId: String): File {
return File(getSessionDir(sessionId), THUMBS_DIR).also { it.mkdirs() }
}
private fun generateImageThumbnail(
sourceFile: File,
sessionId: String,
id: String
): String? {
return try {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(sourceFile.absolutePath, options)
val sampleSize = calculateInSampleSize(options, THUMB_SIZE, THUMB_SIZE)
options.inJustDecodeBounds = false
options.inSampleSize = sampleSize
val bitmap = BitmapFactory.decodeFile(sourceFile.absolutePath, options)
?: return null
val scaled = Bitmap.createScaledBitmap(
bitmap,
minOf(THUMB_SIZE, bitmap.width),
minOf(THUMB_SIZE, bitmap.height),
true
)
if (scaled != bitmap) bitmap.recycle()
val thumbFile = File(getThumbsDir(sessionId), "${id}_thumb.jpg")
thumbFile.outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
scaled.recycle()
thumbFile.absolutePath
} catch (e: Exception) {
null // Thumbnail failure is non-fatal
}
}
private fun generateVideoThumbnail(
uri: Uri,
sessionId: String,
id: String
): String? {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val bitmap = retriever.getFrameAtTime(0) ?: return null
retriever.release()
val scaled = Bitmap.createScaledBitmap(
bitmap,
minOf(THUMB_SIZE, bitmap.width),
minOf(THUMB_SIZE, bitmap.height),
true
)
if (scaled != bitmap) bitmap.recycle()
val thumbFile = File(getThumbsDir(sessionId), "${id}_thumb.jpg")
thumbFile.outputStream().use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
scaled.recycle()
thumbFile.absolutePath
} catch (e: Exception) {
null
}
}
private fun getFileName(contentResolver: ContentResolver, uri: Uri): String {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0) return cursor.getString(nameIndex)
}
}
return uri.lastPathSegment ?: "unknown"
}
private fun getFileSize(contentResolver: ContentResolver, uri: Uri): Long {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (sizeIndex >= 0) return cursor.getLong(sizeIndex)
}
}
return 0L
}
private fun getImageDimensions(file: File): Pair<Int, Int>? {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, options)
return if (options.outWidth > 0 && options.outHeight > 0) {
Pair(options.outWidth, options.outHeight)
} else null
}
private fun getVideoDimensions(uri: Uri): Pair<Int, Int>? {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val width = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH
)?.toIntOrNull()
val height = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT
)?.toIntOrNull()
retriever.release()
if (width != null && height != null) Pair(width, height) else null
} catch (e: Exception) { null }
}
private fun getVideoDuration(uri: Uri): Long? {
return try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val duration = retriever.extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION
)?.toLongOrNull()
retriever.release()
duration
} catch (e: Exception) { null }
}
private fun calculateInSampleSize(
options: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int
): Int {
val (height, width) = options.outHeight to options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
}
/**
* Pending attachment before it is associated with a message.
* Created when user selects a file, before message is sent.
*/
data class PendingAttachment(
val id: String,
val type: AttachmentType,
val fileName: String,
val mimeType: String,
val fileSize: Long,
val filePath: String,
val thumbnailPath: String?,
val width: Int?,
val height: Int?,
val durationMs: Long?
)
API Adapter Layer
ApiMessage Changes
/**
* Located in: data/remote/adapter/ApiMessage.kt
*
* MODIFIED: User message now supports multimodal content parts.
*/
sealed class ApiMessage {
data class User(
val content: String,
val attachments: List<ApiAttachment> = emptyList()
) : ApiMessage()
data class Assistant(
val content: String?,
val toolCalls: List<ApiToolCall>? = null
) : ApiMessage()
data class ToolResult(
val toolCallId: String,
val content: String
) : ApiMessage()
}
/**
* Attachment data prepared for API transmission.
* Contains pre-encoded base64 data.
*/
data class ApiAttachment(
val type: AttachmentType,
val mimeType: String,
val base64Data: String,
val fileName: String
)
Provider-Specific Formatting
Each adapter formats multimodal messages differently. The key changes are in the message serialization methods.
OpenAI Adapter – images as image_url with base64 data URI:
// In OpenAiAdapter -- when building the messages JSON array:
private fun buildUserMessage(message: ApiMessage.User): JsonObject {
if (message.attachments.isEmpty()) {
// Text-only: simple format
return buildJsonObject {
put("role", "user")
put("content", message.content)
}
}
// Multimodal: content array format
return buildJsonObject {
put("role", "user")
putJsonArray("content") {
// Add text part (if non-empty)
if (message.content.isNotBlank()) {
addJsonObject {
put("type", "text")
put("text", message.content)
}
}
// Add image parts (skip unsupported types)
message.attachments
.filter { it.type == AttachmentType.IMAGE }
.forEach { attachment ->
addJsonObject {
put("type", "image_url")
putJsonObject("image_url") {
put("url", "data:${attachment.mimeType};base64,${attachment.base64Data}")
}
}
}
}
}
}
Anthropic Adapter – images as image content blocks:
// In AnthropicAdapter:
private fun buildUserMessage(message: ApiMessage.User): JsonObject {
if (message.attachments.isEmpty()) {
return buildJsonObject {
put("role", "user")
put("content", message.content)
}
}
return buildJsonObject {
put("role", "user")
putJsonArray("content") {
// Add image parts
message.attachments
.filter { it.type == AttachmentType.IMAGE ||
(it.type == AttachmentType.FILE && it.mimeType == "application/pdf") }
.forEach { attachment ->
addJsonObject {
put("type", "image")
putJsonObject("source") {
put("type", "base64")
put("media_type", attachment.mimeType)
put("data", attachment.base64Data)
}
}
}
// Add text part
if (message.content.isNotBlank()) {
addJsonObject {
put("type", "text")
put("text", message.content)
}
}
}
}
}
Gemini Adapter – inlineData parts:
// In GeminiAdapter:
private fun buildUserParts(message: ApiMessage.User): JsonArray {
return buildJsonArray {
// Add text part
if (message.content.isNotBlank()) {
addJsonObject {
put("text", message.content)
}
}
// Add attachment parts (Gemini supports images and video)
message.attachments
.filter { it.type == AttachmentType.IMAGE || it.type == AttachmentType.VIDEO }
.forEach { attachment ->
addJsonObject {
putJsonObject("inlineData") {
put("mimeType", attachment.mimeType)
put("data", attachment.base64Data)
}
}
}
}
}
Provider Capability Check
/**
* Located in: core/model/ProviderCapability.kt
*
* Defines what attachment types each provider supports.
*/
object ProviderCapability {
fun supportsAttachmentType(providerType: ProviderType, attachmentType: AttachmentType): Boolean {
return when (providerType) {
ProviderType.OPENAI -> attachmentType == AttachmentType.IMAGE
ProviderType.ANTHROPIC -> attachmentType in listOf(AttachmentType.IMAGE, AttachmentType.FILE)
ProviderType.GEMINI -> attachmentType in listOf(AttachmentType.IMAGE, AttachmentType.VIDEO)
}
}
fun getUnsupportedTypes(
providerType: ProviderType,
attachments: List<PendingAttachment>
): List<AttachmentType> {
return attachments
.map { it.type }
.distinct()
.filter { !supportsAttachmentType(providerType, it) }
}
}
UI Layer
ChatInput Changes
/**
* Located in: feature/chat/ChatScreen.kt
*
* MODIFIED: Add attachment button and preview row to ChatInput.
*/
@Composable
fun ChatInput(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onStop: () -> Unit,
onSkillClick: () -> Unit = {},
onAttachClick: () -> Unit = {}, // NEW
pendingAttachments: List<PendingAttachment> = emptyList(), // NEW
onRemoveAttachment: (String) -> Unit = {}, // NEW
isStreaming: Boolean,
hasConfiguredProvider: Boolean,
focusRequester: FocusRequester = remember { FocusRequester() }
) {
// ... existing TextFieldValue setup ...
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(28.dp),
modifier = Modifier
.fillMaxWidth()
.imePadding()
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
// NEW: Attachment preview row (above text field)
if (pendingAttachments.isNotEmpty()) {
AttachmentPreviewRow(
attachments = pendingAttachments,
onRemove = onRemoveAttachment,
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Layer 1: Text Field (unchanged)
BasicTextField(/* ... existing code ... */)
// Layer 2: Action Row
Row(/* ... existing modifiers ... */) {
// Skill button (unchanged)
Box(/* ... */) { /* Skill icon */ }
// NEW: Attachment button
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable(onClick = onAttachClick),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = "Attach",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
// Stop button (unchanged)
// Send button -- MODIFIED: also enabled when attachments present
val sendEnabled = (text.isNotBlank() || pendingAttachments.isNotEmpty())
&& hasConfiguredProvider
// ... rest unchanged ...
}
}
}
}
AttachmentPreviewRow
/**
* Located in: feature/chat/components/AttachmentPreviewRow.kt
*
* Horizontally scrollable row of attachment previews in the chat input.
*/
@Composable
fun AttachmentPreviewRow(
attachments: List<PendingAttachment>,
onRemove: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(attachments, key = { it.id }) { attachment ->
Box(modifier = Modifier.size(72.dp)) {
when (attachment.type) {
AttachmentType.IMAGE -> {
AsyncImage(
model = attachment.thumbnailPath ?: attachment.filePath,
contentDescription = attachment.fileName,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
AttachmentType.VIDEO -> {
Box {
AsyncImage(
model = attachment.thumbnailPath,
contentDescription = attachment.fileName,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
// Play icon overlay
Icon(
Icons.Default.PlayCircle,
contentDescription = null,
modifier = Modifier
.size(28.dp)
.align(Alignment.Center),
tint = Color.White.copy(alpha = 0.9f)
)
// Duration label
attachment.durationMs?.let { ms ->
Text(
text = formatDuration(ms),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.background(
Color.Black.copy(alpha = 0.6f),
RoundedCornerShape(4.dp)
)
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
}
AttachmentType.FILE -> {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Text(
text = attachment.fileName,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
// Remove button (top-right corner)
IconButton(
onClick = { onRemove(attachment.id) },
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.background(
MaterialTheme.colorScheme.surface,
CircleShape
)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
AttachmentPickerSheet
/**
* Located in: feature/chat/components/AttachmentPickerSheet.kt
*
* Bottom sheet for selecting attachment source.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentPickerSheet(
onDismiss: () -> Unit,
onPickPhoto: () -> Unit,
onPickVideo: () -> Unit,
onTakePhoto: () -> Unit,
onPickFile: () -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Text(
text = "Attach",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
PickerOption(
icon = Icons.Default.Image,
label = "Photo",
onClick = { onPickPhoto(); onDismiss() }
)
PickerOption(
icon = Icons.Default.Videocam,
label = "Video",
onClick = { onPickVideo(); onDismiss() }
)
PickerOption(
icon = Icons.Default.CameraAlt,
label = "Camera",
onClick = { onTakePhoto(); onDismiss() }
)
PickerOption(
icon = Icons.Default.Folder,
label = "File",
onClick = { onPickFile(); onDismiss() }
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun PickerOption(
icon: ImageVector,
label: String,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(12.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.secondaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.labelMedium
)
}
}
AttachmentDisplay (Message Bubble)
/**
* Located in: feature/chat/components/AttachmentDisplay.kt
*
* Displays attachments within a message bubble.
*/
@Composable
fun AttachmentDisplay(
attachments: List<Attachment>,
onImageClick: (Attachment) -> Unit,
onVideoClick: (Attachment) -> Unit,
onFileClick: (Attachment) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
attachments.forEach { attachment ->
when (attachment.type) {
AttachmentType.IMAGE -> {
AsyncImage(
model = attachment.filePath,
contentDescription = attachment.fileName,
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { onImageClick(attachment) },
contentScale = ContentScale.FitWidth
)
}
AttachmentType.VIDEO -> {
Box(
modifier = Modifier
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { onVideoClick(attachment) }
) {
AsyncImage(
model = attachment.thumbnailPath,
contentDescription = attachment.fileName,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FitWidth
)
Icon(
Icons.Default.PlayCircleFilled,
contentDescription = "Play video",
modifier = Modifier
.size(48.dp)
.align(Alignment.Center),
tint = Color.White.copy(alpha = 0.9f)
)
}
}
AttachmentType.FILE -> {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { onFileClick(attachment) }
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = formatFileSize(attachment.fileSize),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
ImageViewerDialog
/**
* Located in: feature/chat/components/ImageViewerDialog.kt
*
* Full-screen image viewer with pinch-to-zoom and pan.
*/
@Composable
fun ImageViewerDialog(
imagePath: String,
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss)
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
AsyncImage(
model = imagePath,
contentDescription = "Full-screen image",
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
offset = if (scale > 1f) {
Offset(
x = offset.x + pan.x,
y = offset.y + pan.y
)
} else {
Offset.Zero
}
}
},
contentScale = ContentScale.Fit
)
// Close button
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.statusBarsPadding()
) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
ChatViewModel Changes
/**
* Located in: feature/chat/ChatViewModel.kt
*
* Key additions for attachment management.
*/
class ChatViewModel(
// ... existing dependencies ...
private val attachmentFileManager: AttachmentFileManager, // NEW
private val attachmentRepository: AttachmentRepository // NEW
) : ViewModel() {
// NEW: Pending attachments (before message is sent)
private val _pendingAttachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
val pendingAttachments: StateFlow<List<PendingAttachment>> = _pendingAttachments.asStateFlow()
// NEW: Image viewer state
private val _viewingImage = MutableStateFlow<String?>(null)
val viewingImage: StateFlow<String?> = _viewingImage.asStateFlow()
// NEW: Attachment picker visibility
private val _showAttachmentPicker = MutableStateFlow(false)
val showAttachmentPicker: StateFlow<Boolean> = _showAttachmentPicker.asStateFlow()
fun showAttachmentPicker() {
_showAttachmentPicker.value = true
}
fun hideAttachmentPicker() {
_showAttachmentPicker.value = false
}
/**
* Add an attachment from a content URI (gallery/file picker).
*/
fun addAttachment(uri: Uri) {
viewModelScope.launch {
val sessionId = currentSessionId ?: return@launch
when (val result = attachmentFileManager.copyFromUri(uri, sessionId)) {
is AppResult.Success -> {
_pendingAttachments.update { it + result.data }
}
is AppResult.Error -> {
// Show error via snackbar event
_uiState.update { it.copy(snackbarMessage = result.message) }
}
}
}
}
/**
* Add a camera-captured photo.
*/
fun addCameraPhoto(file: File) {
viewModelScope.launch {
val sessionId = currentSessionId ?: return@launch
when (val result = attachmentFileManager.copyFromCameraFile(file, sessionId)) {
is AppResult.Success -> {
_pendingAttachments.update { it + result.data }
}
is AppResult.Error -> {
_uiState.update { it.copy(snackbarMessage = result.message) }
}
}
}
}
/**
* Remove a pending attachment.
*/
fun removeAttachment(id: String) {
_pendingAttachments.update { list -> list.filter { it.id != id } }
// Note: File remains on disk -- cleaned up on session delete or if never sent
}
fun openImageViewer(imagePath: String) {
_viewingImage.value = imagePath
}
fun closeImageViewer() {
_viewingImage.value = null
}
/**
* MODIFIED: sendMessage now includes pending attachments.
*/
fun sendMessage() {
val text = _uiState.value.inputText
val attachments = _pendingAttachments.value
if (text.isBlank() && attachments.isEmpty()) return
// Check for unsupported attachment types
val providerType = currentProviderType
if (providerType != null && attachments.isNotEmpty()) {
val unsupported = ProviderCapability.getUnsupportedTypes(providerType, attachments)
if (unsupported.isNotEmpty()) {
val typeNames = unsupported.joinToString(", ") { it.name.lowercase() }
_uiState.update {
it.copy(snackbarMessage =
"${typeNames.replaceFirstChar { c -> c.uppercase() }} attachments " +
"are not supported by this provider and will be skipped")
}
}
// If all attachments unsupported and no text, block send
val supported = attachments.filter {
ProviderCapability.supportsAttachmentType(providerType, it.type)
}
if (supported.isEmpty() && text.isBlank()) {
_uiState.update {
it.copy(snackbarMessage = "No supported content to send")
}
return
}
}
// Clear pending state
_pendingAttachments.value = emptyList()
_uiState.update { it.copy(inputText = "") }
viewModelScope.launch {
// Create user message and save attachments to DB
// Then invoke SendMessageUseCase with attachments
// ...
}
}
}
ChatUiState Changes
/**
* Located in: feature/chat/ChatUiState.kt
*
* MODIFIED: Add attachments to ChatMessageItem.
*/
data class ChatMessageItem(
val id: String,
val type: MessageType,
val content: String,
val thinkingContent: String? = null,
val toolCallId: String? = null,
val toolName: String? = null,
val toolInput: String? = null,
val toolOutput: String? = null,
val toolStatus: ToolCallStatus? = null,
val toolDurationMs: Long? = null,
val modelId: String? = null,
val tokenCountInput: Int? = null,
val tokenCountOutput: Int? = null,
val isRetryable: Boolean = false,
val timestamp: Long = 0,
val attachments: List<Attachment> = emptyList() // NEW
)
SendMessageUseCase Changes
/**
* In SendMessageUseCase:
*
* MODIFIED: Accept attachments and include in API message.
*
* When building the ApiMessage.User, attach base64-encoded files
* that are supported by the current provider.
*/
// In the message building step:
val apiAttachments = pendingAttachments
.filter { ProviderCapability.supportsAttachmentType(providerType, it.type) }
.map { attachment ->
ApiAttachment(
type = attachment.type,
mimeType = attachment.mimeType,
base64Data = attachmentFileManager.readAsBase64(attachment.filePath),
fileName = attachment.fileName
)
}
val userMessage = ApiMessage.User(
content = messageText,
attachments = apiAttachments
)
Loading Attachments with Messages
When loading messages for display, attachments must be loaded alongside:
/**
* In ChatViewModel, when collecting messages from the repository:
*
* For each message, also load its attachments.
* Use getAttachmentsForMessages() to batch-load efficiently.
*/
private fun observeMessages(sessionId: String) {
viewModelScope.launch {
messageRepository.getMessagesForSession(sessionId).collect { messages ->
val messageIds = messages.map { it.id }
val allAttachments = attachmentRepository
.getAttachmentsForMessages(messageIds)
val attachmentsByMessage = allAttachments.groupBy { it.messageId }
val items = messages.map { message ->
message.toChatMessageItem(
attachments = attachmentsByMessage[message.id] ?: emptyList()
)
}
_uiState.update { it.copy(messages = items) }
}
}
}
Implementation Plan
Phase 1: Data Foundation
- Create
Attachmentdomain model andAttachmentTypeenum - Create
AttachmentEntitywith Room annotations - Create
AttachmentDao - Create
AttachmentMapper - Add database migration for
attachmentstable - Create
AttachmentRepositoryinterface andAttachmentRepositoryImpl - Create
AttachmentFileManagerwith copy, thumbnail, and cleanup logic - Create
PendingAttachmentdata class - Update DI modules (
DatabaseModule,RepositoryModule) - Write unit tests for
AttachmentFileManagerandAttachmentRepositoryImpl
Phase 2: API Adapter Extensions
- Add
ApiAttachmentdata class - Modify
ApiMessage.Userto includeattachmentslist - Update
OpenAiAdaptermessage formatting for multimodal - Update
AnthropicAdaptermessage formatting for multimodal - Update
GeminiAdaptermessage formatting for multimodal - Create
ProviderCapabilityutility - Write unit tests for adapter multimodal formatting
Phase 3: ViewModel and Use Case
- Add
pendingAttachmentsstate toChatViewModel - Implement
addAttachment(),removeAttachment(),addCameraPhoto() - Modify
sendMessage()to include attachments and provider capability check - Modify
SendMessageUseCaseto accept and encode attachments - Implement message loading with attachments (batch query)
- Add
snackbarMessagefor unsupported attachment warnings - Update DI (
FeatureModule) for new dependencies
Phase 4: UI Components
- Create
AttachmentPickerSheetbottom sheet - Create
AttachmentPreviewRowfor chat input - Modify
ChatInputto add attachment button and preview row - Create
AttachmentDisplayfor message bubbles - Modify message bubble rendering to include
AttachmentDisplay - Create
ImageViewerDialogwith zoom/pan - Wire video/file tap to system Intent
- Wire
ChatScreento new ViewModel states and callbacks - Add camera photo capture with
FileProviderandTakePicturecontract
Phase 5: Testing and Verification
- Layer 1A:
./gradlew test– all unit tests pass - Layer 1B: Instrumented tests for database operations
- Manual testing: attach image, video, file via each picker
- Manual testing: camera photo capture
- Manual testing: message bubble display for each type
- Manual testing: image viewer zoom/pan
- Manual testing: video playback via system player
- Manual testing: unsupported type warning flow
- Manual testing: session deletion cleans up files
- Write test report
Error Handling
| Error | Cause | Handling |
|---|---|---|
| File too large | Selected file > 20MB | AppResult.Error -> Snackbar “File is too large (max 20MB)” |
| Copy failure | I/O error, no storage space | AppResult.Error -> Snackbar “Failed to save attachment” |
| Camera unavailable | No camera hardware or permission denied | Launch permission request or show error |
| Thumbnail failure | Corrupt image/video | Non-fatal; use placeholder icon |
| Base64 encoding failure | File deleted between select and send | Skip attachment, warn user |
| Provider rejects file | File format or size rejected by API | Error shown in AI response bubble |
| All attachments unsupported | Provider doesn’t support any selected types, no text | Block send, show error message |
| Partial unsupported | Some attachments unsupported | Snackbar warning, send supported ones + text |
Performance Considerations
| Operation | Expected Time | Notes |
|---|---|---|
| File copy (10MB) | ~500ms | I/O bound, runs on Dispatchers.IO |
| Thumbnail generation (image) | ~200ms | Bitmap decode + scale + compress |
| Thumbnail generation (video) | ~300ms | MediaMetadataRetriever + scale |
| Base64 encoding (10MB) | ~200ms | In-memory conversion |
| Attachment preview rendering | < 16ms | Coil async image loading |
| Message bubble with images | < 16ms | Pre-loaded thumbnails via Coil |
Memory
| Resource | Peak Usage | Notes |
|---|---|---|
| Base64 in memory (20MB file) | ~27MB | Temporary during API call, GC’d after |
| Thumbnail bitmap | ~256KB | 256x256 JPEG |
| Full-res image in viewer | ~10-30MB | Single image, Coil manages cache |
| Pending attachments list | Negligible | Metadata only, files on disk |
Security Considerations
- File isolation: All attachment files are stored in app-internal storage (
context.filesDir), not accessible to other apps without explicit sharing - URI permissions: Content URIs from pickers use temporary read permissions that expire after use
- No external storage: Never writes to shared/external storage
- File validation: MIME type checked at selection time; file size limited to 20MB
- Base64 encoding: Performed in-memory, never written to disk as base64
- Camera FileProvider: Camera photos are captured via a
FileProvider-backed URI, not written to public directories
Dependencies
External Dependencies
- Coil (image loading): For async image loading in previews and message bubbles. If not already in the project, add
io.coil-kt:coil-compose. - Android Photo Picker: System component (
ActivityResultContracts.PickVisualMedia) - Android Camera: System component (
ActivityResultContracts.TakePicture) - Android SAF: System component (
ActivityResultContracts.OpenDocument) - MediaMetadataRetriever: Android framework class for video metadata
Internal Dependencies
Toolsystem is NOT involved (this is user-initiated, not AI-tool-initiated)Messagemodel andMessageRepositoryApiMessageand all three adapter implementationsAppDatabase(Room migration)ChatViewModelandChatScreen
Alternatives Considered
1. JSON Column Instead of Separate Table
Approach: Store attachments as a JSON string column in the messages table.
Rejected because: Harder to query (e.g., “find all images in session X”), no FK constraints, no index support. A separate table is cleaner and aligns with the existing Room patterns in the project.
2. In-Memory Only (No Database)
Approach: Keep attachment metadata only in memory, re-scan the file system on load. Rejected because: Loses metadata (original filename, dimensions, duration). File system scanning is slower than a database query. Message-attachment association would be lost on app restart.
3. Coil for Thumbnails Instead of Pre-generating
Approach: Let Coil generate and cache thumbnails on-the-fly.
Rejected because: Video thumbnails require MediaMetadataRetriever which Coil doesn’t handle out of the box. Pre-generated thumbnails are faster for initial display and work offline.
Future Extensions
- Image compression/resizing options before sending
- Clipboard paste support for images
- Camera video recording
- Drag-and-drop from other apps
- Inline PDF preview
- AI-generated image display
- Attachment search across sessions
- Cloud storage integration (Google Drive, Dropbox)
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |