Push-уведомления во Flutter-приложении с бэкендом на Laravel: App Store и RuStore
В этой статье разберём, как с нуля сделать push-уведомления в мобильном приложении на Flutter, если бэкенд написан на Laravel, а приложение публикуется в App Store и RuStore. Пройдём весь путь: какие сервисы доставки нужны и почему их два, что настроить в Apple Developer, Firebase и консоли RuStore, как приложение получает токен и передаёт его серверу, как Laravel хранит токены и отправляет уведомления через очередь, как открыть нужный экран по нажатию и как всё это проверить. В конце — типичные ошибки и чек-лист запуска.
Сквозной пример — приложение фитнес-клуба: пользователь записывается на тренировку, а за два часа до начала получает push «Тренировка через 2 часа» и по нажатию попадает на экран этой тренировки. Названия классов и маршрутов условные, их легко заменить на свои. Версии на октябрь 2026 года: Laravel 12–13, Flutter 3.35+, firebase_messaging 17.x, flutter_rustore_push 7.x.
Как устроены push-уведомления
Ваш сервер не может сам «достучаться» до телефона: у телефона нет постоянного адреса, он то в Wi-Fi, то в мобильной сети, то спит. Поэтому доставкой занимается сервис платформы, с которым телефон держит одно постоянное соединение на все приложения сразу:
- на iPhone — APNs (Apple Push Notification service);
- на Android с сервисами Google — FCM (Firebase Cloud Messaging);
- на Android из RuStore — RuStore Push (работает через приложение RuStore, сервисы Google не нужны).
Схема всегда одна и та же:
1. Приложение ──регистрация──> Сервис доставки (APNs / FCM / RuStore)
│
2. Приложение <─────токен─────────┘
│
3. └──POST /api/v1/device-tokens──> Laravel (сохраняет токен за пользователем)
4. Событие на сервере ("тренировка через 2 часа")
Laravel ──токен + текст──> Сервис доставки ──> Телефон ──> Шторка уведомлений
Токен — это адрес конкретной установки приложения на конкретном устройстве. Не пользователя: у одного человека может быть iPhone и Android-планшет, то есть два токена. Токен может смениться (переустановка приложения, восстановление из бэкапа, обновление SDK), и тогда старый перестаёт работать. Из этого следуют три правила, на которых держится вся архитектура:
- токенов у пользователя много, храним их отдельной таблицей;
- приложение отправляет токен при каждом запуске и при его обновлении, а сервер делает upsert;
- сервер удаляет токен, когда сервис доставки отвечает, что токен больше недействителен.
Какой сервис выбрать для каждой платформы
Для App Store вариантов по сути нет: доставка на iPhone всегда идёт через APNs. Вопрос только в том, ходит ли ваш сервер в APNs напрямую или через Firebase, который сам переадресует сообщение в APNs. Для Android из RuStore разумный выбор — RuStore Push: он работает и на устройствах без сервисов Google (например, на новых Huawei), а приложение RuStore у пользователя гарантированно есть — он из него ваше приложение и установил.
| Канал публикации | Сервис доставки | Flutter-пакет | Что нужно серверу |
|---|---|---|---|
| App Store (iOS) | APNs через FCM | firebase_messaging |
Сервисный аккаунт Google (JSON) и ID проекта Firebase |
| RuStore (Android) | RuStore Push | flutter_rustore_push |
ID проекта и сервисный токен из консоли RuStore |
| Google Play (если появится) | FCM | firebase_messaging |
То же, что для iOS |
Почему для iOS удобно взять Firebase, а не APNs напрямую: пакет firebase_messaging — самый распространённый и хорошо документированный способ получить токен, обработать нажатие и показ уведомлений во Flutter. Сервер при этом общается с одним HTTP API от Google, а не реализует HTTP/2 и JWT-подпись для Apple. Если зависимость от Google для вас неприемлема, сервер может слать в APNs напрямую с ключом .p8 — логика на стороне Laravel меняется только в одном классе отправки (дальше увидите, что он изолирован).
Если позже приложение появится в Google Play, к Android-сборке для Google Play добавится FCM, а таблица токенов и код отправки уже будут к этому готовы: у каждого токена хранится провайдер.
Шаг 1. Подготовка сервисов
iOS: ключ APNs и проект Firebase
- В Apple Developer → Certificates, Identifiers & Profiles → Keys создайте ключ с галочкой Apple Push Notifications service (APNs). Скачайте файл
.p8(скачать можно только один раз), запишите Key ID и Team ID. Один ключ работает для всех приложений команды и для обоих окружений — sandbox и production. - В Identifiers у App ID приложения включите capability Push Notifications.
- Создайте проект в консоли Firebase, добавьте в него iOS-приложение с вашим Bundle ID.
- Project settings → Cloud Messaging → Apple app configuration — загрузите ключ
.p8, укажите Key ID и Team ID. - Project settings → Service accounts → Generate new private key — скачается JSON сервисного аккаунта. Это секрет для сервера: он кладётся на сервер вне репозитория и никогда не попадает в приложение.
Ключ APNs в формате .p8 предпочтительнее старых сертификатов .p12: он не истекает каждый год и не привязан к одному приложению.
Android: проект в консоли RuStore
- В консоли RuStore откройте Push-уведомления → Проекты и создайте проект.
- Укажите имя пакета (
applicationId) и SHA-256-отпечаток ключа подписи той сборки, которая уходит в RuStore. - Скопируйте ID проекта (нужен и приложению, и серверу) и сервисный токен (только для сервера).
Отпечаток должен совпадать с ключом, которым на самом деле подписан APK/AAB для RuStore. Частая ловушка: отпечаток взяли от debug-ключа или от ключа другой сборки, и токен в приложении не выдаётся. Посмотреть отпечаток подписанного файла можно так:
keytool -printcert -jarfile app-release.apk
Шаг 2. Хранение токенов в Laravel
Таблица и модель
Провайдер выносим в enum: и в валидации, и при выборе способа отправки это одно и то же значение.
// app/Enums/PushProvider.php
namespace App\Enums;
enum PushProvider: string
{
case Fcm = 'fcm';
case RuStore = 'rustore';
}
// database/migrations/2026_10_16_000000_create_device_tokens_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('device_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider', 16); // fcm | rustore
$table->string('platform', 16); // ios | android
$table->string('token', 512)->unique();
$table->string('app_version', 32)->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('device_tokens');
}
};
Уникальность — по самому токену, а не по паре «пользователь + токен». Если на одном телефоне сначала вошёл Иван, а потом Мария, токен должен перейти к Марии, иначе Иван продолжит получать её уведомления на чужой телефон.
// app/Models/DeviceToken.php
namespace App\Models;
use App\Enums\PushProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DeviceToken extends Model
{
protected $fillable = ['user_id', 'provider', 'platform', 'token', 'app_version', 'last_seen_at'];
protected function casts(): array
{
return [
'provider' => PushProvider::class,
'last_seen_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
В модели User — связь:
public function deviceTokens(): HasMany
{
return $this->hasMany(DeviceToken::class);
}
API для приложения
Валидация — в отдельном FormRequest:
// app/Http/Requests/StoreDeviceTokenRequest.php
namespace App\Http\Requests;
use App\Enums\PushProvider;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreDeviceTokenRequest extends FormRequest
{
public function rules(): array
{
return [
'token' => ['required', 'string', 'max:512'],
'provider' => ['required', Rule::enum(PushProvider::class)],
'platform' => ['required', Rule::in(['ios', 'android'])],
'app_version' => ['nullable', 'string', 'max:32'],
];
}
}
// app/Http/Controllers/Api/V1/DeviceTokenController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDeviceTokenRequest;
use App\Models\DeviceToken;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class DeviceTokenController extends Controller
{
public function store(StoreDeviceTokenRequest $request): Response
{
DeviceToken::updateOrCreate(
['token' => $request->validated('token')],
[
...$request->validated(),
'user_id' => $request->user()->id,
'last_seen_at' => now(),
],
);
return response()->noContent();
}
public function destroy(Request $request): Response
{
$request->validate(['token' => ['required', 'string']]);
$request->user()->deviceTokens()
->where('token', $request->input('token'))
->delete();
return response()->noContent();
}
}
// routes/api.php
use App\Http\Controllers\Api\V1\DeviceTokenController;
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
Route::post('device-tokens', [DeviceTokenController::class, 'store']);
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']);
});
last_seen_at обновляется при каждом запуске приложения. По нему видно «живые» устройства, а токены, которые не обновлялись, например, полгода, можно чистить по расписанию.
Шаг 3. Отправка уведомлений из Laravel
Отправку строим из четырёх частей:
PushMessage— что отправляем (заголовок, текст, данные);PushSender— интерфейс «отправь сообщение на этот токен» с двумя реализациями: FCM и RuStore;PushChannel— свой канал для стандартных уведомлений Laravel: выбирает отправщика по провайдеру токена и удаляет мёртвые токены;- само уведомление, например
WorkoutReminder, которое ставится в очередь.
Так бизнес-код пишет привычное $user->notify(...) и ничего не знает про Firebase и RuStore, а смена провайдера затрагивает один класс.
Настройки
// config/services.php
'fcm' => [
'project_id' => env('FCM_PROJECT_ID'),
'credentials' => env('FCM_CREDENTIALS'), // абсолютный путь к JSON сервисного аккаунта
],
'rustore_push' => [
'project_id' => env('RUSTORE_PUSH_PROJECT_ID'),
'service_token' => env('RUSTORE_PUSH_SERVICE_TOKEN'),
],
# .env на сервере (в репозиторий не коммитим)
FCM_PROJECT_ID=fitness-club-app
FCM_CREDENTIALS=/var/www/secrets/firebase-service-account.json
RUSTORE_PUSH_PROJECT_ID=...
RUSTORE_PUSH_SERVICE_TOKEN=...
Сообщение и интерфейс отправщика
// app/Services/Push/PushMessage.php
namespace App\Services\Push;
final readonly class PushMessage
{
/**
* @param array<string, string> $data только строки: так требуют и FCM, и RuStore
*/
public function __construct(
public string $title,
public string $body,
public array $data = [],
) {}
}
// app/Services/Push/PushSender.php
namespace App\Services\Push;
interface PushSender
{
/**
* @throws InvalidPushToken если сервис доставки сообщил, что токен больше не действует
*/
public function send(string $token, PushMessage $message): void;
}
// app/Services/Push/InvalidPushToken.php
namespace App\Services\Push;
use RuntimeException;
class InvalidPushToken extends RuntimeException {}
Отправка через FCM (iOS)
FCM принимает запросы по HTTP v1 API. Авторизация — OAuth 2.0 токен доступа, который получается из JSON сервисного аккаунта. Для этого есть официальная библиотека Google:
composer require google/auth
Токен доступа живёт час, поэтому кешируем его на 50 минут, а не запрашиваем на каждое уведомление.
// app/Services/Push/FcmSender.php
namespace App\Services\Push;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class FcmSender implements PushSender
{
public function send(string $token, PushMessage $message): void
{
$payload = [
'token' => $token,
'notification' => ['title' => $message->title, 'body' => $message->body],
'apns' => ['payload' => ['aps' => ['sound' => 'default']]],
];
if ($message->data !== []) {
$payload['data'] = $message->data;
}
$response = Http::withToken($this->accessToken())
->post(sprintf(
'https://fcm.googleapis.com/v1/projects/%s/messages:send',
config('services.fcm.project_id'),
), ['message' => $payload]);
// 404 UNREGISTERED — приложение удалено или токен устарел.
if ($response->notFound()) {
throw new InvalidPushToken($token);
}
$response->throw();
}
private function accessToken(): string
{
return Cache::remember('push.fcm.access_token', now()->addMinutes(50), function (): string {
$credentials = new ServiceAccountCredentials(
'https://www.googleapis.com/auth/firebase.messaging',
json_decode(file_get_contents(config('services.fcm.credentials')), true, flags: JSON_THROW_ON_ERROR),
);
return $credentials->fetchAuthToken()['access_token'];
});
}
}
Если не хочется писать отправку руками, есть готовые пакеты: kreait/laravel-firebase и канал laravel-notification-channels/fcm. Свой класс на 40 строк проще понять и отладить, а для RuStore готового канала всё равно нет — подход получается единым для обоих провайдеров.
Отправка через RuStore Push (Android)
API RuStore Push повторяет формат FCM: тот же messages:send, тот же объект message с token, notification и data. Авторизация проще — постоянный сервисный токен из консоли в заголовке Authorization: Bearer.
// app/Services/Push/RuStoreSender.php
namespace App\Services\Push;
use Illuminate\Support\Facades\Http;
final class RuStoreSender implements PushSender
{
public function send(string $token, PushMessage $message): void
{
$payload = [
'token' => $token,
'notification' => ['title' => $message->title, 'body' => $message->body],
];
if ($message->data !== []) {
$payload['data'] = $message->data;
}
$response = Http::withToken(config('services.rustore_push.service_token'))
->post(sprintf(
'https://vkpns.rustore.ru/v1/projects/%s/messages:send',
config('services.rustore_push.project_id'),
), ['message' => $payload]);
// 404 NOT_FOUND — токен неверный или истёк.
if ($response->notFound()) {
throw new InvalidPushToken($token);
}
$response->throw();
}
}
Ограничения RuStore Push, которые стоит знать: размер сообщения — до 4096 байт, без title в notification SDK уведомление не покажет, а при превышении лимита запросов API отвечает статусом TOO_MANY_REQUESTS.
Канал уведомлений
Свой канал — это обычный класс с методом send(). Laravel вызывает его, если уведомление вернуло этот класс из via().
// app/Notifications/Channels/PushChannel.php
namespace App\Notifications\Channels;
use App\Enums\PushProvider;
use App\Models\User;
use App\Notifications\Contracts\HasPushMessage;
use App\Services\Push\FcmSender;
use App\Services\Push\InvalidPushToken;
use App\Services\Push\RuStoreSender;
final class PushChannel
{
public function __construct(
private readonly FcmSender $fcm,
private readonly RuStoreSender $ruStore,
) {}
public function send(User $notifiable, HasPushMessage $notification): void
{
$message = $notification->toPush($notifiable);
foreach ($notifiable->deviceTokens as $device) {
$sender = match ($device->provider) {
PushProvider::Fcm => $this->fcm,
PushProvider::RuStore => $this->ruStore,
};
try {
$sender->send($device->token, $message);
} catch (InvalidPushToken) {
$device->delete();
}
}
}
}
// app/Notifications/Contracts/HasPushMessage.php
namespace App\Notifications\Contracts;
use App\Models\User;
use App\Services\Push\PushMessage;
interface HasPushMessage
{
public function toPush(User $notifiable): PushMessage;
}
Удаление токена по ответу 404 — обязательная часть, а не оптимизация. Без неё таблица со временем наполняется мёртвыми токенами, каждая рассылка тратит запросы впустую, а сервисы доставки могут ограничивать отправителей с большой долей ошибок.
Уведомление и очередь
// app/Notifications/WorkoutReminder.php
namespace App\Notifications;
use App\Models\User;
use App\Models\Workout;
use App\Notifications\Channels\PushChannel;
use App\Notifications\Contracts\HasPushMessage;
use App\Services\Push\PushMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class WorkoutReminder extends Notification implements HasPushMessage, ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(private readonly Workout $workout) {}
/** @return list<int> пауза перед повторами, секунды */
public function backoff(): array
{
return [10, 60];
}
public function via(object $notifiable): array
{
return [PushChannel::class];
}
public function toPush(User $notifiable): PushMessage
{
return new PushMessage(
title: 'Тренировка через 2 часа',
body: sprintf('%s в %s', $this->workout->title, $this->workout->starts_at->format('H:i')),
data: ['route' => '/workouts/'.$this->workout->id],
);
}
}
Отправка из любого места кода:
$user->notify(new WorkoutReminder($workout));
ShouldQueue здесь принципиален. Запрос к FCM или RuStore — это сетевой вызов на сотни миллисекунд, который может упасть по таймауту. В очереди он не тормозит ответ API и автоматически повторяется при временной ошибке (tries и backoff). На сервере нужен постоянно работающий воркер — php artisan queue:work под Supervisor или systemd. Сами напоминания «за два часа» запускаются командой по расписанию, например Schedule::command('workouts:send-reminders')->everyFiveMinutes() в routes/console.php.
Ограничение этой простой схемы: при повторе задача заново отправит уведомление на все устройства пользователя, включая те, куда оно уже дошло. Для напоминаний это приемлемо. Для массовых рассылок на тысячи устройств лучше ставить в очередь отдельную задачу на каждый токен (или пачку токенов) и учитывать лимиты запросов сервисов.
Шаг 4. Flutter-приложение
Зависимости и общий интерфейс
flutter pub add firebase_core firebase_messaging flutter_rustore_push permission_handler
Код экранов не должен знать, какой сервис доставки работает на этом устройстве. Поэтому делаем интерфейс и две реализации:
// lib/push/push_service.dart
abstract interface class PushService {
/// Значение для поля `provider` в API: `fcm` или `rustore`.
String get provider;
/// Спрашивает разрешение. `false` — пользователь отказал или push недоступен.
Future<bool> requestPermission();
Future<String?> getToken();
Stream<String> get onTokenRefresh;
/// Данные (`data`) уведомления, по нажатию на которое открыли приложение.
Stream<Map<String, String>> get onOpened;
/// То же для холодного старта: приложение было закрыто и запущено нажатием.
Future<Map<String, String>?> initialMessage();
}
Map<String, String> stringMap(Map<Object?, Object?> raw) => {
for (final entry in raw.entries)
if (entry.key != null && entry.value != null) '${entry.key}': '${entry.value}',
};
iOS: Firebase Messaging
Подготовка проекта:
flutterfire configure --platforms=ios— создастlib/firebase_options.dartиGoogleService-Info.plist(нужен FlutterFire CLI).- В Xcode → Runner → Signing & Capabilities добавьте Push Notifications и Background Modes с галочкой Remote notifications.
// lib/push/firebase_push_service.dart
import 'package:firebase_messaging/firebase_messaging.dart';
import 'push_service.dart';
class FirebasePushService implements PushService {
final _messaging = FirebaseMessaging.instance;
@override
String get provider => 'fcm';
@override
Future<bool> requestPermission() async {
final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true);
// Без этого iOS не показывает баннер, пока приложение открыто.
await _messaging.setForegroundNotificationPresentationOptions(alert: true, badge: true, sound: true);
return settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional;
}
@override
Future<String?> getToken() async {
// На iOS FCM-токен выдаётся только после APNs-токена, а он приходит с задержкой.
for (var attempt = 0; attempt < 5; attempt++) {
if (await _messaging.getAPNSToken() != null) {
return _messaging.getToken();
}
await Future<void>.delayed(const Duration(seconds: 1));
}
return null;
}
@override
Stream<String> get onTokenRefresh => _messaging.onTokenRefresh;
@override
Stream<Map<String, String>> get onOpened =>
FirebaseMessaging.onMessageOpenedApp.map((message) => stringMap(message.data));
@override
Future<Map<String, String>?> initialMessage() async {
final message = await _messaging.getInitialMessage();
return message == null ? null : stringMap(message.data);
}
}
Android: RuStore Push
В android/app/src/main/AndroidManifest.xml внутри <application> укажите ID проекта из консоли RuStore, а вне <application> — разрешение на уведомления для Android 13+:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application ...>
<meta-data
android:name="ru.rustore.sdk.pushclient.project_id"
android:value="ВАШ_ID_ПРОЕКТА" />
</application>
</manifest>
Если в release-сборке включена минификация (R8), добавьте правило ProGuard из документации SDK — иначе сборка может работать в debug и молча ломаться в release.
// lib/push/rustore_push_service.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_rustore_push/flutter_rustore_push.dart';
import 'package:permission_handler/permission_handler.dart';
import 'push_service.dart';
class RuStorePushService implements PushService {
RuStorePushService() {
RustorePushClient.attachCallbacks(
onNewToken: (token) => _tokens.add('$token'),
onMessageReceived: (message) {},
onDeletedMessages: () {},
onError: (error) => debugPrint('RuStore Push: $error'),
onMessageOpenedApp: (message) => _opened.add(stringMap(message.data)),
);
}
final _tokens = StreamController<String>.broadcast();
final _opened = StreamController<Map<String, String>>.broadcast();
@override
String get provider => 'rustore';
@override
Future<bool> requestPermission() async {
try {
// false, если RuStore не установлен или устарел.
if (!await RustorePushClient.available()) return false;
} catch (error) {
debugPrint('RuStore Push недоступен: $error');
return false;
}
// На Android 12 и ниже разрешение выдано по умолчанию — вернётся granted.
return (await Permission.notification.request()).isGranted;
}
@override
Future<String?> getToken() => RustorePushClient.getToken();
@override
Stream<String> get onTokenRefresh => _tokens.stream;
@override
Stream<Map<String, String>> get onOpened => _opened.stream;
@override
Future<Map<String, String>?> initialMessage() async {
final message = await RustorePushClient.getInitialMessage();
return message == null ? null : stringMap(message.data);
}
}
Если в сообщении заполнен notification, RuStore SDK сам показывает уведомление в шторке. Если нужно, чтобы приложение решило само (например, не показывать уведомление о сообщении в чате, который сейчас открыт), отправляйте только data, а показ делайте в onMessageReceived — например, через flutter_local_notifications.
Регистрация токена на сервере
Отдельный класс связывает push-сервис с API. Ошибки здесь не должны ронять приложение: push — дополнительная функция, без него приложение обязано работать.
// lib/push/push_registrar.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'push_service.dart';
class PushRegistrar {
PushRegistrar(this._push, this._dio, this._appVersion);
final PushService _push;
final Dio _dio;
final String _appVersion;
StreamSubscription<String>? _refresh;
String? _token;
/// Вызывать после входа и при каждом запуске, если пользователь уже вошёл.
Future<void> register() async {
try {
if (!await _push.requestPermission()) return;
final token = await _push.getToken();
if (token != null) await _send(token);
_refresh ??= _push.onTokenRefresh.listen(_send);
} catch (error) {
debugPrint('Push: не удалось зарегистрировать токен: $error');
}
}
/// Вызывать при выходе, ДО удаления токена авторизации.
Future<void> unregister() async {
await _refresh?.cancel();
_refresh = null;
final token = _token;
if (token == null) return;
try {
await _dio.delete('/v1/device-tokens', data: {'token': token});
} catch (error) {
debugPrint('Push: не удалось удалить токен: $error');
}
_token = null;
}
Future<void> _send(String token) async {
_token = token;
await _dio.post('/v1/device-tokens', data: {
'token': token,
'provider': _push.provider,
'platform': Platform.isIOS ? 'ios' : 'android',
'app_version': _appVersion,
});
}
}
Порядок в unregister() важен: удалить токен на сервере можно только пока пользователь ещё авторизован. Если сначала стереть токен Sanctum, запрос DELETE получит 401, и бывший пользователь продолжит получать уведомления на этот телефон.
Сборка всего вместе и переход по нажатию
// lib/main.dart (фрагмент)
import 'dart:io' show Platform;
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'push/firebase_push_service.dart';
import 'push/push_service.dart';
import 'push/rustore_push_service.dart';
Future<PushService> createPushService() async {
if (Platform.isIOS) {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
return FirebasePushService();
}
return RuStorePushService();
}
Выбор по Platform.isIOS работает, пока Android-сборка одна — для RuStore. Когда появится сборка для Google Play, выбор переносят на признак сборки: flavor или --dart-define=STORE=google_play.
Переход по нажатию: сервер кладёт в data маршрут, приложение открывает его через роутер. Принимать стоит только известные маршруты, а не любую строку из сообщения:
// lib/push/push_navigation.dart
import 'package:go_router/go_router.dart';
import 'push_service.dart';
final _allowedRoute = RegExp(r'^/workouts/\d+$');
Future<void> listenPushTaps(PushService push, GoRouter router) async {
void open(Map<String, String>? data) {
final route = data?['route'];
if (route != null && _allowedRoute.hasMatch(route)) router.push(route);
}
open(await push.initialMessage()); // холодный старт
push.onOpened.listen(open); // приложение было в фоне
}
Когда спрашивать разрешение
Спрашивать разрешение на первом же экране — худший вариант: человек ещё не понял, зачем ему уведомления, и нажимает «Не разрешать». На iOS системный диалог показывается один раз; после отказа включить уведомления можно только в настройках телефона. Лучше показать свой экран-объяснение в момент, когда польза очевидна, — например, сразу после записи на первую тренировку: «Напомнить за 2 часа до начала?» — и только после «Да» вызывать requestPermission().
Шаг 5. Проверка
Ручная отправка
Сначала убедитесь, что доставка работает без вашего бэкенда. Для RuStore — прямой запрос с токеном, который приложение вывело в лог:
curl -X POST "https://vkpns.rustore.ru/v1/projects/$PROJECT_ID/messages:send" \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message":{"token":"ТОКЕН_УСТРОЙСТВА","notification":{"title":"Проверка","body":"Push работает"}}}'
Успешный ответ — 200 и пустой объект {}. Для iOS тестовое сообщение удобно отправить из консоли Firebase: Messaging → создать кампанию → Send test message и вставить FCM-токен.
Затем — через Laravel, например из php artisan tinker:
User::find(1)->notify(new App\Notifications\WorkoutReminder(App\Models\Workout::first()));
Проверяйте три состояния приложения: открыто, свёрнуто, полностью закрыто. Это три разных пути обработки, и ошибки обычно прячутся в одном из них. Надёжнее всего проверять на реальных устройствах, причём на iOS — и на сборке из TestFlight: она использует production-окружение APNs, а сборка из Xcode — sandbox.
Автотесты на Pest
Внешние API в тестах подменяются через Http::fake(). Самое ценное, что стоит покрыть, — удаление мёртвых токенов и выбор провайдера:
use App\Enums\PushProvider;
use App\Models\User;
use App\Models\Workout;
use App\Notifications\WorkoutReminder;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config([
'services.rustore_push.project_id' => 'test-project',
'services.rustore_push.service_token' => 'test-token',
]);
});
it('sends reminder through RuStore Push', function () {
Http::fake(['vkpns.rustore.ru/*' => Http::response([], 200)]);
$user = User::factory()->create();
$user->deviceTokens()->create(['provider' => PushProvider::RuStore, 'platform' => 'android', 'token' => 'alive']);
$user->notify(new WorkoutReminder(Workout::factory()->create()));
Http::assertSent(fn ($request) => $request['message']['token'] === 'alive'
&& $request->hasHeader('Authorization', 'Bearer test-token'));
});
it('deletes token rejected by RuStore', function () {
Http::fake(['vkpns.rustore.ru/*' => Http::response(['error' => ['code' => 404, 'status' => 'NOT_FOUND']], 404)]);
$user = User::factory()->create();
$user->deviceTokens()->create(['provider' => PushProvider::RuStore, 'platform' => 'android', 'token' => 'stale']);
$user->notify(new WorkoutReminder(Workout::factory()->create()));
expect($user->deviceTokens()->count())->toBe(0);
});
В тестах очередь обычно работает синхронно (QUEUE_CONNECTION=sync в phpunit.xml), поэтому уведомление отправляется сразу. Для кода, который только решает, кому отправить, удобнее Notification::fake() и Notification::assertSentTo().
Безопасность и правила магазинов
- Секреты — только на сервере. JSON сервисного аккаунта Firebase и сервисный токен RuStore дают право слать уведомления всем вашим пользователям. В приложение попадают только ID проекта и публичный
GoogleService-Info.plist— это нормально. - Не кладите в push чувствительные данные. Текст виден на заблокированном экране и проходит через серверы Apple, Google или VK. «У вас новое сообщение» вместо текста сообщения, «Заказ оплачен» вместо суммы и адреса.
- Маркетинговые push — только с явного согласия. Правила App Store (пункт 4.5.4 App Review Guidelines) запрещают делать уведомления обязательными для работы приложения и требуют отдельного согласия на рекламные рассылки с возможностью отказаться. Удобно хранить настройки по типам: «Напоминания о тренировках» включены, «Акции клуба» — по желанию.
- Проверяйте
dataв приложении. Маршрут из уведомления — внешние данные; открывайте только известные экраны (пример сRegExpвыше).
Типичные ошибки
| Симптом | Частая причина | Что сделать |
|---|---|---|
На iOS getToken() возвращает null или падает |
Нет capability Push Notifications, APNs-токен ещё не пришёл | Добавить capability в Xcode и App ID, подождать getAPNSToken() |
| В TestFlight push не приходят, из Xcode — приходят | В Firebase загружен сертификат только для sandbox | Использовать ключ .p8: он работает для обоих окружений |
| На iOS нет баннера, пока приложение открыто | Так по умолчанию | setForegroundNotificationPresentationOptions(alert: true, ...) |
| RuStore не выдаёт токен | Отпечаток SHA-256 в консоли не совпадает с ключом подписи | Сверить keytool -printcert -jarfile с проектом в консоли |
| RuStore-уведомления приходят с большой задержкой | У приложения RuStore нет разрешения на работу в фоне | Подсказать пользователю выдать RuStore разрешение на фоновую работу |
| На Android 13+ ничего не показывается | Не запрошено POST_NOTIFICATIONS |
Запросить разрешение во время работы приложения |
| Работает в debug, не работает в release | Минификация вырезала классы SDK | Добавить правило ProGuard из документации |
| Уведомления приходят бывшему пользователю | Токен не удалён при выходе | Вызывать DELETE /device-tokens до сброса авторизации |
| Таблица токенов растёт, ошибок отправки всё больше | Не удаляются токены после ответа 404 | Ловить InvalidPushToken и удалять запись |
| Ответ API приходит долго, иногда 500 | Отправка push внутри HTTP-запроса | Уведомление с ShouldQueue и работающий воркер очереди |
Итог: чек-лист запуска
- Apple: ключ APNs
.p8, capability Push Notifications у App ID и в Xcode, Background Modes → Remote notifications. - Firebase: проект с iOS-приложением, загружен ключ
.p8, на сервере — JSON сервисного аккаунта вне репозитория. - RuStore: проект в разделе «Push-уведомления» с правильным
applicationIdи SHA-256 ключа release-подписи; ID проекта — в манифесте, сервисный токен — в.envсервера. - Laravel: таблица
device_tokensс уникальным токеном и провайдером, APIPOST/DELETE /api/v1/device-tokens, отправщики FCM и RuStore за общим интерфейсом, свой канал уведомлений, удаление токенов по 404. - Очередь: уведомления с
ShouldQueue, повторы с паузой, воркер под Supervisor или systemd. - Flutter: один интерфейс
PushServiceи две реализации; токен отправляется при каждом запуске и при обновлении, удаляется при выходе до сброса авторизации. - UX: разрешение спрашиваем в момент, когда польза понятна; маркетинговые уведомления — отдельным согласием.
- Переход по нажатию: маршрут в
data, проверка по списку разрешённых экранов, обработка холодного старта и фона. - Проверка: ручная отправка через
curlи консоль Firebase, три состояния приложения на реальных устройствах, сборка из TestFlight, автотесты сHttp::fake().
Главная мысль: push-уведомления — это не одна интеграция, а две независимые половины. Приложение отвечает за разрешение и актуальный токен, сервер — за хранение токенов, очередь и уборку мёртвых адресов. Когда эти обязанности чётко разделены, добавить новый канал (Google Play, прямой APNs или Huawei Push Kit) — это ещё один класс-отправщик и одна реализация PushService, а не переписывание всей системы.