How to buildOctober 11, 20267 min read
React Native + ExpoStep 7 of 9

Camera, location and permissions: device APIs

In short

An app gets camera, location and photo access in two steps: at build time it declares the permissions (the Android manifest and iOS Info.plist purpose strings — in Expo via app.json and config plugins), and at runtime it asks the user. Ask at the moment the person starts the feature, and only for what that feature needs. After a denial the system dialog may never appear again — then the screen explains what's unavailable, offers a workaround and a button that opens Settings.

How-toLevel: IntermediateDevice APIsPractice: ~1–2 h

After this you will be able to

  • Explain how the iOS and Android permission models differ and what canAskAgain means
  • Set purpose strings and drop unnecessary permissions in app.json, checking the result with npx expo config --type introspect
  • Request location in response to a user action and sort branches by distance with expo-location
  • Handle denial: show a workaround and open Settings with Linking.openSettings()

Verified with

Expo SDK 57 (expo 57.0.27)React Native 0.86.3React 19.2.3TypeScript 6.0.3expo-location 57.0.20expo-camera 57.0.6expo-image-picker 57.0.20jest-expo 57.0.5 + Jest 29.7@testing-library/react-native 14.1.0Node.js 24 LTS (24.18.0)

Checked against docs: October 11, 2026

Contents
  1. 01How iOS and Android ask for permissions
  2. 02Purpose strings and permissions in app.json
  3. 03Permission status → screen state
  4. 04expo-location: the nearest branch
  5. 05Camera and photos: expo-camera and expo-image-picker
  6. 06Denial, Settings and store requirements
  7. 07Verification: logic, build and what's left for the device

How iOS and Android ask for permissions

Camera, microphone, precise location and photos are protected resources. On Android these are dangerous permissions: from Android 6.0 declaring them in the manifest isn't enough — you must request them at runtime. On iOS the app must explain in Info.plist why it needs access: the system shows this purpose string in its own dialog. Without it the request won't work, and the app may fail review.

Where to declare

Android
uses-permission in AndroidManifest.xml
iOS
An NS…UsageDescription key in Info.plist with a purpose string

How many times you can ask

Android
If the user taps Deny twice, the dialog is no longer shown — a permanent denial
iOS
The system asks once and remembers the answer

Temporary access

Android
Since Android 11, location, camera and microphone offer “Only this time”
iOS
Location offers “Allow Once” — until the app session ends

After a denial

Android
Explain the benefit and, if needed, send the user to Settings
iOS
Settings only: there is no second system dialog

Expo reduces both models to one PermissionResponse object: status (granted, denied or undetermined), granted, expires and canAskAgain. The last field matters most for the UI: when it's false, the system dialog won't appear again, and Settings is the only way.

User taps “Find nearby”In-context explanationSystem dialogGranted or deniedWorkaround and Settings
Request a permission in response to a user action, not at app launch.

Purpose strings and permissions in app.json

In Expo you don't edit native files by hand: packages add their permissions through config plugins, and the strings go into app.json. Expo Go doesn't need this configuration for testing, but in a development build or a release build you can't request the permission without it. Info.plist changes can't ship over the air — they need a new native build.

bash
npx expo install expo-location expo-camera expo-image-picker
Packages compatible with the current Expo SDK
app.json
{
  "expo": {
    "name": "Branches",
    "ios": {
      "bundleIdentifier": "com.example.branches"
    },
    "android": {
      "package": "com.example.branches",
      "blockedPermissions": [
        "android.permission.RECORD_AUDIO"
      ]
    },
    "plugins": [
      [
        "expo-location",
        {
          "locationWhenInUsePermission": "Геолокация нужна, чтобы показать ближайший к вам филиал и расстояние до него."
        }
      ],
      [
        "expo-camera",
        {
          "cameraPermission": "Камера нужна, чтобы сканировать QR-код на чеке и фотографировать товар для обращения в поддержку.",
          "microphonePermission": false,
          "recordAudioAndroid": false
        }
      ],
      [
        "expo-image-picker",
        {
          "photosPermission": "Доступ к фото нужен, чтобы прикрепить снимок к обращению в поддержку.",
          "cameraPermission": "Камера нужна, чтобы сканировать QR-код на чеке и фотографировать товар для обращения в поддержку.",
          "microphonePermission": false
        }
      ]
    ]
  }
}
app.json fragment: iOS strings and blocked audio recording on Android (icons and other fields omitted)
  • The string answers “why” and names the benefit: Apple recommends a short, complete sentence in the active voice. “Microphone access is needed for a better experience” is the bad example from Apple's HIG.
  • Two packages use the camera — expo-camera and expo-image-picker — and both write NSCameraUsageDescription. Give them the same text: there is one key, so only one of the strings ends up in Info.plist.
  • By default expo-camera and expo-image-picker add RECORD_AUDIO on Android. If you don't need video with sound, turn the microphone off in the plugin options and block the permission with android.blockedPermissions: unnecessary dangerous permissions are a reason for Google Play rejection.

You can check what ends up in the native files without building: npx expo config --type introspect prints the resulting Info.plist and manifest after the plugins run.

terminal
$ npx expo config --type introspect
      NSPhotoLibraryUsageDescription: 'Доступ к фото нужен, чтобы прикрепить снимок к обращению в поддержку.',
      NSCameraUsageDescription: 'Камера нужна, чтобы сканировать QR-код на чеке и фотографировать товар для обращения в поддержку.',
      NSLocationAlwaysAndWhenInUseUsageDescription: 'Allow $(PRODUCT_NAME) to access your location',
      NSLocationAlwaysUsageDescription: 'Allow $(PRODUCT_NAME) to access your location',
      NSLocationWhenInUseUsageDescription: 'Геолокация нужна, чтобы показать ближайший к вам филиал и расстояние до него.',
      NSMotionUsageDescription: 'Allow $(PRODUCT_NAME) to detect your current motion activity',
      …
      'android.permission.ACCESS_COARSE_LOCATION',
      'android.permission.ACCESS_FINE_LOCATION',
      'android.permission.CAMERA'
      …
                  'android:name': 'android.permission.RECORD_AUDIO',
                  'tools:node': 'remove'
Output fragment: our strings are in place, RECORD_AUDIO is removed; expo-location filled the Always and Motion keys with default strings

Permission status → screen state

A camera or map screen shouldn't decide “do we have access” in ten places. It's easier to map PermissionResponse once into one of four UI states and render from that. The function is pure, so it's easy to test.

src/permissions/permissionView.ts
import type { PermissionResponse } from 'expo';

// Что показать на экране для текущего статуса разрешения
export type PermissionView = 'loading' | 'explain' | 'granted' | 'settings';

export function permissionView(permission: PermissionResponse | null): PermissionView {
  if (!permission) return 'loading'; // статус ещё читается
  if (permission.granted) return 'granted';
  // ещё не спрашивали или систему можно спросить повторно
  if (permission.status === 'undetermined' || permission.canAskAgain) return 'explain';
  return 'settings'; // системный диалог больше не появится — только настройки
}
Four states: still reading, explain and ask, granted, Settings only

loading

What to show
Nothing or a skeleton: the hook is still reading the status

explain

What to show
A short in-context explanation and one button that triggers the system dialog

granted

What to show
The feature itself

settings

What to show
What exactly is unavailable, a workaround and an “Open Settings” button

expo-location: the nearest branch

The practical task is a “Find the nearest branch” screen. Without location it shows a plain list; with location it sorts branches by distance. Distance is computed on the device: coordinates aren't sent anywhere, and the explanation can honestly say so.

src/branches/nearest.ts
export type Coords = { latitude: number; longitude: number };
export type Branch = { id: string; name: string; address: string } & Coords;
export type BranchWithDistance = Branch & { distanceM: number };

// расстояние по поверхности Земли (формула гаверсинусов), в метрах
export function distanceM(a: Coords, b: Coords): number {
  const R = 6_371_000;
  const toRad = (deg: number) => (deg * Math.PI) / 180;
  const dLat = toRad(b.latitude - a.latitude);
  const dLon = toRad(b.longitude - a.longitude);
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRad(a.latitude)) * Math.cos(toRad(b.latitude)) * Math.sin(dLon / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}

export function sortByDistance(branches: Branch[], from: Coords): BranchWithDistance[] {
  return branches
    .map((branch) => ({ ...branch, distanceM: distanceM(from, branch) }))
    .sort((x, y) => x.distanceM - y.distanceM);
}

export function formatDistance(m: number): string {
  if (m < 1000) return `${Math.round(m / 10) * 10} м`;
  return `${(m / 1000).toFixed(1).replace('.', ',')} км`;
}
Haversine distance, sorting and formatting
src/screens/NearestBranchScreen.tsx
import { useState } from 'react';
import { ActivityIndicator, Button, FlatList, Linking, StyleSheet, Text, View } from 'react-native';
import * as Location from 'expo-location';
import { BRANCHES } from '../branches/data';
import { formatDistance, sortByDistance, type Branch, type BranchWithDistance } from '../branches/nearest';
import { permissionView } from '../permissions/permissionView';

export default function NearestBranchScreen() {
  // хук читает статус при монтировании, но не показывает системный диалог
  const [permission, requestPermission] = Location.useForegroundPermissions();
  const [sorted, setSorted] = useState<BranchWithDistance[] | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function findNearest() {
    setBusy(true);
    setError(null);
    try {
      // системный диалог — только по нажатию, в момент, когда человек сам попросил
      const result = permission?.granted ? permission : await requestPermission();
      if (!result.granted) return; // экран сам перейдёт в состояние «explain» или «settings»

      if (!(await Location.hasServicesEnabledAsync())) {
        setError('Геолокация выключена в системе. Включите её или выберите филиал из списка.');
        return;
      }
      // быстрый ответ из кэша системы, иначе — новое определение с точностью ~100 м
      const position =
        (await Location.getLastKnownPositionAsync({ maxAge: 5 * 60 * 1000 })) ??
        (await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }));
      setSorted(sortByDistance(BRANCHES, position.coords));
    } catch {
      setError('Не удалось определить местоположение. Выберите филиал из списка.');
    } finally {
      setBusy(false);
    }
  }

  const view = permissionView(permission);
  const rows: (Branch & { distanceM?: number })[] = sorted ?? BRANCHES;

  return (
    <View style={styles.screen}>
      {view === 'explain' && !sorted && (
        <View style={styles.card}>
          <Text>Покажем ближайший филиал и расстояние до него. Местоположение нужно только сейчас и не сохраняется.</Text>
          <Button title="Найти ближайший" onPress={findNearest} disabled={busy} />
        </View>
      )}
      {view === 'granted' && !sorted && (
        <Button title="Найти ближайший" onPress={findNearest} disabled={busy} />
      )}
      {view === 'settings' && (
        <View style={styles.card}>
          <Text>Доступ к геолокации запрещён. Его можно включить в настройках — или выберите филиал вручную.</Text>
          <Button title="Открыть настройки" onPress={() => Linking.openSettings()} />
        </View>
      )}
      {busy && <ActivityIndicator />}
      {error && <Text style={styles.error}>{error}</Text>}

      {/* список доступен всегда: без разрешения функция деградирует, а не исчезает */}
      <FlatList
        data={rows}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <View style={styles.row}>
            <Text style={styles.name}>{item.name}</Text>
            <Text>
              {item.address}
              {item.distanceM !== undefined ? ` · ${formatDistance(item.distanceM)}` : ''}
            </Text>
          </View>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 12 },
  card: { gap: 8, padding: 12, borderRadius: 12, backgroundColor: '#f2f4f7' },
  error: { color: '#b00020' },
  row: { paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth },
  name: { fontWeight: '600' },
});
The screen: request on tap, location services check, a fast answer from the system cache
  1. The hook doesn't ask by itself

    useForegroundPermissions() only reads the current status. The system dialog appears after the user taps “Find nearest”.

  2. While in use only

    requestForegroundPermissionsAsync maps to the iOS When In Use permission. Background (Always) access is requested separately and only if the feature can't work without it.

  3. Services may be off

    Permission is granted but location is disabled system-wide — hasServicesEnabledAsync() lets you say so directly.

  4. Fast answer first

    getLastKnownPositionAsync returns the last known position without a new fix; if there is none, getCurrentPositionAsync, which can take several seconds indoors.

Lowest

Approximate error per the docs
up to 3 km
Good enough for
City or region

Low

Approximate error per the docs
up to 1 km
Good enough for
Neighbourhood

Balanced (default)

Approximate error per the docs
up to 100 m
Good enough for
Nearest branch, local delivery

High

Approximate error per the docs
within 10 m
Good enough for
A map pin, a delivery address

Highest / BestForNavigation

Approximate error per the docs
best available
Good enough for
Navigation, courier tracking — costlier on battery

