| 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 @@ 48 48 - Flutter 49 49 - permission_handler_apple (9.3.0): 50 50 - Flutter 51 + - push (0.0.1):52 + - Flutter53 + - FlutterMacOS51 54 - record_ios (1.2.0): 52 55 - Flutter 53 56 - SDWebImage (5.21.7): .. .. @@ -71,6 +74,7 @@ 71 74 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) 72 75 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 73 76 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) 77 + - push (from `.symlinks/plugins/push/darwin`)74 78 - record_ios (from `.symlinks/plugins/record_ios/ios`) 75 79 - share_plus (from `.symlinks/plugins/share_plus/ios`) 76 80 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) .. .. @@ -100,6 +104,8 @@ 100 104 :path: ".symlinks/plugins/image_picker_ios/ios" 101 105 permission_handler_apple: 102 106 :path: ".symlinks/plugins/permission_handler_apple/ios" 107 + push:108 + :path: ".symlinks/plugins/push/darwin"103 109 record_ios: 104 110 :path: ".symlinks/plugins/record_ios/ios" 105 111 share_plus: .. .. @@ -120,6 +126,7 @@ 120 126 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 121 127 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 122 128 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 129 + push: 91373ae39c5341c6de6adefa3fda7f7287d646bf123 130 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 124 131 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf 125 132 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a ios/Runner.xcodeproj/project.pbxproj
.. .. @@ -53,6 +53,7 @@ 53 53 6E8ED95FB2D20F34294BD581 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 54 54 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 55 55 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>"; };56 57 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 57 58 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 58 59 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 @@ 150 151 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 151 152 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 152 153 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 154 + A1B2C3D4E5F601234567890A /* Runner.entitlements */,153 155 ); 154 156 path = Runner; 155 157 sourceTree = "<group>"; .. .. @@ -492,6 +494,7 @@ 492 494 buildSettings = { 493 495 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 494 496 CLANG_ENABLE_MODULES = YES; 497 + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;495 498 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 496 499 DEVELOPMENT_TEAM = 7KU642K5ZL; 497 500 ENABLE_BITCODE = NO; .. .. @@ -675,6 +678,7 @@ 675 678 buildSettings = { 676 679 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 677 680 CLANG_ENABLE_MODULES = YES; 681 + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;678 682 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 679 683 DEVELOPMENT_TEAM = 7KU642K5ZL; 680 684 ENABLE_BITCODE = NO; .. .. @@ -698,6 +702,7 @@ 698 702 buildSettings = { 699 703 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 700 704 CLANG_ENABLE_MODULES = YES; 705 + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;701 706 CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; 702 707 DEVELOPMENT_TEAM = 7KU642K5ZL; 703 708 ENABLE_BITCODE = NO; ios/Runner/AppDelegate.swift
.. .. @@ -1,5 +1,6 @@ 1 1 import Flutter 2 2 import UIKit 3 +import UserNotifications3 4 4 5 @main 5 6 @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { .. .. @@ -13,4 +14,37 @@ 13 14 func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { 14 15 GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) 15 16 } 17 +18 + // Forward APNs token registration to the push plugin19 + override func application(20 + _ application: UIApplication,21 + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data22 + ) {23 + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)24 + }25 +26 + override func application(27 + _ application: UIApplication,28 + didFailToRegisterForRemoteNotificationsWithError error: Error29 + ) {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) -> Void38 + ) {39 + super.userNotificationCenter(center, willPresent: notification, withCompletionHandler: completionHandler)40 + }41 +42 + // Forward notification tap43 + override func userNotificationCenter(44 + _ center: UNUserNotificationCenter,45 + didReceive response: UNNotificationResponse,46 + withCompletionHandler completionHandler: @escaping () -> Void47 + ) {48 + super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)49 + }16 50 } ios/Runner/Info.plist
.. .. @@ -86,6 +86,7 @@ 86 86 <key>UIBackgroundModes</key> 87 87 <array> 88 88 <string>audio</string> 89 + <string>remote-notification</string>89 90 </array> 90 91 </dict> 91 92 </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 @@ 20 20 import '../services/audio_service.dart'; 21 21 import '../services/message_store.dart'; 22 22 import '../services/mqtt_service.dart'; 23 +import '../services/push_service.dart';23 24 import '../theme/app_theme.dart'; 24 25 import '../widgets/command_bar.dart'; 25 26 import '../widgets/input_bar.dart'; .. .. @@ -51,6 +52,7 @@ 51 52 class _ChatScreenState extends ConsumerState<ChatScreen> 52 53 with WidgetsBindingObserver { 53 54 MqttService? _ws; 55 + PushService? _push;54 56 final TextEditingController _textController = TextEditingController(); 55 57 final ScrollController _scrollController = ScrollController(); 56 58 final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); .. .. @@ -185,6 +187,9 @@ 185 187 final activeId = ref.read(activeSessionIdProvider); 186 188 _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null); 187 189 // catch_up is sent after sessions arrive (in _handleSessions) 190 +191 + // Re-register APNs token after reconnect so daemon always has a fresh token192 + _push?.onMqttConnected();188 193 }; 189 194 _ws!.onResume = () { 190 195 // App came back from background with connection still alive. .. .. @@ -206,6 +211,19 @@ 206 211 ); 207 212 208 213 await _ws!.connect(); 214 +215 + // Initialize push notifications after MQTT is set up so token can be216 + // sent immediately if already connected.217 + _push = PushService(mqttService: _ws!);218 + _push!.onNotificationTap = (data) {219 + // If notification carried a sessionId, switch to it220 + 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();209 227 } 210 228 211 229 void _handleMessage(Map<String, dynamic> msg) { lib/services/mqtt_service.dart
.. .. @@ -673,6 +673,24 @@ 673 673 onError?.call('Cannot send message: missing sessionId'); 674 674 } 675 675 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 when678 + /// 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 +676 694 /// Disconnect intentionally. 677 695 void disconnect() { 678 696 _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 on11 +/// `pailot/device/token`.12 +/// 3. On notification tap (app was killed), the [onNotificationTap] callback13 +/// 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 payload21 + /// (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 notifications39 + Push.instance.registerForRemoteNotifications();40 +41 + // Listen for new/refreshed tokens42 + 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 it49 + 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 @@ 11 11 import file_picker 12 12 import file_selector_macos 13 13 import flutter_secure_storage_macos 14 +import push14 15 import record_macos 15 16 import share_plus 16 17 import shared_preferences_foundation .. .. @@ -22,6 +23,7 @@ 22 23 FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) 23 24 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) 24 25 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) 26 + PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))25 27 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) 26 28 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) 27 29 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) pubspec.lock
.. .. @@ -744,6 +744,14 @@ 744 744 url: "https://pub.dev" 745 745 source: hosted 746 746 version: "2.2.0" 747 + push:748 + dependency: "direct main"749 + description:750 + name: push751 + sha256: "0ab12f0c194d22e000d5b1e911d66fd5a090b7786cc19228cb77946c922513b3"752 + url: "https://pub.dev"753 + source: hosted754 + version: "3.3.3"747 755 record: 748 756 dependency: "direct main" 749 757 description: pubspec.yaml
.. .. @@ -31,6 +31,7 @@ 31 31 flutter_markdown: ^0.7.7+1 32 32 bonsoir: ^6.0.2 33 33 crypto: ^3.0.7 34 + push: ^3.3.334 35 35 36 dev_dependencies: 36 37 flutter_test: