Instagram-style media upload must be resilient: compress before upload, retry on failure, show progress, and survive process death. WorkManager is the right foundation.
Upload Pipeline Architecture
User picks photo/video
↓
Compress (background thread)
↓
WorkManager UploadWorker
↓
Multipart upload with progress
↓
Server transcodes (async)
↓
Polling or webhook → mark as processed
↓
Show in feed
Step 1: Local Compression Before Upload
class MediaCompressor(private val context: Context) {
suspend fun compressImage(sourceUri: Uri, targetMaxSizeKb: Int = 500): File =
withContext(Dispatchers.IO) {
val original = MediaStore.Images.Media.getBitmap(context.contentResolver, sourceUri)
val outputFile = File(context.cacheDir, "compressed_${System.currentTimeMillis()}.jpg")
var quality = 90
do {
outputFile.outputStream().use { out ->
original.compress(Bitmap.CompressFormat.JPEG, quality, out)
}
quality -= 10
} while (outputFile.length() > targetMaxSizeKb * 1024 && quality > 30)
outputFile
}
suspend fun compressVideo(sourceUri: Uri): File = withContext(Dispatchers.IO) {
// Use android.media.MediaTranscoder (API 29+) or a library
val outputFile = File(context.cacheDir, "compressed_${System.currentTimeMillis()}.mp4")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val transcoder = MediaTranscoder.getInstance()
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1280, 720).apply {
setInteger(MediaFormat.KEY_BIT_RATE, 3_000_000) // 3Mbps
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
}
// transcoder.transcodeVideo(...)
}
outputFile
}
}
Step 2: WorkManager Upload Worker
class MediaUploadWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val api: MediaApi,
private val postDao: PostDao
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val localFilePath = inputData.getString("local_file_path") ?: return Result.failure()
val postId = inputData.getString("post_id") ?: return Result.failure()
setForeground(createForegroundInfo())
return try {
val file = File(localFilePath)
if (!file.exists()) return Result.failure(
workDataOf("error" to "File not found: $localFilePath")
)
// Upload with progress
var lastReportedProgress = 0
val mediaUrl = api.uploadMedia(file) { bytesSent, totalBytes ->
val progress = (bytesSent * 100 / totalBytes).toInt()
if (progress > lastReportedProgress + 5) {
setProgress(workDataOf("progress" to progress))
lastReportedProgress = progress
}
}
// Update local DB with server URL
postDao.updateMediaUrl(postId, mediaUrl)
postDao.updateStatus(postId, PostStatus.UPLOADED)
// Clean up local cache file
file.delete()
Result.success(workDataOf("media_url" to mediaUrl))
} catch (e: IOException) {
postDao.updateStatus(postId, PostStatus.UPLOAD_FAILED)
if (runAttemptCount < 3) Result.retry() else Result.failure(workDataOf("error" to e.message))
}
}
private fun createForegroundInfo(): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, "uploads")
.setContentTitle("Uploading post…")
.setSmallIcon(R.drawable.ic_upload)
.setOngoing(true)
.setProgress(100, 0, true)
.build()
return ForegroundInfo(1001, notification)
}
}
Step 3: Enqueue Upload from ViewModel
fun enqueueUpload(localFilePath: String, postId: String) {
val work = OneTimeWorkRequestBuilder<MediaUploadWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.setInputData(workDataOf(
"local_file_path" to localFilePath,
"post_id" to postId
))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("upload_$postId")
.build()
workManager.enqueueUniqueWork("upload_$postId", ExistingWorkPolicy.KEEP, work)
}
Step 4: Observe Upload Progress
// In ViewModel
val uploadProgress: Flow<Int> = workManager
.getWorkInfosByTagFlow("upload_$postId")
.map { infos ->
val info = infos.firstOrNull() ?: return@map 0
when (info.state) {
WorkInfo.State.RUNNING -> info.progress.getInt("progress", 0)
WorkInfo.State.SUCCEEDED -> 100
WorkInfo.State.FAILED -> -1
else -> 0
}
}
// In Compose
val progress by viewModel.uploadProgress.collectAsStateWithLifecycle()
LinearProgressIndicator(progress = progress / 100f)
Key Takeaways
| Pattern | Rule |
|---|---|
| Compress first | Reduce file size before upload; target < 500KB for images |
| WorkManager | Survives process death; built-in retry; constraint-aware |
setForeground | Required for uploads > a few seconds (prevents system kill) |
ExistingWorkPolicy.KEEP | Don't restart an in-progress upload |
Progress via setProgress | Observe with getWorkInfosByTagFlow; update UI without polling |
| Exponential backoff | BackoffPolicy.EXPONENTIAL with 30s initial delay |