Разрешения: спросить, получить отказ, жить с ним
Здесь будет встроен видео-разбор темы (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:
Статусов три, но главное поле — canAskAgain: покажет ли система окно, если попросить снова. В документации пакета про него сказано прямо: если нет, пользователя надо отправить в приложение «Настройки». Поэтому denied бывает двух видов: с canAskAgain: true — «отказал, но спросить ещё можно», с false — «больше не спрашивать». Когда флаг становится false, платформы решают по-разному:
| Платформа | Когда canAskAgain становится false | Где это решено |
|---|---|---|
| iOS | после первого же отказа: системное окно на каждое разрешение показывается один раз | EXPermissionsService.m: canAskAgain = status != EXPermissionStatusDenied |
| Android 11 и новее | после второго отказа: повторный отказ система считает «больше не спрашивать» | PermissionsService.kt: отказ, после которого shouldShowRequestPermissionRationale вернул false, Expo помечает заблокированным |
веб, expo-notifications | после любого ответа браузера: и у granted, и у denied | NotificationPermissionsModule.ts, функция convertPermissionStatus |
Соберём модель системы с этими правилами и проведём через неё трёх пользователей. Модуль ляжет в nativeModuleProxy из прошлой статьи под настоящим именем — ExpoNotificationPermissionsModule: его expo-notifications 57 передаёт в requireNativeModule в файле NotificationPermissionsModule.native.ts. Экрана пока нет, вместо него функция ensureNotifications: прочитать статус, спросить, если можно, и сказать, чем кончилось. Прогноз: сколько системных окон увидит Олег на iPhone и сколько на Android и что ответит последняя попытка каждого?
Лена разрешила — и вторая попытка обошлась без окна: getPermissionsAsync сразу вернул granted. Олег на iPhone отказал один раз, и это навсегда: iOS второе окно не покажет, canAskAgain стал false сразу. Олегу на Android досталось второе окно: после первого отказа флаг ещё true, после второго — false. Последняя попытка у обоих Олегов упёрлась в одно: спросить нельзя, остались только настройки.
Отсюда правило, которое пригодится в задаче 1: status === "denied" ещё не значит «никогда». Экран, который по одному статусу отправляет в настройки, на Android отнимает у пользователя второе окно. Решает canAskAgain.
Сам Android этого не знает. Статус разрешения система отдаёт двумя значениями — выдано или нет, — и «ещё не спрашивали» выглядит так же, как «запретили навсегда». Различить их можно только в момент ответа на запрос: если после отказа shouldShowRequestPermissionRationale вернул false, окна больше не будет. Expo запоминает оба факта сам, в SharedPreferences приложения:
Следствий два. 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, кнопка «Напоминать, когда отдых кончится», и он нажимает её дважды, оба раза отказывая. Прогноз: сколько раз сменится надпись на экране?
Надпись сменилась один раз, хотя окон было два. После первого отказа 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:
Объяснение показывается, только когда система сама считает его уместным, а 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(); почему не настоящий метод, покажет следующий раздел. В подложке теперь и фабрика хука из прошлого раздела.
На экране два одинаковых напоминания, но второе при возврате в приложение перечитывает статус. Прогноз: что покажет каждое после того, как Олег включил уведомления в настройках?
Первое так и осталось с советом открыть настройки, хотя система уже разрешила: хук прочитал статус при монтировании и больше не спрашивал. Второе на "active" позвало getPermission — третий элемент того же массива, ради этого он и возвращается. При уходе в настройки приходил и "background", и его пример пропустил: перечитывать статус имеет смысл только на возврате.
Хуки разрешений Expo читают статус только при монтировании. Экран, который переживает поход в настройки, перечитывает его на возврате в active сам.
Почему в прошлом разделе настройки — модель, а не настоящий Linking.openSettings()? Вот кнопка с настоящим методом. Прогноз: сколько ошибок покажет компилятор и что будет в консоли после нажатия?
Компилятор молчит: в типах react-native 0.86 openSettings(): Promise<void> у Linking объявлен. А Linking в react-native-web 0.21 умеет openURL, canOpenURL, getInitialURL и подписку на события — openSettings в нём нет вовсе, и вызов падает с TypeError. Картина та же, что с PermissionsAndroid в статье «Что в этой песочнице настоящее»: типы описывают телефон, а рантайм предпросмотра — браузер. Для разрешений ловушка особенно злая, потому что весь их API — это ветки по платформам.
На телефоне у этой ловушки есть тихая версия. PermissionsAndroid на iPhone существует и не падает — он делает вид, что пользователь отказал:
Приложение, где PermissionsAndroid.request зовут без проверки платформы, на iPhone не упадёт и окна не покажет, а получит "denied" — и экран решит, что пользователь запретил. Предупреждение останется в консоли Metro при разработке, в выпущенной сборке его никто не прочтёт. Развести платформы можно проверкой Platform.OS (это задача 2) или файлами с расширениями, между которыми выбирает сборщик, — как в статье «Одно приложение, три платформы»:
Экран импортирует permission-settings без расширения: на телефоне показывает кнопку, в вебе по canOpenSettings — подсказку про настройки сайта. Ни одной проверки платформы в самом экране, и ни одного вызова, которого нет в рантайме.
Всё, что выполнялось выше, — модели. На телефоне их место занимают пакеты Expo, и код экрана почти не меняется: имена методов те же, объект ответа тот же. Это листинг: пакеты Expo в предпросмотр не собираются, expo-modules-core импортирует TurboModuleRegistry, которого у react-native-web нет.
Разница с моделью — в деталях платформ, которые пакет прячет в нативный код:
| iOS | Android | Веб | |
|---|---|---|---|
| Статус | 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:
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 это отнимает у Олега второе окно: после первого отказа система спросила бы ещё раз, а экран уже зовёт в настройки. Исправьте условие так, чтобы второе нажатие показывало второе системное окно, и только после него появлялся совет про настройки.
Спрашивайте не статус, а флаг, который отвечает ровно на нужный вопрос, — покажет ли система окно ещё раз:
Теперь после первого отказа canAskAgain ещё true, экран остаётся кнопкой, и второе нажатие показывает второе окно. Совет про настройки появляется, только когда окна кончились. На iPhone разницы не будет — там canAskAgain становится false вместе с первым denied, — поэтому ошибку легко пропустить, проверяя только на одном телефоне.
Уведомления на всех платформах
Функция просит разрешение на уведомления через PermissionsAndroid — сразу на всех платформах. Компилятор молчит, в предпросмотре TypeError, а на iPhone было бы тише и хуже: «выключены» без всякого окна. Разведите платформы: на Android остаётся PermissionsAndroid, остальным отвечает модуль уведомлений из реестра — в подложке он зарегистрирован с моделью системы.
Проверьте платформу до обращения к PermissionsAndroid, а остальным ответьте модулем из реестра:
В предпросмотре Platform.OS — "web", ветка Android не выполняется, и отвечает модель: «диалог системы: разрешить», «напоминания: включены». Заметьте, что проверка ничего не сообщила компилятору: он пропустил бы PermissionsAndroid и вне ветки, так что порядок здесь держится только на вас. В настоящем приложении обе ветки заменил бы Notifications.requestPermissionsAsync(): платформы он разводит в нативном коде, а на Android ниже 13 окна не показывает вовсе и только проверяет, не выключены ли уведомления в настройках.