ImagePickerKMP v1.0.41
GitHub
NEW v1.0.41 — Camera preview scale type + confirmation image scale type
Open Source • MIT License

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.

Android API 24+ iOS 12.0+ Desktop JVM JS / Web WASM
rememberImagePickerKMP NEW in v1.0.35
The new recommended Compose API — one call, zero booleans. Returns an ImagePickerKMPState with reactive result, launchCamera() & launchGallery(). No manual state, no showCamera = true toggles ever again.
See the new API

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.

Cloud OCR

Gemini, OpenAI, Claude, Azure, Ollama and custom endpoints. All platforms.

Compression

LOW / MEDIUM / HIGH quality levels with async processing and bitmap recycling.

PDF & Format Support

JPEG, PNG, HEIC, HEIF, WebP, GIF, BMP and PDF across all platforms.

UI Customization

Custom button colors, icons, permission dialogs, confirmation screens and camera callbacks.


Requirements

RequirementMinimum VersionNotes
Kotlin2.3.20Breaking — ABI incompatible with < 2.3.x
Compose Multiplatform1.10.3Requires Kotlin 2.3.x
Android minSdk24
Android compileSdk36
iOS12.0+
JDK (Desktop)11+
Kotlin version is mandatory. Projects using Kotlin < 2.3.x will fail with ABI version incompatible. If you need Kotlin 2.1.x, use a previous release.

Installation

Kotlin Multiplatform

build.gradle.kts
// commonMain dependencies
implementation("io.github.ismoy:imagepickerkmp:1.0.41")

React / JavaScript (NPM)

terminal
npm install imagepickerkmp
Always wrap launchers in a visible container. Place ImagePickerLauncher inside a Box(Modifier.fillMaxSize()) — otherwise the camera preview won't render.

Correct vs Incorrect Usage

Correct
Box(Modifier.fillMaxSize()) {
    if (showCamera) {
        ImagePickerLauncher(
            config = ImagePickerConfig(...)
        )
    }
}
Incorrect — camera not visible
// No container — preview invisible
if (showCamera) {
    ImagePickerLauncher(
        config = ImagePickerConfig(...)
    )
}

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" />
Zero config on Android. The library auto-manages camera and media store permissions internally. No manifest entries are needed. The 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().

Recommended for all new code. ImagePickerLauncher / GalleryPickerLauncher remain compatible for existing projects.

Full real-world example

A complete example of how to use rememberImagePickerKMP with camera and multi-gallery, handling all states and displaying results:

Kotlin
@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:

Kotlin
// 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

ParameterTypeDefaultDescription
cameraCaptureConfigCameraCaptureConfigdefaultsCamera behaviour: compression, EXIF, button size, UI styling
galleryConfigGalleryConfigdefaultsMulti-select, MIME types, selection limit, EXIF, redact GPS
cropConfigCropConfigdefaultsCrop UI — set CropConfig(enabled = true) to activate crop after every capture or selection
uiConfigUiConfigdefaultsCamera UI button colors and icons
permissionAndConfirmationConfigPermissionAndConfirmationConfigdefaultsCustom @Composable permission and confirmation dialogs

GalleryConfig — Parameters

ParameterTypeDefaultDescription
allowMultipleBooleanfalseAllow multiple file selection
mimeTypesList<MimeType>[IMAGE_ALL]Allowed file types
selectionLimitInt30Maximum selectable files (requires allowMultiple = true)
includeExifBooleanfalseExtract EXIF metadata from images
redactGpsDataBooleantrueRemove GPS (lat/lon/alt) from EXIF before delivering it
mimeTypeMismatchMessageString?nullCustom message when the file does not match mimeTypes

ImagePickerKMPState — Methods & properties

Method / PropertyDescription
result: ImagePickerResultReactive state. Starts as Idle. Observe with when. Updates automatically.
isCropActive: Booleantrue 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.
There is no 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

StateWhen it occursAvailable data
IdleInitial state, before any action, and after reset()
LoadingThe picker is open and waiting for user selection
SuccessThe user selected / captured images successfullyphotos: List<PhotoResult>, first: PhotoResult?
DismissedThe user closed the picker without selecting anything
ErrorAn error occurred during capture or selectionexception: Exception
Kotlin — Exhaustive state handling
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

Before (legacy)
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")
}
After (recommended)
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 ImagePickerLauncher to open the native camera. Control flash, skip confirmation, add crop and set compression.

Basic Camera

Kotlin
var showCamera by remember { mutableStateOf(false) }
var photo by remember { mutableStateOf<PhotoResult?>(null) }

Box(modifier = Modifier.fillMaxSize()) {
    if (showCamera) {
        ImagePickerLauncher(
            config = ImagePickerConfig(
                onPhotoCaptured = { result ->
                    photo = result
                    showCamera = false
                },
                onError = { showCamera = false },
                onDismiss = { showCamera = false }
            )
        )
    }
    Button(onClick = { showCamera = true }) {
        Text("Open Camera")
    }
}

Camera with Compression & Crop

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result -> /* handle result */ },
        onDismiss = { showCamera = false },
        cameraCaptureConfig = CameraCaptureConfig(
            compressionLevel = CompressionLevel.MEDIUM,
            skipConfirmation = false,
            includeExif = true,
            cropConfig = CropConfig(
                enabled = true,
                circularCrop = true,
                squareCrop = true,
                freeformCrop = true
            )
        )
    )
)

CameraCaptureConfig Parameters

ParameterTypeDefaultDescription
compressionLevelCompressionLevel?nullImage compression level
skipConfirmationBooleanfalseSkip preview/confirm screen
includeExifBooleanfalseExtract EXIF metadata
cropConfigCropConfig?nullCrop configuration
mimeTypesList<MimeType>[IMAGE_ALL]Allowed MIME types


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.

Kotlin
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 ImagePickerLauncher (legacy)

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result -> photo = result },
        onDismiss = { showCamera = false },
        cameraCaptureConfig = CameraCaptureConfig(
            cropConfig = CropConfig(
                enabled = true,
                circularCrop = true,  // show circle crop button
                squareCrop = true,    // show square crop button
                freeformCrop = true   // show free-form crop button
            )
        )
    )
)
ParameterTypeDefaultDescription
enabledBooleanfalseEnable crop UI after capture
circularCropBooleanfalseShow circular crop option
squareCropBooleanfalseShow square crop option
freeformCropBooleantrueShow free-form crop option
Tracking crop state with 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.

Kotlin
// Camera with compression
ImagePickerLauncher(
    config = ImagePickerConfig(
        cameraCaptureConfig = CameraCaptureConfig(
            compressionLevel = CompressionLevel.HIGH
        )
    )
)

// Gallery with compression
GalleryPickerLauncher(
    compressionLevel = CompressionLevel.MEDIUM,
    onPhotosSelected = { /* compressed photos */ },
    onError = { },
    onDismiss = { }
)
LevelJPEG QualityMax DimensionUse Case
LOW85%3840 px (4K)Near-lossless, large files
MEDIUM70%1920 px (FHD)Balanced quality/size
HIGH50%1280 px (HD)Maximum size reduction
Default compression is MEDIUM. CameraCaptureConfig uses CompressionLevel.MEDIUM by default. 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.

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result ->
            result.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("Exposure: ${exif.exposureTime}")
                println("Size: ${exif.imageWidth} x ${exif.imageHeight} px")
            }
        },
        cameraCaptureConfig = CameraCaptureConfig(includeExif = true)
    )
)

