Camera, photos and the media picker
When Priya sells onion at Lasalgaon, the commission agent hands her a patti, a sale slip: the weight, the rate she got, his commission and what she is owed. It is a scrap of paper that gets wet and lost, and it is the only record of whether she sold above or below the day's modal price. This lesson lets her keep a photo of it in Mandi Bhav, next to the price, taken with the camera or chosen from her gallery. The interesting part is what the feature does not need. It asks for no permission at all, and the two most common ways of building it, both still all over tutorials and older code, either crash or get the app questioned by Google Play.
Four ways to get a picture
| Way | Permission | What the app sees | Use it when |
|---|---|---|---|
Photo picker (PickVisualMedia) |
None | Only the pictures the user chose | Almost always |
Another app's camera (TakePicture with a FileProvider) |
None | The one photo it took | You need a new photo, not your own camera screen |
| Your own camera screen (CameraX) | CAMERA, asked at runtime |
Every frame, while the screen is open | Scanning, filters, a custom capture UI |
The whole library (MediaStore query) |
READ_MEDIA_IMAGES on Android 13 and later, with READ_MEDIA_VISUAL_USER_SELECTED from 14 |
Every picture, or the ones the user allowed | Gallery and photo-editing apps, whose core purpose is the library |
Mandi Bhav needs one picture at a time, so it uses the first two. The last row is restricted by Google Play: since 28 May 2025, an app targeting Android 13 or later may request READ_MEDIA_IMAGES only if a system picker cannot provide its core function, and must explain why in a declaration in the Play Console. A price app that asks for a farmer's whole photo library for one receipt would be refused, and should be.
Where the slip lives
A slip is one photo per commodity per market, the last one she attached, with the date of the price it went with. Two parts, as with prices: a row in Room and a file on disk. data/local/SaleSlipEntity.kt:
@Entity(tableName = "sale_slips", primaryKeys = ["market", "commodity"])
data class SaleSlipEntity(
val market: String,
val commodity: String,
val date: LocalDate,
val fileName: String,
)
A new table is schema 6, which Room migrates by itself (AutoMigration(5, 6), tested from 5 and from 1). The row holds the file's name; the picture is a file under files/slips/ in the app's private storage. No other app can read it, it needs no permission, and it goes when the app is uninstalled. The backup rules from module 8 include only the home market, so slips stay on this phone; a Practice question asks whether they should.
FileSaleSlipRepository.attach copies whatever picture it is given into a new file, and only then points the row at it:
override suspend fun attach(market: String, commodity: String, date: LocalDate, source: Uri) =
withContext(Dispatchers.IO) {
slipsDir.mkdirs()
val copy = File(slipsDir, "${UUID.randomUUID()}.jpg")
try {
val input = context.contentResolver.openInputStream(source)
?: throw IOException("Could not open $source")
input.use { from -> copy.outputStream().use { to -> from.copyTo(to) } }
if (copy.length() == 0L) throw IOException("$source was empty")
} catch (e: IOException) {
copy.delete()
throw e
} catch (e: SecurityException) {
copy.delete()
throw IOException("No longer allowed to read $source", e)
}
val before = dao.get(market, commodity)
dao.upsert(SaleSlipEntity(market, commodity, date, copy.name))
before?.let { File(slipsDir, it.fileName).delete() }
// ...
}
A new name every time, so Coil never shows yesterday's picture from its cache under today's name, and the old file is deleted only after the new row is saved: a failed copy leaves the slip exactly as it was. FileSaleSlipRepositoryTest runs all of this under Robolectric against real files.
The photo picker
val pickPhoto = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
// null: the user backed out of the picker.
if (uri != null) viewModel.attach(price.date, uri)
}
OutlinedButton(onClick = {
pickPhoto.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}) { Text("Choose photo") }
On the emulator, "Choose photo" opened the system's picker in a half-height sheet, with the line "Mandi Bhav will only have access to the photos you select". Choosing a picture of a slip (a 38,272-byte JPEG pushed to the emulator for the test) put a 38,272-byte copy in files/slips/, and dumpsys package still listed no permission beyond location and notifications. The picker runs in another process and hands back a Uri for the one picture:
content://media/picker/0/com.android.providers.media.photopicker/media/28
On phones without the picker, PickVisualMedia falls back to the system's document picker, ACTION_OPEN_DOCUMENT, by itself; Android's documentation lists which older phones get the picker through Google Play system updates.
The trap: keeping the Uri
The obvious design keeps that Uri in the database and shows the picture from it. A scratch build did exactly that, then tried to read the kept Uri when the app next started:
I M11Uri : read content://media/picker/0/com.android.providers.media.photopicker/media/28 again: java.lang.SecurityException: Calling uid ( 10240 ) does not have permission to access picker uri: content://media/picker/0/com.android.providers.media.photopicker/media/28
That was after a force-stop and a relaunch. A Uri from the picker is a loan, not a gift: access lasts until the app stops or the phone restarts. The slip would have shown fine for the rest of the day, and been a grey box the next morning. Mandi Bhav reads the picture once, now, and keeps its own copy. If an app genuinely needs the original kept (to upload a large video later, say), contentResolver.takePersistableUriPermission keeps the grant; it is capped, and the original can still be deleted by its owner, so a copy is the safer default for something the user expects to keep.
The camera, without the camera permission
The camera app on the phone already knows how to take a photo. Mandi Bhav asks it to, with ActivityResultContracts.TakePicture, and gives it somewhere to write: a file in Mandi Bhav's cache, offered as a content:// Uri through a FileProvider, which grants the camera app write access to that one file for that one request. In the manifest:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
and res/xml/file_paths.xml, the only folder it will hand out:
<paths>
<cache-path name="camera" path="camera/" />
</paths>
${applicationId} matters: with it, a debug build with a suffix gets its own authority, and two builds on one phone do not clash. The launcher:
var pendingCapture by rememberSaveable { mutableStateOf<Uri?>(null) }
val takePhoto = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { saved ->
val capture = pendingCapture
pendingCapture = null
if (saved && capture != null) viewModel.attach(price.date, capture)
}
The camera does not return the photo, only true or false, so the app has to remember where it asked for it to be written. On the emulator, "Take photo" started the camera app with the Uri in the intent:
START u0 {act=android.media.action.IMAGE_CAPTURE flg=0x3 … cmp=com.android.camera2/com.android.camera.CaptureActivity clip={text/uri-list hasLabel(0) {U(content)}} (has extras)}
flg=0x3 is read and write permission on that Uri, and nothing else. Shutter, Done, and the slip appeared under the price: 63,379 bytes from the emulator's virtual camera.
Three things went wrong on the way, each found by running it.
Trap one: declaring CAMERA. Plenty of code declares the camera permission "because the app uses the camera". A scratch build added the line to the manifest and never asked for it. "Take photo":
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: java.lang.SecurityException: Permission Denial: starting Intent { act=android.media.action.IMAGE_CAPTURE … } from ProcessRecord{… com.riztechacademy.mandibhav.m11/u0a240} (pid=16018, uid=10240) with revoked permission android.permission.CAMERA
An app that declares CAMERA and has not been granted it may not start the camera app at all. Without the line, it needs nothing. You need CAMERA only when your own code opens the camera, with CameraX or Camera2, and then you ask for it like any runtime permission, from the button. Check the merged manifest after adding a library, because a library can add it for you.
Trap two: the process dies while the camera is open. On a cheap phone, the camera app is large, and Android may kill Mandi Bhav's process while it is in front. adb shell am kill does that on purpose, between Shutter and Done. In a scratch build with remember in place of rememberSaveable:
I M11Proc : process started, pid 17524
I M11Cam : saved=true pendingCapture=null
The camera said it saved the photo, and it had: a 63,863-byte file sat in cache/camera/. But the new process had forgotten where it asked for it, the slip stayed empty, and Priya had to take the photo again. With rememberSaveable, the same test gave a new process (pid 14303, from 14081) and the slip attached. The Uri is Parcelable, so the saver needs no help.
Trap three: disk on the main thread. The first version made the file and its Uri in the button's onClick. StrictMode, which module 9 turned on for debug builds, logged it at once:
D StrictMode: StrictMode policy violation; ~duration=28 ms: android.os.strictmode.DiskReadViolation
D StrictMode: at android.content.ContextWrapper.getCacheDir(ContextWrapper.java:328)
D StrictMode: at com.riztechacademy.mandibhav.data.FileSaleSlipRepository.newCameraCapture(SaleSlipRepository.kt:97)
followed by a DiskWriteViolation from mkdirs() and the FileProvider's own path check. Twenty-eight milliseconds on the emulator's fast disk is a dropped frame on a farmer's phone. newCameraCapture is now a suspend function on Dispatchers.IO, launched from rememberCoroutineScope(), and the slip's Flow is moved there with flowOn(Dispatchers.IO) after StrictMode caught getFilesDir too.
A phone with no camera app makes launch throw ActivityNotFoundException; the section catches it and says "There is no camera app on this phone. Choose a photo instead." A phone with no camera at all does not show the button (FEATURE_CAMERA_ANY).
When an app truly needs the library
If you are ever building the app the last row of the first table is for, the shape since Android 14 is this. Declare READ_MEDIA_IMAGES (and READ_MEDIA_VIDEO if you need videos) plus READ_MEDIA_VISUAL_USER_SELECTED, and request them together. The dialog offers full access, "select photos", or nothing. With "select photos", the app holds only READ_MEDIA_VISUAL_USER_SELECTED, its MediaStore queries return only the chosen items, and it must offer a button to choose more. An app that does not declare the new permission still works, in a compatibility mode where the access is temporary. It is a great deal of code and a Play declaration to get what the picker gives in five lines.
What it costs
The copy costs storage: one slip per commodity per market, so a farmer who sells three crops at two markets keeps six files. The emulator's photos were about 60 KB; a real phone's camera writes far larger files. Shrinking the photo before saving it is the harder Practice question. And a photo of a slip is personal financial information: Mandi Bhav keeps it in private storage and never uploads it. If a later version sends slips anywhere, that changes what the app must declare in Play's Data safety section, and what it owes the farmer in explanation.
Check your work
Why CAMERA is not in Mandi Bhav's manifest. The camera app takes the photo, so Mandi Bhav needs no permission; and declaring CAMERA without being granted it makes ACTION_IMAGE_CAPTURE throw SecurityException.
Why the picked picture is copied, not its Uri kept. The picker's grant lasts until the app stops; after a force-stop, reading the kept Uri threw SecurityException.
Why pendingCapture is in rememberSaveable. The camera returns only true or false. If the process dies while the camera is open, a plain remember loses the Uri, and the photo is left unattached in the cache.
What the FileProvider gives the camera app. Read and write access to one content:// Uri, for that request (flg=0x3), under a folder the paths file names.
When an app needs READ_MEDIA_IMAGES. Only when browsing the whole library is its core function, such as a gallery; Play requires a declaration, and everyone else uses the picker.
Practice
- Add the slips: entity and DAO, schema 6 and its migration test,
FileSaleSlipRepositoryand its tests, theFileProvider, andSaleSlipSectionunder the price. - Push a picture to the emulator (
adb push slip.jpg /sdcard/Pictures/, thenadb shell content call --uri content://media --method scan_file --arg /sdcard/Pictures/slip.jpg), choose it, and compare its size with the copy infiles/slips/usingadb shell run-as. - Take a photo, and between Shutter and Done run
adb shell am kill com.riztechacademy.mandibhav. Confirm the slip attaches, and that the process id changed. - Break it on purpose, one at a time, in a scratch copy under another
applicationIdSuffix: declareCAMERA; userememberforpendingCapture; keep the picker's Uri instead of copying it. Read what each one does. - Decide whether slips should be in the cloud backup, and write down the argument on each side. If yes, change the backup rules and say what restoring them to a new phone needs.
- Harder: shrink each slip to at most 1,600 pixels on its long side before saving. Read the size first with
BitmapFactory.Options.inJustDecodeBounds, decode with aninSampleSize, and find out what happens to a photo taken with the phone held sideways.
Official documentation
- Photo picker — The contracts, which phones have it, how long access lasts, and
takePersistableUriPermission. - FileProvider — Every path element the paths file accepts, and how grants work.
- Grant partial access to photos and videos — Android 14's selected-photos access, for the apps that truly need the library.
- Restricted permissions with minimum-scope alternatives — Play's rule for
READ_MEDIA_IMAGESandREAD_MEDIA_VIDEO, the picker it expects instead, and the declaration.
Next: location, and how to find the nearest market without knowing where the farmer is.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship