Push-уведомление отправляет ваш сервер, а доставляет на телефон служба платформы: Firebase Cloud Messaging на Android и Apple Push Notification service на iOS. Приложение получает токен — адрес устройства для этой службы — и передаёт его вашему серверу; в Expo это ExpoPushToken, а сервер отправляет сообщения через Expo Push Service, который сам говорит с FCM и APNs. В SDK 57 push не работают в Expo Go: нужна development build, учётные данные FCM и платный аккаунт Apple Developer для iOS.
После материала вы сможете
- объяснить цепочку «сервер → Expo Push Service → FCM/APNs → устройство» и чем
ExpoPushTokenотличается от нативного токена - получить
ExpoPushTokenв development build с правильным порядком «канал → разрешение → токен» и сохранить его на сервере - отправить уведомление через
expo-server-sdk, обработать тикеты и квитанции и удалять токены сDeviceNotRegistered - открыть нужный экран по нажатию на уведомление, проверяя путь по белому списку
Что знать заранее
Проверено на
Expo SDK 57 (expo 57.0.27)React Native 0.86.3expo-notifications 57.0.22expo-constants 57.0.21expo-server-sdk 7.2.0TypeScript 6.0.3 / 7.0.2jest-expo 57.0.5 + Jest 29.7undici 7.30.0Node.js 24 LTS (24.18.0)
Сверено с документацией: 11 октября 2026 г.
Содержание
Как устроены push: сервер, служба платформы и токен
Телефон не держит соединение с каждым сервером, который хочет ему что-то сообщить. Вместо этого система поддерживает одно соединение со службой платформы: на Android это Firebase Cloud Messaging (FCM), на iOS — Apple Push Notification service (APNs). Чтобы служба знала, кому доставить сообщение, приложение регистрируется и получает токен — по формулировке Apple, адрес приложения на конкретном устройстве. Токен уходит на ваш сервер, и сервер указывает его в каждом сообщении.
- 01Ваш сервер
- 02Expo Push Service
- 03FCM / APNs
- 04Телефон
Expo добавляет промежуточное звено: getExpoPushTokenAsync() выдаёт ExpoPushToken вида ExponentPushToken[…], а сервер отправляет сообщения в Expo Push API одним форматом для обеих платформ. Expo Push Service кладёт их в очередь и передаёт в FCM и APNs с вашими учётными данными. Это не обязательный путь: getDevicePushTokenAsync() возвращает нативный токен, с которым можно работать с FCM и APNs напрямую.
Токен
- Expo Push Service
ExpoPushToken, один формат- Напрямую FCM и APNs
- Нативный токен FCM и токен APNs
Сервер
- Expo Push Service
- Один HTTPS-запрос или
expo-server-sdk - Напрямую FCM и APNs
- Две интеграции: FCM HTTP v1 и APNs
Возможности
- Expo Push Service
- Основные поля: заголовок, текст, данные, канал, бейдж, звук
- Напрямую FCM и APNs
- Все функции платформ
Стоимость и лимиты
- Expo Push Service
- Бесплатно; 600 уведомлений в секунду на проект
- Напрямую FCM и APNs
- По правилам Google и Apple
Уведомления бывают двух видов. Обычное (с title и body) система показывает сама, если приложение не на экране. «Безголовое» фоновое уведомление несёт только данные и будит JavaScript-задачу — но доставку в приложение ОС не гарантирует, а Apple советует слать такие не чаще двух-трёх в час. Expo рекомендует обычные уведомления, если фоновая обработка не нужна.
Ограничения и подготовка проекта
- Expo Go с SDK 53 не поддерживает push: нужна development build — своя сборка приложения с нативным модулем уведомлений.
- Проверять можно на физических устройствах, на эмуляторе Android с Google Play services и на симуляторе iOS из Xcode 14+ (macOS 13+, iOS 16+).
- Для Android нужны учётные данные FCM v1 из проекта Firebase, загруженные в EAS.
- Для iOS нужен платный аккаунт Apple Developer: при первой сборке EAS CLI предложит создать ключ APNs.
- Изменения в нативной конфигурации (плагин, канал по умолчанию, иконка) требуют новой сборки — по воздуху они не обновляются.
npx expo install expo-notifications expo-constants
npm install --global eas-cli
eas init # создать или привязать проект EAS
eas build # development build с учётными данными push; профили — в eas.json{
"expo": {
"name": "Orders",
"scheme": "orders",
"plugins": [
[
"expo-notifications",
{
"color": "#1F6FEB",
"defaultChannel": "orders"
}
]
]
}
}Пример состоит из двух частей: приложение Expo (пути файлов — от корня проекта) и сервер на Node.js в отдельной папке server/.
Разрешение и токен в приложении
Как и с камерой, спрашивать разрешение лучше в момент, когда пользователь понимает пользу: кнопка «Сообщать о статусе заказа» после оформления работает лучше, чем диалог при первом запуске. iOS не требует текста-обоснования для уведомлений. На Android 13 и новее разрешение POST_NOTIFICATIONS запрашивается во время работы, и системный диалог не появится, пока приложение не создало хотя бы один канал уведомлений — поэтому канал создаётся первым.
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
export type PushRegistration =
| { status: 'granted'; token: string }
| { status: 'denied' } // пользователь отказал — работаем без push
| { status: 'error'; reason: string }; // нет сети, нет projectId — повторим позже
export async function registerForPush(): Promise<PushRegistration> {
// Android 13+: системный запрос не появится, пока в приложении нет ни одного канала
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('orders', {
name: 'Статус заказов',
importance: Notifications.AndroidImportance.HIGH,
});
}
const current = await Notifications.getPermissionsAsync();
const permission = current.granted ? current : await Notifications.requestPermissionsAsync();
if (!permission.granted) return { status: 'denied' };
const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
if (!projectId) return { status: 'error', reason: 'no-project-id' };
try {
// запрос идёт на серверы Expo — без сети он отклоняется
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return { status: 'granted', token: data };
} catch (e) {
return { status: 'error', reason: String(e) };
}
}
export async function sendTokenToServer(apiUrl: string, authToken: string, pushToken: string) {
const response = await fetch(`${apiUrl}/v1/push-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
body: JSON.stringify({ token: pushToken, platform: Platform.OS }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
}import './src/push/handler';
import { useCallback, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
import { registerForPush, sendTokenToServer, type PushRegistration } from './src/push/registerForPush';
import { useNotificationNavigation } from './src/push/useNotificationNavigation';
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';
export default function App() {
const [screen, setScreen] = useState('/orders');
const [push, setPush] = useState<PushRegistration | null>(null);
// в проекте с Expo Router здесь будет router.push(url)
const open = useCallback((url: string) => setScreen(url), []);
useNotificationNavigation(open);
async function enablePush() {
const result = await registerForPush();
setPush(result);
if (result.status === 'granted') {
// токен авторизации берётся из SecureStore после входа; здесь — заглушка
await sendTokenToServer(API_URL, 'YOUR_AUTH_TOKEN', result.token).catch(() => undefined);
}
}
return (
<View style={styles.container}>
<Text>Экран: {screen}</Text>
{push?.status !== 'granted' && (
<Button title="Сообщать о статусе заказа" onPress={enablePush} />
)}
{push?.status === 'denied' && (
<Text>Уведомления выключены. Статус заказа всегда виден в разделе «Мои заказы».</Text>
)}
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, paddingTop: 64, gap: 12, backgroundColor: '#fff' },
});getExpoPushTokenAsync() ходит на серверы Expo, поэтому без сети отклоняется: документация советует перехватить ошибку и повторить позже. Регистрацию стоит выполнять при каждом запуске уже разрешившего уведомления пользователя — Apple прямо предупреждает, что токен нельзя кэшировать навсегда: он меняется при восстановлении из резервной копии, установке на новое устройство и переустановке ОС.
Хранение токенов на сервере и их устаревание
На сервере токен — это запись «пользователь — устройство». У одного человека может быть несколько устройств, а одно устройство после смены аккаунта должно перейти к новому владельцу. В учебном примере хранилище в памяти, но интерфейс тот же, что у таблицы в базе.
// Хранилище push-токенов. В учебном примере — в памяти; в проекте — таблица
// push_tokens (user_id, token, platform, updated_at) в вашей базе.
export interface TokenStore {
save(userId: string, token: string): Promise<void>;
listForUser(userId: string): Promise<string[]>;
remove(token: string): Promise<void>;
}
export class MemoryTokenStore implements TokenStore {
private byUser = new Map<string, Set<string>>();
async save(userId: string, token: string) {
for (const tokens of this.byUser.values()) tokens.delete(token); // токен принадлежит одному пользователю
const tokens = this.byUser.get(userId) ?? new Set<string>();
tokens.add(token);
this.byUser.set(userId, tokens);
}
async listForUser(userId: string) {
return [...(this.byUser.get(userId) ?? [])];
}
async remove(token: string) {
for (const tokens of this.byUser.values()) tokens.delete(token);
}
}Вход и каждый запуск с разрешением
- Что делать с токеном
- Сохранить или обновить:
ExpoPushTokenне меняется при обновлении приложения, но на Android может смениться после переустановки
Выход из аккаунта
- Что делать с токеном
- Удалить запись на сервере, чтобы уведомления прошлого пользователя не приходили новому
Тикет или квитанция с DeviceNotRegistered
- Что делать с токеном
- Удалить токен и не слать на него, пока устройство не зарегистрируется заново
Смена applicationId или проекта Expo
- Что делать с токеном
- Токены станут другими — старые перестанут работать
Отправка: Expo Push API и expo-server-sdk
Expo Push API — это HTTPS POST на https://exp.host/--/api/v2/push/send с JSON-сообщением или массивом до 100 сообщений. Запрос ниже — формат из документации с плейсхолдером вместо токена; настоящий токен берётся с устройства.
curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{
"to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"title": "Заказ готов",
"body": "Заказ №1042 можно забрать",
"data": { "url": "/orders/1042" },
"channelId": "orders"
}'Ответ — не доставка, а тикет: status: "ok" и id означают только, что Expo принял сообщение. Через некоторое время по id можно получить квитанцию (push receipt): приняли ли сообщение FCM или APNs. Expo советует проверять квитанции примерно через 15 минут, хранятся они 24 часа. Даже ok в квитанции не гарантирует, что выключенный телефон получил уведомление.
import { Expo, type ExpoPushMessage } from 'expo-server-sdk';
import type { TokenStore } from './store.ts';
export type PushContent = { title: string; body: string; url: string };
export type PendingReceipt = { receiptId: string; token: string };
export async function notifyUser(
expo: Expo,
store: TokenStore,
userId: string,
content: PushContent,
): Promise<PendingReceipt[]> {
const tokens = (await store.listForUser(userId)).filter((t) => Expo.isExpoPushToken(t));
const messages: ExpoPushMessage[] = tokens.map((to) => ({
to,
title: content.title,
body: content.body,
data: { url: content.url }, // куда открыть приложение по нажатию
sound: 'default',
channelId: 'orders', // канал должен быть создан в приложении
}));
const pending: PendingReceipt[] = [];
// не больше 100 сообщений в запросе — SDK режет на части сам
for (const chunk of expo.chunkPushNotifications(messages)) {
const tickets = await expo.sendPushNotificationsAsync(chunk);
for (const [i, ticket] of tickets.entries()) {
const token = chunk[i].to as string;
if (ticket.status === 'ok') pending.push({ receiptId: ticket.id, token });
else if (ticket.details?.error === 'DeviceNotRegistered') await store.remove(token);
else console.error('push ticket error', ticket.message);
}
}
return pending; // квитанции проверяем позже — Expo советует через 15 минут
}
export async function checkReceipts(expo: Expo, store: TokenStore, pending: PendingReceipt[]) {
const tokenById = new Map(pending.map((p) => [p.receiptId, p.token]));
let failed = 0;
for (const ids of expo.chunkPushNotificationReceiptIds([...tokenById.keys()])) {
const receipts = await expo.getPushNotificationReceiptsAsync(ids);
for (const [id, receipt] of Object.entries(receipts)) {
if (receipt.status === 'ok') continue;
failed++;
// устройство больше не принимает push: перестаём слать на этот токен
if (receipt.details?.error === 'DeviceNotRegistered') await store.remove(tokenById.get(id)!);
}
}
return { checked: tokenById.size, failed };
}import { createServer, type IncomingMessage } from 'node:http';
import { Expo } from 'expo-server-sdk';
import { notifyUser } from './push.ts';
import type { TokenStore } from './store.ts';
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
let raw = '';
for await (const chunk of req) raw += chunk;
return raw ? JSON.parse(raw) : {};
}
// Учебная авторизация: в проекте userId берут из проверенной сессии или JWT, а не из заголовка
function userIdOf(req: IncomingMessage): string | null {
const id = req.headers['x-demo-user'];
return typeof id === 'string' && id ? id : null;
}
export function createPushServer(expo: Expo, store: TokenStore) {
return createServer(async (req, res) => {
const send = (status: number, body?: unknown) => {
if (body === undefined) return void res.writeHead(status).end();
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
const userId = userIdOf(req);
if (!userId) return send(401, { error: 'unauthorized' });
try {
if (req.method === 'POST' && req.url === '/v1/push-tokens') {
const { token } = await readJson(req);
if (!Expo.isExpoPushToken(token)) return send(400, { error: 'invalid_token' });
await store.save(userId, token);
return send(204);
}
if (req.method === 'POST' && req.url === '/v1/test-push') {
const pending = await notifyUser(expo, store, userId, {
title: 'Заказ готов',
body: 'Заказ №1042 можно забрать',
url: '/orders/1042',
});
return send(202, { queued: pending.length });
}
send(404, { error: 'not_found' });
} catch (error) {
console.error(error);
send(500, { error: 'internal' });
}
});
}expo-server-sdkот команды Expo режет сообщения на пачки, ограничивает число одновременных соединений, сжимает большие запросы и повторяет их с паузами при 429 и 5xx — Expo рекомендует его вместо ручных запросов.- Общий размер уведомления — не больше 4096 байт; при превышении придёт ошибка
MessageTooBig. Вdataкладут идентификатор и путь, а не весь заказ. - Если токены утекут, кто угодно сможет отправить от вашего имени сообщение вашим пользователям. В панели EAS на expo.dev можно включить расширенную защиту push (enhanced push security): тогда запросы без access token отклоняются, а SDK передаёт его через опцию
accessToken. channelIdдолжен совпадать с каналом, созданным в приложении: на Android уведомление в несуществующий канал не показывается.
import { Expo } from 'expo-server-sdk';
import { createPushServer } from './server.ts';
import { MemoryTokenStore } from './store.ts';
// EXPO_ACCESS_TOKEN нужен, если в настройках проекта включена защита push (Enhanced Security)
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const port = Number(process.env.PORT ?? 3000);
createPushServer(expo, new MemoryTokenStore()).listen(port, () => {
console.log(`push server on http://localhost:${port}`);
});node src/main.ts (Node.js 24 выполняет TypeScript без сборки)Нажатие на уведомление и переход на экран
В SDK 57 уведомление, пришедшее при открытом приложении, по умолчанию не показывается: поведение задают через setNotificationHandler, и он должен ответить за 3 секунды. В SDK 58 это меняется — уведомления на переднем плане будут показываться по умолчанию, поэтому явный обработчик полезен в обоих случаях.
import * as Notifications from 'expo-notifications';
// В SDK 57 уведомление при открытом приложении по умолчанию не показывается —
// решаем явно. Вызывается один раз, на верхнем уровне модуля.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});Переход по нажатию складывается из двух случаев. Если приложение было закрыто и запущено нажатием, ответ лежит в getLastNotificationResponse(). Если работало — срабатывает addNotificationResponseReceivedListener. Путь берём из data.url, но не открываем что попало: данные могли устареть или быть подменены, поэтому допускаем только экраны из белого списка.
// Путь из data.url уведомления — только внутренние экраны из белого списка.
// Push может прийти с устаревшими или подменёнными данными: не открываем что попало.
const ALLOWED = [/^\/orders\/[\w-]+$/, /^\/orders$/, /^\/profile$/];
export function notificationUrl(data: unknown): string | null {
if (typeof data !== 'object' || data === null) return null;
const url = (data as { url?: unknown }).url;
if (typeof url !== 'string') return null;
return ALLOWED.some((re) => re.test(url)) ? url : null;
}import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { notificationUrl } from './notificationUrl';
export function useNotificationNavigation(open: (url: string) => void) {
useEffect(() => {
function handle(response: Notifications.NotificationResponse) {
const url = notificationUrl(response.notification.request.content.data);
if (url) open(url);
}
// приложение было закрыто и запущено нажатием на уведомление
const last = Notifications.getLastNotificationResponse();
if (last) handle(last);
// приложение уже работало (на экране или в фоне)
const subscription = Notifications.addNotificationResponseReceivedListener(handle);
return () => subscription.remove();
}, [open]);
}open — это router.pushПроверка: что проверено без устройства
Реальную доставку можно увидеть только на устройстве с development build: Expo предлагает для этого веб-инструмент push notifications tool, куда вставляют токен с телефона. Всё остальное проверяется тестами. Сервер тестируется встроенным node:test, а Expo Push API подменяется MockAgent из undici: SDK принимает его через опцию httpAgent, и ни один запрос не уходит на настоящий exp.host.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { MockAgent } from 'undici';
import { Expo } from 'expo-server-sdk';
import { checkReceipts } from '../src/push.ts';
import { createPushServer } from '../src/server.ts';
import { MemoryTokenStore } from '../src/store.ts';
// плейсхолдеры в формате Expo — настоящих токенов и устройств нет
const TOKEN_A = 'ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]';
const TOKEN_B = 'ExponentPushToken[bbbbbbbbbbbbbbbbbbbbbb]';
let agent: MockAgent;
let store: MemoryTokenStore;
let expo: Expo;
beforeEach(() => {
agent = new MockAgent();
agent.disableNetConnect(); // ни одного запроса к настоящему exp.host
store = new MemoryTokenStore();
expo = new Expo({ httpAgent: agent });
});
async function withServer(fn: (url: string) => Promise<void>) {
const server = createPushServer(expo, store).listen(0);
await new Promise((r) => server.once('listening', r));
try {
await fn(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
} finally {
server.close();
}
}
test('регистрация токена: 401 без пользователя, 400 на мусор, 204 на токен Expo', async () => {
await withServer(async (url) => {
const post = (body: unknown, user?: string) =>
fetch(`${url}/v1/push-tokens`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(user ? { 'x-demo-user': user } : {}) },
body: JSON.stringify(body),
});
assert.equal((await post({ token: TOKEN_A })).status, 401);
assert.equal((await post({ token: 'not-a-token' }, 'u1')).status, 400);
assert.equal((await post({ token: TOKEN_A }, 'u1')).status, 204);
assert.deepEqual(await store.listForUser('u1'), [TOKEN_A]);
});
});
test('отправка: тикет DeviceNotRegistered удаляет токен, ok попадает в квитанции', async () => {
await store.save('u1', TOKEN_A);
await store.save('u1', TOKEN_B);
let sentBody: Array<Record<string, unknown>> = [];
agent
.get('https://exp.host')
.intercept({ path: '/--/api/v2/push/send', method: 'POST' })
.reply(200, (opts) => {
sentBody = JSON.parse(String(opts.body)); // SDK сжимает gzip только большие запросы
return {
data: [
{ status: 'ok', id: 'receipt-1' },
{ status: 'error', message: 'not registered', details: { error: 'DeviceNotRegistered' } },
],
};
});
await withServer(async (url) => {
const res = await fetch(`${url}/v1/test-push`, { method: 'POST', headers: { 'x-demo-user': 'u1' } });
assert.equal(res.status, 202);
assert.deepEqual(await res.json(), { queued: 1 });
});
assert.equal(sentBody.length, 2);
assert.deepEqual(sentBody[0].data, { url: '/orders/1042' });
assert.equal(sentBody[0].channelId, 'orders');
assert.deepEqual(await store.listForUser('u1'), [TOKEN_A]);
});
test('квитанции: DeviceNotRegistered удаляет токен', async () => {
await store.save('u1', TOKEN_A);
agent
.get('https://exp.host')
.intercept({ path: '/--/api/v2/push/getReceipts', method: 'POST' })
.reply(200, { data: { 'receipt-1': { status: 'error', details: { error: 'DeviceNotRegistered' } } } });
const result = await checkReceipts(expo, store, [{ receiptId: 'receipt-1', token: TOKEN_A }]);
assert.deepEqual(result, { checked: 1, failed: 1 });
assert.deepEqual(await store.listForUser('u1'), []);
});В приложении jest-expo с моками expo-notifications и expo-constants проверяет порядок «канал → разрешение → токен», отказ, ошибку сети и навигацию по нажатию, в том числе отказ открывать путь не из белого списка.
$ cd server && npx tsc --noEmit && npm test
✔ регистрация токена: 401 без пользователя, 400 на мусор, 204 на токен Expo (30.1206ms)
✔ отправка: тикет DeviceNotRegistered удаляет токен, ok попадает в квитанции (14.2506ms)
✔ квитанции: DeviceNotRegistered удаляет токен (1.4411ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
$ cd ../app && npx tsc --noEmit && npx jest
PASS __tests__/notificationNavigation.test.tsx
PASS __tests__/registerForPush.test.ts
Tests: 6 passed, 6 total
$ npx expo export --platform android --platform ios
Android Bundled 6092ms index.ts (643 modules)
iOS Bundled 9340ms index.ts (645 modules)
Exported: dist- Development build установлена, push работает не в Expo Go
- Учётные данные FCM v1 и ключ APNs загружены в EAS
- Канал Android создан до запроса токена,
channelIdв сообщениях совпадает - Токен сохраняется на сервере при входе и удаляется при выходе
DeviceNotRegisteredв тикетах и квитанциях удаляет токен- Нажатие открывает только разрешённые экраны
Как проверено2 · пройдено3 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
server/src/{store,push,server,main}.ts
временный Node-проект (ESM, Node.js 24.18.0 с выполнением TypeScript без сборки): tsc --noEmit (TypeScript 7.0.2); node --test — 3 теста с undici 7.30.0 MockAgent вместо exp.host (disableNetConnect), expo-server-sdk 7.2.0; ручной запуск node src/main.ts: POST /v1/push-tokens → 204, мусорный токен → 400
- запущено, работает
app/src/push/{registerForPush,notificationUrl,useNotificationNavigation,handler}.ts, App.tsx, app.json
create-expo-app (blank-typescript), Expo SDK 57.0.27, expo-notifications 57.0.22; tsc --noEmit; jest-expo 57.0.5 — 6 тестов с моками expo-notifications/expo-constants (порядок канал→разрешение→токен, отказ, ошибка сети, холодный старт, белый список); npx expo export --platform android --platform ios; expo-doctor 21/21; npx expo config --type introspect — aps-environment и канал FCM по умолчанию
- не запускалось
curl к Expo Push API (плейсхолдер токена)
формат из документации Expo; запрос не отправлялся — реальных токенов нет
- не запускалось
eas init / eas build, учётные данные FCM v1 и APNs
нет аккаунтов Firebase/Apple Developer для учебного проекта; шаги — по документации Expo
- не запускалось
получение токена и реальная доставка push на устройство
устройства и development build недоступны; поведение — по документации Expo, Apple и Firebase
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.
