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:
@@ -17,22 +17,29 @@ import flutter_callkit_incoming
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let controller = window?.rootViewController as! FlutterViewController
|
// Bez force-castu: kdyby root nebyl FlutterViewController (jiný storyboard,
|
||||||
let channel = FlutterMethodChannel(
|
// budoucí scény), appka by spadla hned při startu. Registrar dá messenger
|
||||||
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger)
|
// enginu i tak.
|
||||||
channel.setMethodCallHandler { [weak self] call, result in
|
let messenger =
|
||||||
switch call.method {
|
(window?.rootViewController as? FlutterViewController)?.binaryMessenger
|
||||||
case "register":
|
?? registrar(forPlugin: "SepotApns")?.messenger()
|
||||||
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
|
if let messenger {
|
||||||
application.registerForRemoteNotifications()
|
let channel = FlutterMethodChannel(
|
||||||
result(nil)
|
name: "cz.sepot.sepot/apns", binaryMessenger: messenger)
|
||||||
case "getToken":
|
channel.setMethodCallHandler { [weak self] call, result in
|
||||||
result(self?.pendingToken)
|
switch call.method {
|
||||||
default:
|
case "register":
|
||||||
result(FlutterMethodNotImplemented)
|
// 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)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import Flutter
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class SceneDelegate: FlutterSceneDelegate {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -74,7 +74,7 @@ class PushBootstrap {
|
|||||||
static Future<bool> registrationFailed() async =>
|
static Future<bool> registrationFailed() async =>
|
||||||
await _store.read(key: _registrationFailedKey) == '1';
|
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.write(key: _registrationFailedKey, value: '1')
|
||||||
: _store.delete(key: _registrationFailedKey);
|
: _store.delete(key: _registrationFailedKey);
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class PushBootstrap {
|
|||||||
await UnifiedPush.initialize(
|
await UnifiedPush.initialize(
|
||||||
onNewEndpoint: _onNewEndpoint,
|
onNewEndpoint: _onNewEndpoint,
|
||||||
onRegistrationFailed: (_, _) =>
|
onRegistrationFailed: (_, _) =>
|
||||||
unawaited(_setRegistrationFailed(true)),
|
unawaited(setRegistrationFailed(true)),
|
||||||
onUnregistered: _onUnregistered,
|
onUnregistered: _onUnregistered,
|
||||||
onMessage: _onMessage,
|
onMessage: _onMessage,
|
||||||
);
|
);
|
||||||
@@ -138,7 +138,7 @@ class PushBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
unawaited(setRegistrationFailed(false)); // registrace se povedla
|
||||||
final s = _service;
|
final s = _service;
|
||||||
if (s != null) {
|
if (s != null) {
|
||||||
s.handleNewEndpoint(endpoint.url);
|
s.handleNewEndpoint(endpoint.url);
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class PushNotifier {
|
|||||||
await _plugin.initialize(
|
await _plugin.initialize(
|
||||||
InitializationSettings(
|
InitializationSettings(
|
||||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
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(
|
linux: LinuxInitializationSettings(
|
||||||
defaultActionName: l10n.pushActionOpen,
|
defaultActionName: l10n.pushActionOpen,
|
||||||
),
|
),
|
||||||
@@ -48,17 +56,23 @@ class PushNotifier {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
category: AndroidNotificationCategory.message,
|
category: AndroidNotificationCategory.message,
|
||||||
),
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
linux: const LinuxNotificationDetails(),
|
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 {
|
static Future<void> requestPermission() async {
|
||||||
await _ensureInit();
|
await _ensureInit();
|
||||||
await _plugin
|
await _plugin
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
?.requestNotificationsPermission();
|
?.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í.
|
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.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/calls/call_ringer.dart';
|
||||||
import 'package:sepot/core/push/apns_ios.dart';
|
import 'package:sepot/core/push/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_bootstrap.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:sepot/proto/sepot.pb.dart' as pb;
|
||||||
import 'package:unifiedpush/unifiedpush.dart';
|
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 {
|
class PushService {
|
||||||
PushService(this._messaging);
|
PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
|
||||||
|
: _ios = ios;
|
||||||
|
|
||||||
final MessagingService _messaging;
|
final MessagingService _messaging;
|
||||||
|
final IosPushPlatform _ios;
|
||||||
StreamSubscription<String>? _fcmTokenSub;
|
StreamSubscription<String>? _fcmTokenSub;
|
||||||
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
||||||
|
|
||||||
@@ -94,34 +112,66 @@ class PushService {
|
|||||||
|
|
||||||
String? _apnsToken;
|
String? _apnsToken;
|
||||||
|
|
||||||
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
|
/// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
|
||||||
/// (relay drží obojí u zařízení).
|
/// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
|
||||||
Future<void> _registerApnsToken(String token) async {
|
/// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
|
||||||
_apnsToken = token;
|
/// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
|
||||||
final voip = await CallRinger.voipToken();
|
@visibleForTesting
|
||||||
final reg = _registration(
|
static pb.RegisterPush? apnsRegistration({
|
||||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
String? apnsToken,
|
||||||
Uint8List.fromList(utf8.encode(token)),
|
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);
|
if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
|
||||||
_messaging.registerPush(reg);
|
return reg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
|
Future<void> _sendApnsRegistration() async {
|
||||||
Future<void> refreshVoipToken() async {
|
final reg = apnsRegistration(
|
||||||
final token = _apnsToken;
|
apnsToken: _apnsToken,
|
||||||
if (token != null) await _registerApnsToken(token);
|
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
|
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||||
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
/// 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 {
|
Future<void> enableApns() async {
|
||||||
await init();
|
await init();
|
||||||
await PushNotifier.requestPermission();
|
await _ios.registerApns();
|
||||||
await ApnsBridge.register();
|
await _ios.writeMode('apns');
|
||||||
final existing = await ApnsBridge.currentToken();
|
final existing = await _ios.apnsToken();
|
||||||
if (existing != null) _registerApnsToken(existing);
|
if (existing != null) {
|
||||||
await PushBootstrap.writeMode('apns');
|
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.
|
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
||||||
@@ -245,10 +295,15 @@ class PushService {
|
|||||||
Future<void> restoreMode() async {
|
Future<void> restoreMode() async {
|
||||||
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
||||||
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
||||||
if (Platform.isIOS) {
|
if (_ios.isIOS) {
|
||||||
try {
|
try {
|
||||||
await enableApns();
|
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();
|
await init();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user