Back to blog
kotlinandroidcompose

Building a Gallery App With Jetpack Compose and Material3

How I built a local media gallery app for Android using Jetpack Compose, Material3, and the MediaStore API — with lazy grids, photo details, and a clean architecture.

TS
Tharun Sai Putta
June 1, 2024
6 min read

There's a particular kind of Android project that's useful precisely because it touches several unrelated platform APIs at once. A local gallery app is that kind of project — it requires MediaStore queries, runtime permissions, image loading, navigation, and a UI that needs to feel fluid at 60 fps. Building one from scratch with Jetpack Compose taught me more about the current state of Android development than any single tutorial could.

Why Build This?

I'd been using Compose for a few months on smaller screens, but I wanted to push it with something that had real performance requirements. A photo grid with hundreds of items, smooth scrolling, and fast full-screen transitions is a genuine test. I also wanted to practice clean architecture — the pattern of Repository → ViewModel → UI is easy to understand conceptually and surprisingly easy to get wrong in practice.

Architecture

The app follows a unidirectional data flow:

MediaStore (system)
      ↓
MediaRepository (data layer)
      ↓
GalleryViewModel (state holder)
      ↓
GalleryScreen / PhotoDetailScreen (UI)

The MediaRepository is the only class that knows about ContentResolver and MediaStore. The ViewModel knows nothing about Android platform APIs — it holds StateFlows that the UI collects. This separation made it easy to test the ViewModel with a fake repository.

The key data class:

data class MediaItem(
    val id: Long,
    val uri: Uri,
    val displayName: String,
    val dateTaken: Long,
    val width: Int,
    val height: Int,
    val mimeType: String
)

Keeping Uri in the data class rather than a file path is important — on Android 10+, file paths to external storage are not directly accessible, but content URIs always work.

Querying the MediaStore

The MediaStore.Images.Media content provider returns exactly what you ask for via a projection. Asking for everything is slow; asking for only the columns you need is fast:

class MediaRepository(private val context: Context) {
 
    suspend fun getImages(): List<MediaItem> = withContext(Dispatchers.IO) {
        val images = mutableListOf<MediaItem>()
        val projection = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.DATE_TAKEN,
            MediaStore.Images.Media.WIDTH,
            MediaStore.Images.Media.HEIGHT,
            MediaStore.Images.Media.MIME_TYPE
        )
        val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
 
        context.contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            projection,
            null, null,
            sortOrder
        )?.use { cursor ->
            val idCol          = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val nameCol        = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
            val dateTakenCol   = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
            val widthCol       = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH)
            val heightCol      = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT)
            val mimeTypeCol    = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
 
            while (cursor.moveToNext()) {
                val id = cursor.getLong(idCol)
                val uri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
                )
                images += MediaItem(
                    id = id,
                    uri = uri,
                    displayName = cursor.getString(nameCol),
                    dateTaken = cursor.getLong(dateTakenCol),
                    width = cursor.getInt(widthCol),
                    height = cursor.getInt(heightCol),
                    mimeType = cursor.getString(mimeTypeCol)
                )
            }
        }
        images
    }
}

The .use {} block on the cursor ensures it's always closed, even if an exception is thrown. I've seen more than one production app with cursor leaks causing intermittent CursorWindowAllocationException crashes — the .use extension is the right habit.

Building the Grid

LazyVerticalGrid with a GridCells.Adaptive specification gives you a responsive grid that adjusts column count based on available width:

@Composable
fun GalleryScreen(
    viewModel: GalleryViewModel = hiltViewModel(),
    onPhotoClick: (MediaItem) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 
    when (val state = uiState) {
        is GalleryUiState.Loading -> LoadingIndicator()
        is GalleryUiState.Error   -> ErrorScreen(message = state.message)
        is GalleryUiState.Success -> {
            LazyVerticalGrid(
                columns = GridCells.Adaptive(minSize = 120.dp),
                contentPadding = PaddingValues(2.dp),
                horizontalArrangement = Arrangement.spacedBy(2.dp),
                verticalArrangement   = Arrangement.spacedBy(2.dp)
            ) {
                items(
                    items = state.images,
                    key = { it.id }
                ) { item ->
                    GridThumbnail(
                        item = item,
                        onClick = { onPhotoClick(item) }
                    )
                }
            }
        }
    }
}
 
