Lesson 34 of 83 intermediate

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

Permissions are like asking your flatmate before borrowing something. Runtime permissions are knocking on the door each time you need the camera — they can say yes or deny. Scoped storage is like each flatmate having their own locked room — your app can only access its own room unless specifically invited into shared spaces. The photo picker is a vending machine — users select what to give you without ever handing over their house keys.

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

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. 1. ContextCompat.checkSelfPermission() returns PERMISSION_GRANTED or PERMISSION_DENIED — always check before requesting to avoid unnecessary permission dialogs.
  2. 2. shouldShowRequestPermissionRationale() true after first denial means show an explanation dialog before re-requesting the permission.
  3. 3. cameraPermissionLauncher.launch() triggers the system permission dialog — the callback is called with true/false on the main thread.
  4. 4. After denial with 'Don't ask again', shouldShowRequestPermissionRationale() returns false — at this point the only path is to open app settings.
  5. 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. 6. ActivityResultContracts.PickVisualMedia() launches the system photo picker — no permission required on any API level with the Jetpack backport.
  7. 7. takePersistableUriPermission() preserves read access to the selected URI across app restarts — without this, the URI becomes invalid after the process dies.
  8. 8. MediaStore.Images.Media.RELATIVE_PATH sets the subfolder within Pictures/ — 'Pictures/HaziraKhata' creates a named album in the gallery.
  9. 9. IS_PENDING = 1 hides the file from the gallery scanner until writing is complete — prevents half-written images from appearing.
  10. 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. 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. 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?
Check for deprecated permission APIs, lifecycle-unsafe permission handling, direct file path access on Android 10+, and raw file URI sharing.
Show answer
Bug 1: READ_EXTERNAL_STORAGE is deprecated on Android 13+ (API 33) and no longer grants access to images — it was replaced by READ_MEDIA_IMAGES. Also, requestPermissions() is deprecated and lifecycle-unsafe. Fix: Use the Photo Picker (PickVisualMedia) which requires no permission at all, or request READ_MEDIA_IMAGES on API 33+ and READ_EXTERNAL_STORAGE on API <33. Bug 2: onRequestPermissionsResult() is deprecated and lifecycle-unsafe — it can be called after the Fragment is detached. Fix: Use registerForActivityResult(ActivityResultContracts.RequestPermission()) which is lifecycle-aware and handles Fragment recreation correctly. Bug 3: Writing to getExternalStoragePublicDirectory() via FileOutputStream fails silently or throws IOException on Android 10+ (scoped storage) without MANAGE_EXTERNAL_STORAGE. Fix: Use MediaStore API — ContentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) then write via contentResolver.openOutputStream(uri). Bug 4: Uri.fromFile(file) creates a file:// URI which is blocked for sharing between apps since Android 7 (FileUriExposedException). Fix: Use FileProvider — declare it in the manifest, then use FileProvider.getUriForFile(context, authority, file) and add Intent.FLAG_GRANT_READ_URI_PERMISSION to the share intent.

Explain like I'm 5

Your phone has stuff you own (camera, photos, contacts) that apps want to borrow. Runtime permissions are asking permission each time — like knocking before entering a room. Scoped storage means each app gets its own drawer — it can't snoop in other drawers unless you hand something over. The Photo Picker is like a shop window — you point at exactly what you want to share, and the app only gets that thing, not your whole shop.

Fun fact

The Android Photo Picker introduced in Android 13 doesn't grant your app any read permission to the user's gallery. Instead, it returns a temporary grant URI that your app can read. Even if the user selects a photo, your app cannot access any other photo in their gallery — this is a fundamental privacy-by-design approach that Google is actively expanding to older Android versions via a Jetpack backport.

Hands-on challenge

Build a privacy-safe photo feature: (1) A permission helper that checks CAMERA permission state, shows rationale on first denial, shows a settings dialog on permanent denial, and logs the permission decision to Analytics; (2) A photo capture flow using CameraX or camera Intent that saves the captured photo to app-private storage (getCacheDir()) for processing; (3) A gallery import flow using the Photo Picker (no permissions) with Coil image loading; (4) A save-to-gallery function using MediaStore with IS_PENDING for atomic write; (5) Request READ_MEDIA_IMAGES only if the device is below API 33 and you need direct gallery access.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery