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.
After this you will be able to
- Explain how the iOS and Android permission models differ and what
canAskAgainmeans - Set purpose strings and drop unnecessary permissions in
app.json, checking the result withnpx 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()
Know beforehand
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
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-permissionin AndroidManifest.xml- iOS
- An
NS…UsageDescriptionkey 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.
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.
npx expo install expo-location expo-camera expo-image-picker{
"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
}
]
]
}
}- 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-cameraandexpo-image-picker— and both writeNSCameraUsageDescription. Give them the same text: there is one key, so only one of the strings ends up in Info.plist. - By default
expo-cameraandexpo-image-pickeraddRECORD_AUDIOon Android. If you don't need video with sound, turn the microphone off in the plugin options and block the permission withandroid.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.
$ 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'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.
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'; // системный диалог больше не появится — только настройки
}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.
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('.', ',')} км`;
}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 hook doesn't ask by itself
useForegroundPermissions()only reads the current status. The system dialog appears after the user taps “Find nearest”.While in use only
requestForegroundPermissionsAsyncmaps to the iOS When In Use permission. Background (Always) access is requested separately and only if the feature can't work without it.Services may be off
Permission is granted but location is disabled system-wide —
hasServicesEnabledAsync()lets you say so directly.Fast answer first
getLastKnownPositionAsyncreturns 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.
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 },
});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.
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;
}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 aLinking.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_LOCATIONand 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_pickerandpermission_handlerpackages, 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.
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();
});$ 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!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.