Разрешения: спросить, получить отказ, жить с ним

натив≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья кончилась выводом: вызов, на который отвечает не натив, а человек, обязан быть промисом. Разрешение — ровно такой вызов. Таймеру отдыха в «Подходе» нужно право на уведомления, камере — на съёмку; спрашивает система, а отвечает пользователь: разрешить, запретить — или уже никак, потому что после отказа система может больше не спросить. Проведём экран через все три ответа. Системного окна в предпросмотре нет, поэтому систему соберём моделью по правилам, прочитанным в исходниках expo-modules-core 57. Хук, которым её спрашивает экран, — та же фабрика, что лежит в пакете, а AppState, который замечает возврат из настроек, — настоящий. И увидим ловушку: код с настоящим API разрешений компилируется без единой ошибки и падает при запуске.

Разделы темы:·

У разрешения два метода. getPermissionsAsync только читает статус — на экране ничего не появляется. requestPermissionsAsync показывает системное окно и ждёт человека. Этаж — Expo: модули SDK — expo-notifications, expo-image-picker, expo-camera, expo-location — отвечают одним и тем же объектом, и объявлен он в expo-modules-core:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Статусов три, но главное поле — canAskAgain: покажет ли система окно, если попросить снова. В документации пакета про него сказано прямо: если нет, пользователя надо отправить в приложение «Настройки». Поэтому denied бывает двух видов: с canAskAgain: true — «отказал, но спросить ещё можно», с false — «больше не спрашивать». Когда флаг становится false, платформы решают по-разному:

ПлатформаКогда canAskAgain становится falseГде это решено
iOSпосле первого же отказа: системное окно на каждое разрешение показывается один разEXPermissionsService.m: canAskAgain = status != EXPermissionStatusDenied
Android 11 и новеепосле второго отказа: повторный отказ система считает «больше не спрашивать»PermissionsService.kt: отказ, после которого shouldShowRequestPermissionRationale вернул false, Expo помечает заблокированным
веб, expo-notificationsпосле любого ответа браузера: и у granted, и у deniedNotificationPermissionsModule.ts, функция convertPermissionStatus

Соберём модель системы с этими правилами и проведём через неё трёх пользователей. Модуль ляжет в nativeModuleProxy из прошлой статьи под настоящим именем — ExpoNotificationPermissionsModule: его expo-notifications 57 передаёт в requireNativeModule в файле NotificationPermissionsModule.native.ts. Экрана пока нет, вместо него функция ensureNotifications: прочитать статус, спросить, если можно, и сказать, чем кончилось. Прогноз: сколько системных окон увидит Олег на iPhone и сколько на Android и что ответит последняя попытка каждого?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Лена разрешила — и вторая попытка обошлась без окна: getPermissionsAsync сразу вернул granted. Олег на iPhone отказал один раз, и это навсегда: iOS второе окно не покажет, canAskAgain стал false сразу. Олегу на Android досталось второе окно: после первого отказа флаг ещё true, после второго — false. Последняя попытка у обоих Олегов упёрлась в одно: спросить нельзя, остались только настройки.

Отсюда правило, которое пригодится в задаче 1: status === "denied" ещё не значит «никогда». Экран, который по одному статусу отправляет в настройки, на Android отнимает у пользователя второе окно. Решает canAskAgain.

Сам Android этого не знает. Статус разрешения система отдаёт двумя значениями — выдано или нет, — и «ещё не спрашивали» выглядит так же, как «запретили навсегда». Различить их можно только в момент ответа на запрос: если после отказа shouldShowRequestPermissionRationale вернул false, окна больше не будет. Expo запоминает оба факта сам, в SharedPreferences приложения:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Следствий два. undetermined на Android — это «Expo ещё ни разу не просил», и после переустановки или очистки данных приложения эта память пропадает. А голый PermissionsAndroid с этажа React Native своей памяти не ведёт: его check() отвечает только true или false, а never_ask_again приходит лишь из request() — по тому же вопросу shouldShowRequestPermissionRationale в PermissionsModule.kt.

Функция из прошлого раздела ничего не рисует. Экрану статус нужен в состоянии, и модули Expo дают для этого хуки: ImagePicker.useCameraPermissions(), useMediaLibraryPermissions(). Все они собраны одной фабрикой — createPermissionHook из expo-modules-core, и она короткая. Внутри — обычные useState, useCallback и useEffect, как в части IV. Решение в ней одно, но важное: при монтировании хук только читает статус и никого не спрашивает. Спросить — второй элемент возвращённого массива, и зовёт его экран, когда пользователь нажал кнопку.

В подложке — модель системы из прошлого раздела, теперь со сценарием ответов. Олег на Android, кнопка «Напоминать, когда отдых кончится», и он нажимает её дважды, оба раза отказывая. Прогноз: сколько раз сменится надпись на экране?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Надпись сменилась один раз, хотя окон было два. После первого отказа canAskAgain остался true, надпись та же — и это правильно: кнопка всё ещё может спросить. После второго экран честно говорит про настройки. А если нажать в третий раз, requestPermissionsAsync вернёт ответ сразу, без окна. На телефоне это кнопка, которая молча ничего не делает, — частая жалоба на приложения, не читающие canAskAgain.

В настоящем хуке есть ещё две вещи. Флаг isMounted не пускает в состояние ответ, пришедший после размонтирования, — в исходнике он подписан «Workaround for unmounting components receiving state updates». Это обход старого предупреждения React об обновлении размонтированного компонента; React 18 предупреждение убрал, поэтому в миниатюре флага нет. И опции: useMediaLibraryPermissions({ writeOnly: true }) передаёт их в оба метода, а { request: true } велит хуку спросить сразу при монтировании. Этой опцией лучше не пользоваться, и вот почему.

Системное окно — ресурс: на iPhone оно одно на всю жизнь установки, на Android их два. Поэтому просят не при запуске, а в момент, когда пользователю понятно зачем, — он нажал «Напоминать, когда отдых кончится». И часто перед системным окном показывают своё, с объяснением и кнопкой «Не сейчас»: свой отказ ничего не стоит, системный стоит разрешения. В PermissionsAndroid.request для этого есть встроенный ход — объяснение, rationale:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Объяснение показывается, только когда система сама считает его уместным, а shouldShowRequestPermissionRationale возвращает true лишь после первого отказа. Перед самым первым окном Android объяснять не станет: своё объяснение до первого запроса пишется руками, на любой платформе.

Когда canAskAgain стал false, у приложения остаётся один ход — отправить человека в настройки. Этаж — React Native: Linking.openSettings() на iOS открывает UIApplicationOpenSettingsURLString, страницу приложения в «Настройках», на Android — интент Settings.ACTION_APPLICATION_DETAILS_SETTINGS с адресом package: приложения (IntentModule.kt). Промис разрешается, как только настройки открылись. Когда пользователь щёлкнет переключатель и вернётся, хуку никто не сообщит: у createPermissionHook нет подписки на изменения, статус он читает один раз.

О возвращении приложение узнаёт из AppState — состояния процесса вместо visibilitychange, как в статье «Свернули и вернулись». В предпросмотре AppState настоящий: react-native-web строит его на visibilitychange, и подложка изображает уход в настройки, переключая видимость страницы. Сами настройки — модель Linking.openSettings(); почему не настоящий метод, покажет следующий раздел. В подложке теперь и фабрика хука из прошлого раздела.

На экране два одинаковых напоминания, но второе при возврате в приложение перечитывает статус. Прогноз: что покажет каждое после того, как Олег включил уведомления в настройках?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первое так и осталось с советом открыть настройки, хотя система уже разрешила: хук прочитал статус при монтировании и больше не спрашивал. Второе на "active" позвало getPermission — третий элемент того же массива, ради этого он и возвращается. При уходе в настройки приходил и "background", и его пример пропустил: перечитывать статус имеет смысл только на возврате.

Хуки разрешений Expo читают статус только при монтировании. Экран, который переживает поход в настройки, перечитывает его на возврате в active сам.

Почему в прошлом разделе настройки — модель, а не настоящий Linking.openSettings()? Вот кнопка с настоящим методом. Прогноз: сколько ошибок покажет компилятор и что будет в консоли после нажатия?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Компилятор молчит: в типах react-native 0.86 openSettings(): Promise<void> у Linking объявлен. А Linking в react-native-web 0.21 умеет openURL, canOpenURL, getInitialURL и подписку на события — openSettings в нём нет вовсе, и вызов падает с TypeError. Картина та же, что с PermissionsAndroid в статье «Что в этой песочнице настоящее»: типы описывают телефон, а рантайм предпросмотра — браузер. Для разрешений ловушка особенно злая, потому что весь их API — это ветки по платформам.

На телефоне у этой ловушки есть тихая версия. PermissionsAndroid на iPhone существует и не падает — он делает вид, что пользователь отказал:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Приложение, где PermissionsAndroid.request зовут без проверки платформы, на iPhone не упадёт и окна не покажет, а получит "denied" — и экран решит, что пользователь запретил. Предупреждение останется в консоли Metro при разработке, в выпущенной сборке его никто не прочтёт. Развести платформы можно проверкой Platform.OS (это задача 2) или файлами с расширениями, между которыми выбирает сборщик, — как в статье «Одно приложение, три платформы»:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Экран импортирует permission-settings без расширения: на телефоне показывает кнопку, в вебе по canOpenSettings — подсказку про настройки сайта. Ни одной проверки платформы в самом экране, и ни одного вызова, которого нет в рантайме.

Всё, что выполнялось выше, — модели. На телефоне их место занимают пакеты Expo, и код экрана почти не меняется: имена методов те же, объект ответа тот же. Это листинг: пакеты Expo в предпросмотр не собираются, expo-modules-core импортирует TurboModuleRegistry, которого у react-native-web нет.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Разница с моделью — в деталях платформ, которые пакет прячет в нативный код:

