Уведомления: планировщик, канал, нажатие

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

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

Прошлая статья кончилась обещанием: ещё один модуль Expo, снова разрешение и переход на нужный экран по нажатию на уведомление. Таймер отдыха в «Подходе» считает девяносто секунд, а телефон в это время лежит на скамье экраном вниз: приложение свёрнуто, а то и выгружено системой. Звонок об окончании отдыха не может зависеть от того, жив ли JavaScript, поэтому секунды считает система, а приложение только оставляет ей запрос. Разберём три вещи из названия: планировщик — кто и когда покажет уведомление; канал — как Android решает, всплыть ему поверх экрана или тихо лечь в шторку; нажатие — как открыть по нему тренировку, в том числе когда процесса к этому моменту уже нет. Телефон соберём моделью по исходникам expo-notifications 57.0.14: система с часами, расписанием и шторкой, процесс, который можно выгрузить, нативные модули под настоящими именами в реестре из этой главы и модель разрешений из статьи про разрешения. AppRegistry и AppState в модели настоящие.

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

Экранный таймер отдыха из главы 8 считает сам JavaScript: значение, подписка на кадр, таймеры. Пока экран открыт, этого хватает. Но таймер живёт в процессе приложения, а процессом распоряжается система. Свёрнутое приложение iOS приостанавливает почти сразу, и таймеры в нём не срабатывают; Android — позже, но и там процесс могут выгрузить в любую минуту, а с ним и все таймеры. О том, как приложение замечает уход в фон, была статья «Свернули и вернулись: процесс вместо вкладки».

Поэтому звонок об окончании отдыха отдают системе. Этаж — Expo: scheduleNotificationAsync из expo-notifications передаёт запрос нативному модулю ExpoNotificationScheduler, а тот — системе: на iOS это UNUserNotificationCenter, на Android — будильник AlarmManager и запись запроса в SharedPreferences. JS-половина функции в пакете короткая:

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

Три детали. Идентификатор запроса даёт приложение, а если не дало, пакет берёт свежий uuid.v4() — к этому вернёмся в задаче 1. Триггер parseTrigger переводит в нативную форму: { type: "timeInterval", seconds: 90 } становится { type: "timeInterval", seconds: 90, repeats: false }. И промис разрешается идентификатором, как только система приняла запрос, — задолго до самого уведомления.

В подложке — модель телефона. Система в ней живёт отдельно от процесса: у неё свои часы (это счётчик секунд, двигает его passTime), расписание и шторка. Платформу задаёт bootPhone, а Platform.OS в предпросмотре по-прежнему "web". Процесс запускается настоящим AppRegistry.runApplication("main") — так натив запускает приложение Expo, это было в статье «Свой проект: где кончается React Native и начинается Expo», — а killApp его выгружает: дерево размонтируется, экземпляры модулей и таймеры процесса пропадают. Таймер процесса — processTimeout, то есть setTimeout, который идёт по часам модели.

Экран отдыха заводит и свой таймер, и запрос к системе. Обёртку scheduleNotificationAsync пишем по образцу пакета. Прогноз: что из двух напомнит Олегу об окончании отдыха, если через 20 секунд система выгрузила процесс, и на какой секунде?

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

Напомнила система, ровно на 90-й секунде. Таймер JavaScript не сработал вовсе: он умер вместе с процессом на 20-й. Первая строка вывода — не из модели: её печатает настоящий AppRegistry.runApplication отладочной сборки react-native-web, и дальше она будет отмечать каждый запуск процесса. Запрос пережил процесс, потому что никогда в нём и не лежал.

iOS. SchedulerModule.swift собирает UNNotificationRequest с идентификатором и отдаёт его UNUserNotificationCenter.current().add. Дальше запрос хранит система, и приложению для срабатывания не нужно быть запущенным.

Android. ExpoSchedulingDelegate.kt пишет запрос в SharedPreferences под его идентификатором и ставит будильник AlarmManager. Будильник точный, только если приложению можно ставить точные будильники: с Android 12 это проверяет canScheduleExactAlarms(), а без права пакет ставит неточный setAndAllowWhileIdle, и система вправе позвонить позже. Будильники Android не переживают перезагрузку, поэтому манифест пакета подписан на BOOT_COMPLETED: после включения телефона расписание ставится заново из сохранённых запросов.

Одно звено до сих пор пропущено — разрешение. Экран отдыха выше завёл напоминание, ни о чём не спросив: в модели по умолчанию Олег давно разрешил уведомления. Теперь в подложке и модель системы разрешений из статьи «Разрешения: спросить, получить отказ, жить с ним» — bootPhone принимает её вторым аргументом, — и обёртка scheduleNotificationAsync из примера выше. Олег на Android ответил «запретить» в первом же окне, а экран отдыха этого не проверил. Прогноз: отклонит ли scheduleNotificationAsync запрос без разрешения и что Олег увидит на 90-й секунде?

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

Запрос приняли: промис разрешился идентификатором, как и с разрешением. expo-notifications при планировании разрешение не проверяет — ни в JavaScript, ни в SchedulerModule.swift, ни в ExpoSchedulingDelegate.kt, — а в описании scheduleNotificationAsync так и сказано: запланированное уведомление не обязательно покажут, когда оно сработает. Показ решает система в момент срабатывания. На Android 13 и новее без POST_NOTIFICATIONS — или когда уведомления приложения выключены в настройках — уведомления не будет; на iPhone без разрешения тоже. Ни ошибки, ни предупреждения, только тишина на 90-й секунде. Поэтому разрешение спрашивают до первого отдыха — это askRestNotifications из статьи про разрешения, — а после отказа экран отдыха честно говорит, что звонка не будет. На Android у Олега осталось второе окно: canAskAgain ещё true.

Всё, что должно случиться, когда приложения может не быть, отдают системе. Таймер в JavaScript — только для экрана, который открыт.

Олег не сворачивает приложение и смотрит, как тают секунды. Что сделает система, когда запрос сработает при открытом приложении? Решение она поручает самому приложению: на iOS это метод делегата willPresent, на Android — модуль Expo NotificationsHandler. А отвечает JavaScript через setNotificationHandler: ему передают уведомление, он возвращает, как его показать — баннером поверх экрана, строкой в шторке, со звуком.

Под этой функцией — ещё один модуль в реестре, ExpoNotificationsHandlerModule: событие onHandleNotification с уведомлением и метод handleNotificationAsync(id, behavior) для ответа. Функцию из пакета перепишем так же, как createPermissionHook в статье про разрешения, — без таймаута и обработки ошибок. Прогноз: что увидит Олег в конце первого отдыха, пока обработчика нет, и в конце второго?

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

В первый раз — ничего: ни баннера, ни строки в шторке, ни звука, хотя запрос сработал вовремя. Так поступает iOS: модуль обработчика становится делегатом NotificationCenterManager, только когда на него подписан JavaScript (OnStartObserving в HandlerModule.swift), а без делегата менеджер сразу зовёт completionHandler([]) — «не показывать». Android приходит к тому же другим путём: SingleNotificationHandlerTask отправляет в JS событие и ждёт ответа три секунды (SECONDS_TO_TIMEOUT = 3), а не дождавшись, уведомление не показывает. Документация setNotificationHandler говорит то же: без обработчика или без ответа за 3 секунды уведомление в открытом приложении не показывается.

Отсюда два правила. Обработчик ставят один раз, при старте и вне компонентов: в пакете подписка хранится в переменной модуля, и новый вызов снимает прежнюю. И отвечать надо быстро — не ходить в обработчике в сеть или в базу. Решать при этом можно: если человек и так смотрит на таймер, баннер лишний и хватит звука. А флаг shouldShowAlert из старых примеров в SDK 57 устарел: пакет печатает предупреждение и советует shouldShowBanner и shouldShowList.

В открытом приложении уведомление показывает не система, а ваш ответ ей. Нет setNotificationHandler — нет и баннера.

Начиная с Android 8 каждое уведомление принадлежит каналу. Канал создаёт приложение: имя, которое пользователь увидит в настройках, и важность — всплывать ли поверх экрана, звучать ли. Дальше канал принадлежит пользователю: он может выключить «Конец отдыха» и оставить остальные уведомления. Этаж — Expo поверх Android: setNotificationChannelAsync(id, { name, importance }) зовёт модуль ExpoNotificationChannelManager, а уведомление выбирает канал полем channelId в триггере. На iOS и в вебе каналов нет: там setNotificationChannelAsync возвращает null и пишет в консоль «Notification channels feature is only supported on Android.»

