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
+21 -14
View File
@@ -17,22 +17,29 @@ import flutter_callkit_incoming
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller = window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel(
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "register":
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
application.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
// Bez force-castu: kdyby root nebyl FlutterViewController (jiný storyboard,
// budoucí scény), appka by spadla hned při startu. Registrar dá messenger
// enginu i tak.
let messenger =
(window?.rootViewController as? FlutterViewController)?.binaryMessenger
?? registrar(forPlugin: "SepotApns")?.messenger()
if let messenger {
let channel = FlutterMethodChannel(
name: "cz.sepot.sepot/apns", binaryMessenger: messenger)
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "register":
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
application.registerForRemoteNotifications()
result(nil)
case "getToken":
result(self?.pendingToken)
default:
result(FlutterMethodNotImplemented)
}
}
apnsChannel = channel
}
apnsChannel = channel
GeneratedPluginRegistrant.register(with: self)
-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+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;
+130
View File
@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/push/push_service.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/proto/sepot.pb.dart' as pb;
/// iPhony relayi neposílaly APNs ani VoIP token: init lokálních notifikací
/// na iOS házel výjimku, ta zastavila registraci u Apple a restoreMode ji
/// spolkl. Tady se hlídá pořadí kroků a skládání registrace bez iPhonu.
class _FakeMessaging extends MessagingService {
_FakeMessaging() : super(storage: SecureStorageService());
final registrations = <pb.RegisterPush>[];
@override
void registerPush(pb.RegisterPush registration) =>
registrations.add(registration);
}
class _FakeIos extends IosPushPlatform {
_FakeIos({
this.apns,
this.voip,
this.permissionFails = false,
this.registerFails = false,
});
final String? apns;
final String? voip;
final bool permissionFails;
final bool registerFails;
final calls = <String>[];
bool? failedFlag;
String? mode;
@override
bool get isIOS => true;
@override
Future<void> registerApns() async {
calls.add('register');
if (registerFails) throw StateError('apns channel');
}
@override
Future<String?> apnsToken() async => apns;
@override
Future<String?> voipToken() async => voip;
@override
Future<void> requestPermission() async {
calls.add('permission');
if (permissionFails) {
throw ArgumentError('iOS settings must be set');
}
}
@override
Future<void> writeMode(String m) async => mode = m;
@override
Future<void> setRegistrationFailed(bool failed) async => failedFlag = failed;
}
void main() {
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
String text(List<int> bytes) => utf8.decode(bytes);
group('apnsRegistration', () {
test('APNs i VoIP v jedné registraci', () {
final reg = PushService.apnsRegistration(
apnsToken: 'ab12',
voipToken: 'cd34',
)!;
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
expect(text(reg.endpoint), 'ab12');
expect(text(reg.voipToken), 'cd34');
});
test('samotný VoIP token jde s prázdným endpointem', () {
final reg = PushService.apnsRegistration(voipToken: 'cd34')!;
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
expect(reg.endpoint, isEmpty);
expect(text(reg.voipToken), 'cd34');
});
test('bez tokenů se nic neposílá', () {
expect(PushService.apnsRegistration(), isNull);
});
});
test(
'selhané oprávnění nezastaví registraci u Apple ani token pro relay',
() async {
final messaging = _FakeMessaging();
final ios = _FakeIos(apns: 'ab12', voip: 'cd34', permissionFails: true);
await PushService(messaging, ios: ios).restoreMode();
expect(ios.calls, [
'register',
'permission',
], reason: 'registrace u Apple před dotazem na oprávnění');
expect(ios.mode, 'apns');
expect(ios.failedFlag, isFalse);
final reg = messaging.registrations.single;
expect(text(reg.endpoint), 'ab12');
expect(text(reg.voipToken), 'cd34');
},
);
test('VoIP token odejde, i když APNs token ještě nepřišel', () async {
final messaging = _FakeMessaging();
final push = PushService(messaging, ios: _FakeIos(voip: 'cd34'));
await push.refreshVoipToken();
final reg = messaging.registrations.single;
expect(reg.endpoint, isEmpty);
expect(text(reg.voipToken), 'cd34');
});
test('selhaná registrace se nespolkne — stav pushů ukáže chybu', () async {
final ios = _FakeIos(registerFails: true);
await PushService(_FakeMessaging(), ios: ios).restoreMode();
expect(ios.failedFlag, isTrue);
});
}