Introduction
Managing photos on Android has evolved significantly over the past few years, and as Android developers building with Kotlin, we now have cleaner, safer options for letting users pick existing images or capture new ones. Camera and image picking for Android & Kotlin developers is no longer about requesting broad file permissions and hoping for the best—modern APIs give us granular control, better user experience, and compliance with platform expectations.
In this article, we'll walk through the practical approaches we use in production apps: the Photo Picker for zero-permission image selection, CameraX for custom camera experiences, compression strategies before upload, and defensive handling of large image files that can easily blow up memory on mid-range devices.
Photo Picker needs no permissions
The Photo Picker is one of the best gifts Google gave us. Introduced in Android 12 (API 31) and backported to Android 11 and 10 via Google Play Services, it lets users select images without your app requesting READ_EXTERNAL_STORAGE or READ_MEDIA_IMAGES. This is a game-changer for privacy-conscious users and reduces friction in app onboarding.
In our experience across several e-commerce and SaaS projects, users are far more comfortable granting temporary access to a single image than blanket media permissions. The Photo Picker handles that elegantly.
To integrate it, add the dependency in your build.gradle.kts:
dependencies {
implementation("androidx.activity:activity-ktx:1.8.1")
}
Then set up a contract and launcher in your Fragment or Activity:
private val pickImage = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri: Uri? ->
uri?.let { selectedImageUri ->
// Handle the URI
viewModel.onImageSelected(selectedImageUri)
}
}
private fun launchPhotoPicker() {
pickImage.launch(
PickVisualMediaRequest(
mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
}
The returned URI is temporary—you'll need to read it immediately or copy it to persistent storage. We typically do this in a ViewModel or repository, reading the bitmap synchronously on a background coroutine:
fun onImageSelected(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return@launch
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
// Now compress and save or upload
_imageEvent.postValue(bitmap)
} catch (e: Exception) {
_errorEvent.postValue("Failed to load image: ${e.message}")
}
}
}
One important note: the Photo Picker only provides read access to that specific image. You don't get permission to delete or modify it, which is exactly what we want from a security perspective.
CameraX for custom camera screens
When you need a custom camera experience—think a profile photo crop overlay, document scanning alignment guides, or QR code scanning in a trading or ride-hailing app—CameraX is your foundation. It abstracts away the complexity of the low-level Camera2 API and handles device-specific quirks gracefully.
CameraX gives you three use cases: Preview (what the user sees), ImageCapture (when they tap the button), and ImageAnalysis (real-time frame processing). Most of our apps use Preview and ImageCapture together.
Add the dependency:
dependencies {
implementation("androidx.camera:camera-core:1.3.0")
implementation("androidx.camera:camera-camera2:1.3.0")
implementation("androidx.camera:camera-lifecycle:1.3.0")
}
Set up a Composable or Fragment with a PreviewView:
class CameraFragment : Fragment() {
private lateinit var cameraProvider: ProcessCameraProvider
private lateinit var imageCapture: ImageCapture
private val cameraExecutor = Executors.newSingleThreadExecutor()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val previewView = binding.previewView
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
cameraProviderFuture.addListener({
cameraProvider = cameraProviderFuture.result
bindCameraUseCases(previewView)
}, ContextCompat.getMainExecutor(requireContext()))
}
private fun bindCameraUseCases(previewView: PreviewView) {
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
viewLifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (e: Exception) {
Log.e("CameraFragment", "Binding failed", e)
}
}
fun captureImage() {
val outputOptions = ImageCapture.OutputFileOptions.Builder(
File(context?.cacheDir, "temp_image.jpg")
).build()
imageCapture.takePicture(
outputOptions,
cameraExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
val savedUri = outputFileResults.savedUri
Log.d("CameraFragment", "Image saved: $savedUri")
viewModel.onCaptureComplete(savedUri)
}
override fun onError(exception: ImageCaptureException) {
Log.e("CameraFragment", "Capture failed", exception)
}
}
)
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
}
Don't forget to request CAMERA permission in your manifest and at runtime. For Android 14+, also include READ_MEDIA_VISUAL_USER_SELECTED if you're combining camera with photo picking.
Compress images before uploading
Raw images from the camera are massive. A single 12MP shot can be 4–6 MB. When you're uploading hundreds of thousands of images across your e-commerce or travel platform, that bandwidth cost and server storage adds up fast. We always compress.
Kotlin's standard library doesn't include image compression, so we lean on the system's Bitmap class and write to JPEG:
object ImageCompression {
fun compressImage(
inputFile: File,
outputFile: File,
quality: Int = 80,
maxWidth: Int = 1920,
maxHeight: Int = 1920
): File {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(inputFile.absolutePath, options)
val sampleSize = calculateInSampleSize(
options.outWidth,
options.outHeight,
maxWidth,
maxHeight
)
options.inJustDecodeBounds = false
options.inSampleSize = sampleSize
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath, options)
?: throw IllegalStateException("Failed to decode bitmap")
FileOutputStream(outputFile).use { fos ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
bitmap.recycle()
return outputFile
}
private fun calculateInSampleSize(
width: Int,
height: Int,
maxWidth: Int,
maxHeight: Int
): Int {
var inSampleSize = 1
if (height > maxHeight || width > maxWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= maxHeight && halfWidth / inSampleSize >= maxWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
}
In a real app, we'd run this on a background thread (Dispatchers.IO) and show progress. A quality of 80 gives us good visual fidelity with significant size reduction. For thumbnails, we drop it to 70 or even 60.
If you're handling lots of images (like a gallery upload scenario), consider using a library like Coil or Glide which have built-in compression and caching. But for a one-off upload in a profile photo flow, manual compression is lightweight and explicit.
Handle large images carefully
Even after compression, you need to be cautious. A mid-range Android device might have only 1–2 GB of RAM, and a large bitmap decoded all at once can trigger an OutOfMemoryError that crashes the app.
When loading an image for display (not manipulation), always use inSampleSize in BitmapFactory.Options to downsample before decoding:
fun decodeSampledBitmap(
file: File,
reqWidth: Int,
reqHeight: Int
): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(
options.outWidth,
options.outHeight,
reqWidth,
reqHeight
)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
?: throw IllegalStateException("Failed to decode bitmap")
}
In production, we rarely handle bitmaps directly anymore. Instead, we pass URIs to image libraries like Coil or Glide, which handle memory efficiently:
Coil.imageLoader(context).enqueue(
ImageRequest.Builder(context)
.data(imageUri)
.target { drawable ->
imageView.setImageDrawable(drawable)
}
.build()
)
For image upload pipelines in a CRM or SaaS backend, we also implement retry logic with exponential backoff in case the network hiccup. A 5 MB upload on a 3G connection might take 30 seconds—users need feedback and patience.
Common mistakes
- Forgetting to close streams: After reading an image URI from the Photo Picker, always close the InputStream. Leaking file descriptors will eventually crash the app.
- Not requesting CAMERA permission: CameraX will silently fail at runtime if you skip the manifest entry or runtime request. Test on a device, not just the emulator.
- Decoding full-resolution images into memory: A 12 MP photo decoded without downsampling can be 50+ MB in RAM. Always use inSampleSize or delegate to a library.
- Ignoring device rotation: The camera captures in device orientation, but the EXIF metadata might not match. Libraries like Coil handle this; manual Bitmap code doesn't.
- Compressing already-compressed images: JPEG is lossy. Re-encoding a JPEG multiple times degrades quality. Compress once and store the compressed version.
Key takeaways
- Use Photo Picker (ActivityResultContracts.PickVisualMedia) for user-selected images—no permissions required, better privacy compliance.
- Reach for CameraX when you need a custom camera UI; it handles device quirks and lifecycle binding cleanly.
- Always compress images to 70–80 JPEG quality before upload; raw camera images waste bandwidth and storage.
- Load bitmaps with inSampleSize or use Coil/Glide to avoid OutOfMemoryError on mid-range devices.
- Test image flows on real devices with limited RAM; emulators mask memory pressure issues.
- Close streams, handle exceptions gracefully, and give users progress feedback during uploads.






Comments
Loading comments…