AndroidImportanceЗначениеЧто делает уведомление
MIN3без звука; только в шторке, внизу, без значка в строке состояния
LOW4без звука; в шторке и в строке состояния
DEFAULT5со звуком; в шторке и в строке состояния, поверх экрана не всплывает
HIGH6со звуком и баннером поверх экрана

Числа — из перечисления AndroidImportance пакета, натив переводит их в константы Android. Об одно свойство канала спотыкаются при первом же обновлении. В setNotificationChannelAsync.ts оно записано так: после создания канала можно менять только имя и описание, и это ограничение самого Android. Точнее, NotificationManager.createNotificationChannel у существующего канала меняет имя и описание, а важность — только вниз, и то пока пользователь не трогал настройки канала. Модель в подложке следует этому правилу, а канал, которого нет, подменяет так же, как BaseNotificationBuilder.kt. Прогноз: какая важность окажется у канала после обновления и как покажутся два уведомления в свёрнутом приложении?

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

Имя сменилось, а важность осталась 5 — это DEFAULT: поднять её до HIGH обновление не смогло, и конец отдыха тихо лёг в шторку. У тех, кто поставит версию 1.1 с нуля, канал сразу будет HIGH, так что одна и та же версия ведёт себя на двух телефонах по-разному. Выход один — новый канал с другим id, например "rest-v2", и удаление старого через deleteNotificationChannelAsync.

Второе уведомление просило канал, которого нет. Такое уведомление Android не показал бы вовсе, поэтому Expo подставляет запасной канал и пишет об этом в logcat — строка в выводе взята из BaseNotificationBuilder.kt, в консоль JavaScript она не попадает. Запасной канал называется «Miscellaneous», а важность у него IMPORTANCE_HIGH: забытое напоминание о растяжке всплыло громче, чем задуманное об отдыхе.

Уведомление несёт не только текст. В content.data кладут то, что понадобится по нажатию, — в «Подходе» это адрес экрана: { url: "/workout/w1" }. Нажатие приходит в приложение ответом NotificationResponse: в нём само уведомление и actionIdentifier, у обычного нажатия это expo.modules.notifications.actions.DEFAULT. Доставляет ответ модуль ExpoNotificationsEmitter событием onDidReceiveNotificationResponse; в пакете на него подписывает addNotificationResponseReceivedListener.

Корень приложения — стек из статьи «Собираем стек экранов с историей», он в подложке. Корень подписывается на нажатия в эффекте и кладёт адрес из уведомления в стек. Олег нажимает уведомление дважды: днём, когда приложение просто свёрнуто, и вечером, когда система успела выгрузить процесс. Прогноз: где окажется Олег после каждого нажатия?

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

Днём всё сработало: процесс жив, подписка на месте, в стек легла тренировка. Вечером нажатие запустило приложение заново — снова строка «Running application», — и Олег оказался в ленте. Нажатие не потерялось по дороге: натив его получил. Но получил раньше, чем в JavaScript появился подписчик.

Порядок на холодном старте такой. Система запускает процесс и отдаёт нажатие нативной стороне. Модуля ExpoNotificationsEmitter ещё нет, и нажатие ждёт в очереди: на iOS это pendingResponses в NotificationCenterManager.swift, на Android — pendingNotificationResponsesFromExtras в NotificationManager.kt. Потом модуль создаётся и забирает очередь: запоминает ответ и отправляет событие. Подписчиков в JavaScript в этот миг нет — корень ещё не отрисован, эффект не выполнен, — и событие уходит в пустоту. Остаётся только запомненное.

Запомненное отдаёт getLastNotificationResponse(). В нативном модуле она объявлена как Function, а не AsyncFunction — различие из статьи «Почему у React Native больше нет моста»: модуль просто возвращает поле, и вызов синхронный. Читать его нужно на старте, рядом с подпиской. А одно нажатие может прийти двумя путями — из getLastNotificationResponse и подпиской, — поэтому ответ сверяют по identifier: так делает хук пакета useLastNotificationResponse (функция determineNextResponse), так сделано и в приложении автора. Прогноз: что будет в стеке после вечернего нажатия теперь?

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

