Permissions & Privacy
Irbisa · cheatsheetSeptember 13, 2026

Permissions & Privacy

Runtime permissions, purpose strings, ATT, location, photos, privacy manifests, consent, SDK audit

Middle Developer16 itemscompressed for a skim
  1. 01

    The iOS build is killed the instant the scanner opens, while the same Android build just shows a dialog. How do the two platforms ask?

    Easy

    iOS has no generic "request permission" call — the system prompts on first use of the API, and it terminates your process if the matching purpose string is missing from Info.plist.

    // Info.plist — the sentence the user reads, verbatim. A missing key kills the process at the
    // call site with "attempted to access privacy-sensitive data without a usage description".
    // <key>NSCameraUsageDescription</key>
    // <string>Take photos of your receipts so we can file them for you.</string>
    
    func openScanner() async -> Bool {
    …
  2. 02

    Onboarding asks for location, contacts and notifications on the first screen, and most users deny all three. What do you change?

    Easy

    Move each request to the moment the user does the thing that needs it, and put your own explanation in front of the system prompt — a denial is close to permanent on both platforms, so the prompt is something you spend once.

    // One feature, one permission, asked at the tap that starts the feature.
    @Composable
    fun ExportToCalendarButton(vm: ExportViewModel) {
      val ctx = LocalContext.current
      var showPrimer by remember { mutableStateOf(false) }
    …
  3. 03

    A user who granted the camera three months ago is denied on reopening, and your rationale check reads false on a fresh install too. What is going on?

    Medium

    Two unrelated mechanisms: Android auto-reset revoked the old grant while the app went unused, and shouldShowRequestPermissionRationale returns false in two opposite situations, so on its own it cannot tell "never asked" from "permanently denied".

    // The platform tracks two states. The third one you have to persist yourself.
    class PermissionGate(private val prefs: DataStore<Preferences>) {
      private fun askedKey(permission: String) = booleanPreferencesKey("asked_$permission")
    
      suspend fun state(activity: Activity, permission: String): PermissionState = when {
        ContextCompat.checkSelfPermission(activity, permission) == PERMISSION_GRANTED ->
    …
  4. 04

    A courier app needs the driver's location while it is in the background. Which permissions do you request, in what order, and what will the stores ask you for?

    Medium

    Foreground first, background as a separate later request that the user often answers in Settings — and background location is a reviewed feature on both stores, not just another manifest line.

    // Foreground first. Coarse and fine go in one request, because the dialog may return coarse only.
    private val foreground = registerForActivityResult(
      ActivityResultContracts.RequestMultiplePermissions()
    ) { result ->
      when {
        result[Manifest.permission.ACCESS_FINE_LOCATION] == true ->
    …
  5. 05

    A PR adds READ_MEDIA_IMAGES and full photo-library access so users can attach an avatar. What do you say in review?

    Medium

    Attaching a photo needs no permission at all on either platform: the system pickers run outside your process and hand back exactly the items the user chose.

    // Attaching a photo: no permission, no manifest entry, nothing for the user to deny.
    val pickAvatar = registerForActivityResult(
      ActivityResultContracts.PickVisualMedia()
    ) { uri -> if (uri != null) upload(uri) }   // a read grant for exactly this one item
    
    fun chooseAvatar() =
    …
  6. 06

    QA says the microphone indicator stays lit for minutes after a voice note was sent. Why does that matter, and what do you fix?

    Medium

    The indicator follows the session, not the recording: an AVAudioSession left active keeps the hardware claimed, and the user reads a lit dot as the app listening after it was told to stop.

    final class VoiceNoteRecorder {
      private let session = AVAudioSession.sharedInstance()
      private var recorder: AVAudioRecorder?
    
      func start(to url: URL, settings: [String: Any]) throws {
        try session.setCategory(.record, mode: .default)
    …
  7. 07

    Marketing wants a second notification prompt for everyone who said no. What can you actually offer them on each platform?

    Medium

    Not a second prompt — both platforms give you exactly one, and after a denial the only route is a deep link into Settings; what you can offer is spending the prompt later, and on iOS not spending it at all.

    // The prompt appears only while the status is .notDetermined. After that this is a plain read.
    func askForNotifications() async -> Bool {
      let center = UNUserNotificationCenter.current()
      let settings = await center.notificationSettings()
    
      switch settings.authorizationStatus {
    …
  8. 08

    You add contact upload for friend-finding and a workout screen that reads health data. What will the review teams want to see before either one ships?

    Medium

    A prominent disclosure before the system prompt, a working in-app path a reviewer can walk without your credentials, and a privacy policy that lists exactly what your code sends — these categories are judged by a person, not by a lint rule.

    // Health Connect: one permission per record type, plus a rationale screen the platform launches.
    private val healthPermissions = setOf(
      HealthPermission.getReadPermission(StepsRecord::class),
      HealthPermission.getWritePermission(ExerciseSessionRecord::class),
    )
    …
  9. 09

    Your upload is rejected by email citing ITMS-91053 for an SDK you did not write — what is the privacy manifest actually checking?

    Medium

    A privacy manifest (PrivacyInfo.xcprivacy) is a property list Apple reads at upload time, and ITMS-91053 means a required-reason API is used somewhere in the build without an approved reason code — usually inside a dependency, which has to declare it in its own manifest.

    <!-- PrivacyInfo.xcprivacy — one per target, at the resource root -->
    <plist version="1.0">
    <dict>
      <!-- true here obliges you to run ATT before any domain below becomes reachable -->
      <key>NSPrivacyTracking</key><false/>
      <key>NSPrivacyTrackingDomains</key>
    …
  10. 10

    An analytics event carries the user's search string and a crash breadcrumb carries their email — what does that do to the Data safety form and the App Store label?

    Medium

    Both forms are claims about what leaves the device, so those two fields change the answer: a free-text search string is user content rather than app activity, and an email in a breadcrumb moves crash data from "not linked to you" into "linked to you".

    // Every field here picks a row on both forms. Write the mapping down where the
    // payload is built, not in a spreadsheet nobody opens.
    fun trackSearch(query: String, resultCount: Int) {
      analytics.log("search_performed", mapOf(
        "query" to query,          // ❌ raw text => Play "user-generated content",
                                   //    Apple "Search History", and linked once a user id rides along
    …
  11. 11

    Marketing says iOS installs stopped attributing and asks you to send the IDFA anyway — what do you tell them?

    Medium

    Without an authorised ATT prompt the advertising identifier reads as all zeros, and substituting some other durable signal is fingerprinting, which Apple bans outright — attribution moves to the platform's aggregated postbacks instead.

    import AppTrackingTransparency
    import AdSupport
    
    // ❌ nothing happens: at launch the app is not active yet, so the alert is skipped
    // func application(_ app: UIApplication, didFinishLaunchingWithOptions: ...) -> Bool {
    //   ATTrackingManager.requestTrackingAuthorization { _ in }   // returns .notDetermined
    …
  12. 12

    Every analytics call sits behind a consent flag, yet the DPO catches a request to the vendor before the dialog is answered — where did it come from?

    Medium

    Almost always from auto-initialisation: Firebase and most SDKs install a ContentProvider or an androidx.startup initialiser that runs before Application.onCreate, so your flag is never consulted at all.

    // AndroidManifest.xml — the only place that stops auto-init, because it runs first
    // <meta-data android:name="firebase_analytics_collection_enabled"   android:value="false"/>
    // <meta-data android:name="firebase_crashlytics_collection_enabled" android:value="false"/>
    // ❌ android:name="firebase_analytics_collection_deactivated" value="true"
    //    permanently off — setAnalyticsCollectionEnabled(true) will not revive it
    …
  13. 13

    A minor SDK bump adds READ_PHONE_STATE to your release manifest — how do you find that, and what are your options before shipping?

    Medium

    Library manifests are merged into yours, so any dependency can contribute a uses-permission nobody on your team wrote; the merged manifest tells you it happened and the merger report tells you which library did it.

    // build.gradle.kts (:app) — fail the build when a dependency smuggles a permission in
    import com.android.build.api.artifact.SingleArtifact
    
    abstract class CheckPermissions : DefaultTask() {
        @get:InputFile abstract val mergedManifest: RegularFileProperty
        @get:InputFile abstract val allowList: RegularFileProperty
    …
  14. 14

    A user taps "Delete my account" — what has to happen beyond deleting the row, and what do you tell them about your backups?

    Hard

    Both stores require the deletion to start inside the app, and the row is the easy part: the work is fanning the delete out to every processor, wiping the device, and being honest that backups age out on a retention clock rather than vanishing on the tap.

    func deleteAccount() async throws {
      // 1. The server owns the fan-out. The client must not try to delete per vendor.
      try await api.post("/v1/account/delete")   // enqueues a durable, retried job
    
      // 2. Sign in with Apple has to be revoked explicitly, or the link survives deletion
      if let refreshToken = keychain.string(for: .appleRefreshToken) {
    …
  15. 15

    Product wants to open the app to under-13s — what changes in the store listing, in the ad stack, and in what you may collect at all?

    Hard

    A child audience switches most of your telemetry off — no advertising id, no behavioural ads, no third-party analytics you cannot personally vouch for — and the declaration in each console is what makes the rest of the rules apply to you.

    // One switch, applied before any SDK initialises, not sprinkled per call site
    enum class Audience { CHILD, TEEN, ADULT }
    
    fun configureForAudience(audience: Audience) {
      val childDirected = audience == Audience.CHILD
    …
  16. 16

    A reviewer asks you to prove the app only sends what your privacy form claims — what evidence can you actually produce?

    Hard

    Three artefacts: a data-flow inventory reviewed like code, a capture from a real device on a fresh install, and a CI gate that fails when a build contacts a host the inventory does not list.

    #!/usr/bin/env bash
    set -euo pipefail
    # Fresh install, permissions denied, consent refused: nothing outside the inventory
    # may be contacted. Run it again with consent granted for the second expectation.
    ALLOWED=infra/privacy/allowed-hosts.txt
    …