Доступ к камере, геолокации и фото приложение получает в два этапа: при сборке объявляет разрешения (Android-манифест, тексты-обоснования в iOS Info.plist — в Expo через app.json и config plugins), а во время работы запрашивает их у пользователя. Спрашивать нужно в момент, когда человек сам запустил функцию, и только то, что ей нужно. После отказа системный диалог может больше не появиться — тогда экран объясняет, что недоступно, предлагает обходной путь и кнопку перехода в настройки.
После материала вы сможете
- объяснить разницу моделей разрешений iOS и Android и смысл поля
canAskAgain - задать тексты обоснования и убрать лишние разрешения в
app.json, проверив результат черезnpx expo config --type introspect - запросить геолокацию по действию пользователя и отсортировать филиалы по расстоянию с
expo-location - обработать отказ: показать обходной путь и открыть настройки через
Linking.openSettings()
Что знать заранее
Проверено на
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)
Сверено с документацией: 11 октября 2026 г.
Содержание
Как iOS и Android спрашивают разрешения
Камера, микрофон, точная геолокация, фото — защищённые ресурсы. На Android такие разрешения называются опасными (dangerous): начиная с Android 6.0 их мало объявить в манифесте — их нужно запросить во время работы (runtime permission). На iOS приложение обязано описать в Info.plist, зачем ему доступ: этот текст (purpose string) система показывает в своём диалоге. Без него запрос не сработает, а приложение может не пройти ревью.
Где объявить
- Android
uses-permissionв AndroidManifest.xml- iOS
- Ключ
NS…UsageDescriptionв Info.plist с текстом-обоснованием
Сколько раз можно спросить
- Android
- Если пользователь дважды нажал «Запретить», диалог больше не показывается — это окончательный отказ
- iOS
- Система спрашивает один раз и запоминает ответ
Временный доступ
- Android
- С Android 11 для геолокации, камеры и микрофона есть вариант «Только в этот раз»
- iOS
- Для геолокации есть «Разрешить один раз» — до конца сессии приложения
Если отказали
- Android
- Объяснить пользу и, если нужно, отправить в настройки
- iOS
- Только настройки: повторного системного диалога не будет
Expo сводит обе модели к одному объекту PermissionResponse: status (granted, denied или undetermined), granted, expires и canAskAgain. Последнее поле — главное для интерфейса: если оно false, системный диалог больше не появится, и единственный путь — экран настроек.
Тексты обоснования и разрешения в app.json
В Expo нативные файлы не правят руками: пакеты добавляют разрешения сами через config plugins, а тексты задаются в app.json. В Expo Go эта настройка для тестов не нужна, но в development build и релизной сборке без неё разрешение не запросить. Изменения Info.plist не доставляются обновлением по воздуху — нужна новая нативная сборка.
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
}
]
]
}
}- Текст отвечает на вопрос «зачем» и называет пользу: Apple советует короткое законченное предложение в активном залоге. «Нужен доступ для лучшего опыта» — пример плохого текста из Apple HIG.
- Камеру используют два пакета —
expo-cameraиexpo-image-picker, и оба пишутNSCameraUsageDescription. Задайте им одинаковый текст: ключ один, и в Info.plist попадёт только одна из формулировок. expo-cameraиexpo-image-pickerпо умолчанию добавляют на AndroidRECORD_AUDIO. Если видео со звуком не нужно, отключите микрофон в настройках плагинов и заблокируйте разрешение черезandroid.blockedPermissions: лишние опасные разрешения — повод для отказа в Google Play.
Проверить, что получится в нативных файлах, можно без сборки: npx expo config --type introspect показывает итоговые Info.plist и манифест после работы плагинов.
$ 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'Статус разрешения → состояние экрана
Экран с камерой или картой не должен решать «есть доступ или нет» в десяти местах. Удобнее один раз перевести PermissionResponse в одно из четырёх состояний интерфейса и рисовать экран по нему. Функция чистая, поэтому её легко покрыть тестами.
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
- Что показать
- Ничего или скелет экрана: хук ещё читает статус
explain
- Что показать
- Короткое объяснение в контексте и одну кнопку, которая вызывает системный диалог
granted
- Что показать
- Саму функцию
settings
- Что показать
- Что именно недоступно, обходной путь и кнопку «Открыть настройки»
expo-location: ближайший филиал
Практическая задача — экран «Найти ближайший филиал». Без геолокации он показывает обычный список, с геолокацией — сортирует филиалы по расстоянию. Расстояние считаем на устройстве: координаты никуда не отправляются, и это можно честно написать в объяснении.
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' },
});Хук не спрашивает сам
useForegroundPermissions()только читает текущий статус. Системный диалог появляется после нажатия «Найти ближайший».Только «при использовании»
requestForegroundPermissionsAsyncсоответствует iOS-разрешению When In Use. Фоновое (Always) просят отдельно и только если без него функция невозможна.Службы могут быть выключены
Разрешение есть, а геолокация в системе отключена —
hasServicesEnabledAsync()позволяет сказать об этом прямо.Сначала быстрый ответ
getLastKnownPositionAsyncвозвращает последнюю известную позицию без нового определения; если её нет —getCurrentPositionAsync, который в помещении может занять несколько секунд.
Lowest
- Порядок погрешности по документации
- до 3 км
- Когда хватит
- Город или регион
Low
- Порядок погрешности по документации
- до 1 км
- Когда хватит
- Район
Balanced (по умолчанию)
- Порядок погрешности по документации
- до 100 м
- Когда хватит
- Ближайший филиал, доставка по району
High
- Порядок погрешности по документации
- до 10 м
- Когда хватит
- Точка на карте, адрес доставки
Highest / BestForNavigation
- Порядок погрешности по документации
- лучшая доступная
- Когда хватит
- Навигация, трекинг курьера — дороже по батарее
Камера и фото: expo-camera и expo-image-picker
Для своей камеры в интерфейсе — например, сканера QR-кода — нужен expo-camera: компонент CameraView показывает превью и умеет распознавать штрихкоды. Документация предупреждает, что активным может быть только одно превью камеры, поэтому на неактивных экранах компонент нужно размонтировать. Снимки takePictureAsync сохраняет в кэш приложения, и их URI временный: для постоянного хранения файл копируют через expo-file-system.
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 },
});Если нужно просто прикрепить фото, своя камера не нужна: expo-image-picker открывает системный интерфейс. Выбор из галереи по документации не требует разрешения — пользователь сам отдаёт приложению конкретный файл. Съёмка через launchCameraAsync требует доступа к камере.
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;
}Отказ, настройки и требования сторов
Android формулирует принципы прямо: спрашивать в контексте, когда человек начал пользоваться функцией; не блокировать интерфейс; при отказе плавно урезать функцию, а не всё приложение; не давить повторными просьбами. Apple в Human Interface Guidelines добавляет: не спрашивать при запуске, если без доступа приложение работает, а экран-пояснение перед системным диалогом делать с одной кнопкой вроде «Продолжить» — без слова «Разрешить» и без кнопки, которая уводит в сторону.
- Разрешение запрашивается по нажатию, а не при старте приложения
- Перед запросом понятно, что получит пользователь
- При отказе функция деградирует: список без сортировки, ручной ввод номера, выбор фото из галереи
- Если
canAskAgain === false— кнопкаLinking.openSettings(), а не повторный запрос - Разрешение проверяется при каждом использовании: пользователь мог отозвать его в настройках
- В манифесте нет лишних опасных разрешений, в Info.plist — понятные тексты для всех ключей
- Фоновая геолокация на Android требует
ACCESS_BACKGROUND_LOCATIONи отдельной проверки Google Play; для foreground service с геолокацией тоже нужна заявка. На iOS фоновая геолокация не работает в Expo Go — только в development build. - Apple показывает на странице приложения в App Store сведения о том, какие данные оно собирает; их заполняют при публикации.
- Во Flutter те же задачи решают пакеты
geolocator,image_pickerиpermission_handler, в нативной разработке — системные API Android и Core Location на iOS. Модель разрешений и правила UX от фреймворка не зависят.
Проверка: логика, сборка и что остаётся устройству
Поведение системных диалогов воспроизводится только на телефоне или эмуляторе. Но логику — какое состояние показать, когда вызывать запрос, что делать при выключенных службах — можно проверить тестами, подменив expo-location моком. Тестовая обвязка та же, что в уроке про API и хранение: jest-expo и 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!На устройстве проверьте три сценария: первый запуск, отказ и отказ «навсегда». Повторно увидеть системный диалог мешает сама ОС, поэтому Expo советует для таких проверок удалять и переустанавливать приложение; на Android флаги отказа можно сбросить командой adb shell pm clear-permission-flags из документации Android.
Как проверено3 · пройдено1 · частично1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
app.json (config plugins expo-location, expo-camera, expo-image-picker; blockedPermissions)
npx expo config --type introspect: тексты NS…UsageDescription на месте, RECORD_AUDIO с tools:node=remove; для упражнения — ACCESS_FINE_LOCATION с tools:node=remove. Expo SDK 57.0.27
- запущено, работает
src/permissions/permissionView.ts, src/branches/nearest.ts
jest-expo 57.0.5: __tests__/logic.test.ts (3 теста), tsc --noEmit (TypeScript 6.0.3)
- запущено, работает
src/screens/NearestBranchScreen.tsx
@testing-library/react-native 14.1.0 с моком expo-location: 3 теста (запрос только по кнопке и сортировка, окончательный отказ → Linking.openSettings, выключенные службы)
- частично
src/screens/ScanReceiptScreen.tsx, src/support/attachPhoto.ts, App.tsx
tsc --noEmit + npx expo export --platform android --platform ios (бандлы собраны); expo-doctor 21/21; поведение камеры и пикера тестами не покрыто
- не запускалось
системные диалоги, реальная камера/GPS, Linking.openSettings на устройстве
устройства и эмуляторы недоступны; поведение разрешений — по документации Expo, Android и Apple
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.