Messaging apps with attachments (photos, videos, voice notes, documents) need a thoughtful persistence strategy — storing large files correctly, tracking download/upload state, and cleaning up when messages are deleted.
Attachment Data Model
@Entity(tableName = "attachments")
data class AttachmentEntity(
@PrimaryKey val id: String,
val messageId: String,
val type: AttachmentType, // IMAGE, VIDEO, AUDIO, DOCUMENT
val remoteUrl: String, // CDN URL
val localPath: String?, // null = not downloaded yet
val fileSize: Long,
val mimeType: String,
val width: Int? = null, // for images/videos
val height: Int? = null,
val duration: Long? = null, // for audio/video, in ms
val uploadState: UploadState = UploadState.UPLOADED,
val downloadState: DownloadState = DownloadState.NOT_DOWNLOADED,
val thumbnailPath: String? = null
)
enum class DownloadState { NOT_DOWNLOADED, DOWNLOADING, DOWNLOADED, FAILED }
enum class UploadState { UPLOADING, UPLOADED, FAILED }
File Storage Location
class AttachmentStorage(private val context: Context) {
// For user-accessible media (photos, videos): MediaStore or Downloads
// For app-private attachments (voice notes, documents): filesDir
fun getAttachmentDir(type: AttachmentType): File {
return when (type) {
AttachmentType.IMAGE -> File(context.filesDir, "images")
AttachmentType.VIDEO -> File(context.filesDir, "videos")
AttachmentType.AUDIO -> File(context.filesDir, "audio")
AttachmentType.DOCUMENT -> File(context.filesDir, "documents")
}.also { it.mkdirs() }
}
fun getAttachmentFile(attachmentId: String, type: AttachmentType, extension: String): File {
return File(getAttachmentDir(type), "$attachmentId.$extension")
}
// Save to MediaStore so user can access from gallery
suspend fun saveImageToGallery(file: File, displayName: String): Uri? {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp")
}
val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: return null
context.contentResolver.openOutputStream(uri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
}
return uri
}
}
Upload with WorkManager
class AttachmentUploadWorker(
context: Context,
params: WorkerParameters,
private val api: AttachmentApi,
private val dao: AttachmentDao
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val attachmentId = inputData.getString("attachment_id") ?: return Result.failure()
val attachment = dao.getAttachment(attachmentId) ?: return Result.failure()
val file = File(attachment.localPath ?: return Result.failure())
if (!file.exists()) return Result.failure()
dao.updateDownloadState(attachmentId, uploadState = UploadState.UPLOADING)
return try {
val response = api.uploadAttachment(
file = file.asRequestBody(attachment.mimeType.toMediaType()),
messageId = attachment.messageId
)
dao.updateRemoteUrl(attachmentId, response.remoteUrl)
dao.updateDownloadState(attachmentId, uploadState = UploadState.UPLOADED)
setProgress(workDataOf("progress" to 100))
Result.success()
} catch (e: Exception) {
dao.updateDownloadState(attachmentId, uploadState = UploadState.FAILED)
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
fun enqueueAttachmentUpload(attachmentId: String, workManager: WorkManager) {
val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>()
.setInputData(workDataOf("attachment_id" to attachmentId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("attachment_upload")
.build()
workManager.enqueueUniqueWork(
"upload_$attachmentId",
ExistingWorkPolicy.KEEP,
request
)
}
Download with Progress
class AttachmentDownloader(
private val httpClient: OkHttpClient,
private val storage: AttachmentStorage,
private val dao: AttachmentDao
) {
suspend fun download(attachment: AttachmentEntity): Flow<DownloadProgress> = flow {
dao.updateDownloadState(attachment.id, downloadState = DownloadState.DOWNLOADING)
val request = Request.Builder().url(attachment.remoteUrl).build()
val response = httpClient.newCall(request).await()
if (!response.isSuccessful) {
dao.updateDownloadState(attachment.id, downloadState = DownloadState.FAILED)
emit(DownloadProgress.Failed)
return@flow
}
val body = response.body ?: run {
dao.updateDownloadState(attachment.id, downloadState = DownloadState.FAILED)
emit(DownloadProgress.Failed)
return@flow
}
val file = storage.getAttachmentFile(attachment.id, attachment.type, "bin")
val totalBytes = body.contentLength()
var downloadedBytes = 0L
file.outputStream().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
if (totalBytes > 0) {
emit(DownloadProgress.Progress((downloadedBytes * 100 / totalBytes).toInt()))
}
}
}
}
dao.updateLocalPath(attachment.id, file.absolutePath, downloadState = DownloadState.DOWNLOADED)
emit(DownloadProgress.Complete(file))
}
}
sealed class DownloadProgress {
data class Progress(val percent: Int) : DownloadProgress()
data class Complete(val file: File) : DownloadProgress()
object Failed : DownloadProgress()
}
Cleanup: Delete Files with Messages
class AttachmentCleanupManager(
private val storage: AttachmentStorage,
private val dao: AttachmentDao
) {
// Called when a message is deleted
suspend fun onMessageDeleted(messageId: String) {
val attachments = dao.getAttachmentsForMessage(messageId)
attachments.forEach { attachment ->
attachment.localPath?.let { path ->
File(path).delete()
}
attachment.thumbnailPath?.let { path ->
File(path).delete()
}
dao.deleteAttachment(attachment.id)
}
}
// Periodic cleanup of orphaned files
suspend fun cleanupOrphanedFiles() {
val storedPaths = dao.getAllLocalPaths().toSet()
storage.getAttachmentDir(AttachmentType.IMAGE).listFiles()?.forEach { file ->
if (file.absolutePath !in storedPaths) {
file.delete()
}
}
}
}
Key Takeaways
| Concept | Rule |
|---|---|
| Private vs public storage | Use filesDir for app-private files; MediaStore for user-accessible photos |
| WorkManager for uploads | Survives app restart; exponential backoff on failure |
| Idempotent uploads | Track upload state in DB; don't re-upload on retry if already succeeded |
| Download progress | Flow-based progress; store local path only on full completion |
| Cleanup on delete | Delete files when messages are deleted; schedule periodic orphan cleanup |
| Never store large files in Room | Store file path in DB; file content on disk |