ImagePickerKMP – Kotlin Multiplatform Camera & Gallery Picker
The most complete camera & gallery picker library for Kotlin Multiplatform. One unified API for Android, iOS, Desktop, Web and WASM.
rememberImagePickerKMP NEW
The recommended Compose API. One call returns ImagePickerKMPState — reactive result, launchCamera() & launchGallery(). Zero booleans. Explore →
Camera Capture
Native camera with flash, rotation, zoom, crop and compression on Android & iOS.
Gallery Picker
Single & multiple selection, MIME filtering, selection limit, EXIF extraction.
Crop & Edit
Free, square and circular crop with zoom, rotation and aspect ratio lock.
EXIF Metadata
GPS, altitude, camera model, ISO, aperture, focal length — Android & iOS.
Compression
LOW / MEDIUM / HIGH quality levels with async processing and bitmap recycling.
Format Support
JPEG, PNG, HEIC, HEIF, WebP, GIF, BMP across all platforms.
UI Customization
Custom button colors, icons, permission dialogs, confirmation screens and camera callbacks.
Requirements
| Requirement | Minimum Version | Notes |
|---|---|---|
| Kotlin | 2.3.20 | Breaking — ABI incompatible with < 2.3.x |
| Compose Multiplatform | 1.10.3 | Requires Kotlin 2.3.x |
| Android minSdk | 24 | |
| Android compileSdk | 36 | |
| iOS | 12.0+ | |
| JDK (Desktop) | 21+ | |
| imagepicker-core | 0.0.1 | Transitive — included automatically via any module |
ABI version incompatible. If you need Kotlin 2.1.x, use a previous release.Installation
Kotlin Multiplatform
// commonMain dependencies — add only what your app uses
// ✅ Published on Maven Central
implementation("io.github.ismoy:imagepickerkmp:1.1.0") // Photo
// 🔜 Coming soon — not yet published
// implementation("io.github.ismoy:imagepickerkmp-video:??")
// implementation("io.github.ismoy:imagepickerkmp-audio:??")
// implementation("io.github.ismoy:imagepickerkmp-audio-player:??")
// implementation("io.github.ismoy:imagepickerkmp-scanner:??")
// implementation("io.github.ismoy:imagepickerkmp-video-player:??")
React / JavaScript (NPM)
npm install imagepickerkmp
imagepickerkmp and imagepicker-core are currently published on Maven Central. The ecosystem modules (video, audio, scanner, player) are documented here as preview — they will be published as separate artifacts once released.Permissions Setup
<!-- Info.plist -->
<key>NSCameraUsageDescription</key>
<string>Camera access to capture photos</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access to select images</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save captured photos to your library</string>
<!-- Required when includeExif = true -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Location for photo geotagging</string>
<!-- AndroidManifest.xml -->
<!-- No permissions required — the library manages them automatically -->
<!-- Optional: declare CAMERA only if your app directly uses the camera -->
<uses-permission
android:name="android.permission.CAMERA"
android:required="false" />
CAMERA permission is optional — add it only if your app directly accesses the camera outside of this library.rememberImagePickerKMP NEW
The idiomatic and recommended Compose entry point. Call rememberImagePickerKMP() in your composable and you get an ImagePickerKMPState — a stable state holder that controls when the picker opens, in which mode, and exposes the result reactively. No showCamera / showGallery booleans, no Render() call needed — the picker self-manages when you invoke launchCamera() or launchGallery().
rememberImagePickerKMP() is the only public API. No legacy wrappers needed.Full real-world example
A complete example of how to use rememberImagePickerKMP with camera and multi-gallery, handling all states and displaying results:
@Composable
fun MyScreen(innerPadding: PaddingValues) {
// 1. Create the picker with a global config
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
enableCrop = false,
galleryConfig = GalleryConfig(
allowMultiple = true,
selectionLimit = 10,
includeExif = true,
redactGpsData = true,
mimeTypes = listOf(MimeType.IMAGE_JPEG)
)
)
)
// 2. Read the reactive result
val result = picker.result
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
// 3. React to each picker state
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center
) {
when (result) {
is ImagePickerResult.Loading -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Text("Selecting...", color = Color.Gray)
}
}
is ImagePickerResult.Success -> {
val photos = result.photos
if (photos.size == 1) {
// Single photo (camera or simple gallery)
val painter = photos.first().loadPainter()
if (painter != null) {
Image(painter = painter, contentDescription = "Captured photo")
}
} else {
// Multiple gallery photos
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(photos) { photo ->
val painter = photo.loadPainter()
if (painter != null) {
Image(painter = painter, contentDescription = null)
}
}
}
}
}
is ImagePickerResult.Error ->
Text("Error: ${result.exception.message}", color = Color.Red)
is ImagePickerResult.Dismissed,
is ImagePickerResult.Idle ->
Text("No image selected", color = Color.Gray)
}
}
// 4. Buttons that trigger the picker directly
Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Button(
onClick = { picker.launchCamera() },
modifier = Modifier.weight(1f)
) { Text("Camera") }
Button(
onClick = { picker.launchGallery() },
modifier = Modifier.weight(1f)
) { Text("Gallery") }
}
}
}
Per-launch overrides
You can override the global config for a specific launch without changing the base config:
// JPEG only, max 5 photos, with EXIF for this launch
picker.launchGallery(
allowMultiple = true,
selectionLimit = 5,
mimeTypes = listOf(MimeType.IMAGE_JPEG),
includeExif = true,
redactGpsData = false // GPS included just for this launch
)
// Camera with HIGH compression, only for this tap
// Crop is controlled via ImagePickerKMPConfig(cropConfig = CropConfig(enabled = true))
picker.launchCamera(
cameraCaptureConfig = CameraCaptureConfig(
compressionLevel = CompressionLevel.HIGH,
includeExif = true
),
onDismiss = { /* specific callback on close */ },
onError = { e -> println("Error: ${e.message}") }
)
ImagePickerKMPConfig — Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cameraCaptureConfig | CameraCaptureConfig | defaults | Camera behaviour: compression, EXIF, crop config |
galleryConfig | GalleryConfig | defaults | Multi-select, MIME types, selection limit, EXIF, redact GPS |
cropConfig | CropConfig | defaults | Crop UI — set CropConfig(enabled = true) to activate crop after every capture or selection |
permissionAndConfirmationConfig | PermissionAndConfirmationConfig | defaults | Custom @Composable permission dialogs & handlers |
GalleryConfig — Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
allowMultiple | Boolean | false | Allow multiple file selection |
mimeTypes | List<MimeType> | [IMAGE_ALL] | Allowed file types |
selectionLimit | Int | 30 | Maximum selectable files (requires allowMultiple = true) |
includeExif | Boolean | false | Extract EXIF metadata from images |
redactGpsData | Boolean | true | Remove GPS (lat/lon/alt) from EXIF before delivering it |
mimeTypeMismatchMessage | String? | null | Custom message when the file does not match mimeTypes |
ImagePickerKMPState — Methods & properties
| Method / Property | Description |
|---|---|
result: ImagePickerResult | Reactive state. Starts as Idle. Observe with when. Updates automatically. |
isCropActive: Boolean | true while the crop UI is shown. Automatically resets to false when a final result is delivered or reset() is called. |
launchCamera(cameraCaptureConfig?, onDismiss?, onError?) | Opens the camera. All parameters are optional — they override the global config for this launch only. Crop is controlled globally via ImagePickerKMPConfig(cropConfig = CropConfig(enabled = true)). |
launchGallery(allowMultiple?, mimeTypes?, selectionLimit?, includeExif?, redactGpsData?, mimeTypeMismatchMessage?, cameraCaptureConfig?, onDismiss?, onError?) | Opens the gallery. Any parameter overrides the global GalleryConfig for this launch only. |
reset() | Resets result to Idle, clears isCropActive, and closes any active picker. |
Render() or launchPicker() in this API. The picker self-manages internally when you call launchCamera() or launchGallery(). No additional composable is needed.ImagePickerResult — State hierarchy
| State | When it occurs | Available data |
|---|---|---|
Idle | Initial state, before any action, and after reset() | — |
Loading | The picker is open and waiting for user selection | — |
Success | The user selected / captured images successfully | photos: List<PhotoResult>, first: PhotoResult? |
Dismissed | The user closed the picker without selecting anything | — |
Error | An error occurred during capture or selection | exception: Exception |
when (val result = picker.result) {
is ImagePickerResult.Idle ->
Text("No image selected", color = Color.Gray)
is ImagePickerResult.Loading ->
CircularProgressIndicator()
is ImagePickerResult.Success -> {
// result.photos: List<PhotoResult>
// result.first: PhotoResult? (first photo, useful for camera)
result.photos.forEach { photo ->
val painter = photo.loadPainter() // Painter for Compose
val bytes = photo.loadBytes() // ByteArray for file operations
val bitmap = photo.loadImageBitmap() // ImageBitmap for graphics
val base64 = photo.loadBase64() // Base64 for APIs
}
}
is ImagePickerResult.Dismissed ->
Text("Cancelled by user")
is ImagePickerResult.Error ->
Text("Error: ${result.exception.message}", color = Color.Red)
}
Before vs After
var showCamera by remember {
mutableStateOf(false)
}
var photo by remember {
mutableStateOf<PhotoResult?>(null)
}
if (showCamera) {
ImagePickerLauncher(
config = ImagePickerConfig(
onPhotoCaptured = {
photo = it
showCamera = false
},
onError = { showCamera = false },
onDismiss = { showCamera = false }
)
)
}
Button(onClick = { showCamera = true }) {
Text("Camera")
}
val picker = rememberImagePickerKMP()
val imagePickerResponse = picker.result
Button(onClick = {
picker.launchCamera()
}) {
Text("Camera")
}
when(imagePickerResponse) {
is ImagePickerResult.Success ->
Image(imagePickerResponse.first!!.loadPainter()!!, null)
is ImagePickerResult.Loading ->
CircularProgressIndicator()
is ImagePickerResult.Error ->
Text("Error: ${imagePickerResponse.exception.message}", color = Color.Red)
is ImagePickerResult.Dismissed ->
Text("Cancelled") Text("Selection cancelled", color = Color.Gray)
is ImagePickerResult.Idle ->
Text("Press a button to get started", color = Color.Gray)
}
Camera Capture
Use rememberImagePickerKMP() to open the native camera. Control flash, skip confirmation, add crop and set compression.
Basic Camera
val picker = rememberImagePickerKMP()
Button(onClick = { picker.launchCamera() }) { Text("Camera") }
when (val result = picker.result) {
is ImagePickerResult.Success -> { val photo = result.photos.first() }
is ImagePickerResult.Error -> { /* handle error */ }
is ImagePickerResult.Dismissed -> { /* cancelled */ }
else -> {}
}
Camera with Compression & Crop
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cameraCaptureConfig = CameraCaptureConfig(
compressionLevel = CompressionLevel.MEDIUM,
includeExif = true,
redactGpsData = false,
cropConfig = CropConfig(
enabled = true,
circularCrop = true,
squareCrop = true,
freeformCrop = false
)
)
)
)
Button(onClick = { picker.launchCamera() }) { Text("Camera") }
when (val result = picker.result) {
is ImagePickerResult.Success -> { val photo = result.photos.first() }
is ImagePickerResult.Error -> { /* handle error */ }
is ImagePickerResult.Dismissed -> { /* cancelled */ }
else -> {}
}
CameraCaptureConfig Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
compressionLevel | CompressionLevel? | LOW | Image compression level (LOW / MEDIUM / HIGH / null) |
includeExif | Boolean | false | Extract EXIF metadata upon capture |
redactGpsData | Boolean | true | Strip GPS coordinates from EXIF for privacy |
cropConfig | CropConfig | CropConfig() | Crop configuration for camera captures |
permissionAndConfirmationConfig | PermissionAndConfirmationConfig | PermissionAndConfirmationConfig() | Permission dialogs & handlers |
Gallery Picker
Use rememberImagePickerKMP() for single or multiple photo selection from the device's photo library.
Single Selection
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
galleryConfig = GalleryConfig(allowMultiple = false)
)
)
Button(onClick = { picker.launchGallery() }) { Text("Gallery") }
when (val result = picker.result) {
is ImagePickerResult.Success -> { val photo = result.photos.first() }
is ImagePickerResult.Error -> { /* handle error */ }
is ImagePickerResult.Dismissed -> { /* cancelled */ }
else -> {}
}
Multiple Selection with MIME Filter
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
galleryConfig = GalleryConfig(
allowMultiple = true,
selectionLimit = 10,
mimeTypes = listOf(
MimeType.IMAGE_JPEG,
MimeType.IMAGE_PNG,
MimeType.IMAGE_WEBP
)
)
)
)
Button(onClick = { picker.launchGallery() }) { Text("Gallery") }
when (val result = picker.result) {
is ImagePickerResult.Success -> { result.photos.forEach { /* use photo */ } }
is ImagePickerResult.Error -> { /* handle error */ }
is ImagePickerResult.Dismissed -> { /* cancelled */ }
else -> {}
}
Image Crop
Built-in crop UI with free, square and circular modes plus zoom and rotation controls. Works on Android, iOS, Desktop and Web.
With rememberImagePickerKMP NEW
Enable crop globally in ImagePickerKMPConfig. Crop applies automatically after every capture or gallery selection.
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cropConfig = CropConfig(
enabled = true,
circularCrop = true,
squareCrop = true,
freeformCrop = true
)
)
)
// isCropActive = true while the crop UI is shown
if (picker.isCropActive) {
CircularProgressIndicator()
}
Button(onClick = { picker.launchCamera() }) { Text("Camera + Crop") }
Button(onClick = { picker.launchGallery() }) { Text("Gallery + Crop") }
With rememberImagePickerKMP (crop enabled)
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cropConfig = CropConfig(enabled = true)
)
)
picker.launchCamera()
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | Boolean | false | Enable crop UI after capture |
circularCrop | Boolean | true | Show circular crop option |
squareCrop | Boolean | true | Show square crop option |
freeformCrop | Boolean | false | Show free-form crop option |
rememberImagePickerKMP: when crop is active,
picker.isCropActive is true and picker.result is reset to Idle until the user confirms or cancels the crop.
Once the crop operation completes, isCropActive returns to false and result transitions to Success, Dismissed, or Error.
Use this flag to show a loading indicator or disable UI elements while the crop UI is visible.
if (picker.isCropActive) {
CircularProgressIndicator() // crop UI is open
}
Image Compression
Automatic background compression with configurable levels. Works for camera and gallery on Android and iOS.
// Camera with compression
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cameraCaptureConfig = CameraCaptureConfig(
compressionLevel = CompressionLevel.HIGH
)
)
)
picker.launchCamera()
// Gallery with compression
val galleryPicker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cameraCaptureConfig = CameraCaptureConfig(
compressionLevel = CompressionLevel.MEDIUM
)
)
)
galleryPicker.launchGallery()
| Level | JPEG Quality | Max Dimension | Use Case |
|---|---|---|---|
LOW | 85% | 3840 px (4K) | Near-lossless, large files |
MEDIUM | 70% | 1920 px (FHD) | Balanced quality/size |
HIGH | 50% | 1280 px (HD) | Maximum size reduction |
CameraCaptureConfig uses CompressionLevel.LOW by default (near-lossless, 85% quality). Set to null to disable compression entirely and get the original full-quality image.EXIF Metadata
Extract rich metadata from photos on Android and iOS. Requires includeExif = true.
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
cameraCaptureConfig = CameraCaptureConfig(includeExif = true)
)
)
picker.launchCamera()
when (val result = picker.result) {
is ImagePickerResult.Success -> {
result.photos.first().exif?.let { exif ->
println("GPS: ${exif.latitude}, ${exif.longitude}")
println("Camera: ${exif.cameraModel}")
println("Date: ${exif.dateTaken}")
println("Flash: ${exif.flash}")
println("ISO: ${exif.iso}")
println("Shutter: ${exif.shutterSpeed}")
println("Size: ${exif.imageWidth} x ${exif.imageHeight} px")
}
}
else -> {}
}
// Gallery EXIF
val galleryPicker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
galleryConfig = GalleryConfig(includeExif = true)
)
)
galleryPicker.launchGallery()
| Field | Type | Description |
|---|---|---|
| GPS | ||
latitude | Double? | GPS latitude — redacted by default (redactGpsData = true) |
longitude | Double? | GPS longitude — redacted by default |
altitude | Double? | GPS altitude in meters — redacted by default |
| Date & Time | ||
dateTaken | String? | Date and time photo was taken |
dateTime | String? | General date/time (alias of dateTaken) |
digitizedTime | String? | Date image was digitized |
modifiedTime | String? | Last modified date |
| Camera | ||
cameraModel | String? | Camera/device model |
cameraManufacturer | String? | Camera manufacturer |
software | String? | Processing software |
| Capture Settings | ||
flash | String? | Flash status |
iso | String? | ISO sensitivity |
aperture | String? | Aperture f-stop value |
shutterSpeed | String? | Shutter speed (exposure time) |
focalLength | String? | Focal length in mm |
whiteBalance | String? | White balance setting |
exposureBias | String? | Exposure compensation |
meteringMode | String? | Metering mode used |
| Image Properties | ||
imageWidth | Int? | Original width in pixels |
imageHeight | Int? | Original height in pixels |
orientation | String? | Image rotation/orientation |
colorSpace | String? | Color space (sRGB, Adobe RGB, etc.) |
thumbnail | String? | Base64 thumbnail (~5–20 KB). Avoid caching. |
latitude, longitude and altitude are set to null unless you explicitly set redactGpsData = false in CameraCaptureConfig or GalleryConfig. Only disable redaction when the user has been clearly informed.Extension Functions
Process PhotoResult and GalleryPhotoResult with built-in extensions for UI rendering, file system paths, and kotlinx-io streaming.
val picker = rememberImagePickerKMP()
when (val result = picker.result) {
is ImagePickerResult.Success -> {
val photo = result.photos.first()
// ── UI & Compose ──────────────────────────────────────────────
val painter: Painter? = photo.loadPainter()
val bitmap: ImageBitmap? = photo.loadImageBitmap()
// ── Data Conversion ──────────────────────────────────────────
val bytes: ByteArray = photo.loadBytes()
val base64: String = photo.loadBase64()
// ── File System & Paths ───────────────────────────────────────
val absPath: String = photo.absolutePath // Direct file path string
val path: Path = photo.asPath() // kotlinx.io.files.Path
val fileExists: Boolean = photo.exists() // Check if file exists on disk
// ── kotlinx-io Streaming ──────────────────────────────────────
val source: Source = photo.asSource() // Buffered Source
val rawSource: RawSource = photo.asRawSource() // Unbuffered RawSource
// Stream directly to a RawSink (file upload / copy)
val destinationSink = SystemFileSystem.sink(Path("destination.jpg"))
photo.transferToSink(destinationSink)
}
else -> {}
}
| Extension | Return Type | Description |
|---|---|---|
photo.loadPainter() | Painter? | Decodes image into Compose Painter for Image() composables |
photo.loadImageBitmap() | ImageBitmap? | Decodes image into Compose ImageBitmap for Canvas rendering |
photo.loadBytes() | ByteArray | Reads complete file content into a byte array |
photo.loadBase64() | String | Encodes image data to Base64 string for REST API payloads |
photo.absolutePath | String | Returns direct platform file path (resolves content:// and file://) |
photo.asPath() | Path | Converts absolutePath into a kotlinx.io.files.Path |
photo.exists() | Boolean | Verifies if the file exists on the local file system |
photo.asSource() | Source | Opens a buffered kotlinx.io.Source for high-performance reading |
photo.asRawSource() | RawSource | Opens an unbuffered kotlinx.io.RawSource for low-level file reading |
photo.transferToSink(sink) | Unit | Streams the photo content directly into any kotlinx.io.RawSink |
MIME Types
Use MimeType enum values to filter what files can be selected in rememberImagePickerKMP via GalleryConfig.
| Value | MIME String | Description |
|---|---|---|
MimeType.IMAGE_ALL | image/* | All image formats (default) |
MimeType.IMAGE_JPEG | image/jpeg | JPEG images |
MimeType.IMAGE_PNG | image/png | PNG images |
MimeType.IMAGE_WEBP | image/webp | WebP images |
MimeType.IMAGE_GIF | image/gif | GIF images |
MimeType.IMAGE_BMP | image/bmp | BMP images |
MimeType.IMAGE_HEIC | image/heic | HEIC — iOS native format |
MimeType.IMAGE_HEIF | image/heif | HEIF — iOS native format |
MimeType.APPLICATION_PDF | application/pdf | PDF documents |
Utility Methods
// Convert to string list
val strings = MimeType.toMimeTypeStrings(
MimeType.IMAGE_JPEG,
MimeType.IMAGE_PNG
)
// → ["image/jpeg", "image/png"]
// Parse from string
val mt = MimeType.fromString("image/webp")
// → MimeType.IMAGE_WEBP
// Predefined groups
MimeType.COMMON_IMAGE_TYPES // JPEG, PNG, GIF, WebP
MimeType.ALL_SUPPORTED_TYPES // all enum entries
UI Customization
Customize permission handling, denied dialogs, settings prompts, and localized dialog strings.
PermissionAndConfirmationConfig — Permission & Dialog Options
Configure custom permission handlers, denied dialogs, settings redirection, and iOS alert labels. All properties are optional.
| Property | Type | Default | Description |
|---|---|---|---|
customPermissionHandler | ((PermissionConfig) -> Unit)? | null | Custom permission check/request callback receiving localized PermissionConfig |
customDeniedDialog | @Composable ((onRetry: ()->Unit, onDismiss: ()->Unit) -> Unit)? | null | Custom Composable dialog shown when permission is denied |
customSettingsDialog | @Composable ((onOpenSettings: ()->Unit, onDismiss: ()->Unit) -> Unit)? | null | Custom Composable dialog shown when permission is permanently denied |
cancelButtonTextIOS | String? | null | iOS permission alert cancel button label override |
onCancelPermissionConfigIOS | (() -> Unit)? | null | iOS callback executed when user cancels the permission alert |
1 — Custom Permission Denied Dialog
Receives onRetry to re-trigger the permission request and onDismiss to cancel.
PermissionAndConfirmationConfig(
customDeniedDialog = { onRetry, onDismiss ->
Dialog(onDismissRequest = onDismiss) {
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Camera Permission Required", fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Text("Please grant camera access to take photos.", color = Color.Gray)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { Text("Cancel") }
Button(onClick = onRetry, modifier = Modifier.weight(1f)) { Text("Retry") }
}
}
}
}
}
)
2 — Custom Settings Dialog (Permanently Denied)
Receives onOpenSettings to navigate to platform app settings and onDismiss to cancel.
PermissionAndConfirmationConfig(
customSettingsDialog = { onOpenSettings, onDismiss ->
Dialog(onDismissRequest = onDismiss) {
Card(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Permission Permanently Denied", fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Text("Open system settings to enable camera access manually.", color = Color.Gray)
Spacer(Modifier.height(20.dp))
Button(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) {
Text("Open Settings")
}
}
}
}
}
)
PermissionConfig — Localized Strings
Holds localized titles, descriptions, and button strings for permission dialogs.
// Create localized PermissionConfig using i18nKonfig strings
val config: PermissionConfig = PermissionConfig.createLocalizedComposable()
// Custom PermissionConfig
val customConfig = PermissionConfig(
titleDialogConfig = "Camera Permission",
descriptionDialogConfig = "We need camera access to capture photos.",
btnDialogConfig = "Open Settings",
titleDialogDenied = "Permission Denied",
descriptionDialogDenied = "Camera permission is required.",
btnDialogDenied = "Grant Permission",
btnCancel = "Cancel"
)
result.uri (not .image), result.fileSize in bytes (not KB), result.mimeType (not .format), result.width, result.height.Gallery Options — GalleryConfig
val picker = rememberImagePickerKMP(
config = ImagePickerKMPConfig(
galleryConfig = GalleryConfig(
allowMultiple = true,
selectionLimit = 10, // iOS only — max 10 items
includeExif = true,
redactGpsData = false, // expose GPS (inform user!)
mimeTypes = listOf(MimeType.IMAGE_JPEG, MimeType.IMAGE_PNG)
)
)
)
Button(onClick = { picker.launchGallery() }) { Text("Gallery") }
| Parameter | Type | Default | Description |
|---|---|---|---|
allowMultiple | Boolean | false | Enable multi-file selection |
mimeTypes | List<MimeType> | [IMAGE_ALL] | Allowed file types |
selectionLimit | Int | 30 | Max items when allowMultiple (iOS) |
includeExif | Boolean | false | Extract EXIF metadata |
redactGpsData | Boolean | true | Strip GPS from EXIF (privacy default) |
React / Web Integration
ImagePickerKMP is available as an NPM package for JavaScript/TypeScript projects including React, Vue, Angular and Vanilla JS.
Installation
npm install imagepickerkmp
React Component
import { useImagePicker } from 'imagepickerkmp';
function PhotoUploader() {
const { openCamera, openGallery, result } = useImagePicker({
onPhotoCaptured: (photo) => {
console.log('Photo:', photo.fileName, photo.fileSize);
},
onError: (err) => console.error(err)
});
return (
<div>
<button onClick={openCamera}>Open Camera</button>
<button onClick={openGallery}>Open Gallery</button>
{result && <img src={result.uri} alt="captured" />}
</div>
);
}
WebRTC Camera
Browser camera via WebRTC. Works on mobile & desktop.
Drag & Drop
File picker with drag and drop support.
TypeScript
Full type definitions included out of the box.
Cross-Framework
React, Vue, Angular, Vanilla JS.
Video — imagepickerkmp-video Coming Soon
Video recording via the native camera and gallery video picking. Returns a VideoResult with duration, dimensions, file size and codec metadata.
implementation("io.github.ismoy:imagepickerkmp-video:TBD" // coming soon — not yet published)
iOS — Info.plist
<key>NSCameraUsageDescription</key>
<string>Required to record video.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Required to record audio in videos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Required to select videos from your library.</string>
Basic Usage
val picker = rememberVideoPicker(
config = VideoPickerConfig(
quality = VideoQuality.FULL_HD_1080P,
allowedMimeTypes = listOf(VideoMimeType.MP4),
audio = AudioConfig.Default,
output = VideoOutputConfig(format = VideoOutputFormat.MP4),
validation = VideoValidationConfig(
maxDuration = 60.seconds,
maxFileSize = 100.mb
)
)
)
Button(onClick = { picker.launchCamera() }) { Text("Record") }
Button(onClick = { picker.launchGallery() }) { Text("Pick Video") }
when (val state = picker.result) {
is VideoPickerState.Success -> Text("${state.video.fileName} — ${state.video.durationMs / 1000}s")
is VideoPickerState.Error -> Text("Error: ${state.cause}")
is VideoPickerState.Cancelled -> Text("Cancelled")
else -> {}
}
| State | When |
|---|---|
Idle | Initial or after reset() |
Loading | Picker is open |
Success(video) | Video selected / recorded — contains VideoResult |
Cancelled | User dismissed |
Error(cause) | Error or validation failure |
Audio — imagepickerkmp-audio Coming Soon
Two independent APIs for audio recording with waveform visualization and file picking. Includes a built-in AudioPlayer composable.
implementation("io.github.ismoy:imagepickerkmp-audio:TBD" // coming soon — not yet published)
1. Inline widget — AudioRecorder
Embeds a hold-to-record mic button directly in your layout. No dialog, no state holder.
AudioRecorder(
config = AudioRecorderConfig(
gesture = RecordGesture.HoldAndSlide,
showWaveform = true,
showDuration = true
),
onResult = { audioResult: AudioResult? ->
if (audioResult != null) {
println("Saved: ${audioResult.uri}, ${audioResult.durationMs}ms")
}
}
)
2. Modal state-holder — rememberAudioPicker
val picker = rememberAudioPicker(
config = AudioPickerConfig(
validation = AudioPickerValidation(
maxFileSizeBytes = 20 * 1024 * 1024L,
maxDurationMs = 300_000L
)
),
recorderConfig = AudioRecorderConfig(showWaveform = true)
)
Button(onClick = { picker.launchRecorder() }) { Text("Record") }
Button(onClick = { picker.launchGallery() }) { Text("Pick File") }
when (val state = picker.result) {
is AudioPickerState.Success -> {
val audio = state.audio
Text("${audio.fileName} — ${audio.durationMs / 1000}s")
ImagePickerAudioPlayer(
state = rememberAudioPlayerState(
AudioSource.Local(
uri = audio.uri,
durationMs = audio.durationMs,
title = audio.fileName
)
),
layout = AudioPlayerLayout.VoiceMessage()
) }
is AudioPickerState.Error -> Text("Error: ${state.cause}")
is AudioPickerState.Cancelled -> Text("Cancelled")
else -> {}
}
| Player Layout | Best for |
|---|---|
AudioPlayerLayout.VoiceMessage | Chat apps, read/unread status |
AudioPlayerLayout.Compact | Toolbars, minimal space |
AudioPlayerLayout.Music | Full player with cover art |
AudioPlayerLayout.Podcast | Podcast / audiobook with skip controls |
Scanner — imagepickerkmp-scanner Coming Soon
Live barcode and QR code scanning via the camera. Supports 18 barcode formats, batch mode, and static scanning from an image ByteArray.
implementation("io.github.ismoy:imagepickerkmp-scanner:TBD" // coming soon — not yet published)
Basic Usage
val scanner = rememberScannerPicker(
config = ScannerPickerConfig(
camera = ScannerCameraConfig(
behavior = ScannerBehaviorConfig(
allowedFormats = listOf(BarcodeFormat.QR_CODE, BarcodeFormat.EAN_13),
playSound = true
)
)
)
)
Button(onClick = { scanner.launchScanner() }) { Text("Scan") }
when (val result = scanner.result) {
is ScannerPickerState.Success -> Text("${result.result.code} (${result.result.format})")
is ScannerPickerState.BatchSuccess -> result.results.forEach { Text(it.code) }
is ScannerPickerState.Cancelled -> Text("Cancelled")
is ScannerPickerState.Error -> Text("Error: ${result.error}")
else -> {}
}
createStaticCodeScanner().scanImage(bytes) to decode a barcode from a ByteArray without opening the camera. Available on Android and iOS.Audio Player — imagepickerkmp-audio-player Coming Soon
Low-level audio playback engine. Use this module when you need full control over the player UI — play/pause/seek/speed from your own composables. The built-in ImagePickerAudioPlayer in imagepickerkmp-audio is powered by this same engine.
| Use case | Module |
|---|---|
| Ready-made voice message / podcast / music UI | imagepickerkmp-audio — ImagePickerAudioPlayer |
| Custom player UI built from scratch | imagepickerkmp-audio-player — rememberAudioPlayerState |
implementation("io.github.ismoy:imagepickerkmp-audio-player:TBD" // coming soon — not yet published)
Usage — rememberAudioPlayerState
@Composable
fun CustomPlayerScreen(audioUri: String, durationMs: Long) {
val playerState = rememberAudioPlayerState(
AudioSource.Local(
uri = audioUri,
durationMs = durationMs,
title = "Voice Memo"
)
)
val isPlaying = playerState.isPlaying
val position = playerState.currentPositionMs
val duration = playerState.durationMs
val progress = if (duration > 0) position.toFloat() / duration else 0f
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Button(onClick = {
if (isPlaying) playerState.pause() else playerState.play()
}) { Text(if (isPlaying) "Pause" else "Play") }
// Seek from a button
TextButton(onClick = { playerState.seekTo(0) }) { Text("Restart") }
}
AudioSource types
| Type | When to use |
|---|---|
AudioSource.Local(uri, durationMs, title, waveform?) | Local file — from recording or gallery. Pass waveform to show amplitude bars. |
AudioSource.Url(url, title) | Remote streaming URL (HTTP/HTTPS) |
AudioSource.Playlist(items) | Ordered list of AudioSource for multi-track playback |
PlayerState — properties & methods
| Method / Property | Description |
|---|---|
isPlaying: Boolean | Whether audio is currently playing |
currentPositionMs: Long | Current playback position in milliseconds |
durationMs: Long | Total duration in milliseconds |
play() | Start or resume playback |
pause() | Pause at current position |
seekTo(positionMs) | Seek to an arbitrary position |
rememberAudioPlayerState with the built-in layouts. Pass it directly to ImagePickerAudioPlayer(state = ..., layout = ...) if you want separate state control while still using a ready-made player UI.Video Player — imagepickerkmp-video-player Coming Soon
Full-featured video playback. Supports HTTP/HLS/DASH streaming, local files, playlists, PiP, quality selection, fullscreen, pinch-to-zoom, and custom UI slots.
implementation("io.github.ismoy:imagepickerkmp-video-player:TBD" // coming soon — not yet published)
Basic Usage
// Simple — just pass a source
ImagePickerVideoPlayer(
source = VideoSource.Url("https://example.com/video.mp4"),
config = VideoPlayerConfig(
behavior = VideoBehaviorConfig(autoPlay = true, enablePinchToZoom = true)
)
)
// With programmatic control
val player = rememberVideoPlayerState(source = VideoSource.Url(videoUrl))
ImagePickerVideoPlayer(state = player, config = VideoPlayerConfig())
Button(onClick = { player.play() }) { Text("Play") }
Button(onClick = { player.pause() }) { Text("Pause") }
Button(onClick = { player.seekTo(30_000L) }) { Text("Seek 30s") }
Button(onClick = { player.toggleFullscreen() }) { Text("Fullscreen") }
Button(onClick = { player.enterPiP() }) { Text("PiP") }
| Source Type | Example |
|---|---|
VideoSource.Url | HTTP/HTTPS, HLS .m3u8, DASH .mpd |
VideoSource.Local | File path or Android content:// URI |
VideoSource.Playlist | Ordered list of VideoSource items |
Custom Layout (TikTok / Reels HUD style)
Use VideoUIExtensions(customLayout = { uiState -> ... }) to replace the entire player UI. Access playback state and controls through uiState.
val config = VideoPlayerConfig(
behavior = VideoBehaviorConfig(
autoPlay = true,
enablePinchToZoom = true,
repeatToggleModes = RepeatToggleModes.ONE_AND_ALL
),
uiExtensions = VideoUIExtensions(
customLayout = { uiState ->
val isPlaying = uiState.status == VideoPlayerStatus.Playing
Box(modifier = Modifier.fillMaxSize()) {
// Custom play/pause button on the right edge
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { if (isPlaying) uiState.pause() else uiState.play() }
.align(Alignment.CenterEnd),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
tint = Color.White
)
}
// Position counter
Text(
text = "${uiState.currentPosition / 1000}s",
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
)
)
ImagePickerVideoPlayer(source = VideoSource.Url("https://example.com/video.mp4"), config = config)
Platform Support Matrix
| Feature | Android | iOS | Desktop | JS/Web | WASM |
|---|---|---|---|---|---|
| Camera Capture | |||||
| Gallery Picker | |||||
| Crop UI | |||||
| EXIF Metadata | |||||
| Compression | |||||
| Multiple Selection | |||||
Video — imagepickerkmp-video — coming soon | |||||
| Video — Camera Record | |||||
| Video — Gallery Pick | |||||
Audio — imagepickerkmp-audio — coming soon | |||||
| Audio — Record | |||||
| Audio — Gallery Pick | |||||
Audio Player — imagepickerkmp-audio-player — coming soon | |||||
| Audio Playback | |||||
Scanner — imagepickerkmp-scanner — coming soon | |||||
| QR / Barcode Scan | |||||
Video Player — imagepickerkmp-video-player — coming soon | |||||
| HTTP/HTTPS Streaming | |||||
| Local File Playback | |||||
| Picture-in-Picture | |||||
API Reference
rememberImagePickerKMP NEW
Recommended entry point. See the rememberImagePickerKMP section for full examples and configuration details.
| Method / Property | Description |
|---|---|
result: ImagePickerResult | Reactive picker state: Idle | Loading | Success | Dismissed | Error |
isCropActive: Boolean | true while the crop UI is active. Resets to false on final result or reset(). |
launchCamera(cameraCaptureConfig?, onDismiss?, onError?) | Opens the camera. All parameters are optional and override the global config for this launch only. Crop is set globally via cropConfig = CropConfig(enabled = true). |
launchGallery(allowMultiple?, mimeTypes?, selectionLimit?, includeExif?, redactGpsData?, mimeTypeMismatchMessage?, cameraCaptureConfig?, onDismiss?, onError?) | Opens the gallery. All parameters are optional and override the global GalleryConfig for this launch only. |
reset() | Resets result to Idle, clears isCropActive, and closes any active picker. |
ImagePickerKMPConfig — Main Configuration
| Parameter | Type | Default | Description |
|---|---|---|---|
cameraCaptureConfig | CameraCaptureConfig | CameraCaptureConfig() | Camera capture, compression & EXIF settings |
galleryConfig | GalleryConfig | GalleryConfig() | Gallery selection, MIME filtering & limit settings |
cropConfig | CropConfig | CropConfig() | Post-capture & post-selection crop UI settings |
permissionAndConfirmationConfig | PermissionAndConfirmationConfig | PermissionAndConfirmationConfig() | Permission handler & custom dialog configurations |
CameraCaptureConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
compressionLevel | CompressionLevel? | CompressionLevel.LOW | Image compression level (LOW / MEDIUM / HIGH / null) |
includeExif | Boolean | false | Extract EXIF metadata upon capture |
redactGpsData | Boolean | true | Strip GPS coordinates from EXIF for privacy |
permissionAndConfirmationConfig | PermissionAndConfirmationConfig | …() | Custom permission dialogs & handlers |
cropConfig | CropConfig | CropConfig() | Crop configuration after camera capture |
GalleryConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
allowMultiple | Boolean | false | Enable multi-file selection |
mimeTypes | List<MimeType> | [IMAGE_ALL] | Allowed MIME types |
selectionLimit | Int | 30 | Maximum selection limit (iOS) |
includeExif | Boolean | false | Extract EXIF metadata upon gallery pick |
redactGpsData | Boolean | true | Strip GPS coordinates from EXIF |
mimeTypeMismatchMessage | String? | null | Custom warning message when unselected MIME type is picked |
PermissionAndConfirmationConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
customPermissionHandler | ((PermissionConfig) -> Unit)? | null | Custom callback to manage permission request flow |
customDeniedDialog | @Composable ((onRetry: ()->Unit, onDismiss: ()->Unit) -> Unit)? | null | Custom dialog when permission is denied |
customSettingsDialog | @Composable ((onOpenSettings: ()->Unit, onDismiss: ()->Unit) -> Unit)? | null | Custom dialog when permission is permanently denied |
cancelButtonTextIOS | String? | null | Custom cancel text for iOS alert dialogs |
onCancelPermissionConfigIOS | (() -> Unit)? | null | iOS callback when permission alert is cancelled |
CropConfig
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled | Boolean | false | Enable crop UI after capture or gallery pick |
aspectRatioLocked | Boolean | false | Lock aspect ratio during crop manipulation |
circularCrop | Boolean | true | Enable circular crop handle/overlay |
squareCrop | Boolean | true | Enable square 1:1 crop preset |
freeformCrop | Boolean | false | Enable freeform ratio cropping |
PhotoResult / GalleryPhotoResult
GalleryPhotoResult is a typealias for PhotoResult — both camera and gallery results use the same object model.
| Field / Property | Type | Description |
|---|---|---|
uri | String | Platform-native URI string of the selected/captured file |
fileName | String? | File name with extension (e.g., "photo.jpg") |
fileSize | Long? | File size in bytes (divide by 1024 for KB) |
mimeType | String? | MIME type string (e.g. "image/jpeg", "image/png") |
width | Int? | Image width in pixels |
height | Int? | Image height in pixels |
exif | ExifData? | EXIF metadata — populated when includeExif = true |
absolutePath | String (Ext) | Absolute file system path as String |
asPath() | Path (Ext) | Converts to kotlinx.io.files.Path |
exists() | Boolean (Ext) | Checks if file exists on disk |
loadBytes() | ByteArray (Ext) | Reads complete file content into ByteArray |
loadBase64() | String (Ext) | Encodes file content to Base64 String |
loadPainter() | Painter? (Ext) | Decodes into Compose Painter |
loadImageBitmap() | ImageBitmap? (Ext) | Decodes into Compose ImageBitmap |
asSource() | Source (Ext) | Opens a buffered kotlinx.io.Source |
transferToSink(sink) | Unit (Ext) | Streams file data directly to a kotlinx.io.RawSink |
imagepicker-core Architecture
The core infrastructure module (io.github.ismoy:imagepicker-core) provides cross-platform abstractions used by all modules:
| Component | Package | Description |
|---|---|---|
CoreServices | core | Service locator providing access to PermissionManager, FileSystemManager, and MediaLogger |
PermissionManager | core.permissions | Abstracts platform permission checks & requests (Camera, Photos) |
FileSystemManager | core.filesystem | Abstracts file creation, URI resolution, temporary storage, and path mapping |
MediaLogger | core.logger | Internal structured console logger with configurable LogLevel |
PlatformFile | core.filesystem | Cross-platform file abstraction wrapper |
PlatformUri | core.uri | Cross-platform URI resolution helper |
fileSize returns bytes (previously KB). Migrate: val sizeKB = (result.fileSize ?: 0) / 1024.0ExifData Fields
All fields are nullable. Availability depends on the device, image origin and whether redactGpsData is false.
| Field | Type | Description |
|---|---|---|
| GPS & Location | ||
latitude | Double? | GPS latitude (stripped if redactGpsData = true) |
longitude | Double? | GPS longitude |
altitude | Double? | GPS altitude in metres |
| Date & Time | ||
dateTaken | String? | Original date/time when the photo was taken (yyyy:MM:dd HH:mm:ss) |
dateTime | String? | General date/time — alias of dateTaken for compatibility |
digitizedTime | String? | Date image was digitized |
originalTime | String? | Original creation time |
modifiedTime | String? | Last modified date |
utcTime | String? | UTC date and time |
| Camera | ||
cameraManufacturer | String? | Camera manufacturer (also exposed as cameraMake) |
cameraMake | String? | Alias for cameraManufacturer |
cameraModel | String? | Camera / device model name |
software | String? | Software used to produce the image |
owner | String? | Owner / copyright information |
focalLength | String? | Focal length in mm |
aperture | String? | Aperture (f-number) |
| Capture Settings | ||
iso | String? | ISO sensitivity |
shutterSpeed | String? | Shutter speed |
exposureBias | String? | Exposure compensation (EV) |
meteringMode | String? | Metering mode (multi, spot, etc.) |
flash | String? | Flash fired / not fired |
whiteBalance | String? | White balance mode |
sceneCaptureType | String? | Scene capture type (standard, landscape, portrait, night) |
| Image Properties | ||
imageWidth | Int? | Image pixel width |
imageHeight | Int? | Image pixel height |
orientation | String? | EXIF orientation (1–8) |
colorSpace | String? | Color space (sRGB, Adobe RGB, etc.) |
xResolution | String? | Horizontal resolution (DPI) |
yResolution | String? | Vertical resolution (DPI) |
resolutionUnit | String? | Resolution unit (inches / cm) |
compression | String? | Compression method |
thumbnail | String? | Base64 encoded thumbnail data (~5–20 KB). Avoid caching. |
Changelog
Recent releases and what changed in each version. Full history on GitHub Releases.
- New:
imagepickerkmp-video — coming soon— video recording and gallery video picking.rememberVideoPicker(),VideoPickerConfig, quality presets (360p–1080p),VideoResultwith codec info, validation constraints. - New:
imagepickerkmp-audio — coming soon— audio recording with real-time waveform. Two APIs: inlineAudioRecorderwidget (hold-to-record / tap-to-record) and modalrememberAudioPicker(). Includes built-inAudioPlayerwith four layouts (VoiceMessage, Compact, Music, Podcast). - New:
imagepickerkmp-audio-player — coming soon— low-level audio playback engine.rememberAudioPlayerManager()with fullPlaybackState(position, duration, speed) for custom player UIs. - New:
imagepickerkmp-scanner — coming soon— live barcode and QR code scanning.rememberScannerPicker(), 18 barcode formats, batch mode, static image scanning, custom layout support. - New:
imagepickerkmp-video-player — coming soon— full-featured video playback. HLS/DASH streaming, playlist, PiP (Android 8+ / iOS 14+), quality selector, fullscreen, pinch-to-zoom, custom UI slots.
- Breaking: Project refactored into a modular ecosystem. The library is now split into
imagepicker-core(shared infrastructure) andimagepickerkmp-photo(photo capture, gallery, crop, EXIF, compression). The dependency artifactio.github.ismoy:imagepickerkmpcontinues to work seamlessly. - New:
imagepicker-coremodule — shared permissions, filesystem, URI handling, and logging across modules. - New: Auto-initialization on Android via
ContentProvider(CoreInitializer) — manual initialization no longer required. - New: Internationalization support via
i18nKonfigin the core module. - New: Tag-based auto-release CI workflow for streamlined automated publishing.
- Fix (iOS) #135: Gallery picker is dramatically faster and no longer spikes memory. Image processing now uses ImageIO hardware-accelerated downsampling (decodes directly at target size from the file) instead of decoding the full-resolution bitmap into memory.
- Fix (iOS): Multi-image selection now processes with bounded parallelism, keeping peak memory low even when selecting many high-resolution photos.
- Fix (iOS): Picker launch uses a lightweight
PHPickerConfiguration, removing the cold-start delay on first open. EXIF is read directly from the file viaCGImageSource. - Fix (iOS): Resolved a false-positive dismissal during the presentation animation that could freeze or cancel the picker.
- Fix (Android) #136:
PhotoResult.absolutePathno longer crashes withFileNotFoundExceptionfor cropped images. Raw absolute file paths (e.g. cropped files in cacheDir) are now handled alongsidecontent://andfile://URIs.
- Breaking: Legacy API removed from public surface.
ImagePickerLauncherandGalleryPickerLauncherare nowinternal— onlyrememberImagePickerKMP()is the public API. - Breaking:
ImagePickerConfigis nowinternal. UseImagePickerKMPConfiginstead. - New: Internal functions renamed to
PlatformCameraRenderer/PlatformGalleryRendererfor clarity in the open-source codebase. - New: WasmJs target fully implemented — camera and gallery now work in browser via HTML file input.
- New: WasmJs compilation enabled (was previously disabled).
- Fix (iOS): Crop crash after camera capture resolved — dismiss camera completion handler now fires before opening crop Dialog, preventing "Unbalanced calls to begin/end appearance transitions".
- New:
CameraScaleTypeenum — controls camera preview scaling (FILL_CENTER,FIT_CENTER, etc.). Android only. - New:
PermissionAndConfirmationConfig.confirmationImageContentScale— controls how captured photo is scaled in confirmation screen. - Docs: All documentation updated to only show the new
rememberImagePickerKMPAPI. Legacy examples removed.
- New:
PhotoResult.absolutePathextension — returns the absolute file system path as a String for direct file access without URI parsing. - Platform implementations: Android uses ContentResolver to resolve content:// URIs, iOS uses URL.path, Desktop/Web use direct path extraction from file:// URIs.
- Complements: Works alongside the existing
toPath()extension (v1.0.38) for kotlinx-io compatibility.
- Fix (Android 7–11): Camera preview was blank/black on Android 11 (API 30) and below. Root cause:
PreviewViewwas hardcoded toImplementationMode.PERFORMANCE(SurfaceView), which does not render inside Jetpack Compose on these versions. - Fix:
HighPerformanceConfig.requiresCompatibilityMode()now returnstrueforSDK ≤ 30(Android 11 and below), switching toImplementationMode.COMPATIBLE(TextureView) for correct rendering. - Fix:
setLayerType(LAYER_TYPE_HARDWARE)is no longer applied on Android ≤ 11, eliminating the conflict with TextureView. - Fix: Camera initialization delay now applies to Android 7–11 (was only Android 10), preventing surface-not-ready errors on older devices.
- Fix: Minor stability improvements and dependency updates.
- Updated Kotlin to
2.3.20, Compose Multiplatform to1.10.3, AGP to8.13.2.
- New:
rememberImagePickerKMP(config)— unified Compose state-holder API. ReturnsImagePickerKMPStatewithlaunchCamera(),launchGallery()and reactiveresult. NoRender()or manual booleans needed. - New:
ImagePickerKMPConfig— single configuration object for camera, gallery, crop, UI and permissions. - New:
ImagePickerResult— sealed hierarchy:Idle | Loading | Success | Dismissed | Errorfor exhaustive result handling. - New: Per-launch overrides — override any parameter in
launchCamera()/launchGallery()without mutating the global config. - Fix (Android):
ImagePickerLauncheris now wrapped in a fullscreenDialog— fixes camera not visible when placed outside a container. - The
rememberImagePickerKMP()API is the only public API going forward.
- Breaking:
fileSizenow returns bytes (was KB). Divide by 1024 to get KB. - New: Improved WASM target support with better browser compatibility.
- New:
MimeType.APPLICATION_PDF— select PDFs from gallery. - Fixed crop rotation on iOS in landscape orientation.
- Detekt static analysis integrated into CI pipeline.
- Improved error handling with typed
ImagePickerException.
- New: Circular, square and freeform crop modes with zoom/rotation.
- New: EXIF metadata extraction — GPS, ISO, exposure, camera model.
- Fix: Memory leak on Android when cancelling gallery picker.
- Kotlin 2.3.x required — ABI incompatible with older versions.
- New: Multiple gallery selection with
allowMultiple = true. - New: MIME type filtering with
mimeTypeMismatchMessage. - Android smart picker: images → gallery, PDFs → file explorer, mixed → file explorer.