fix(push): register APNs and VoIP tokens on iOS

The local notifications plugin was initialised without iOS settings, so
flutter_local_notifications threw "iOS settings must be set" before the
app registered with Apple, and restoreMode swallowed the error. iPhones
never sent the relay an APNs token for message wake-ups or a VoIP token
for calls.

- add DarwinInitializationSettings and request iOS permission explicitly
- register with Apple before asking for notification permission (silent
  wake-ups do not need it)
- send the VoIP token on its own (PLATFORM_APNS with an empty endpoint)
  when no APNs token is available yet
- record a failed registration so the chat list shows it instead of
  failing silently
- drop the force cast of rootViewController in AppDelegate and the unused
  SceneDelegate
This commit is contained in:
Julius Joska
2026-10-23 10:51:02 +02:00
parent 717aeed4ff
commit 59992af778
6 changed files with 246 additions and 46 deletions
+3 -3
View File
@@ -74,7 +74,7 @@ class PushBootstrap {
static Future<bool> registrationFailed() async =>
await _store.read(key: _registrationFailedKey) == '1';
static Future<void> _setRegistrationFailed(bool failed) => failed
static Future<void> setRegistrationFailed(bool failed) => failed
? _store.write(key: _registrationFailedKey, value: '1')
: _store.delete(key: _registrationFailedKey);
@@ -93,7 +93,7 @@ class PushBootstrap {
await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)),
unawaited(setRegistrationFailed(true)),
onUnregistered: _onUnregistered,
onMessage: _onMessage,
);
@@ -138,7 +138,7 @@ class PushBootstrap {
}
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla
unawaited(setRegistrationFailed(false)); // registrace se povedla
final s = _service;
if (s != null) {
s.handleNewEndpoint(endpoint.url);
+15 -1
View File
@@ -27,6 +27,14 @@ class PushNotifier {
await _plugin.initialize(
InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
// se žádá až v [requestPermission], ne při první notifikaci.
iOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
linux: LinuxInitializationSettings(
defaultActionName: l10n.pushActionOpen,
),
@@ -48,17 +56,23 @@ class PushNotifier {
priority: Priority.high,
category: AndroidNotificationCategory.message,
),
iOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(),
);
}
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
/// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
static Future<void> requestPermission() async {
await _ensureInit();
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
}
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
+77 -22
View File
@@ -27,6 +27,7 @@ import 'dart:typed_data';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:sepot/core/calls/call_ringer.dart';
import 'package:sepot/core/push/apns_ios.dart';
import 'package:sepot/core/push/push_bootstrap.dart';
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
import 'package:unifiedpush/unifiedpush.dart';
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
/// kroků a skládání registrace ověřit testem bez iPhonu.
class IosPushPlatform {
const IosPushPlatform();
bool get isIOS => Platform.isIOS;
Future<void> registerApns() => ApnsBridge.register();
Future<String?> apnsToken() => ApnsBridge.currentToken();
Future<String?> voipToken() => CallRinger.voipToken();
Future<void> requestPermission() => PushNotifier.requestPermission();
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
Future<void> setRegistrationFailed(bool failed) =>
PushBootstrap.setRegistrationFailed(failed);
}
class PushService {
PushService(this._messaging);
PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
: _ios = ios;
final MessagingService _messaging;
final IosPushPlatform _ios;
StreamSubscription<String>? _fcmTokenSub;
StreamSubscription<RemoteMessage>? _fcmMessageSub;
@@ -94,34 +112,66 @@ class PushService {
String? _apnsToken;
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
/// (relay drží obojí u zařízení).
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
final voip = await CallRinger.voipToken();
final reg = _registration(
pb.RegisterPush_Platform.PLATFORM_APNS,
Uint8List.fromList(utf8.encode(token)),
/// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
/// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
/// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
/// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
@visibleForTesting
static pb.RegisterPush? apnsRegistration({
String? apnsToken,
String? voipToken,
}) {
if (apnsToken == null && voipToken == null) return null;
final reg = pb.RegisterPush(
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
endpoint: apnsToken == null
? Uint8List(0)
: Uint8List.fromList(utf8.encode(apnsToken)),
);
if (voip != null) reg.voipToken = utf8.encode(voip);
_messaging.registerPush(reg);
if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
return reg;
}
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
Future<void> refreshVoipToken() async {
final token = _apnsToken;
if (token != null) await _registerApnsToken(token);
Future<void> _sendApnsRegistration() async {
final reg = apnsRegistration(
apnsToken: _apnsToken,
voipToken: await _ios.voipToken(),
);
if (reg != null) _messaging.registerPush(reg);
}
Future<void> _registerApnsToken(String token) async {
_apnsToken = token;
await _sendApnsRegistration();
}
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
/// pro zprávy ještě nedorazil.
Future<void> refreshVoipToken() => _sendApnsRegistration();
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
/// asynchronně a sám se pošle relayi. Bez Googlu.
///
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
/// zastavit doručování zpráv.
Future<void> enableApns() async {
await init();
await PushNotifier.requestPermission();
await ApnsBridge.register();
final existing = await ApnsBridge.currentToken();
if (existing != null) _registerApnsToken(existing);
await PushBootstrap.writeMode('apns');
await _ios.registerApns();
await _ios.writeMode('apns');
final existing = await _ios.apnsToken();
if (existing != null) {
await _registerApnsToken(existing);
} else {
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
await _sendApnsRegistration();
}
try {
await _ios.requestPermission();
} catch (e, s) {
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
}
}
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
@@ -245,10 +295,15 @@ class PushService {
Future<void> restoreMode() async {
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
if (Platform.isIOS) {
if (_ios.isIOS) {
try {
await enableApns();
} catch (_) {
await _ios.setRegistrationFailed(false);
} catch (e, s) {
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
// uvidí stav pushů v chat listu (PushHealth.failed).
debugPrint('APNs registrace selhala: $e\n$s');
await _ios.setRegistrationFailed(true);
await init();
}
return;