A modern, Jetpack Compose-ready image picker for Android β because hand-rolling
ActivityResultContracts and permission flows for the fifth project in a row
should not be anyone's personality trait.
- Works seamlessly with Jetpack Compose, XML + Kotlin, or both.
- Supports Camera and Gallery.
- Gallery picking needs no runtime permission at all (Android's Photo Picker) β camera capture is the only permission ever requested
- Supports multiple image selection and compression
- Optional crop step (free, square, or custom aspect ratio) shown automatically β no extra UI code
- Provides structured result callbacks for success and error handling
- Just works β no hidden setup, no
ActivityResultContracts, and no more permission nightmares!
Two steps. We tried to make it one, but Android's FileProvider insisted.
1. Add the JitPack repository to your settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}2. Add the dependency, using a released tag from the Releases page (or see the badge above for the latest):
dependencies {
implementation("com.github.nerojust:JetImagePicker:v2.0.0")
}Or, if you're working inside this repo as a module:
implementation(project(":JetImagePicker"))A little manifest housekeeping, then you're done. Promise.
Gallery picking uses Android's Photo Picker and needs no storage permission. Only camera capture needs a runtime permission:
<uses-permission android:name="android.permission.CAMERA" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application><?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="images" path="." />
</paths>Copy, paste, run. No fourteen-step tutorial required.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.nerojust.jetimagepicker.config.JetImagePickerConfig
import com.nerojust.jetimagepicker.result.ImagePickerResult
import com.nerojust.jetimagepicker.state.rememberJetImagePickerState
import com.nerojust.jetimagepicker.ui.ImagePreview
import com.nerojust.jetimagepicker.ui.MultiImagePreview
@Composable
fun ImagePickerScreen() {
val context = LocalContext.current
var message by remember { mutableStateOf<String?>(null) }
val pickerState = rememberJetImagePickerState(
context = context,
config = JetImagePickerConfig(
enableCompression = true,
compressionQuality = 70,
allowMultiple = true,
targetWidth = 1024,
targetHeight = 1024
)
) { result ->
when (result) {
is ImagePickerResult.Success -> message = null
is ImagePickerResult.PermissionDenied -> {
message = "Permission denied: ${result.permission}"
//go ahead, all good
}
is ImagePickerResult.PermissionPermanentlyDenied -> {
message = "Permanently denied: ${result.permission}"
//do something
}
is ImagePickerResult.ShowRationale -> {
message = "Please allow ${result.permission} to proceed."
//do some business logic here
}
}
}
Column(Modifier.padding(16.dp)) {
Button(onClick = pickerState.pickFromGallery, enabled = !pickerState.isLoading) {
Text("Pick from Gallery")
}
Spacer(Modifier.height(8.dp))
Button(onClick = pickerState.captureWithCamera, enabled = !pickerState.isLoading) {
Text("Capture with Camera")
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = pickerState.clearSelection,
enabled = pickerState.selectedImageUris.isNotEmpty()
) {
Text("Clear Selection")
}
Spacer(Modifier.height(16.dp))
if (pickerState.isLoading) {
CircularProgressIndicator()
}
when (pickerState.selectedImageUris.size) {
1 -> ImagePreview(uri = pickerState.selectedImageUris.first())
in 2..Int.MAX_VALUE -> MultiImagePreview(imageUris = pickerState.selectedImageUris)
}
message?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
}π‘ The
appmodule in this repo is a fuller interactive demo β toggle single vs. multiple selection, compression, and crop-to-square on/off live, and see each image's file size update accordingly.
Set enableCrop = true and a crop dialog appears automatically β right between picking/capturing
and compression, no extra composable needed. It applies whenever exactly one image is in play:
always for camera capture, and for gallery picks only when a single image was selected.
JetImagePickerConfig(
enableCrop = true,
cropAspectRatio = CropAspectRatio.Square, // or Free, or Custom(ratioX = 16f, ratioY = 9f)
)CropAspectRatio |
Description |
|---|---|
Free |
No constraint β the user can freely resize the crop region. (default) |
Square |
1:1 β the classic avatar/profile-picture crop. |
Custom(ratioX, ratioY) |
Any arbitrary ratio, e.g. Custom(16f, 9f) for 16:9. |
Cancelling the crop dialog is treated like any other cancellation in this library β an empty result, nothing picked.
JetImagePickerConfig(
enableCompression = true,
compressionQuality = 70, // 0β100
allowMultiple = true,
targetWidth = 1024,
targetHeight = 1024,
enableCrop = false,
cropAspectRatio = CropAspectRatio.Free
)Use the ImagePickerResult sealed class:
sealed class ImagePickerResult {
data class Success(val uris: List<Uri>) : ImagePickerResult()
data class PermissionDenied(val permission: String) : ImagePickerResult()
data class PermissionPermanentlyDenied(val permission: String) : ImagePickerResult()
data class ShowRationale(val permission: String) : ImagePickerResult()
}| Member | Description |
|---|---|
selectedImageUris |
All currently selected/captured image URIs. |
selectedImageUri |
The first URI in selectedImageUris, or null if nothing is selected. |
isLoading |
true while picked images are being compressed. |
pickFromGallery |
Launches the gallery picker (Photo Picker on supported devices). |
captureWithCamera |
Launches the system camera to capture a new photo. |
clearSelection |
Resets selectedImageUris/selectedImageUri back to empty. |
Because Android's permission model has main-character energy and never keeps it simple. Here's what's actually happening, step by step:
- First-ever ask, user taps "Deny" β you get
ShowRationale. Android's take: "They said no, but you can ask again β maybe explain yourself first this time." Show a small dialog explaining why you need the permission, then callpickFromGallery/captureWithCameraagain. - Asked before, denied again, OS still willing to listen β
PermissionDenied. Same energy as above β not fatal, just mildly rude. Let the user retry. - User checked "Don't ask again," or the OS has simply had enough of this conversation β
PermissionPermanentlyDenied. This is Android saying "we are not doing this a third time." Your only move now is Settings:
is ImagePickerResult.PermissionPermanentlyDenied -> {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}This whole dance is camera-only, by the way β gallery picking goes through Android's Photo Picker, which asks for no permission at all. One less state to lose sleep over.
targetWidth and targetHeight are a matched set β set one without the other and the library quietly does nothing (no crash, no log, no resizing, just vibes). Bring both or bring neither:
JetImagePickerConfig(
targetWidth = 1024,
targetHeight = 1024 // <- skip this and targetWidth silently does nothing
)Found a bug? Have an idea? Just want to say the Common Gotchas section made
you laugh? Open an issue or a PR β all of it is welcome, and none of it needs
to be perfect before you send it.
If JetImagePicker saved you from writing your own permission-handling code at 2am, consider fueling the next release:
No pressure though β a β on the repo is free and helps just as much.
Made with π (and a healthy amount of adb logcat squinting) by Nerojust
Also findable here:
- Medium
- Dev.to β building payment flows in Android, lessons from real fintech apps
- GitHub β follows appreciated
MIT License. See LICENSE for details.
