androidengineers.Book a session

Designing a Messaging App

Local Persistence & Attachments

article20 minHard

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

ConceptRule
Private vs public storageUse filesDir for app-private files; MediaStore for user-accessible photos
WorkManager for uploadsSurvives app restart; exponential backoff on failure
Idempotent uploadsTrack upload state in DB; don't re-upload on retry if already succeeded
Download progressFlow-based progress; store local path only on full completion
Cleanup on deleteDelete files when messages are deleted; schedule periodic orphan cleanup
Never store large files in RoomStore file path in DB; file content on disk

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Local Persistence & Attachments | Android System Design | Android Engineers