Matthias Nott
2026-04-01 f68a986682535dca139515741dd60be26a82edd6
feat: add APNs push notification support

- Add push package (3.3.3) for native iOS push without Firebase
- Add Runner.entitlements with aps-environment=development
- Wire CODE_SIGN_ENTITLEMENTS in all three build configs (Debug/Release/Profile)
- Update AppDelegate.swift to forward UNUserNotificationCenter delegate methods
- Add remote-notification to UIBackgroundModes in Info.plist
- Add PushService: requests permission, listens for tokens, sends to daemon
via MQTT on pailot/device/token, handles notification tap for session routing
- Add MqttService.sendDeviceToken(): publishes token to pailot/device/token
- Initialize PushService after MQTT connect in chat_screen.dart
- Re-register token on MQTT reconnect so daemon always has fresh token
2 files added
9 files modified
changed files
ios/Podfile.lock patch | view | blame | history
ios/Runner.xcodeproj/project.pbxproj patch | view | blame | history
ios/Runner/AppDelegate.swift patch | view | blame | history
ios/Runner/Info.plist patch | view | blame | history
ios/Runner/Runner.entitlements patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/services/push_service.dart patch | view | blame | history
macos/Flutter/GeneratedPluginRegistrant.swift patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
ios/Podfile.lock
....@@ -48,6 +48,9 @@
4848 - Flutter
4949 - permission_handler_apple (9.3.0):
5050 - Flutter
51
+ - push (0.0.1):
52
+ - Flutter
53
+ - FlutterMacOS
5154 - record_ios (1.2.0):
5255 - Flutter
5356 - SDWebImage (5.21.7):
....@@ -71,6 +74,7 @@
7174 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
7275 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
7376 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
77
+ - push (from `.symlinks/plugins/push/darwin`)
7478 - record_ios (from `.symlinks/plugins/record_ios/ios`)
7579 - share_plus (from `.symlinks/plugins/share_plus/ios`)
7680 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
....@@ -100,6 +104,8 @@
100104 :path: ".symlinks/plugins/image_picker_ios/ios"
101105 permission_handler_apple:
102106 :path: ".symlinks/plugins/permission_handler_apple/ios"
107
+ push:
108
+ :path: ".symlinks/plugins/push/darwin"
103109 record_ios:
104110 :path: ".symlinks/plugins/record_ios/ios"
105111 share_plus:
....@@ -120,6 +126,7 @@
120126 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
121127 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
122128 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
129
+ push: 91373ae39c5341c6de6adefa3fda7f7287d646bf
123130 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
124131 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
125132 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
ios/Runner.xcodeproj/project.pbxproj
....@@ -53,6 +53,7 @@
5353 6E8ED95FB2D20F34294BD581 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
5454 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
5555 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
56
+ A1B2C3D4E5F601234567890A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
5657 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
5758 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
5859 923BC14277A1E9646A04D00D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
....@@ -150,6 +151,7 @@
150151 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
151152 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
152153 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
154
+ A1B2C3D4E5F601234567890A /* Runner.entitlements */,
153155 );
154156 path = Runner;
155157 sourceTree = "<group>";
....@@ -492,6 +494,7 @@
492494 buildSettings = {
493495 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
494496 CLANG_ENABLE_MODULES = YES;
497
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
495498 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
496499 DEVELOPMENT_TEAM = 7KU642K5ZL;
497500 ENABLE_BITCODE = NO;
....@@ -675,6 +678,7 @@
675678 buildSettings = {
676679 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
677680 CLANG_ENABLE_MODULES = YES;
681
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
678682 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
679683 DEVELOPMENT_TEAM = 7KU642K5ZL;
680684 ENABLE_BITCODE = NO;
....@@ -698,6 +702,7 @@
698702 buildSettings = {
699703 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
700704 CLANG_ENABLE_MODULES = YES;
705
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
701706 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
702707 DEVELOPMENT_TEAM = 7KU642K5ZL;
703708 ENABLE_BITCODE = NO;
ios/Runner/AppDelegate.swift
....@@ -1,5 +1,6 @@
11 import Flutter
22 import UIKit
3
+import UserNotifications
34
45 @main
56 @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
....@@ -13,4 +14,37 @@
1314 func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
1415 GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
1516 }
17
+
18
+ // Forward APNs token registration to the push plugin
19
+ override func application(
20
+ _ application: UIApplication,
21
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
22
+ ) {
23
+ super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
24
+ }
25
+
26
+ override func application(
27
+ _ application: UIApplication,
28
+ didFailToRegisterForRemoteNotificationsWithError error: Error
29
+ ) {
30
+ super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
31
+ }
32
+
33
+ // Forward notification presentation (foreground)
34
+ override func userNotificationCenter(
35
+ _ center: UNUserNotificationCenter,
36
+ willPresent notification: UNNotification,
37
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
38
+ ) {
39
+ super.userNotificationCenter(center, willPresent: notification, withCompletionHandler: completionHandler)
40
+ }
41
+
42
+ // Forward notification tap
43
+ override func userNotificationCenter(
44
+ _ center: UNUserNotificationCenter,
45
+ didReceive response: UNNotificationResponse,
46
+ withCompletionHandler completionHandler: @escaping () -> Void
47
+ ) {
48
+ super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
49
+ }
1650 }
ios/Runner/Info.plist
....@@ -86,6 +86,7 @@
8686 <key>UIBackgroundModes</key>
8787 <array>
8888 <string>audio</string>
89
+ <string>remote-notification</string>
8990 </array>
9091 </dict>
9192 </plist>
ios/Runner/Runner.entitlements
....@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+ <key>aps-environment</key>
6
+ <string>development</string>
7
+</dict>
8
+</plist>
lib/screens/chat_screen.dart
....@@ -20,6 +20,7 @@
2020 import '../services/audio_service.dart';
2121 import '../services/message_store.dart';
2222 import '../services/mqtt_service.dart';
23
+import '../services/push_service.dart';
2324 import '../theme/app_theme.dart';
2425 import '../widgets/command_bar.dart';
2526 import '../widgets/input_bar.dart';
....@@ -51,6 +52,7 @@
5152 class _ChatScreenState extends ConsumerState<ChatScreen>
5253 with WidgetsBindingObserver {
5354 MqttService? _ws;
55
+ PushService? _push;
5456 final TextEditingController _textController = TextEditingController();
5557 final ScrollController _scrollController = ScrollController();
5658 final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
....@@ -185,6 +187,9 @@
185187 final activeId = ref.read(activeSessionIdProvider);
186188 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
187189 // catch_up is sent after sessions arrive (in _handleSessions)
190
+
191
+ // Re-register APNs token after reconnect so daemon always has a fresh token
192
+ _push?.onMqttConnected();
188193 };
189194 _ws!.onResume = () {
190195 // App came back from background with connection still alive.
....@@ -206,6 +211,19 @@
206211 );
207212
208213 await _ws!.connect();
214
+
215
+ // Initialize push notifications after MQTT is set up so token can be
216
+ // sent immediately if already connected.
217
+ _push = PushService(mqttService: _ws!);
218
+ _push!.onNotificationTap = (data) {
219
+ // If notification carried a sessionId, switch to it
220
+ final sessionId = data['sessionId'] as String?;
221
+ if (sessionId != null && mounted) {
222
+ ref.read(activeSessionIdProvider.notifier).state = sessionId;
223
+ ref.read(messagesProvider.notifier).switchSession(sessionId);
224
+ }
225
+ };
226
+ await _push!.initialize();
209227 }
210228
211229 void _handleMessage(Map<String, dynamic> msg) {
lib/services/mqtt_service.dart
....@@ -673,6 +673,24 @@
673673 onError?.call('Cannot send message: missing sessionId');
674674 }
675675
676
+ /// Publish the APNs device token to the daemon for push notification delivery.
677
+ /// The daemon stores it in ~/.aibroker/apns-tokens.json and uses it when
678
+ /// no MQTT clients are connected (app is backgrounded or offline).
679
+ void sendDeviceToken(String token) {
680
+ final client = _client;
681
+ if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
682
+ return;
683
+ }
684
+ try {
685
+ final builder = MqttClientPayloadBuilder();
686
+ builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
687
+ client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
688
+ _mqttLog('Push: device token published to pailot/device/token');
689
+ } catch (e) {
690
+ _mqttLog('Push: failed to publish device token: $e');
691
+ }
692
+ }
693
+
676694 /// Disconnect intentionally.
677695 void disconnect() {
678696 _intentionalClose = true;
lib/services/push_service.dart
....@@ -0,0 +1,107 @@
1
+import 'package:flutter/foundation.dart';
2
+import 'package:push/push.dart';
3
+
4
+import 'mqtt_service.dart';
5
+
6
+/// Handles APNs push notification registration and token delivery to the daemon.
7
+///
8
+/// Flow:
9
+/// 1. [initialize] requests permission and registers for remote notifications.
10
+/// 2. On new token, the token is published to the daemon via MQTT on
11
+/// `pailot/device/token`.
12
+/// 3. On notification tap (app was killed), the [onNotificationTap] callback
13
+/// is called so the UI can navigate to the right session.
14
+class PushService {
15
+ PushService({required this.mqttService});
16
+
17
+ final MqttService mqttService;
18
+
19
+ /// Called when the user taps a push notification.
20
+ /// The [data] map contains any custom data from the notification payload
21
+ /// (e.g. `sessionId`).
22
+ void Function(Map<String, dynamic> data)? onNotificationTap;
23
+
24
+ String? _lastToken;
25
+
26
+ /// Initialize APNs: request permission and listen for tokens.
27
+ /// Safe to call multiple times — subsequent calls are no-ops if already done.
28
+ Future<void> initialize() async {
29
+ try {
30
+ // Request permission (returns bool on iOS)
31
+ final granted = await Push.instance.requestPermission(
32
+ alert: true,
33
+ badge: true,
34
+ sound: true,
35
+ );
36
+ debugPrint('[Push] permission granted: $granted');
37
+
38
+ // Register for remote notifications
39
+ Push.instance.registerForRemoteNotifications();
40
+
41
+ // Listen for new/refreshed tokens
42
+ Push.instance.addOnNewToken((token) {
43
+ debugPrint('[Push] new token: ${token.substring(0, 16)}...');
44
+ _lastToken = token;
45
+ _sendTokenToDaemon(token);
46
+ });
47
+
48
+ // If we already have a token (from a previous session), fetch and send it
49
+ final existingToken = await Push.instance.token;
50
+ if (existingToken != null) {
51
+ debugPrint('[Push] existing token: ${existingToken.substring(0, 16)}...');
52
+ _lastToken = existingToken;
53
+ _sendTokenToDaemon(existingToken);
54
+ }
55
+
56
+ // Handle notification tap that launched the app from terminated state.
57
+ // Returns Map<String?, Object?>? — extract custom data from it.
58
+ final terminatedPayload =
59
+ await Push.instance.notificationTapWhichLaunchedAppFromTerminated;
60
+ if (terminatedPayload != null) {
61
+ debugPrint('[Push] app launched from notification tap');
62
+ onNotificationTap?.call(_toStringMap(terminatedPayload));
63
+ }
64
+
65
+ // Handle notification taps while app is in background (suspended).
66
+ Push.instance.addOnNotificationTap((Map<String?, Object?> payload) {
67
+ debugPrint('[Push] notification tapped (background)');
68
+ onNotificationTap?.call(_toStringMap(payload));
69
+ });
70
+
71
+ debugPrint('[Push] initialized');
72
+ } catch (e) {
73
+ debugPrint('[Push] initialization error: $e');
74
+ }
75
+ }
76
+
77
+ /// Convert Map<String?, Object?> to Map<String, dynamic> for easier use.
78
+ Map<String, dynamic> _toStringMap(Map<String?, Object?> src) {
79
+ return {
80
+ for (final entry in src.entries)
81
+ if (entry.key != null) entry.key!: entry.value,
82
+ };
83
+ }
84
+
85
+ /// Re-send the last known token when MQTT reconnects.
86
+ void onMqttConnected() {
87
+ final token = _lastToken;
88
+ if (token != null) {
89
+ debugPrint('[Push] re-registering token after MQTT reconnect');
90
+ _sendTokenToDaemon(token);
91
+ }
92
+ }
93
+
94
+ /// Publish the device token to the daemon via MQTT.
95
+ void _sendTokenToDaemon(String token) {
96
+ if (!mqttService.isConnected) {
97
+ debugPrint('[Push] MQTT not connected, token will be sent on next reconnect');
98
+ return;
99
+ }
100
+ try {
101
+ mqttService.sendDeviceToken(token);
102
+ debugPrint('[Push] token sent to daemon: ${token.substring(0, 16)}...');
103
+ } catch (e) {
104
+ debugPrint('[Push] failed to send token: $e');
105
+ }
106
+ }
107
+}
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -11,6 +11,7 @@
1111 import file_picker
1212 import file_selector_macos
1313 import flutter_secure_storage_macos
14
+import push
1415 import record_macos
1516 import share_plus
1617 import shared_preferences_foundation
....@@ -22,6 +23,7 @@
2223 FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
2324 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
2425 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
26
+ PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
2527 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
2628 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
2729 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
pubspec.lock
....@@ -744,6 +744,14 @@
744744 url: "https://pub.dev"
745745 source: hosted
746746 version: "2.2.0"
747
+ push:
748
+ dependency: "direct main"
749
+ description:
750
+ name: push
751
+ sha256: "0ab12f0c194d22e000d5b1e911d66fd5a090b7786cc19228cb77946c922513b3"
752
+ url: "https://pub.dev"
753
+ source: hosted
754
+ version: "3.3.3"
747755 record:
748756 dependency: "direct main"
749757 description:
pubspec.yaml
....@@ -31,6 +31,7 @@
3131 flutter_markdown: ^0.7.7+1
3232 bonsoir: ^6.0.2
3333 crypto: ^3.0.7
34
+ push: ^3.3.3
3435
3536 dev_dependencies:
3637 flutter_test: