Skip to content

Instantly share code, notes, and snippets.

@decodeandroid
Created February 17, 2025 03:02
Show Gist options
  • Save decodeandroid/62c1336fcf25d28f54d74f3a1d911c52 to your computer and use it in GitHub Desktop.
Save decodeandroid/62c1336fcf25d28f54d74f3a1d911c52 to your computer and use it in GitHub Desktop.
Building a Gallery App with Jetpack Compose and Content Providers
import android.Manifest
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
data class ImageItem(
val id: Long,
val name: String,
val uri: Uri,
val bitmap: ImageBitmap
)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GalleryApp() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val images = remember { mutableStateListOf<ImageItem>() }
// Permission states
var hasPermissions by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(0) } // Tracks the current offset for paging
val lazyGridState = rememberLazyGridState() // LazyGridState to monitor scroll position
// State for selected image dialog
var selectedImage by remember { mutableStateOf<ImageItem?>(null) }
fun loadImagesFromGallery(
context: Context,
limit: Int,
loadNew: Boolean = false,
) {
if (isLoading) return // Prevent multiple simultaneous fetches
isLoading = true
scope.launch(Dispatchers.IO) {
val photos = mutableListOf<ImageItem>()
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.MIME_TYPE
)
val selection = "${MediaStore.Images.Media.MIME_TYPE} = ?"
val selectionArgument = arrayOf("image/jpeg")
val queryArgs = Bundle().apply {
// Limit & Offset
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
putInt(ContentResolver.QUERY_ARG_OFFSET, if (loadNew) 0 else offset)
// Selection
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgument)
// Sort function
putStringArray(
ContentResolver.QUERY_ARG_SORT_COLUMNS,
arrayOf(MediaStore.Files.FileColumns.DATE_MODIFIED)
)
// Sort Order Parameter
putInt(
ContentResolver.QUERY_ARG_SORT_DIRECTION,
ContentResolver.QUERY_SORT_DIRECTION_DESCENDING
)
}
context.contentResolver.query(
collection,
projection,
queryArgs,
null
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val iDate = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
val iSize = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val iPath = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val displayName = cursor.getString(displayNameColumn)
val date = cursor.getLong(iDate)
val imageSize = cursor.getLong(iSize)
val imagePath = cursor.getString(iPath)
val contentUri = Uri.withAppendedPath(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id.toString()
)
try {
val bitmap = context.contentResolver.loadThumbnail(
contentUri,
Size(200, 200), // Reduce size
null
).asImageBitmap()
photos.add(ImageItem(id, displayName, contentUri, bitmap))
} catch (e: Exception) {
e.printStackTrace()
}
}
photos.toList()
}
if (photos.isNotEmpty()) {
if (loadNew) {
images.clear()
offset = 0
}
images.addAll(photos)
offset += limit // Update the offset for the next batch
}
isLoading = false
}
}
// Camera launcher
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success) {
loadImagesFromGallery(context, 40, true)
}
}
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasPermissions = permissions.values.all { it }
if (hasPermissions) {
loadImagesFromGallery(context, 40)
}
}
LaunchedEffect(Unit) {
val permissions = arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.CAMERA
)
if (permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}) {
hasPermissions = true
loadImagesFromGallery(context, 40)
} else {
permissionLauncher.launch(permissions)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Gallery App") },
actions = {
IconButton(onClick = {
if (hasPermissions) {
val uri = createImageUri(context)
if (uri != null) {
cameraLauncher.launch(uri)
}
}
}) {
Icon(imageVector = Icons.Default.Add, contentDescription = "")
}
}
)
}
) { padding ->
if (hasPermissions) {
if (images.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("No images found")
}
} else {
// Observe Scroll Position to Detect Bottom
LaunchedEffect(lazyGridState) {
snapshotFlow { lazyGridState.layoutInfo }
.collect { layoutInfo ->
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isNotEmpty()) {
val lastVisibleItemIndex = visibleItems.last().index
val totalItemsCount = layoutInfo.totalItemsCount
// Check if we are at the last item
if (lastVisibleItemIndex == totalItemsCount - 1 && !isLoading) {
loadImagesFromGallery(context, 40) // Fetch next batch of images
}
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = padding,
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
) {
items(images) { image ->
ImageCard(image) {
selectedImage = image
}
}
// Show Loading Indicator at the Bottom
if (isLoading) {
item(span = { GridItemSpan(3) }) { // Span across all columns
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Permission required to access gallery")
}
}
// Show Dialog when an image is selected
selectedImage?.let { image ->
val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, image.uri)
ImageDialog(
imageBitmap = bitmap.asImageBitmap(),
imageTitle = image.name,
onDismiss = { selectedImage = null }
)
}
}
}
@Composable
fun ImageCard(image: ImageItem, onClick: () -> Unit) {
Card(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1f)
) {
Image(
bitmap = image.bitmap,
contentDescription = "Gallery image",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clickable { onClick() }
)
}
}
// Dialog to Show Full Image
@Composable
fun ImageDialog(imageBitmap: ImageBitmap?, imageTitle: String, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 4.dp,
modifier = Modifier.padding(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = imageTitle, style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(16.dp))
imageBitmap?.let {
Image(
bitmap = it,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Dismiss")
}
}
}
}
}
fun createImageUri(context: Context): Uri? {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "IMG_$timestamp.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
}
return context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
}
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment