EduFlow

Section 4 · Data and the Device

Permissions: Asking at the Right Moment

17 min3 min readCode playground

Lesson at a glance

Ask for location and camera access at the moment it makes sense, handle every permission state including permanent denial, and write the usage descriptions iOS and app review require.

  1. 1The states a permission can be in
  2. 2Ask in context, once you’ve explained
  3. 3Usage descriptions and config plugins
  4. 4Foreground first, background only if you must

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Trailhead’s best feature is live tracking: start a hike, and the app records your route. It needs the phone’s location, and the phone won’t give it without asking the user. That request is the most fragile moment in the app. Ask badly and you lose the feature for that user, sometimes for good.

The states a permission can be in

Every Expo module that needs a permission (location, camera, media library, notifications) returns the same response shape:

TypeScript
{
  status: 'undetermined' | 'granted' | 'denied',
  granted: boolean,
  canAskAgain: boolean,
  expires: 'never' | number,
}
  • undetermined: you’ve never asked. A request will show the system prompt.
  • granted: go ahead. On recent iOS and Android versions, users may grant access only once (for this session) or only approximate location, so be ready to ask again another day and to receive less precise positions.
  • denied with canAskAgain: true: Android after a single denial. You can ask once more, ideally after explaining better.
  • denied with canAskAgain: false: the system won’t show the prompt again. On iOS that’s the state right after the first denial; on Android, after the user denies twice. Only the Settings app can change it now.
From undetermined, the app explains and then requests. The user grants or denies. Granted proceeds to the feature. Denied with canAskAgain true can be requested again; denied with canAskAgain false can only be changed by opening Settings. undetermined explain, then request…() granted → feature denied canAskAgain? yes no → open Settings
Where each permission state leads

Ask in context, once you’ve explained

The worst pattern is the one many apps ship: a cascade of system prompts at first launch, before the user knows what the app does. People deny what they don’t understand, and on iOS that first “Don’t Allow” is permanent unless they dig into Settings.

Trailhead asks for location when the user taps Start tracking, the first moment the request is obviously connected to something they want. Before the system prompt, it shows its own short explanation: “Trailhead records your route while you hike. Your location stays on this phone unless you sync.” Then a Continue button triggers the real request. Your own screen can be dismissed without consequence; the system prompt can’t.

The same rule applies to every permission in the app. Ask for the camera when the user taps Add photo, and for notifications when they turn on reminders, not before. Each request then arrives with its reason already visible on screen, and acceptance rates go up noticeably.

src/hooks/useLocationPermission.ts
import * as Location from 'expo-location';
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { Linking } from 'react-native';

export function useLocationPermission() {
  const [permission, setPermission] = useState<Location.LocationPermissionResponse | null>(null);

  // Re-check whenever the screen regains focus: the user may have
  // changed the setting in the Settings app while Trailhead was in the background.
  useFocusEffect(
    useCallback(() => {
      Location.getForegroundPermissionsAsync().then(setPermission);
    }, []),
  );

  const request = useCallback(async () => {
    const result = await Location.requestForegroundPermissionsAsync();
    setPermission(result);
    return result.granted;
  }, []);

  return { permission, request, openSettings: () => Linking.openSettings() };
}

The tracking screen then reads permission and chooses what to render: a spinner while it’s null, the explanation and Continue button while asking is still possible, a “Location is off for Trailhead” message with an Open Settings button once canAskAgain is false, and the live map once it’s granted. Turning the response into one of those four actions is a small pure function, and it’s this lesson’s exercise.

Usage descriptions and config plugins

iOS shows a sentence from your app in every permission prompt, and the App Store rejects apps whose descriptions are missing or vague (“This app needs your location”). Say what the feature does with the data. You set these strings through each module’s config plugin in app.json:

app.json
{
  "expo": {
    "plugins": [
      [
        "expo-location",
        { "locationWhenInUsePermission": "Trailhead records your route on the map while you track a hike." }
      ],
      [
        "expo-image-picker",
        {
          "photosPermission": "Trailhead lets you attach photos from your library to a hike.",
          "cameraPermission": "Trailhead lets you take trail photos to attach to a hike."
        }
      ]
    ]
  }
}

Config plugins write native configuration (the iOS Info.plist, the Android manifest) when the app is built. That makes them native changes: Fast Refresh won’t apply them, so rebuild your development build after editing them. Expo Go shows its own generic strings, which is one more reason Trailhead moves to a development build now.

Foreground first, background only if you must

Tracking a hike with the screen off needs background location, a separate, more sensitive permission with extra platform requirements and closer review on both stores. Start with foreground (“while using the app”) access, which covers tracking while Trailhead is open. Add background access only when users ask for it, with a clear explanation of why, and expect the review to ask you the same question.

Next you’ll use these permissions for real: the hiker’s position, trail photos, and a reminder notification.

Key takeaways

  • Permission responses have a status of undetermined, granted or denied, plus canAskAgain, which tells you whether a system prompt can still appear.
  • Ask when the user starts the feature that needs it, after a short explanation in your own UI, never as a wall of prompts at launch.
  • Once canAskAgain is false, only the system Settings app can change the answer, so offer a button that calls Linking.openSettings().
  • iOS requires a usage description for each permission; set them through config plugin options in app.json and rebuild.
  • Re-check permissions when a screen regains focus, because users change them in Settings while your app is in the background.

Further reading

Check your understanding

3 questions

Question 1 of 3

Trailhead asks for location the first time the app launches, before the user has seen anything. On iOS, many users tap Don’t Allow. Why does this matter so much?

Question 2 of 3

A permission response is { status: 'denied', granted: false, canAskAgain: false }. What should the Start tracking button do?

Question 3 of 3

You change the location usage description in app.json, reload with Fast Refresh, and the old text still appears in the prompt. Why?

Practice

Code playground

Turn a permission response into a screen

Trailhead’s tracking screen calls permissionAction(response) to decide what to render. response is the object from getForegroundPermissionsAsync(), or null while it’s still loading. Return:

  • 'wait' while the response is null or undefined (show a spinner),
  • 'proceed' when access is granted (show the map),
  • 'ask' when a system prompt can still appear: never asked, or denied with canAskAgain: true (show the explanation and a Continue button),
  • 'open-settings' when it’s denied and canAskAgain is false (show an Open Settings button).
// Decide what the tracking screen shows for a permission response.
// Returns one of: 'wait' | 'proceed' | 'ask' | 'open-settings'
function permissionAction(response) {
  if (response.status === 'granted') return 'proceed';
  return 'ask';
}

console.log(permissionAction({ status: 'denied', granted: false, canAskAgain: false, expires: 'never' }));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • A missing response means wait
  • A granted permission proceeds
  • A permission never asked for leads to asking
  • A denial that can be asked again leads to asking
  • A permanent denial leads to Settings