Тренировка открылась, и под ней лежит лента: «назад» вернёт Олега в ленту, а не закроет приложение. Первый кадр всё же показал ленту: getLastNotificationResponse читается в эффекте, то есть после первого рендера. В expo-router картина та же, и там у неё есть своё требование — о нём в последнем разделе.

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

Всё, что выполнялось выше, — модели и react-native-web. На телефоне их место занимает expo-notifications 57.0.14, а дальше — листинги: в предпросмотр пакеты Expo не собираются, expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет. Типы в листингах сверены компилятором по .d.ts установленных expo-notifications и expo-router. Модуль напоминаний об отдыхе:

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

Обработчик и канал ставятся один раз при старте. Разрешение модуль не спрашивает: перед первым отдыхом это делает askRestNotifications из статьи «Разрешения: спросить, получить отказ, жить с ним», а без согласия пользователя система напоминание не покажет. Идентификатор один на все отдыхи — к этому ведёт задача 1. Разбор нажатия живёт в этом же модуле, а не в раскладке, и причина тому — веб, о нём ниже. Раскладка корня:

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

Эффект корня выполняется после первого рендера, когда Stack уже смонтирован, и router.push здесь доходит по назначению. Раньше времени он тоже не падает: по обычному адресу linkTo только кладёт переход в очередь routingQueue, а разбирает её контейнер навигации в своём эффекте. Падают router.back(), setParams и переход на .. — это они синхронно зовут store.assertIsReady(), откуда и берётся «Attempted to navigate before mounting the Root Layout component».

А push пропадает молча, пока контейнер ждёт начальную ссылку: на Android Linking.getInitialURL() бежит наперегонки со 150-миллисекундным таймаутом, ссылка на навигатор ещё пуста, и разбор очереди опустошает её впустую — в исходнике рядом стоит TODO «actions are silently dropped». Если же контейнер готов, а раскладка всё ещё рисует заставку вместо навигатора, та же ошибка вылетит и по этому пути: при разборе очереди getNavigateAction зовёт тот же assertIsReady() — только вылетит она из эффекта контейнера, а не из push.

Поэтому нажатие разбирают, когда навигатор уже есть, — правда, гард из приложения автора, эффект в ожидании ключа от useRootNavigationState(), не ждёт ничего: ключ есть уже на первом рендере, и выручает та же очередь, о ней подробнее в статье «Ссылки снаружи и переход по уведомлению». У пакета есть и готовый хук useLastNotificationResponse: он отдаёт undefined, пока не знает ответа, потом null или последний ответ и сам подписан на новые.

В браузере у expo-notifications нет планировщика: веб-файл NotificationScheduler.ts — объект с двумя пустыми методами подписки, и scheduleNotificationAsync отклоняется с UnavailabilityError — «The method or property Notifications.scheduleNotificationAsync is not available on web, are you sure you've linked all the native dependencies properly?». getLastNotificationResponse в вебе бросает ту же ошибку, только синхронно, поэтому звать его прямо из раскладки нельзя. Веб-версия «Подхода» получает пустышку по правилу платформенных файлов из статьи «Одно приложение, три платформы» — так же устроен notifications.web.ts в приложении автора:

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

Остальное живёт в сборке. Плагин конфига expo-notifications принимает icon — монохромный значок для шторки Android, color, defaultChannel, sounds и enableBackgroundRemoteNotifications и при npx expo prebuild переносит их в нативные проекты. Так он настроен в приложении автора:

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

И последнее: в Expo Go пакет при импорте предупреждает, что поддерживается там не полностью, и советует свою сборку — development build. Своя сборка — предмет следующей главы.

ЧтоЭтажГде живёт
Расписание, шторка, каналысистемаUNUserNotificationCenter; AlarmManager и NotificationManager
scheduleNotificationAsync, setNotificationHandler, setNotificationChannelAsync, getLastNotificationResponseExpoexpo-notifications 57, модули ExpoNotificationScheduler, ExpoNotificationsHandlerModule, ExpoNotificationChannelManager, ExpoNotificationsEmitter
AppRegistry, AppStateReact Nativeзапуск процесса и уход в фон
router.pushExpoexpo-router