// Gallery EXIF (iOS requires photo library permission)
GalleryPickerLauncher(
    config = GalleryPickerConfig(includeExif = true),
    onPhotosSelected = { photos ->
        photos.forEach { photo ->
            val gps = "${photo.exif?.latitude}, ${photo.exif?.longitude}"
        }
    },
    onError = { },
    onDismiss = { }
)
FieldTypeDescription
GPS
latitudeDouble?GPS latitude — redacted by default (redactGpsData = true)
longitudeDouble?GPS longitude — redacted by default
altitudeDouble?GPS altitude in meters — redacted by default
Date & Time
dateTakenString?Date and time photo was taken
dateTimeString?General date/time (alias of dateTaken)
digitizedTimeString?Date image was digitized
modifiedTimeString?Last modified date
Camera
cameraModelString?Camera/device model
cameraManufacturerString?Camera manufacturer
softwareString?Processing software
Capture Settings
flashString?Flash status
isoString?ISO sensitivity
apertureString?Aperture f-stop value
shutterSpeedString?Shutter speed (exposure time)
focalLengthString?Focal length in mm
whiteBalanceString?White balance setting
exposureBiasString?Exposure compensation
meteringModeString?Metering mode used
Image Properties
imageWidthInt?Original width in pixels
imageHeightInt?Original height in pixels
orientationString?Image rotation/orientation
colorSpaceString?Color space (sRGB, Adobe RGB, etc.)
thumbnailString?Base64 thumbnail (~5–20 KB). Avoid caching.
GPS is redacted by default. 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 common operations.

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result ->
            // For Compose UI
            val painter: Painter = result.loadPainter()

            // For file operations / upload
            val bytes: ByteArray = result.loadBytes()

            // For canvas / graphics
            val bitmap: ImageBitmap = result.loadImageBitmap()

            // For REST APIs
            val base64: String = result.loadBase64()

            // File info
            println("Name: ${result.fileName}")
            println("Size: ${result.fileSize} bytes")
            println("MIME: ${result.mimeType}")

            // Convert to KB
            val sizeKB = (result.fileSize ?: 0) / 1024.0
        }
    )
)

MIME Types

Use MimeType enum values to filter what files can be selected in GalleryPickerLauncher.

ValueMIME StringDescription
MimeType.IMAGE_ALLimage/*All image formats (default)
MimeType.IMAGE_JPEGimage/jpegJPEG images
MimeType.IMAGE_PNGimage/pngPNG images
MimeType.IMAGE_WEBPimage/webpWebP images
MimeType.IMAGE_GIFimage/gifGIF images
MimeType.IMAGE_BMPimage/bmpBMP images
MimeType.IMAGE_HEICimage/heicHEIC — iOS native format
MimeType.IMAGE_HEIFimage/heifHEIF — iOS native format
MimeType.APPLICATION_PDFapplication/pdfPDF documents

Utility Methods

Kotlin
// 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
Android smart picker: images-only → native gallery, PDFs → file explorer, mixed → file explorer. Fully automatic, no config needed.

UI Customization

Customize the camera UI look, lifecycle callbacks, permission dialogs and confirmation screen.

UiConfig — Camera Visual Style

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { /* ... */ },
        cameraCaptureConfig = CameraCaptureConfig(
            captureButtonSize = 80.dp,
            uiConfig = UiConfig(
                buttonColor = Color(0xFF0EA5E9),   // capture button color
                iconColor = Color.White,          // flash / switch icons color
                buttonSize = 80.dp,
                flashIcon = Icons.Default.FlashOn,
                switchCameraIcon = Icons.Default.Cameraswitch
            )
        )
    )
)
ParameterTypeDescription
buttonColorColor?Capture button background color
iconColorColor?Icon color inside camera UI
buttonSizeDp?Capture button size
flashIconImageVector?Custom flash toggle icon
switchCameraIconImageVector?Custom camera switch icon
galleryIconImageVector?Custom gallery access icon

CameraCallbacks — Lifecycle Events

Kotlin
CameraCaptureConfig(
    cameraCallbacks = CameraCallbacks(
        onCameraReady = {
            // Camera preview is ready — hide loading indicator
            isLoading = false
        },
        onCameraSwitch = {
            // User switched between front/back camera
        },
        onPermissionError = { exception ->
            // Permission denied or unavailable
            showError(exception.message)
        },
        onGalleryOpened = {
            // User navigated to gallery from camera
        }
    )
)

CapturePhotoPreference — Quality Mode

ValueDescription
CapturePhotoPreference.FASTPrioritizes capture speed, lower quality
CapturePhotoPreference.BALANCEDDefault — balanced speed and quality
CapturePhotoPreference.QUALITYBest image quality, slower capture

PermissionAndConfirmationConfig — Permission Dialogs & Confirmation Screen

All permission and confirmation customization lives inside PermissionAndConfirmationConfig, nested in CameraCaptureConfig. The parameters below are all optional — omit any you don't need.

ParameterTypeDefaultDescription
skipConfirmationBooleanfalseSkip confirmation screen — deliver photo directly via onPhotoCaptured
customConfirmationView@Composable (PhotoResult, (PhotoResult)->Unit, ()->Unit) -> UnitnullReplace the built-in post-capture preview & confirm screen
customDeniedDialog@Composable ((onRetry: ()->Unit) -> Unit)nullDialog shown when camera permission is denied (can retry)
customSettingsDialog@Composable ((onOpenSettings: ()->Unit) -> Unit)nullDialog shown when permission is permanently denied (open settings)
cancelButtonTextIOSString?"Cancel"iOS only — label of the cancel button in the permission alert
onCancelPermissionConfigIOS(() -> Unit)?nulliOS only — callback when user taps cancel in permission alert
Deprecated / removed: customPickerDialog and directCameraLaunch do not exist in ImagePickerConfig — they are OCR-only parameters (ImagePickerOCRConfig). Do not use them with the standard picker.

1 — Skip confirmation (deliver photo immediately)

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result -> // called immediately after capture },
        onError = { },
        cameraCaptureConfig = CameraCaptureConfig(
            permissionAndConfirmationConfig = PermissionAndConfirmationConfig(
                skipConfirmation = true   // no review screen shown
            )
        )
    )
)

2 — Custom confirmation screen (Android)

Receives photoResult: PhotoResult, onConfirm: (PhotoResult) -> Unit and onRetry: () -> Unit. Use result.uri to display the preview.

Kotlin
CameraCaptureConfig(
    permissionAndConfirmationConfig = PermissionAndConfirmationConfig(
        customConfirmationView = { photoResult, onConfirm, onRetry ->
            MyConfirmationView(
                result = photoResult,
                onConfirm = onConfirm,
                onRetry = onRetry
            )
        }
    )
)

// Example implementation:
@Composable
fun MyConfirmationView(
    result: PhotoResult,
    onConfirm: (PhotoResult) -> Unit,
    onRetry: () -> Unit
) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        // Show preview using result.uri
        AsyncImage(
            model = result.uri,          // ✅ correct — use .uri not .image
            contentDescription = "Preview",
            modifier = Modifier.fillMaxWidth().weight(1f)
                .clip(RoundedCornerShape(16.dp)),
            contentScale = ContentScale.Crop
        )
        // Show file info using correct fields
        Text("Size: ${(result.fileSize ?: 0) / 1024} KB")  // ✅ fileSize in bytes
        Text("Format: ${result.mimeType ?: "unknown"}")         // ✅ mimeType (not .format)
        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            OutlinedButton(onClick = onRetry, modifier = Modifier.weight(1f)) {
                Text("Retry")
            }
            Button(onClick = { onConfirm(result) }, modifier = Modifier.weight(1f)) {
                Text("Use Photo")
            }
        }
    }
}

3 — Custom permission denied dialog

Shown when the user denies camera permission. Provides an onRetry lambda to re-launch the system permission prompt.

Kotlin
PermissionAndConfirmationConfig(
    customDeniedDialog = { onRetry ->
        Dialog(onDismissRequest = {}) {
            Card(shape = RoundedCornerShape(16.dp)) {
                Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
                    Text("Camera permission needed", fontWeight = FontWeight.Bold)
                    Spacer(Modifier.height(12.dp))
                    Text("We need camera access to take photos.", color = Color.Gray)
                    Spacer(Modifier.height(20.dp))
                    Button(onClick = onRetry, modifier = Modifier.fillMaxWidth()) {
                        Text("Grant Permission")
                    }
                }
            }
        }
    }
)

4 — Custom settings dialog (permanently denied)

Shown when the user has permanently denied the permission. Provides onOpenSettings to navigate to the system app settings.

Kotlin
PermissionAndConfirmationConfig(
    customSettingsDialog = { onOpenSettings ->
        Dialog(onDismissRequest = {}) {
            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("Go to Settings > Permissions > Camera to enable access.", color = Color.Gray)
                    Spacer(Modifier.height(20.dp))
                    Button(onClick = onOpenSettings, modifier = Modifier.fillMaxWidth()) {
                        Text("Open Settings")
                    }
                }
            }
        }
    }
)

5 — Full combined example

Kotlin
ImagePickerLauncher(
    config = ImagePickerConfig(
        onPhotoCaptured = { result ->
            // result.uri, result.fileSize (bytes), result.mimeType, result.width, result.height
        },
        onError = { exception -> showError(exception.message) },
        onDismiss = { navigateBack() },
        cameraCaptureConfig = CameraCaptureConfig(
            compressionLevel = CompressionLevel.HIGH,
            preference = CapturePhotoPreference.QUALITY,
            uiConfig = UiConfig(
                buttonColor = Color(0xFF0EA5E9),
                iconColor = Color.White
            ),
            cameraCallbacks = CameraCallbacks(
                onCameraReady = { isLoading = false },
                onPermissionError = { showPermissionBanner() }
            ),
            permissionAndConfirmationConfig = PermissionAndConfirmationConfig(
                skipConfirmation = false,
                customConfirmationView = { photoResult, onConfirm, onRetry ->
                    MyConfirmationView(photoResult, onConfirm, onRetry)
                },
                customDeniedDialog = { onRetry ->
                    MyDeniedDialog(onRetry = onRetry)
                },
                customSettingsDialog = { onOpenSettings ->
                    MySettingsDialog(onOpenSettings = onOpenSettings)
                }
            )
        ),
        enableCrop = false
    )
)
PhotoResult fields: use result.uri (not .image), result.fileSize in bytes (not KB), result.mimeType (not .format), result.width, result.height.

Gallery Options — GalleryConfig

Kotlin
GalleryPickerLauncher(
    config = 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)
    ),
    onPhotosSelected = { photos -> /* ... */ },
    onError = { },
    onDismiss = { }
)
ParameterTypeDefaultDescription
allowMultipleBooleanfalseEnable multi-file selection
mimeTypesList<MimeType>[IMAGE_ALL]Allowed file types
selectionLimitInt30Max items when allowMultiple (iOS)
includeExifBooleanfalseExtract EXIF metadata
redactGpsDataBooleantrueStrip 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
npm install imagepickerkmp

React Component

TypeScript / React
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.

Full React guide available. See the React Integration Guide for complete examples with Next.js, Vite and plain React.

Cloud OCR Experimental

Extract text from images and PDFs using cloud AI providers. Supports Gemini, OpenAI, Claude, Azure, Ollama and any custom HTTP endpoint.

Gemini OCR

Kotlin
var isOCRActive by remember { mutableStateOf(false) }
var ocrResult by remember { mutableStateOf<OCRResult?>(null) }

@OptIn(ExperimentalOCRApi::class)
if (isOCRActive) {
    ImagePickerLauncherOCR(
        config = ImagePickerOCRConfig(
            scanMode = ScanMode.Cloud(
                provider = CloudOCRProvider.Gemini("YOUR_GEMINI_API_KEY")
            ),
            onOCRCompleted = { result ->
                ocrResult = result
                isOCRActive = false
            },
            onError = { isOCRActive = false },
            onCancel = { isOCRActive = false },
            allowedMimeTypes = listOf(MimeType.APPLICATION_PDF, MimeType.IMAGE_ALL),
            directCameraLaunch = false
        )
    )
}

Custom HTTP Endpoint

Kotlin
CloudOCRProvider.Custom(
    name = "My OCR Service",
    baseUrl = "https://api.mycompany.com/ocr/analyze",
    apiKey = "abc123",
    headers = mapOf(
        "X-API-Version" to "2.1",
        "X-Client-ID" to "mobile-app",
        "Authorization" to "Bearer $token"
    ),
    requestFormat = RequestFormat.MULTIPART_FORM,
    model = "enterprise-model-v3"
)
ProviderValue
Google GeminiCloudOCRProvider.Gemini("API_KEY")
OpenAICloudOCRProvider.OpenAI("API_KEY")
Anthropic ClaudeCloudOCRProvider.Claude("API_KEY")
Azure AICloudOCRProvider.Azure("ENDPOINT", "KEY")
Ollama (local)CloudOCRProvider.Ollama("http://localhost:11434")
CustomCloudOCRProvider.Custom(...)

PDF Support

Select PDF documents alongside images. On Android, PDFs automatically open the system file explorer.

Kotlin
GalleryPickerLauncher(
    allowMultiple = true,
    mimeTypes = listOf(
        MimeType.IMAGE_ALL,
        MimeType.APPLICATION_PDF
    ),
    onPhotosSelected = { files ->
        files.forEach { file ->
            when (file.mimeType) {
                "application/pdf" -> handlePDF(file)
                else -> handleImage(file)
            }
        }
    },
    onError = { },
    onDismiss = { showGallery = false }
)

Platform Support Matrix

FeatureAndroidiOSDesktopJS/WebWASM
Camera Capture
Gallery Picker
Crop UI
EXIF Metadata
Compression
OCR
PDF Support
Multiple Selection

API Reference

rememberImagePickerKMP NEW

Recommended entry point. See the rememberImagePickerKMP section for full examples and configuration details.

Method / PropertyDescription
result: ImagePickerResultReactive picker state: Idle | Loading | Success | Dismissed | Error
isCropActive: Booleantrue 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.

ImagePickerConfig (legacy — still supported)

ParameterTypeDescription
onPhotoCaptured(PhotoResult) -> UnitCalled when photo is captured or selected
onError(Exception) -> UnitCalled on any error
onDismiss() -> UnitCalled when dismissed without selecting
cameraCaptureConfigCameraCaptureConfigCamera, compression, UI and crop config
enableCropBooleanShow crop UI after capture (default: false)

CameraCaptureConfig

ParameterTypeDefaultDescription
preferenceCapturePhotoPreferenceBALANCEDQuality/speed tradeoff
captureButtonSizeDp72.dpShutter button size
compressionLevelCompressionLevel?MEDIUMCompression — null = no compression
includeExifBooleanfalseExtract EXIF metadata
redactGpsDataBooleantrueStrip GPS from EXIF (privacy)
uiConfigUiConfigUiConfig()Camera UI visual customization
cameraCallbacksCameraCallbacksCameraCallbacks()Camera lifecycle callbacks
permissionAndConfirmationConfigPermissionAndConfirmationConfig…()Permission dialogs & confirmation screen
cropConfigCropConfigCropConfig()Crop UI configuration

GalleryConfig

ParameterTypeDefaultDescription
allowMultipleBooleanfalseEnable multi-file selection
mimeTypesList<MimeType>[IMAGE_ALL]Allowed MIME types
selectionLimitInt30Max items — iOS only
includeExifBooleanfalseExtract EXIF metadata
redactGpsDataBooleantrueStrip GPS from EXIF

CropConfig

ParameterTypeDefaultDescription
enabledBooleanfalseEnable crop UI after capture
aspectRatioLockedBooleanfalseLock aspect ratio during crop
circularCropBooleantrueShow circular crop option
squareCropBooleantrueShow square crop option
freeformCropBooleanfalseShow free-form crop option

PhotoResult / GalleryPhotoResult

GalleryPhotoResult is a typealias for PhotoResult — they are the same model.

FieldTypeDescription
uriString?Platform-native URI of the selected file
fileNameString?File name (reflects cropped name after crop)
fileSizeLong?Size in bytes since v1.0.35 — divide by 1024 for KB
mimeTypeString?MIME type string (e.g. "image/jpeg", "application/pdf")
widthInt?Image width in pixels
heightInt?Image height in pixels
exifExifData?EXIF metadata — populated when includeExif = true
Breaking change v1.0.35: fileSize returns bytes (previously KB). Migrate: val sizeKB = (result.fileSize ?: 0) / 1024.0

ExifData Fields

All fields are nullable. Availability depends on the device, image origin and whether redactGpsData is false.