iOSAndroidВеб
СтатусUNUserNotificationCenter: authorized → granted, denied → denied, остальное, включая provisional, — undetermined; подробности в поле iosс Android 13 — разрешение POST_NOTIFICATIONS; если уведомления выключены в настройках приложения, статус denied даже при выданном разрешенииNotification.permission браузера
Запроссистемное окно, один разсистемное окно, если и телефон, и targetSdkVersion — 33 и выше; иначе окна нет, пакет только читает areNotificationsEnabled()Notification.requestPermission()
В сборкеничегоPOST_NOTIFICATIONS приезжает в манифест из манифеста самого пакета—

Вторая половина разрешения живёт не в коде, а в сборке. iOS не покажет окно камеры или галереи, если в Info.plist нет строки-объяснения — NSCameraUsageDescription, NSPhotoLibraryUsageDescription: вместо окна система завершит приложение при первом обращении к камере. Android не покажет окно для разрешения, которого нет в AndroidManifest.xml. Руками эти файлы в Expo не правят: их пишет плагин конфига пакета при npx expo prebuild, об этом глава 10. Строки берутся из app.json:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

false у микрофона не просто убирает строку: на Android плагин заносит RECORD_AUDIO в заблокированные, чтобы его не добавил и другой пакет. Если строк не задать, плагин подставит свои из withImagePicker.ts — вроде «Allow $(PRODUCT_NAME) to access your camera». Сборка пройдёт, но App Store ждёт объяснения, зачем приложению доступ, и шаблонная строка — частый повод для отказа на проверке.

Модель системы, фабрика хука и модель настроек с этого места едут в подложке. В следующей статье они понадобятся камере — а галерее, как сказано в документации самого expo-image-picker, разрешение для выбора фото требовалось только на iOS 10.

  • Разрешение — промис, на который отвечает человек. Модули Expo отдают один объект PermissionResponse: status (granted, denied, undetermined), granted, expires и главное поле — canAskAgain.
  • «Больше не спрашивать» наступает по-разному: на iOS после первого отказа, на Android 11+ после второго. denied при canAskAgain: true — ещё не приговор; на Android Expo сам помнит, спрашивал ли он и заблокировано ли разрешение.
  • createPermissionHook из expo-modules-core — это useState, useCallback и useEffect: при монтировании он только читает статус, спрашивает второй элемент массива, перечитывает третий. Просить стоит в момент, когда пользователю понятно зачем.
  • После отказа навсегда остаётся Linking.openSettings(). О возвращении приложение узнаёт из AppState и перечитывает статус само — хук этого не делает.
  • Типы шире рантайма: Linking.openSettings и PermissionsAndroid компилируются без ошибок и падают в предпросмотре с TypeError, а PermissionsAndroid на iPhone тихо отвечает "denied". Платформы разводят проверкой Platform.OS или файлами .web.ts.
  • Разрешению нужны и строки в сборке: объяснения в Info.plist и записи в AndroidManifest.xml. В Expo их пишут плагины конфига пакетов из app.json.

1/4Олег один раз отказал «Подходу» в уведомлениях. Что вернёт getPermissionsAsync на iPhone и на Android 14?

2/4Экран на useCameraPermissions() показывает совет открыть настройки. Пользователь включил камеру в настройках и вернулся. Что покажет экран, если его не дорабатывать?

3/4PermissionsAndroid.request(...) зовут без проверки платформы. Что будет на iPhone?

4/4Почему в предпросмотре Linking.openSettings() падает, а компилятор не показывает ни одной ошибки?

«denied» — ещё не приговор

Экран напоминания отправляет в настройки по status === "denied". На Android это отнимает у Олега второе окно: после первого отказа система спросила бы ещё раз, а экран уже зовёт в настройки. Исправьте условие так, чтобы второе нажатие показывало второе системное окно, и только после него появлялся совет про настройки.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Спрашивайте не статус, а флаг, который отвечает ровно на нужный вопрос, — покажет ли система окно ещё раз:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь после первого отказа canAskAgain ещё true, экран остаётся кнопкой, и второе нажатие показывает второе окно. Совет про настройки появляется, только когда окна кончились. На iPhone разницы не будет — там canAskAgain становится false вместе с первым denied, — поэтому ошибку легко пропустить, проверяя только на одном телефоне.

Уведомления на всех платформах

Функция просит разрешение на уведомления через PermissionsAndroid — сразу на всех платформах. Компилятор молчит, в предпросмотре TypeError, а на iPhone было бы тише и хуже: «выключены» без всякого окна. Разведите платформы: на Android остаётся PermissionsAndroid, остальным отвечает модуль уведомлений из реестра — в подложке он зарегистрирован с моделью системы.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Проверьте платформу до обращения к PermissionsAndroid, а остальным ответьте модулем из реестра:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В предпросмотре Platform.OS — "web", ветка Android не выполняется, и отвечает модель: «диалог системы: разрешить», «напоминания: включены». Заметьте, что проверка ничего не сообщила компилятору: он пропустил бы PermissionsAndroid и вне ветки, так что порядок здесь держится только на вас. В настоящем приложении обе ветки заменил бы Notifications.requestPermissionsAsync(): платформы он разводит в нативном коде, а на Android ниже 13 окна не показывает вовсе и только проверяет, не выключены ли уведомления в настройках.