Модель телефона вместе с моделью разрешений с этого места едет в подложке проекта «Подход». Глава кончилась, а вопрос остался: откуда в нативном проекте берутся строки разрешений, значок уведомления и канал по умолчанию, если руками их никто не писал? Их пишут плагины конфига — с них начинается следующая глава.

  • Напоминание, которое должно сработать без приложения, отдают системе: scheduleNotificationAsync (этаж Expo) передаёт запрос модулю ExpoNotificationScheduler, а тот — UNUserNotificationCenter на iOS или SharedPreferences и AlarmManager на Android. Таймер в JavaScript умирает вместе с процессом. Показ решает система в момент срабатывания: без разрешения запрос примут, а уведомления не будет.
  • Запрос хранится по identifier: без него пакет берёт uuid.v4(), а свой постоянный идентификатор даёт замену вместо второго запроса и отмену после перезапуска.
  • В открытом приложении уведомление показывается, только если ответил setNotificationHandler: без обработчика iOS отказывает в показе сразу, Android — после трёх секунд ожидания ответа. Обработчик ставят один раз при старте.
  • На Android уведомление живёт в канале. Важность задаётся при создании и обновлением не поднимается — нужен новый id канала. Несуществующий канал Expo подменяет запасным «Miscellaneous» с IMPORTANCE_HIGH.
  • Нажатие приходит событием onDidReceiveNotificationResponse, но на холодном старте событие случается раньше подписки. Его хранит натив, читает getLastNotificationResponse(), ответ сверяют по identifier, а разобранный стирают clearLastNotificationResponse().
  • В вебе у expo-notifications нет планировщика, и scheduleNotificationAsync отклоняется с UnavailabilityError, поэтому модуль напоминаний получает .web.ts-пустышку.

1/4Экран отдыха заводит setTimeout на 90 секунд и планирует уведомление на те же 90. Через 20 секунд система выгрузила процесс. Что сработает?

2/4Приложение открыто, setNotificationHandler не вызывали. Срабатывает запланированное уведомление. Что увидит пользователь iPhone?

3/4Приложение выгружено, пользователь нажал уведомление. Корень подписывается addNotificationResponseReceivedListener в эффекте. Почему нажатие до подписки не доходит?

4/4Версия 1.0 создала канал rest с важностью DEFAULT, версия 1.1 зовёт setNotificationChannelAsync("rest", …) с HIGH. Какая важность будет у канала на телефоне, где стояла 1.0?

Два звонка на один отдых

Олег закончил подход и начал отдых, через 10 секунд понял, что подход не засчитан, повторил его и снова нажал «Отдых». Звонков стало два: старый отдых никто не отменил. Сделайте так, чтобы новый отдых заменял запланированный, а не добавлялся к нему. Хранить id первого запроса в переменной и отменять его — не выход: переменная живёт в процессе, а процесс, как вы знаете, выгружают.

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

Дайте запросу свой идентификатор — один на все отдыхи:

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

Система хранит запросы по идентификатору, и запрос с тем же идентификатором заменяет прежний: на iOS так ведёт себя UNUserNotificationCenter.add, на Android — запись в SharedPreferences под тем же ключом и будильник с тем же PendingIntent. Вывод станет «в расписании: 1» и единственный звонок на 100-й секунде. Идентификатор не нужно помнить, он постоянный — поэтому отменить отдых можно и после перезапуска: cancelScheduledNotificationAsync("rest"). Это тот же приём, что id с телефона в главе 6: имя придумывает тот, кто пишет, а не тот, кто хранит.

Тренировка открывается сама

Корень разбирает нажатие и на холодном старте, и в живом процессе. Но при смене аккаунта дерево под ним собирается заново — ключ у корня — id пользователя, и состояние сбрасывается, как в части IV. Эффект выполняется снова, getLastNotificationResponse отдаёт то же давнее нажатие, и у Лены открывается тренировка Олега. Исправьте разбор так, чтобы разобранное нажатие не открывалось второй раз.

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

Сотрите разобранное нажатие в нативе:

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

Натив помнит последний ответ, пока процесс жив, и отдаёт его каждому, кто спросит. После clearLastNotificationResponse() повторный монтаж получает null, и строка «открываем» остаётся одна. Для этого функция и есть — в пакете она описана ровно так: стереть ответ, когда по нему уже выбран маршрут. Сверка по identifier в useRef здесь не спасла бы: ref живёт в дереве и сбрасывается вместе с ним. В приложении автора отметка разобранного нажатия лежит в переменной модуля — вне дерева, — и это второй рабочий способ.