Как делать11 октября 2026 г.6 мин чтения
React Native + ExpoШаг 7 из 9

Камера, геолокация и разрешения: Device API

Коротко

Доступ к камере, геолокации и фото приложение получает в два этапа: при сборке объявляет разрешения (Android-манифест, тексты-обоснования в iOS Info.plist — в Expo через app.json и config plugins), а во время работы запрашивает их у пользователя. Спрашивать нужно в момент, когда человек сам запустил функцию, и только то, что ей нужно. После отказа системный диалог может больше не появиться — тогда экран объясняет, что недоступно, предлагает обходной путь и кнопку перехода в настройки.

РуководствоУровень: СреднийAPI устройстваПрактика: ~1–2 ч

После материала вы сможете

  • объяснить разницу моделей разрешений 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 г.

Содержание
  1. 01Как iOS и Android спрашивают разрешения
  2. 02Тексты обоснования и разрешения в app.json
  3. 03Статус разрешения → состояние экрана
  4. 04expo-location: ближайший филиал
  5. 05Камера и фото: expo-camera и expo-image-picker
  6. 06Отказ, настройки и требования сторов
  7. 07Проверка: логика, сборка и что остаётся устройству

Как 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 не доставляются обновлением по воздуху — нужна новая нативная сборка.

bash
npx expo install expo-location expo-camera expo-image-picker
Пакеты, совместимые с текущим 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: тексты для iOS и запрет записи звука на Android (иконки и прочие поля опущены)
  • Текст отвечает на вопрос «зачем» и называет пользу: Apple советует короткое законченное предложение в активном залоге. «Нужен доступ для лучшего опыта» — пример плохого текста из Apple HIG.
  • Камеру используют два пакета — expo-camera и expo-image-picker, и оба пишут NSCameraUsageDescription. Задайте им одинаковый текст: ключ один, и в Info.plist попадёт только одна из формулировок.
  • expo-camera и expo-image-picker по умолчанию добавляют на Android RECORD_AUDIO. Если видео со звуком не нужно, отключите микрофон в настройках плагинов и заблокируйте разрешение через android.blockedPermissions: лишние опасные разрешения — повод для отказа в Google Play.

Проверить, что получится в нативных файлах, можно без сборки: npx expo config --type introspect показывает итоговые Info.plist и манифест после работы плагинов.

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'
Фрагмент вывода: наши тексты на месте, RECORD_AUDIO удалён; ключи Always и Motion плагин expo-location заполнил текстами по умолчанию

Статус разрешения → состояние экрана

Экран с камерой или картой не должен решать «есть доступ или нет» в десяти местах. Удобнее один раз перевести PermissionResponse в одно из четырёх состояний интерфейса и рисовать экран по нему. Функция чистая, поэтому её легко покрыть тестами.

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'; // системный диалог больше не появится — только настройки
}
Четыре состояния: статус читается, нужно объяснить и спросить, доступ есть, остались только настройки

loading

Что показать
Ничего или скелет экрана: хук ещё читает статус

explain

Что показать
Короткое объяснение в контексте и одну кнопку, которая вызывает системный диалог

granted

Что показать
Саму функцию

settings

Что показать
Что именно недоступно, обходной путь и кнопку «Открыть настройки»

expo-location: ближайший филиал

Практическая задача — экран «Найти ближайший филиал». Без геолокации он показывает обычный список, с геолокацией — сортирует филиалы по расстоянию. Расстояние считаем на устройстве: координаты никуда не отправляются, и это можно честно написать в объяснении.

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('.', ',')} км`;
}
Расстояние по формуле гаверсинусов, сортировка и форматирование
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' },
});
Экран: запрос по кнопке, проверка служб геолокации, быстрый ответ из кэша системы
  1. Хук не спрашивает сам

    useForegroundPermissions() только читает текущий статус. Системный диалог появляется после нажатия «Найти ближайший».

  2. Только «при использовании»

    requestForegroundPermissionsAsync соответствует iOS-разрешению When In Use. Фоновое (Always) просят отдельно и только если без него функция невозможна.

  3. Службы могут быть выключены

    Разрешение есть, а геолокация в системе отключена — hasServicesEnabledAsync() позволяет сказать об этом прямо.

  4. Сначала быстрый ответ

    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.

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 },
});
Сканер QR-кода с теми же четырьмя состояниями; объяснение с одной кнопкой «Продолжить»

Если нужно просто прикрепить фото, своя камера не нужна: expo-image-picker открывает системный интерфейс. Выбор из галереи по документации не требует разрешения — пользователь сам отдаёт приложению конкретный файл. Съёмка через launchCameraAsync требует доступа к камере.

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;
}
Галерея без запроса разрешения, камера — с запросом перед съёмкой

Отказ, настройки и требования сторов

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.

__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();
});
Мок expo-location: тест задаёт статус разрешения и координаты
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!
Временный проект: типы, 6 тестов (вместе с тестами permissionView и расстояний), сборка бандлов Android и iOS

На устройстве проверьте три сценария: первый запуск, отказ и отказ «навсегда». Повторно увидеть системный диалог мешает сама ОС, поэтому 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.