Camera and photos: expo-camera and expo-image-picker

For an in-app camera — a QR scanner, for example — use expo-camera: the CameraView component shows a preview and can detect barcodes. The docs warn that only one camera preview can be active at a time, so unmount the component on unfocused screens. takePictureAsync saves photos to the app cache and the URI is temporary: copy the file with expo-file-system to keep it.

src/screens/ScanReceiptScreen.tsx
import { useState } from 'react';
import { Button, Linking, StyleSheet, Text, View } from 'react-native';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { permissionView } from '../permissions/permissionView';

type Props = { onScanned: (code: string) => void };

export default function ScanReceiptScreen({ onScanned }: Props) {
  const [permission, requestPermission] = useCameraPermissions();
  const [done, setDone] = useState(false);

  function handleScan({ data }: BarcodeScanningResult) {
    if (done) return; // колбэк срабатывает на каждом кадре с кодом — берём первый
    setDone(true);
    onScanned(data);
  }

  switch (permissionView(permission)) {
    case 'loading':
      return <View style={styles.center} />;
    case 'explain':
      return (
        <View style={styles.center}>
          <Text style={styles.text}>Наведите камеру на QR-код на чеке — найдём ваш заказ.</Text>
          <Button title="Продолжить" onPress={requestPermission} />
        </View>
      );
    case 'settings':
      return (
        <View style={styles.center}>
          <Text style={styles.text}>Без камеры код не отсканировать. Включите доступ в настройках или введите номер заказа вручную.</Text>
          <Button title="Открыть настройки" onPress={() => Linking.openSettings()} />
        </View>
      );
    case 'granted':
      return (
        <CameraView
          style={styles.camera}
          facing="back"
          barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
          onBarcodeScanned={done ? undefined : handleScan}
        />
      );
  }
}

const styles = StyleSheet.create({
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 },
  text: { textAlign: 'center' },
  camera: { flex: 1 },
});
A QR scanner with the same four states; the explanation has a single “Continue” button

If you just need to attach a photo, you don't need your own camera: expo-image-picker opens the system UI. Per the docs, picking from the library needs no permission — the user hands the app a specific file. Taking a photo with launchCameraAsync requires camera access.

src/support/attachPhoto.ts
import * as ImagePicker from 'expo-image-picker';

// Галерея: системный пикер не требует разрешения — пользователь сам выбирает файл
export async function pickFromLibrary(): Promise<string | null> {
  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ['images'],
    quality: 0.7,
  });
  return result.canceled ? null : result.assets[0].uri;
}

// Камера: разрешение нужно, запрашиваем прямо перед съёмкой
export async function takePhoto(): Promise<string | null | 'denied'> {
  const permission = await ImagePicker.requestCameraPermissionsAsync();
  if (!permission.granted) return 'denied';
  const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 0.7 });
  return result.canceled ? null : result.assets[0].uri;
}
Library without a permission prompt, camera with a prompt right before shooting

Denial, Settings and store requirements

Android states the principles directly: ask in context, when the user starts using the feature; don't block the UI; on denial degrade the feature gracefully, not the whole app; don't nag. Apple's Human Interface Guidelines add: don't ask at launch unless the app can't work without access, and give a pre-alert screen a single button such as “Continue” — no “Allow” wording and no button that leads elsewhere.

  • The permission is requested on tap, not at app launch
  • Before the request it's clear what the user gets
  • On denial the feature degrades: an unsorted list, manual code entry, picking a photo from the library
  • If canAskAgain === false, show a Linking.openSettings() button instead of asking again
  • The permission is checked on every use: the user may have revoked it in Settings
  • The manifest has no unnecessary dangerous permissions, and Info.plist has clear strings for every key
  • Background location on Android needs ACCESS_BACKGROUND_LOCATION and a separate Google Play review; a location foreground service also requires a declaration. On iOS background location doesn't work in Expo Go — only in a development build.
  • Apple shows the data an app collects on its App Store product page; you fill this in when publishing.
  • In Flutter the same jobs are done by the geolocator, image_picker and permission_handler packages, and in native development by the Android system APIs and Core Location on iOS. The permission model and UX rules don't depend on the framework.

Verification: logic, build and what's left for the device

System dialog behaviour can only be reproduced on a phone or an emulator. But the logic — which state to show, when to request, what to do when services are off — can be tested by mocking expo-location. The test setup is the same as in the lesson on APIs and storage: jest-expo plus React Native Testing Library.

__tests__/NearestBranchScreen.test.tsx
import { Linking } from 'react-native';
import { fireEvent, render, screen } from '@testing-library/react-native';
import * as Location from 'expo-location';
import NearestBranchScreen from '../src/screens/NearestBranchScreen';

// нативный модуль геолокации подменяем: разрешение и координаты задаёт тест
let mockPermission: object | null = null;
const mockRequest = jest.fn();
jest.mock('expo-location', () => ({
  useForegroundPermissions: () => [mockPermission, mockRequest],
  hasServicesEnabledAsync: jest.fn(async () => true),
  getLastKnownPositionAsync: jest.fn(async () => null),
  getCurrentPositionAsync: jest.fn(async () => ({ coords: { latitude: 43.205, longitude: 76.894 } })),
  Accuracy: { Balanced: 3 },
}));

beforeEach(() => jest.clearAllMocks());

test('диалог не появляется сам: сначала объяснение, запрос — по кнопке', async () => {
  mockPermission = { status: 'undetermined', granted: false, canAskAgain: true, expires: 'never' };
  mockRequest.mockResolvedValue({ status: 'granted', granted: true, canAskAgain: true, expires: 'never' });
  await render(<NearestBranchScreen />);
  expect(screen.getByText(/Местоположение нужно только сейчас/)).toBeOnTheScreen();
  expect(mockRequest).not.toHaveBeenCalled();

  await fireEvent.press(screen.getByText('Найти ближайший'));
  expect(mockRequest).toHaveBeenCalledTimes(1);
  expect(await screen.findByText('пр. Учебный, 25 · 180 м')).toBeOnTheScreen();
  expect(Location.getCurrentPositionAsync).toHaveBeenCalledWith({ accuracy: 3 });
});

test('окончательный отказ: кнопка настроек, список филиалов остаётся', async () => {
  mockPermission = { status: 'denied', granted: false, canAskAgain: false, expires: 'never' };
  const openSettings = jest.spyOn(Linking, 'openSettings').mockResolvedValue();
  await render(<NearestBranchScreen />);
  await fireEvent.press(screen.getByText('Открыть настройки'));
  expect(openSettings).toHaveBeenCalled();
  expect(screen.getByText('ул. Примерная, 1')).toBeOnTheScreen();
  expect(mockRequest).not.toHaveBeenCalled();
});

test('геолокация выключена в системе → понятная ошибка', async () => {
  mockPermission = { status: 'granted', granted: true, canAskAgain: true, expires: 'never' };
  (Location.hasServicesEnabledAsync as jest.Mock).mockResolvedValueOnce(false);
  await render(<NearestBranchScreen />);
  await fireEvent.press(screen.getByText('Найти ближайший'));
  expect(await screen.findByText(/Геолокация выключена в системе/)).toBeOnTheScreen();
  expect(Location.getCurrentPositionAsync).not.toHaveBeenCalled();
});
An expo-location mock: the test sets the permission status and coordinates
terminal
$ npx tsc --noEmit
$ npx jest --verbose
PASS __tests__/logic.test.ts
  √ permissionView: от статуса к состоянию экрана (10 ms)
  √ расстояние и сортировка филиалов (1 ms)
  √ formatDistance
PASS __tests__/NearestBranchScreen.test.tsx
  √ диалог не появляется сам: сначала объяснение, запрос — по кнопке (505 ms)
  √ окончательный отказ: кнопка настроек, список филиалов остаётся (7 ms)
  √ геолокация выключена в системе → понятная ошибка (6 ms)
Tests:       6 passed, 6 total
$ npx expo export --platform android --platform ios
iOS Bundled 8485ms index.ts (606 modules)
Android Bundled 11385ms index.ts (604 modules)
Exported: dist
$ npx expo-doctor
21/21 checks passed. No issues detected!
Temporary project: types, 6 tests (including permissionView and distance tests), Android and iOS bundle build

On a device, check three scenarios: first launch, denial and permanent denial. The OS itself prevents you from seeing the system dialog again, so Expo recommends uninstalling and reinstalling the app for these checks; on Android you can reset the denial flags with adb shell pm clear-permission-flags from the Android docs.

How this was verified3 · passed1 · partial1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.