Android Permissions, Storage Changes & Privacy-Safe Handling
Navigate the evolving Android permission landscape without breaking user trust
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Android permissions define what sensitive resources an app can access. The permission model has evolved significantly: runtime permissions (API 23), scoped storage (API 29), granular media permissions (API 33), and the photo picker (API 33+). Building privacy-safe apps means requesting minimum permissions, at the right time, with clear user value — and gracefully handling all denial states.
Real-world relevance
In Hazira Khata school management app, the app needed camera access for attendance photos and storage access for report exports. Scoped storage meant reports were saved to getExternalFilesDir() (no permission needed). For reading student photos uploaded by teachers, the Photo Picker was used — no READ_MEDIA_IMAGES required. CAMERA permission was requested contextually when the attendance feature was opened, with a rationale dialog explaining why.
Key points
- Install-time vs runtime permissions — Install-time (normal) permissions are granted automatically (INTERNET, VIBRATE). Runtime (dangerous) permissions must be explicitly requested and granted by the user at runtime: CAMERA, READ_CONTACTS, ACCESS_FINE_LOCATION, READ_MEDIA_IMAGES. The split is determined by the protection level in the Android framework.
- ActivityResultContracts.RequestPermission — val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> }. Call launcher.launch(Manifest.permission.CAMERA). Never call requestPermissions() directly — the contract-based API handles lifecycle correctly and works in Fragments and Activities.
- shouldShowRequestPermissionRationale() — Returns true when the user has denied the permission once (but not 'Don't ask again'). Show a rationale explaining why you need the permission, then re-request. If it returns false after a denial, the user has permanently denied — redirect to app settings.
- Permission denial states — First request: show rationale if needed, then request. After one denial: shouldShowRequestPermissionRationale()=true — explain and retry. After 'Don't ask again': shouldShowRequestPermissionRationale()=false — show settings deep link: Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).
- Scoped storage (Android 10+) — Apps can no longer access arbitrary file paths with READ_EXTERNAL_STORAGE. Each app has isolated storage: getExternalFilesDir() (no permission needed) and the shared MediaStore (requires READ_MEDIA_* on API 33+). WRITE_EXTERNAL_STORAGE is effectively a no-op on API 29+.
- MediaStore API — ContentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, ...) to query images. ContentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) to save images. Returns a content URI, not a file path. Use ContentResolver.openOutputStream(uri) to write.
- Storage Access Framework (SAF) — ActivityResultContracts.OpenDocument() launches the system file picker — no permissions required. Returns a content URI. Use contentResolver.openInputStream(uri) to read. Persist URI access with contentResolver.takePersistableUriPermission() for long-term access.
- Photo Picker (Android 13+) — ActivityResultContracts.PickVisualMedia() — the recommended approach. Lets users select photos/videos without granting your app access to their entire gallery. No permission required. Returns a content URI valid for the session. Falls back gracefully on older Android versions via Jetpack backport.
- READ_MEDIA_* permissions (Android 13) — Android 13 split READ_EXTERNAL_STORAGE into: READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO. Request only what you need. For displaying images from the gallery, prefer the Photo Picker over requesting READ_MEDIA_IMAGES when possible.
- MANAGE_EXTERNAL_STORAGE caution — Grants access to all files on the device — equivalent to the old unrestricted file access. Google Play requires a policy declaration and review for apps requesting this permission. Approved only for file managers, antivirus, and backup apps. Using it in a regular app will get your app rejected.
- Privacy-first design principles — Request permissions contextually (when the feature is used, not at launch). Request only the minimum scope (fine location only if coarse is insufficient). Provide value first, then request. In Hazira Khata, camera permission was requested when the user tapped 'Take Attendance Photo' — not on app open.
- Content URI vs file path — Content URIs (content://authority/path) are the Android-safe way to share data between apps. File paths (/storage/emulated/0/...) are increasingly restricted. Use FileProvider to share your app's private files with other apps via content URIs, never by exposing raw file paths.
Code example
// 1. Multiple permissions with proper rationale handling
class AttendanceFragment : Fragment() {
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) openCamera()
else handleCameraPermissionDenied()
}
private fun requestCameraForAttendance() {
when {
ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> openCamera()
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
// User denied once — show rationale before asking again
showRationaleDialog(
message = "Camera is needed to take attendance photos.",
onConfirm = {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
)
}
else -> cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun handleCameraPermissionDenied() {
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
// 'Don't ask again' — send to settings
showSettingsDialog()
}
// else: user just denied, they can tap the button again
}
private fun showSettingsDialog() {
AlertDialog.Builder(requireContext())
.setTitle("Camera Permission Required")
.setMessage("Enable camera in Settings to take attendance photos.")
.setPositiveButton("Open Settings") { _, _ ->
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", requireContext().packageName, null)
})
}.show()
}
}
// 2. Photo Picker — no permissions needed (Android 13+ / Jetpack backport)
val photoPickerLauncher = registerForActivityResult(
ActivityResultContracts.PickVisualMedia()
) { uri ->
uri ?: return@registerForActivityResult
// Use content URI to load image — Coil/Glide accept content URIs directly
imageView.load(uri)
// Persist permission for long-term access
requireContext().contentResolver.takePersistableUriPermission(
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
fun openPhotoPicker() {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
// 3. MediaStore — save a report image to shared storage
suspend fun saveReportToGallery(context: Context, bitmap: Bitmap): Uri {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "report_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/HaziraKhata")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.IS_PENDING, 1) // atomic write
}
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
) ?: throw IOException("Failed to create MediaStore entry")
context.contentResolver.openOutputStream(uri)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
context.contentResolver.update(uri, values, null, null)
}
return uri
}Line-by-line walkthrough
- 1. ContextCompat.checkSelfPermission() returns PERMISSION_GRANTED or PERMISSION_DENIED — always check before requesting to avoid unnecessary permission dialogs.
- 2. shouldShowRequestPermissionRationale() true after first denial means show an explanation dialog before re-requesting the permission.
- 3. cameraPermissionLauncher.launch() triggers the system permission dialog — the callback is called with true/false on the main thread.
- 4. After denial with 'Don't ask again', shouldShowRequestPermissionRationale() returns false — at this point the only path is to open app settings.
- 5. Settings.ACTION_APPLICATION_DETAILS_SETTINGS with the package URI opens your app's specific settings page where the user can manually grant permission.
- 6. ActivityResultContracts.PickVisualMedia() launches the system photo picker — no permission required on any API level with the Jetpack backport.
- 7. takePersistableUriPermission() preserves read access to the selected URI across app restarts — without this, the URI becomes invalid after the process dies.
- 8. MediaStore.Images.Media.RELATIVE_PATH sets the subfolder within Pictures/ — 'Pictures/HaziraKhata' creates a named album in the gallery.
- 9. IS_PENDING = 1 hides the file from the gallery scanner until writing is complete — prevents half-written images from appearing.
- 10. contentResolver.openOutputStream(uri) gives you a stream to write bytes — always use .use {} to ensure the stream is closed even if an exception occurs.
- 11. IS_PENDING = 0 update after writing makes the file visible to other apps and the gallery — this is the atomic 'commit' of the write.
- 12. PickVisualMediaRequest(ImageOnly) restricts the picker to images only — use ImageAndVideo or VideoOnly for other media types.
Spot the bug
class ProfileFragment : Fragment() {
fun openGallery() {
// Bug 1 — old approach, fails on Android 13+
if (ContextCompat.checkSelfPermission(requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE) == PERMISSION_GRANTED) {
launchGalleryIntent()
} else {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 100)
}
}
// Bug 2 — deprecated and lifecycle-unsafe
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == 100 && grantResults[0] == PERMISSION_GRANTED) {
launchGalleryIntent()
}
}
fun savePhoto(context: Context, bitmap: Bitmap) {
// Bug 3 — direct file path access, broken on Android 10+
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"profile_photo.jpg"
)
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
}
fun shareFile(context: Context, file: File) {
// Bug 4 — exposes raw file path to other apps
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, Uri.fromFile(file))
}
startActivity(Intent.createChooser(intent, "Share"))
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Request Runtime Permissions — Android Developers (Android Docs)
- Storage in Android — Scoped Storage Overview (Android Docs)
- Photo Picker — Android Developers (Android Docs)
- MediaStore API Guide (Android Docs)
- Privacy Changes in Android 13 (Android Docs)