On Android you declare the permission in the manifest and request it yourself at runtime; iOS has no generic request call: the system prompts on first use of the API and kills the app if the purpose string is missing from Info.plist.
Android: a dangerous permission needs a <uses-permission> entry plus a request through ActivityResultContracts.RequestPermission, at a moment you choose
iOS: each framework has its own call (AVCaptureDevice.requestAccess, CLLocationManager.requestWhenInUseAuthorization), and the NS…UsageDescription text is shown in the alert word for word
Both: the user can revoke a grant in Settings at any time, so check the status right before using the feature, not once at launch
AndroidMiddle
Permissions & Privacy
Runtime permissions, purpose strings, ATT, location, photos, privacy manifests, consent, SDK audit
Questions closed
0 / 19
- 0Review
- 2Asked often
Questions
Since Android 11 the user can grant a one-time permission that is revoked soon after the app leaves the screen, and since Android 12 a location request can come back as coarse only. Both are normal outcomes your code must handle, not errors.
One-time: offered for location, camera and microphone; the grant lives while the app is visible and a short time after, so call checkSelfPermission again every time the feature starts
Approximate: the user may grant ACCESS_COARSE_LOCATION only, accurate to a few square kilometres; the feature should keep working in a reduced mode
Ask for both together: on Android 12+ a request for ACCESS_FINE_LOCATION alone is ignored, so request fine and coarse in one call
Ask at the moment the user starts the feature that needs the permission, and show your own short explanation first. Each system prompt can be shown only once or twice, so asking before the user has seen any value wastes it.
Primer screen: what the feature does and what data it uses, with a Not now button that never opens the system dialog; only people who tap Continue reach the real prompt
Do not imitate the system dialog: a fake Allow/Deny that never opens the real prompt misleads users and risks store rejection
Plan the denied path: the feature degrades (manual address entry, invite by link) instead of hitting a dead end
registerForActivityResult is called inside the click listener, after the fragment is already created, so it throws IllegalStateException: "Fragments must call registerForActivityResult() before they are created". Register once as a property and only launch from the click.
Why the rule exists: the result can arrive after the activity was recreated or the process was killed; the new instance receives it only if it registers the same callback, in the same order, during creation
Activity version: the same mistake fails with "LifecycleOwners must call register before they are STARTED"; in Compose rememberLauncherForActivityResult handles this for you
Check the manifest too: without <uses-permission android:name="android.permission.CAMERA"/> the launcher returns false at once and no dialog appears
Handle the denial: the callback ignores false, so for a user who said no the button silently does nothing
Not from that call alone: it returns true only after a denial the user can still reverse, and false both before the first request and after a permanent denial. Record the result of your own request: a denial with false rationale means blocked, send to Settings.
Never asked: false and no blocked flag: just request
Denied once: true: show your rationale, then request again
Blocked: the callback got a denial and rationale is false: on Android 11+ two denials make it permanent and the launcher returns false with no dialog; only an explicit Deny counts toward the two denials
The way back: only Settings.ACTION_APPLICATION_DETAILS_SETTINGS; the user leaves your app, so recompute the state in onResume
Grants also vanish on their own: auto-reset revokes runtime permissions of apps unused for a few months (Android 11, backported to 6.0+ through Play services), so check at the call site every time
Many: when a system UI acts on the user's explicit choice, your app receives only the result (one photo, one contact, one SMS) and never needs the broad permission. That means no prompt, no denial path to design and an easier Play review.
Photos and files: the Photo Picker and OpenDocument instead of READ_MEDIA_*
Taking a photo: TakePicture hands the job to the camera app; but if your merged manifest declares CAMERA and it is not granted, ACTION_IMAGE_CAPTURE throws SecurityException
Contacts: PickContact grants read access to the one contact the user chose, without READ_CONTACTS
Calls: ACTION_DIAL opens the dialer with the number filled in; only ACTION_CALL needs CALL_PHONE
SMS codes: the SMS Retriever or SMS User Consent API instead of READ_SMS, which Play allows mainly for default SMS apps
Nearby devices: CompanionDeviceManager shows a system device chooser and pairs without BLUETOOTH_SCAN or location; if you scan yourself, BLUETOOTH_SCAN with usesPermissionFlags="neverForLocation" drops the location requirement but is still a runtime permission
The Photo Picker is a system UI that returns only the items the user picks, with no permission at all. Partial access (Android 14) lets the user answer a READ_MEDIA_* request with "selected photos only". For an avatar or a few attachments the picker is the default choice.
No permission, no denial: ActivityResultContracts.PickVisualMedia and PickMultipleVisualMedia; where the picker is unavailable, androidx falls back to ACTION_OPEN_DOCUMENT
Play policy: READ_MEDIA_IMAGES and READ_MEDIA_VIDEO need a declaration and are approved only when broad library access is the app's core function, such as a gallery or a backup app
Partial access: declare READ_MEDIA_VISUAL_USER_SELECTED and request it together with READ_MEDIA_IMAGES; a user who picks some photos grants only the first, and the app must show that state, not an error
Without that declaration: the system gives temporary access to the chosen items, which ends when the app goes to the background
Changing the selection: only a new request lets the user add photos, so give them a button for it
The app saves the picker's content:// URI but not the right to read it. The photo picker grants temporary access that ends when the app process stops, so after a restart openInputStream throws SecurityException.
Why it works at first: the read grant is tied to your running process and lasts until the app stops or the device restarts; the string in SharedPreferences outlives it, so the bug appears only after a cold start, process death or a reboot
The fix: takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) in the callback asks the system to keep the grant across restarts and reboots; it works for picker URIs and for ACTION_OPEN_DOCUMENT, the androidx fallback
Clean up: the number of persisted grants per app is capped, so call releasePersistableUriPermission on the old URI when the user picks a new avatar
Often simpler: copy the image into filesDir or upload it right after picking and store your own path: the user can delete or edit the original in the gallery anyway, and no grant is needed
Catching it: pick a photo, run adb shell am force-stop <package>, reopen; the avatar must still load or show a placeholder
Request fine and coarse location while the app is visible and run the trip in a foreground service of type location; background location is usually not needed. ACCESS_BACKGROUND_LOCATION is only for location with no visible service, such as geofencing.
Step 1: request ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION together and accept coarse as a degraded mode
Step 2, for the delivery: start a service with foregroundServiceType="location", the FOREGROUND_SERVICE_LOCATION permission (Android 14) and an ongoing notification; it cannot be started from the background
Step 3, only if truly needed: ACCESS_BACKGROUND_LOCATION as a separate, later request; bundled with the foreground ones on Android 11+ the whole request is ignored, and on its own it opens your app's settings page where the user picks "Allow all the time"
Review: background location on Play needs a permissions declaration with a demo video and a prominent in-app disclosure; apps are often rejected because a foreground service would have been enough
iOS analog: "When In Use" plus the location background mode covers an active trip; "Always" is only for geofencing and significant-change monitoring
Package visibility: since Android 11 (target 30+) an app sees only a filtered list of other installed apps, so resolveActivity returns null for an app it is not allowed to see. The list of installed apps says a lot about a person (banking, health, dating), so it is now treated as private data.
Simplest fix: call startActivity directly and catch ActivityNotFoundException; launching an implicit intent needs no visibility
If you must check first: declare the intent in <queries> in the manifest: an <intent> with ACTION_VIEW and <data android:scheme="geo"/>, or a <package> for one specific app
**QUERY_ALL_PACKAGES: sees everything, but Play allows it only when listing apps is the core feature (launchers, antivirus, device management), with a declaration
Always visible**: your own app, the app that installed yours, apps that start or bind to yours or use your content provider, and certain system packages
A green dot appears whenever your app uses the camera or microphone, and the user can switch either sensor off for the whole device. With the toggle off you are not denied (you get black frames or silence), so code that waits for data can hang.
The indicator follows the session: a CameraX use case still bound or an AudioRecord not released keeps the dot lit, and users read a lingering dot as spying; unbind and release as soon as the feature ends
Toggles: the permission still reads as granted; the system shows its own unblock dialog when you start capturing, and SensorPrivacyManager.supportsSensorToggle() tells you whether the device has the toggle
Design for silence: time out waiting for the first frame or buffer and hint at the quick-settings toggle
Privacy dashboard: Settings shows which apps used location, camera and microphone and when, so any access you cannot explain is visible to every user
iOS: the same idea: an orange dot for the microphone and a green one for the camera, lit while the app is actually capturing audio or video
Library manifests are merged into yours, so any dependency can add a <uses-permission> nobody on the team wrote. The merged manifest shows the permission, and the manifest merger report names the library it came from.
Find it: the Merged Manifest tab in Android Studio, build/outputs/logs/manifest-merger-release-report.txt, or aapt2 dump permissions on the built APK, which is what Play sees
Remove it: tools:node="remove" in your manifest. This changes behaviour: the SDK may throw SecurityException or silently degrade, so test the code path that used it
Ask the vendor first: many ship a lighter artifact or a config flag that drops the permission
**AD_ID: another common one: ad and analytics SDKs add it; if you show no ads, remove it and answer the Data safety form to match
Prevent the next one**: compare the merged permissions with a checked-in allow-list in CI, and lock dependencies so a transitive bump arrives as a reviewed diff
It is your public declaration of what data the app collects and shares, why, and how it is protected, and it covers everything your SDKs send: Play treats their traffic as yours. A form that disagrees with what the app actually sends leads to policy enforcement, not just a rejected update.
Collected: any user data sent off the device; data processed only on the device is not collected, and ephemeral processing or end-to-end encryption can be exempt
Shared: data sent to a third party; transfers to a service provider working on your behalf, required by law, or started by the user's own action are exempt
Also declared: encryption in transit, whether users can request deletion, and compliance with the Families policy
SDKs: an analytics SDK adds app interactions and device IDs, a crash reporter adds diagnostics, an ad SDK adds the advertising ID and usually sharing; use each vendor's Data safety guidance and the Google Play SDK Index
Fields nobody remembers: a raw search string in an analytics event is user content, an email in a crash key makes crash data personal; send the shape (length, count, a pseudonymous ID), not the value
iOS counterpart: the App Store privacy label, with an extra "used to track you" category
ATT is the iOS prompt an app must show before tracking (linking its data with other companies' data for ads, or passing it to data brokers). After a decline the IDFA reads as all zeros, and attribution moves to aggregated, delayed postbacks from SKAdNetwork or AdAttributionKit.
Not tracking: your own first-party analytics, data that stays on the device, sharing only for fraud prevention
No workarounds: replacing the IDFA with a fingerprint (IP, device model, settings) is banned whether the user agreed or not
Ask while the app is active: a request from didFinishLaunching is silently dropped, and the call returns .denied at once if the user turned off "Allow Apps to Request to Track"
Android counterpart: there is no prompt; reading the advertising ID needs the AD_ID permission (target 33+), and a user who deleted the ID gets zeros
For marketing: plan for campaign-level, delayed numbers plus first-party signals, because most users decline
PrivacyInfo.xcprivacy is a property list where the app and each SDK declare tracking, collected data types and a reason for every "required reason" API they call. ITMS-91053 means the build uses such an API, like UserDefaults or file timestamps, without an approved reason code.
Four keys: NSPrivacyTracking, NSPrivacyTrackingDomains, NSPrivacyCollectedDataTypes, NSPrivacyAccessedAPITypes
The SDK's gap is your rejection: SDKs on Apple's list of commonly used SDKs must ship their own signed manifest (ITMS-91061 when it is missing); the fix is usually a version bump
Tracking domains are enforced at runtime: when the user has not allowed tracking, connections to listed domains fail, so never list your own API host
It is not the App Store label: Xcode's Generate Privacy Report merges all manifests to help you fill in the label, which you still complete by hand
Android has no such file: the Data safety form plays this role, without a build-time check
From auto-initialisation: Firebase and many SDKs start through a ContentProvider or an androidx.startup initializer that runs before Application.onCreate, so they collect before your flag is ever read. The off switch has to live in the manifest.
Disable auto-collection: set firebase_analytics_collection_enabled and firebase_crashlytics_collection_enabled to false, then enable at runtime once the user answers
**Not firebase_analytics_collection_deactivated: it turns analytics off permanently, and the runtime setter cannot bring it back
Consent Mode v2**: default every consent type to denied in the manifest (google_analytics_default_allow_analytics_storage and the ad keys), then call setConsent with ANALYTICS_STORAGE, AD_STORAGE, AD_USER_DATA, AD_PERSONALIZATION
Ads in the EEA and UK: need a Google-certified CMP such as the UMP SDK; it writes the IABTCF_ keys into SharedPreferences, which ad SDKs read when they start, so the CMP must finish before ad initialisation
Other SDKs: remove their entry from the InitializationProvider with tools:node="remove" and initialise them yourself after consent
A later "no": stop collection, drop queued events, call resetAnalyticsData(), and store the decision with a timestamp and the version of the notice you showed
A child audience switches most third-party telemetry off: no advertising ID, no personalised ads, only SDKs certified for families, and verifiable parental consent before collecting personal data. The target-audience declaration in each store console is what puts you under these rules.
Stores
Google Play: a target audience that includes children puts the app under the Families policy: a privacy policy is required, ads only from Families self-certified SDKs, and no advertising ID sent for child users
App Store: the Kids Category bars third-party advertising and analytics with narrow exceptions, and links out of the app and purchases sit behind a parental gate
Law
COPPA (US): verifiable parental consent under 13; the 2025 amendments add separate consent for disclosure to third parties such as targeted advertising, with compliance required since April 2026
GDPR Article 8: the age of digital consent is 13 to 16 depending on the EU country
Age signals: Apple's Declared Age Range API and Google Play's Age Signals API return an age range without the app collecting a birth date, driven by US state app-store laws
In the app
One switch before any SDK starts (with auto-collection off in the manifest): child-directed tags on every ad request, personalisation off, analytics reduced to non-identifying counters
A neutral age gate: a date field asked once with no retry; store the age band, not the birth date, and default to the restricted mode when the age is unknown
Both stores require deletion to start inside the app, and the row is the easy part: the server fans the deletion out to every system and vendor holding the user's data, the device is wiped, and backups are purged on a stated retention schedule.
Store rules
Apple 5.1.1(v): an app that lets users create an account must let them delete it in the app; a support email is a rejection. Sign in with Apple tokens must also be revoked through Apple's REST API
Google Play: an in-app path plus a web link that works without the app, declared in the Data safety form; you may also offer deleting some data without closing the account
Server
A durable job, not one request: fan out to the warehouse, logs, the analytics and crash vendors' deletion APIs, the support desk, CRM, object storage and the search index, with retries and an alert on a stuck job
Tombstone: keep a minimal "deleted" marker so restoring a backup cannot bring the account back
Legal retention: invoices and fraud records may have to stay; document what is kept and why
Device
Wipe local state: database, caches, DataStore, Keystore keys and the push token via FirebaseMessaging.deleteToken(), or the deleted user keeps receiving notifications
What you tell the user
Removed from live systems now, purged from backups within a stated number of days, plus the list of what is kept by law
Three things: a data inventory reviewed like code, a traffic capture from a fresh install on a real device, and a CI check that fails when the app contacts a host the inventory does not list.
Inventory
One row per field: source, destination host, purpose, retention, Data safety type and App Store label category; both forms are filled from it, and it changes in the same PR as the payload
Capture
A fresh install with every permission denied and consent refused, then a second run with consent granted, because consent-gated traffic is exactly what is disputed
Android needs a debuggable audit variant whose network security config trusts user CAs, with certificate pinning off, or a proxy such as mitmproxy sees only TLS
Wait several minutes: some SDKs send their first batch late
On iOS the App Privacy Report in Settings records the domains and sensors each app used: evidence produced by the OS, not by you
In the code
Data access auditing (AppOpsManager.setOnOpNotedCallback, Android 11) reports every access to location, contacts or the camera with a stack trace, and attribution tags show which feature made it
CI gate
Drive a smoke test through a recording proxy and assert the contacted hosts are a subset of the allow-list, so the claim on the form becomes a test