FieldTypeDescription
GPS & Location
latitudeDouble?GPS latitude (stripped if redactGpsData = true)
longitudeDouble?GPS longitude
altitudeDouble?GPS altitude in metres
Date & Time
dateTimeString?Capture date/time (yyyy:MM:dd HH:mm:ss)
dateTimeOriginalString?Original capture date
dateTimeDigitizedString?Digitized date
Camera & Lens
makeString?Camera manufacturer
modelString?Camera model name
lensModelString?Lens model
focalLengthString?Focal length in mm
apertureString?Aperture (f-number)
Capture Settings
isoString?ISO sensitivity
shutterSpeedString?Shutter speed (APEX)
exposureTimeString?Exposure time in seconds
exposureBiasString?Exposure compensation (EV)
meteringModeString?Metering mode (multi, spot, etc.)
flashString?Flash fired / not fired
whiteBalanceString?White balance mode
Image Properties
imageWidthString?Image pixel width (EXIF tag)
imageHeightString?Image pixel height (EXIF tag)
orientationString?EXIF orientation (1–8)
colorSpaceString?Color space (sRGB, Adobe RGB, etc.)
softwareString?Software used to produce the image
thumbnailOffsetInt?Byte offset of embedded thumbnail
thumbnailLengthInt?Byte length of embedded thumbnail

Changelog

Recent releases and what changed in each version. Full history on GitHub Releases.

v1.0.41 New May 2026
  • New: CameraScaleType enum — controls how the camera preview is scaled inside its viewport (FILL_CENTER, FILL_START, FILL_END, FIT_CENTER, FIT_START, FIT_END). Currently applied on Android only.
  • New: CameraCaptureConfig.cameraScaleType — set the preview scale type via config. Defaults to CameraScaleType.FILL_CENTER (preserves prior behavior). Use FIT_CENTER to letterbox the preview so viewfinder framing matches the captured image.
  • New: PermissionAndConfirmationConfig.confirmationImageContentScale — controls how the captured photo is scaled in the post-capture confirmation screen. Accepts any Compose ContentScale value (e.g. Fit, Crop, FillWidth). Defaults to ContentScale.Crop.
  • Improvement: All Spanish-language inline comments across the codebase translated to English.
v1.0.40 New April 2026
  • New: PhotoResult.absolutePath extension — 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.
v1.0.39 Fix April 2026
  • Fix (Android 7–11): Camera preview was blank/black on Android 11 (API 30) and below. Root cause: PreviewView was hardcoded to ImplementationMode.PERFORMANCE (SurfaceView), which does not render inside Jetpack Compose on these versions.
  • Fix: HighPerformanceConfig.requiresCompatibilityMode() now returns true for SDK ≤ 30 (Android 11 and below), switching to ImplementationMode.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.
v1.0.38 Fix April 2026
  • Fix: Minor stability improvements and dependency updates.
  • Updated Kotlin to 2.3.20, Compose Multiplatform to 1.10.3, AGP to 8.13.2.
v1.0.37 New March 2026
  • New: rememberImagePickerKMP(config) — unified Compose state-holder API. Returns ImagePickerKMPState with launchCamera(), launchGallery() and reactive result. No Render() or manual booleans needed.
  • New: ImagePickerKMPConfig — single configuration object for camera, gallery, crop, UI and permissions.
  • New: ImagePickerResult — sealed hierarchy: Idle | Loading | Success | Dismissed | Error for exhaustive result handling.
  • New: Per-launch overrides — override any parameter in launchCamera() / launchGallery() without mutating the global config.
  • Fix (Android): ImagePickerLauncher is now wrapped in a fullscreen Dialog — fixes camera not visible when placed outside a container.
  • All existing ImagePickerLauncher / GalleryPickerLauncher APIs remain fully compatible.
v1.0.35 Breaking New March 2026
  • Breaking: fileSize now 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.
v1.0.34 New February 2026
  • New: Cloud OCR — Gemini, OpenAI, Claude, Azure, Ollama, Custom endpoint.
  • New: @ExperimentalOCRApi annotation for opt-in usage.
  • New: directCameraLaunch flag in OCR config.
  • Improved error handling with typed ImagePickerException.
v1.0.32 New Fix January 2026
  • 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.
v1.0.28 New December 2025
  • New: Multiple gallery selection with allowMultiple = true.
  • New: MIME type filtering with mimeTypeMismatchMessage.
  • Android smart picker: images → gallery, PDFs → file explorer, mixed → file explorer.
View full changelog on GitHub