@Composable
fun GridThumbnail(item: MediaItem, onClick: () -> Unit) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(item.uri)
            .size(256) // load at thumbnail resolution
            .crossfade(true)
            .build(),
        contentDescription = item.displayName,
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .aspectRatio(1f)
            .clickable(onClick = onClick)
    )
}

The key = { it.id } parameter on items() is critical for correct animations and scroll position preservation when the list updates. Without it, Compose can't track which item is which when the data changes.

The .size(256) on the ImageRequest tells Coil to decode the image at thumbnail resolution rather than full resolution. Without this, scrolling through a large gallery would consume enormous amounts of memory loading multi-megapixel images into a 120dp grid cell.

I used the Navigation Compose library with a simple two-destination graph. Rather than passing the full MediaItem as a navigation argument (serialisation overhead), I pass only the image ID and re-fetch from the ViewModel:

NavHost(navController = navController, startDestination = "gallery") {
    composable("gallery") {
        GalleryScreen(onPhotoClick = { item ->
            navController.navigate("photo/${item.id}")
        })
    }
    composable(
        route = "photo/{imageId}",
        arguments = listOf(navArgument("imageId") { type = NavType.LongType })
    ) { backStackEntry ->
        val imageId = backStackEntry.arguments?.getLong("imageId") ?: return@composable
        PhotoDetailScreen(imageId = imageId)
    }
}

The detail screen shows the full-resolution image in a zoomable viewer (using the accompanist zoomable modifier at the time, though the Compose foundation library now has pointer input APIs for this), plus metadata and share/delete actions.

Handling Permissions

Runtime permission handling across API levels is the most brittle part of this app. On Android 13 (API 33) and above, READ_EXTERNAL_STORAGE was replaced by granular media permissions (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO). On older versions you still need the old permission.

val readPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    Manifest.permission.READ_MEDIA_IMAGES
} else {
    Manifest.permission.READ_EXTERNAL_STORAGE
}
 
val permissionState = rememberPermissionState(permission = readPermission)
 
LaunchedEffect(permissionState.status) {
    when (permissionState.status) {
        is PermissionStatus.Granted -> viewModel.loadImages()
        is PermissionStatus.Denied  -> {
            if (permissionState.status.shouldShowRationale) {
                // Show rationale UI
            } else {
                permissionState.launchPermissionRequest()
            }
        }
    }
}

The shouldShowRationale flag is Android's way of telling you whether the user has previously denied the permission. If they have, you should show an explanation before requesting again — just silently re-requesting gets you ignored.

Lessons

Compose state hoisting is worth the ceremony. Moving state up to the ViewModel and having the composables react to it makes the UI trivially easy to test and reason about. Early on I had some state living in remember {} inside composables, and it caused subtle bugs when navigating back.

MediaStore queries on the main thread will freeze the UI. This should be obvious but it's easy to miss early on. Everything in the repository runs on Dispatchers.IO.

Image loading at the right resolution matters more than caching strategy. Coil's caching is excellent, but loading 12MP images into 120dp thumbnails will exhaust your memory pool regardless of cache. Specify a size in every ImageRequest for thumbnail use cases.

The biggest productivity gain from Compose wasn't the declarative syntax — it was the previews. Being able to see a @Preview of a GridThumbnail with mock data without running the app cut my iteration time dramatically.

What I'd Add Next

The obvious missing feature is video support — the same MediaStore approach works for MediaStore.Video.Media. I'd also add folder grouping (querying by BUCKET_DISPLAY_NAME), pinch-to-zoom on the detail screen with proper bounds, and a search bar that filters by filename or date. The architecture is already set up to handle these; they're just a matter of time.

TS

Tharun Sai Putta

Product Engineer @ Protectt.ai

Building Android security SDKs, IDE plugins, and cross-platform tooling. IIITDM Kancheepuram CSE alumnus.