From f68a986682535dca139515741dd60be26a82edd6 Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 01 Apr 2026 12:28:15 +0200
Subject: [PATCH] feat: add APNs push notification support

---
 ios/Podfile.lock                              |    7 +
 ios/Runner.xcodeproj/project.pbxproj          |    5 +
 macos/Flutter/GeneratedPluginRegistrant.swift |    2 
 ios/Runner/AppDelegate.swift                  |   34 ++++++++
 ios/Runner/Info.plist                         |    1 
 ios/Runner/Runner.entitlements                |    8 ++
 lib/services/mqtt_service.dart                |   18 ++++
 lib/services/push_service.dart                |  107 ++++++++++++++++++++++++++
 pubspec.lock                                  |    8 ++
 lib/screens/chat_screen.dart                  |   18 ++++
 pubspec.yaml                                  |    1 
 11 files changed, 209 insertions(+), 0 deletions(-)

diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 65029b7..853dbaf 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -48,6 +48,9 @@
     - Flutter
   - permission_handler_apple (9.3.0):
     - Flutter
+  - push (0.0.1):
+    - Flutter
+    - FlutterMacOS
   - record_ios (1.2.0):
     - Flutter
   - SDWebImage (5.21.7):
@@ -71,6 +74,7 @@
   - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
+  - push (from `.symlinks/plugins/push/darwin`)
   - record_ios (from `.symlinks/plugins/record_ios/ios`)
   - share_plus (from `.symlinks/plugins/share_plus/ios`)
   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -100,6 +104,8 @@
     :path: ".symlinks/plugins/image_picker_ios/ios"
   permission_handler_apple:
     :path: ".symlinks/plugins/permission_handler_apple/ios"
+  push:
+    :path: ".symlinks/plugins/push/darwin"
   record_ios:
     :path: ".symlinks/plugins/record_ios/ios"
   share_plus:
@@ -120,6 +126,7 @@
   flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
   permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
+  push: 91373ae39c5341c6de6adefa3fda7f7287d646bf
   record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
   SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
   share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index bf39338..f1e0700 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -53,6 +53,7 @@
 		6E8ED95FB2D20F34294BD581 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+		A1B2C3D4E5F601234567890A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
 		7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
 		7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
 		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 @@
 				74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
 				7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
 				74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+				A1B2C3D4E5F601234567890A /* Runner.entitlements */,
 			);
 			path = Runner;
 			sourceTree = "<group>";
@@ -492,6 +494,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = 7KU642K5ZL;
 				ENABLE_BITCODE = NO;
@@ -675,6 +678,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = 7KU642K5ZL;
 				ENABLE_BITCODE = NO;
@@ -698,6 +702,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
+				CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
 				CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = 7KU642K5ZL;
 				ENABLE_BITCODE = NO;
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index c30b367..e437af6 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -1,5 +1,6 @@
 import Flutter
 import UIKit
+import UserNotifications
 
 @main
 @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -13,4 +14,37 @@
   func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
     GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
   }
+
+  // Forward APNs token registration to the push plugin
+  override func application(
+    _ application: UIApplication,
+    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+  ) {
+    super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
+  }
+
+  override func application(
+    _ application: UIApplication,
+    didFailToRegisterForRemoteNotificationsWithError error: Error
+  ) {
+    super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
+  }
+
+  // Forward notification presentation (foreground)
+  override func userNotificationCenter(
+    _ center: UNUserNotificationCenter,
+    willPresent notification: UNNotification,
+    withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+  ) {
+    super.userNotificationCenter(center, willPresent: notification, withCompletionHandler: completionHandler)
+  }
+
+  // Forward notification tap
+  override func userNotificationCenter(
+    _ center: UNUserNotificationCenter,
+    didReceive response: UNNotificationResponse,
+    withCompletionHandler completionHandler: @escaping () -> Void
+  ) {
+    super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
+  }
 }
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index d888f20..13d0646 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -86,6 +86,7 @@
 	<key>UIBackgroundModes</key>
 	<array>
 		<string>audio</string>
+		<string>remote-notification</string>
 	</array>
 </dict>
 </plist>
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..903def2
--- /dev/null
+++ b/ios/Runner/Runner.entitlements
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>aps-environment</key>
+	<string>development</string>
+</dict>
+</plist>
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 5017f31..63fc4cd 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -20,6 +20,7 @@
 import '../services/audio_service.dart';
 import '../services/message_store.dart';
 import '../services/mqtt_service.dart';
+import '../services/push_service.dart';
 import '../theme/app_theme.dart';
 import '../widgets/command_bar.dart';
 import '../widgets/input_bar.dart';
@@ -51,6 +52,7 @@
 class _ChatScreenState extends ConsumerState<ChatScreen>
     with WidgetsBindingObserver {
   MqttService? _ws;
+  PushService? _push;
   final TextEditingController _textController = TextEditingController();
   final ScrollController _scrollController = ScrollController();
   final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
@@ -185,6 +187,9 @@
       final activeId = ref.read(activeSessionIdProvider);
       _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null);
       // catch_up is sent after sessions arrive (in _handleSessions)
+
+      // Re-register APNs token after reconnect so daemon always has a fresh token
+      _push?.onMqttConnected();
     };
     _ws!.onResume = () {
       // App came back from background with connection still alive.
@@ -206,6 +211,19 @@
     );
 
     await _ws!.connect();
+
+    // Initialize push notifications after MQTT is set up so token can be
+    // sent immediately if already connected.
+    _push = PushService(mqttService: _ws!);
+    _push!.onNotificationTap = (data) {
+      // If notification carried a sessionId, switch to it
+      final sessionId = data['sessionId'] as String?;
+      if (sessionId != null && mounted) {
+        ref.read(activeSessionIdProvider.notifier).state = sessionId;
+        ref.read(messagesProvider.notifier).switchSession(sessionId);
+      }
+    };
+    await _push!.initialize();
   }
 
   void _handleMessage(Map<String, dynamic> msg) {
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 7625e7b..b88aa31 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -673,6 +673,24 @@
     onError?.call('Cannot send message: missing sessionId');
   }
 
+  /// Publish the APNs device token to the daemon for push notification delivery.
+  /// The daemon stores it in ~/.aibroker/apns-tokens.json and uses it when
+  /// no MQTT clients are connected (app is backgrounded or offline).
+  void sendDeviceToken(String token) {
+    final client = _client;
+    if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
+      return;
+    }
+    try {
+      final builder = MqttClientPayloadBuilder();
+      builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
+      client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
+      _mqttLog('Push: device token published to pailot/device/token');
+    } catch (e) {
+      _mqttLog('Push: failed to publish device token: $e');
+    }
+  }
+
   /// Disconnect intentionally.
   void disconnect() {
     _intentionalClose = true;
diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart
new file mode 100644
index 0000000..e9e09b6
--- /dev/null
+++ b/lib/services/push_service.dart
@@ -0,0 +1,107 @@
+import 'package:flutter/foundation.dart';
+import 'package:push/push.dart';
+
+import 'mqtt_service.dart';
+
+/// Handles APNs push notification registration and token delivery to the daemon.
+///
+/// Flow:
+///   1. [initialize] requests permission and registers for remote notifications.
+///   2. On new token, the token is published to the daemon via MQTT on
+///      `pailot/device/token`.
+///   3. On notification tap (app was killed), the [onNotificationTap] callback
+///      is called so the UI can navigate to the right session.
+class PushService {
+  PushService({required this.mqttService});
+
+  final MqttService mqttService;
+
+  /// Called when the user taps a push notification.
+  /// The [data] map contains any custom data from the notification payload
+  /// (e.g. `sessionId`).
+  void Function(Map<String, dynamic> data)? onNotificationTap;
+
+  String? _lastToken;
+
+  /// Initialize APNs: request permission and listen for tokens.
+  /// Safe to call multiple times — subsequent calls are no-ops if already done.
+  Future<void> initialize() async {
+    try {
+      // Request permission (returns bool on iOS)
+      final granted = await Push.instance.requestPermission(
+        alert: true,
+        badge: true,
+        sound: true,
+      );
+      debugPrint('[Push] permission granted: $granted');
+
+      // Register for remote notifications
+      Push.instance.registerForRemoteNotifications();
+
+      // Listen for new/refreshed tokens
+      Push.instance.addOnNewToken((token) {
+        debugPrint('[Push] new token: ${token.substring(0, 16)}...');
+        _lastToken = token;
+        _sendTokenToDaemon(token);
+      });
+
+      // If we already have a token (from a previous session), fetch and send it
+      final existingToken = await Push.instance.token;
+      if (existingToken != null) {
+        debugPrint('[Push] existing token: ${existingToken.substring(0, 16)}...');
+        _lastToken = existingToken;
+        _sendTokenToDaemon(existingToken);
+      }
+
+      // Handle notification tap that launched the app from terminated state.
+      // Returns Map<String?, Object?>? — extract custom data from it.
+      final terminatedPayload =
+          await Push.instance.notificationTapWhichLaunchedAppFromTerminated;
+      if (terminatedPayload != null) {
+        debugPrint('[Push] app launched from notification tap');
+        onNotificationTap?.call(_toStringMap(terminatedPayload));
+      }
+
+      // Handle notification taps while app is in background (suspended).
+      Push.instance.addOnNotificationTap((Map<String?, Object?> payload) {
+        debugPrint('[Push] notification tapped (background)');
+        onNotificationTap?.call(_toStringMap(payload));
+      });
+
+      debugPrint('[Push] initialized');
+    } catch (e) {
+      debugPrint('[Push] initialization error: $e');
+    }
+  }
+
+  /// Convert Map<String?, Object?> to Map<String, dynamic> for easier use.
+  Map<String, dynamic> _toStringMap(Map<String?, Object?> src) {
+    return {
+      for (final entry in src.entries)
+        if (entry.key != null) entry.key!: entry.value,
+    };
+  }
+
+  /// Re-send the last known token when MQTT reconnects.
+  void onMqttConnected() {
+    final token = _lastToken;
+    if (token != null) {
+      debugPrint('[Push] re-registering token after MQTT reconnect');
+      _sendTokenToDaemon(token);
+    }
+  }
+
+  /// Publish the device token to the daemon via MQTT.
+  void _sendTokenToDaemon(String token) {
+    if (!mqttService.isConnected) {
+      debugPrint('[Push] MQTT not connected, token will be sent on next reconnect');
+      return;
+    }
+    try {
+      mqttService.sendDeviceToken(token);
+      debugPrint('[Push] token sent to daemon: ${token.substring(0, 16)}...');
+    } catch (e) {
+      debugPrint('[Push] failed to send token: $e');
+    }
+  }
+}
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 2bcd467..d0cd42a 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -11,6 +11,7 @@
 import file_picker
 import file_selector_macos
 import flutter_secure_storage_macos
+import push
 import record_macos
 import share_plus
 import shared_preferences_foundation
@@ -22,6 +23,7 @@
   FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
   FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
   FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
+  PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
   RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
   SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 28a7a73..006418e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -744,6 +744,14 @@
       url: "https://pub.dev"
     source: hosted
     version: "2.2.0"
+  push:
+    dependency: "direct main"
+    description:
+      name: push
+      sha256: "0ab12f0c194d22e000d5b1e911d66fd5a090b7786cc19228cb77946c922513b3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.3.3"
   record:
     dependency: "direct main"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 621f726..c940897 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -31,6 +31,7 @@
   flutter_markdown: ^0.7.7+1
   bonsoir: ^6.0.2
   crypto: ^3.0.7
+  push: ^3.3.3
 
 dev_dependencies:
   flutter_test:

--
Gitblit v1.3.1