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