diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index 1be3d7e..241178a 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -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) diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea..0000000 --- a/app/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/app/lib/core/push/push_bootstrap.dart b/app/lib/core/push/push_bootstrap.dart index 71ebef6..23b30ef 100644 --- a/app/lib/core/push/push_bootstrap.dart +++ b/app/lib/core/push/push_bootstrap.dart @@ -74,7 +74,7 @@ class PushBootstrap { static Future registrationFailed() async => await _store.read(key: _registrationFailedKey) == '1'; - static Future _setRegistrationFailed(bool failed) => failed + static Future 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); diff --git a/app/lib/core/push/push_notifier.dart b/app/lib/core/push/push_notifier.dart index f921af4..8ff0c7a 100644 --- a/app/lib/core/push/push_notifier.dart +++ b/app/lib/core/push/push_notifier.dart @@ -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 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í. diff --git a/app/lib/core/push/push_service.dart b/app/lib/core/push/push_service.dart index 7ded2b8..c927bb3 100644 --- a/app/lib/core/push/push_service.dart +++ b/app/lib/core/push/push_service.dart @@ -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 registerApns() => ApnsBridge.register(); + Future apnsToken() => ApnsBridge.currentToken(); + Future voipToken() => CallRinger.voipToken(); + Future requestPermission() => PushNotifier.requestPermission(); + Future writeMode(String mode) => PushBootstrap.writeMode(mode); + Future 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? _fcmTokenSub; StreamSubscription? _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 _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 refreshVoipToken() async { - final token = _apnsToken; - if (token != null) await _registerApnsToken(token); + Future _sendApnsRegistration() async { + final reg = apnsRegistration( + apnsToken: _apnsToken, + voipToken: await _ios.voipToken(), + ); + if (reg != null) _messaging.registerPush(reg); } + Future _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 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 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 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; diff --git a/app/test/push_service_ios_test.dart b/app/test/push_service_ios_test.dart new file mode 100644 index 0000000..b5ac1bf --- /dev/null +++ b/app/test/push_service_ios_test.dart @@ -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 = []; + + @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 = []; + bool? failedFlag; + String? mode; + + @override + bool get isIOS => true; + + @override + Future registerApns() async { + calls.add('register'); + if (registerFails) throw StateError('apns channel'); + } + + @override + Future apnsToken() async => apns; + + @override + Future voipToken() async => voip; + + @override + Future requestPermission() async { + calls.add('permission'); + if (permissionFails) { + throw ArgumentError('iOS settings must be set'); + } + } + + @override + Future writeMode(String m) async => mode = m; + + @override + Future setRegistrationFailed(bool failed) async => failedFlag = failed; +} + +void main() { + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + String text(List 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); + }); +}