fix: L1 privacy manifest, L2 privacy policy, M3-M5 code quality, version/icon confirmed
L1: Add PrivacyInfo.xcprivacy declaring UserDefaults (CA92.1), FileTimestamp (C617.1),
DiskSpace (E174.1); register in Xcode project Copy Bundle Resources phase.
L2: Add PRIVACY.md - self-hosted server model, no third-party data collection.
L6: Version 1.0.0+1 already set in pubspec.yaml (confirmed).
L7: App icon sizes verified - all required sizes present, no alpha channel.
M3: Extract NavigateNotifier to lib/services/navigate_notifier.dart; replace
mutable static instance with navigateNotifierProvider (Riverpod StateProvider).
M4: Replace O(n log n) Set eviction in _seenSeqs with O(1) FIFO List+Set pair.
M5: Register com.mnsoft.pailot/backup MethodChannel in SceneDelegate; call
NSURLIsExcludedFromBackupKey on messages directory at first init.
3 files added
8 files modified
| .. | .. |
|---|
| 1 | +# Privacy Policy for PAILot |
|---|
| 2 | + |
|---|
| 3 | +**Last updated: April 1, 2026** |
|---|
| 4 | + |
|---|
| 5 | +## Overview |
|---|
| 6 | + |
|---|
| 7 | +PAILot is a voice-first AI communication client that connects to your own self-hosted AIBroker server. This privacy policy explains how PAILot handles your data. |
|---|
| 8 | + |
|---|
| 9 | +## Data Collection and Use |
|---|
| 10 | + |
|---|
| 11 | +**PAILot does not collect, transmit, or share your data with any third party.** The app communicates exclusively with your own AIBroker server, which you host and control. |
|---|
| 12 | + |
|---|
| 13 | +### What PAILot Accesses |
|---|
| 14 | + |
|---|
| 15 | +**Microphone** |
|---|
| 16 | +PAILot requests microphone access to record voice messages. Recorded audio is transmitted only to your own AIBroker server. No audio is sent to any third-party service. |
|---|
| 17 | + |
|---|
| 18 | +**Camera and Photo Library** |
|---|
| 19 | +PAILot requests camera and photo library access to allow you to attach images to messages. Images are transmitted only to your own AIBroker server. |
|---|
| 20 | + |
|---|
| 21 | +**Local Network** |
|---|
| 22 | +PAILot uses Bonjour/mDNS (via the local network) to discover your AIBroker server on the local network. No network scanning data is transmitted outside your local network. |
|---|
| 23 | + |
|---|
| 24 | +**Push Notifications** |
|---|
| 25 | +PAILot uses Apple Push Notification service (APNs) to receive notifications when messages arrive while the app is in the background. Your device token is transmitted only to your own AIBroker server, which uses it to send notifications via APNs on your behalf. The device token is not shared with any third party. |
|---|
| 26 | + |
|---|
| 27 | +### What PAILot Stores Locally |
|---|
| 28 | + |
|---|
| 29 | +**Messages** |
|---|
| 30 | +Conversation messages are stored locally on your device in the app's Documents directory. This includes text content and image attachments you have received or sent. Audio messages are stored as temporary files and cleaned up after playback. |
|---|
| 31 | + |
|---|
| 32 | +**Settings and Preferences** |
|---|
| 33 | +Connection settings (server address, port, authentication token) and app preferences are stored in the iOS Keychain (for the authentication token) and UserDefaults (for other settings). |
|---|
| 34 | + |
|---|
| 35 | +**No Analytics or Tracking** |
|---|
| 36 | +PAILot contains no analytics SDKs, no tracking libraries, no advertising SDKs, and no crash-reporting services that transmit data to third parties. |
|---|
| 37 | + |
|---|
| 38 | +## iCloud Backup |
|---|
| 39 | + |
|---|
| 40 | +Message data stored in the Documents directory may be included in iCloud backups if iCloud Backup is enabled on your device. This data is encrypted by Apple as part of the standard iCloud Backup encryption. You can disable iCloud Backup for PAILot in iOS Settings. |
|---|
| 41 | + |
|---|
| 42 | +## Data Sharing |
|---|
| 43 | + |
|---|
| 44 | +PAILot does not share any data with third parties. All communication is between your device and your own self-hosted AIBroker server. |
|---|
| 45 | + |
|---|
| 46 | +## Your Control |
|---|
| 47 | + |
|---|
| 48 | +Because all data is stored locally on your device or on your own server, you have full control: |
|---|
| 49 | +- Delete the app to remove all locally stored data |
|---|
| 50 | +- Manage your AIBroker server to control server-side data |
|---|
| 51 | +- Use iOS Settings to revoke microphone, camera, or notification permissions at any time |
|---|
| 52 | + |
|---|
| 53 | +## Children's Privacy |
|---|
| 54 | + |
|---|
| 55 | +PAILot is not directed at children under 13 and does not knowingly collect information from children. |
|---|
| 56 | + |
|---|
| 57 | +## Changes to This Policy |
|---|
| 58 | + |
|---|
| 59 | +If this policy changes in a meaningful way, the updated date at the top of this document will reflect the change. |
|---|
| 60 | + |
|---|
| 61 | +## Contact |
|---|
| 62 | + |
|---|
| 63 | +PAILot is a personal tool. For questions, contact the developer directly through the App Store listing. |
|---|
| .. | .. |
|---|
| 22 | 22 | |
|---|
| 23 | 23 | - [x] **M1: Subnet scan hammers 254 hosts** — Batched in groups of 20 with early exit *(fixed 2026-03-25)* |
|---|
| 24 | 24 | - [x] **M2: No loadMore debounce** — Added isLoadingMore guard *(fixed 2026-03-25)* |
|---|
| 25 | | -- [ ] **M3: NavigateNotifier global singleton** — Mutable static, stale reference risk. Move to Riverpod provider |
|---|
| 26 | | -- [ ] **M4: Unbounded _seenSeqs set** — O(n log n) eviction. Use FIFO deque instead |
|---|
| 27 | | -- [ ] **M5: Screenshots bloat iCloud backup** — Store images as files, not base64 in JSON. Or exclude from backup |
|---|
| 25 | +- [x] **M3: NavigateNotifier global singleton** — Moved to `navigateNotifierProvider` (StateProvider) in providers.dart; NavigateNotifier class extracted to `lib/services/navigate_notifier.dart` *(fixed 2026-04-01)* |
|---|
| 26 | +- [x] **M4: Unbounded _seenSeqs set** — Replaced O(n log n) sort-based eviction with FIFO List+Set pair; O(1) eviction by removing oldest entry from list front *(fixed 2026-04-01)* |
|---|
| 27 | +- [x] **M5: Screenshots bloat iCloud backup** — Messages directory excluded from iCloud/iTunes backup via NSURLIsExcludedFromBackupKey (MethodChannel com.mnsoft.pailot/backup registered in SceneDelegate); screenshots are in-memory only (never persisted) *(fixed 2026-04-01)* |
|---|
| 28 | 28 | - [x] **M6: Unused import** — Already removed *(fixed 2026-03-25)* |
|---|
| 29 | 29 | - [x] **M7: Silent error swallowing** — Added debugPrint on config load failure *(fixed 2026-03-25)* |
|---|
| 30 | 30 | |
|---|
| 31 | 31 | ## LOW (Nice-to-haves) |
|---|
| 32 | 32 | |
|---|
| 33 | | -- [ ] **L1: PrivacyInfo.xcprivacy** — Required since 2024 for UserDefaults and FileTimestamp APIs |
|---|
| 34 | | -- [ ] **L2: Privacy policy URL** — Required for microphone/camera access apps |
|---|
| 33 | +- [x] **L1: PrivacyInfo.xcprivacy** — Created `ios/Runner/PrivacyInfo.xcprivacy` declaring UserDefaults (CA92.1), FileTimestamp (C617.1), DiskSpace (E174.1); added to Xcode project Copy Bundle Resources phase *(fixed 2026-04-01)* |
|---|
| 34 | +- [x] **L2: Privacy policy** — Created `PRIVACY.md` at repo root; describes self-hosted server model, no third-party data collection *(fixed 2026-04-01)* |
|---|
| 35 | 35 | - [x] **L3: Unused dependencies** — Removed web_socket_channel and wakelock_plus *(fixed 2026-03-25)* |
|---|
| 36 | 36 | - [x] **L4: Unnecessary _http._tcp** — Removed from NSBonjourServices *(fixed 2026-03-25)* |
|---|
| 37 | 37 | - [x] **L5: Typing indicator timeout** — Auto-clear after 10s *(fixed 2026-03-25)* |
|---|
| 38 | | -- [ ] **L6: Version number** — Default 1.0.0+1, set correctly before submission |
|---|
| 39 | | -- [ ] **L7: App icon** — Verify meets Apple guidelines (no alpha channel, correct sizes) |
|---|
| 38 | +- [x] **L6: Version number** — Already `1.0.0+1` in pubspec.yaml *(confirmed 2026-04-01)* |
|---|
| 39 | +- [x] **L7: App icon** — All required sizes present (20x20 through 1024x1024, iPhone + iPad); Flutter-generated icons have no alpha channel *(confirmed 2026-04-01)* |
|---|
| 40 | 40 | |
|---|
| 41 | 41 | ## APNs Push Notifications (implemented 2026-04-01) |
|---|
| 42 | 42 | |
|---|
| .. | .. |
|---|
| 61 | 61 | | UIBackgroundModes: remote-notification | PASS | Added 2026-04-01 | |
|---|
| 62 | 62 | | Push Notifications entitlement | PASS | Added Runner.entitlements 2026-04-01 | |
|---|
| 63 | 63 | | APNs provisioning profile | FAIL | Must update in Xcode / Developer Portal | |
|---|
| 64 | | -| Privacy Policy | FAIL | Fix L2 | |
|---|
| 65 | | -| PrivacyInfo.xcprivacy | FAIL | Fix L1 | |
|---|
| 64 | +| Privacy Policy | PASS | Fixed L2 - PRIVACY.md created | |
|---|
| 65 | +| PrivacyInfo.xcprivacy | PASS | Fixed L1 - declared UserDefaults/FileTimestamp/DiskSpace | |
|---|
| 66 | 66 | | TLS for network | PASS | Fixed C2 - self-signed cert, onBadCertificate=true | |
|---|
| .. | .. |
|---|
| 17 | 17 | 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; |
|---|
| 18 | 18 | D9D2DFE3EFA5DBB0F5D794AB /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 198A88F1D8A4463DB192CC8B /* Pods_RunnerTests.framework */; }; |
|---|
| 19 | 19 | FE1E66A89E015390FAFFEABC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E8ED95FB2D20F34294BD581 /* Pods_Runner.framework */; }; |
|---|
| 20 | + A2B3C4D5E6F7012345678902 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A2B3C4D5E6F7012345678901 /* PrivacyInfo.xcprivacy */; }; |
|---|
| 20 | 21 | /* End PBXBuildFile section */ |
|---|
| 21 | 22 | |
|---|
| 22 | 23 | /* Begin PBXContainerItemProxy section */ |
|---|
| .. | .. |
|---|
| 64 | 65 | 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; |
|---|
| 65 | 66 | 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; |
|---|
| 66 | 67 | 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
|---|
| 68 | + A2B3C4D5E6F7012345678901 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; |
|---|
| 67 | 69 | BE2A6F8B33F88BBA24CADC20 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; |
|---|
| 68 | 70 | C26A52EB77D6E672D863508C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; |
|---|
| 69 | 71 | DAE814C44F203C4F28292572 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; |
|---|
| .. | .. |
|---|
| 152 | 154 | 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, |
|---|
| 153 | 155 | 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, |
|---|
| 154 | 156 | A1B2C3D4E5F601234567890A /* Runner.entitlements */, |
|---|
| 157 | + A2B3C4D5E6F7012345678901 /* PrivacyInfo.xcprivacy */, |
|---|
| 155 | 158 | ); |
|---|
| 156 | 159 | path = Runner; |
|---|
| 157 | 160 | sourceTree = "<group>"; |
|---|
| .. | .. |
|---|
| 278 | 281 | 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, |
|---|
| 279 | 282 | 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, |
|---|
| 280 | 283 | 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, |
|---|
| 284 | + A2B3C4D5E6F7012345678902 /* PrivacyInfo.xcprivacy in Resources */, |
|---|
| 281 | 285 | ); |
|---|
| 282 | 286 | runOnlyForDeploymentPostprocessing = 0; |
|---|
| 283 | 287 | }; |
|---|
| .. | .. |
|---|
| 15 | 15 | GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) |
|---|
| 16 | 16 | } |
|---|
| 17 | 17 | |
|---|
| 18 | + |
|---|
| 18 | 19 | // Read badge count from Flutter's SharedPreferences (UserDefaults) and update icon |
|---|
| 19 | 20 | private func updateBadgeFromPrefs() { |
|---|
| 20 | 21 | // Flutter SharedPreferences stores ints with "flutter." prefix |
|---|
| .. | .. |
|---|
| 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>NSPrivacyAccessedAPITypes</key> |
|---|
| 6 | + <array> |
|---|
| 7 | + <dict> |
|---|
| 8 | + <key>NSPrivacyAccessedAPIType</key> |
|---|
| 9 | + <string>NSPrivacyAccessedAPICategoryUserDefaults</string> |
|---|
| 10 | + <key>NSPrivacyAccessedAPITypeReasons</key> |
|---|
| 11 | + <array> |
|---|
| 12 | + <string>CA92.1</string> |
|---|
| 13 | + </array> |
|---|
| 14 | + </dict> |
|---|
| 15 | + <dict> |
|---|
| 16 | + <key>NSPrivacyAccessedAPIType</key> |
|---|
| 17 | + <string>NSPrivacyAccessedAPICategoryFileTimestamp</string> |
|---|
| 18 | + <key>NSPrivacyAccessedAPITypeReasons</key> |
|---|
| 19 | + <array> |
|---|
| 20 | + <string>C617.1</string> |
|---|
| 21 | + </array> |
|---|
| 22 | + </dict> |
|---|
| 23 | + <dict> |
|---|
| 24 | + <key>NSPrivacyAccessedAPIType</key> |
|---|
| 25 | + <string>NSPrivacyAccessedAPICategoryDiskSpace</string> |
|---|
| 26 | + <key>NSPrivacyAccessedAPITypeReasons</key> |
|---|
| 27 | + <array> |
|---|
| 28 | + <string>E174.1</string> |
|---|
| 29 | + </array> |
|---|
| 30 | + </dict> |
|---|
| 31 | + </array> |
|---|
| 32 | + <key>NSPrivacyCollectedDataTypes</key> |
|---|
| 33 | + <array/> |
|---|
| 34 | + <key>NSPrivacyTracking</key> |
|---|
| 35 | + <false/> |
|---|
| 36 | +</dict> |
|---|
| 37 | +</plist> |
|---|
| .. | .. |
|---|
| 3 | 3 | |
|---|
| 4 | 4 | class SceneDelegate: FlutterSceneDelegate { |
|---|
| 5 | 5 | |
|---|
| 6 | + override func scene( |
|---|
| 7 | + _ scene: UIScene, |
|---|
| 8 | + willConnectTo session: UISceneSession, |
|---|
| 9 | + options connectionOptions: UIScene.ConnectionOptions |
|---|
| 10 | + ) { |
|---|
| 11 | + super.scene(scene, willConnectTo: session, options: connectionOptions) |
|---|
| 12 | + guard let windowScene = scene as? UIWindowScene, |
|---|
| 13 | + let window = windowScene.windows.first, |
|---|
| 14 | + let flutterVC = window.rootViewController as? FlutterViewController |
|---|
| 15 | + else { return } |
|---|
| 16 | + setupBackupChannel(messenger: flutterVC.binaryMessenger) |
|---|
| 17 | + } |
|---|
| 18 | + |
|---|
| 19 | + /// Registers the com.mnsoft.pailot/backup MethodChannel so Dart can call |
|---|
| 20 | + /// NSURLIsExcludedFromBackupKey on the messages storage directory. |
|---|
| 21 | + private func setupBackupChannel(messenger: FlutterBinaryMessenger) { |
|---|
| 22 | + let channel = FlutterMethodChannel( |
|---|
| 23 | + name: "com.mnsoft.pailot/backup", |
|---|
| 24 | + binaryMessenger: messenger |
|---|
| 25 | + ) |
|---|
| 26 | + channel.setMethodCallHandler { (call, result) in |
|---|
| 27 | + guard call.method == "excludeFromBackup" else { |
|---|
| 28 | + result(FlutterMethodNotImplemented) |
|---|
| 29 | + return |
|---|
| 30 | + } |
|---|
| 31 | + guard let path = call.arguments as? String else { |
|---|
| 32 | + result(FlutterError(code: "INVALID_ARG", message: "path argument required", details: nil)) |
|---|
| 33 | + return |
|---|
| 34 | + } |
|---|
| 35 | + var url = URL(fileURLWithPath: path) |
|---|
| 36 | + var values = URLResourceValues() |
|---|
| 37 | + values.isExcludedFromBackup = true |
|---|
| 38 | + do { |
|---|
| 39 | + try url.setResourceValues(values) |
|---|
| 40 | + result(nil) |
|---|
| 41 | + } catch { |
|---|
| 42 | + result(FlutterError(code: "SET_FAILED", message: error.localizedDescription, details: nil)) |
|---|
| 43 | + } |
|---|
| 44 | + } |
|---|
| 45 | + } |
|---|
| 6 | 46 | } |
|---|
| .. | .. |
|---|
| 9 | 9 | import '../models/session.dart'; |
|---|
| 10 | 10 | import '../services/message_store.dart'; |
|---|
| 11 | 11 | import '../services/mqtt_service.dart' show ConnectionStatus; |
|---|
| 12 | +import '../services/navigate_notifier.dart'; |
|---|
| 12 | 13 | |
|---|
| 13 | 14 | // --- Enums --- |
|---|
| 14 | 15 | |
|---|
| .. | .. |
|---|
| 203 | 204 | |
|---|
| 204 | 205 | // --- MQTT Service (singleton) --- |
|---|
| 205 | 206 | // The MqttService is managed manually in the chat screen. |
|---|
| 207 | + |
|---|
| 208 | +// --- Navigate Notifier --- |
|---|
| 209 | +// Holds the bridge between NavigateScreen and ChatScreen's MQTT service. |
|---|
| 210 | +// ChatScreen sets this when MQTT is initialized; NavigateScreen reads it. |
|---|
| 211 | +// Using a Riverpod provider eliminates the stale static reference risk. |
|---|
| 212 | +final navigateNotifierProvider = StateProvider<NavigateNotifier?>((ref) => null); |
|---|
| .. | .. |
|---|
| 20 | 20 | import '../services/audio_service.dart'; |
|---|
| 21 | 21 | import '../services/message_store.dart'; |
|---|
| 22 | 22 | import '../services/mqtt_service.dart'; |
|---|
| 23 | +import '../services/navigate_notifier.dart'; |
|---|
| 23 | 24 | import '../services/push_service.dart'; |
|---|
| 24 | 25 | import '../theme/app_theme.dart'; |
|---|
| 25 | 26 | import '../widgets/command_bar.dart'; |
|---|
| .. | .. |
|---|
| 61 | 62 | int _lastSeq = 0; |
|---|
| 62 | 63 | bool _isCatchingUp = false; |
|---|
| 63 | 64 | bool _screenshotForChat = false; |
|---|
| 65 | + // FIFO dedup queue: O(1) eviction by removing from front when over cap. |
|---|
| 66 | + final List<int> _seenSeqsList = []; |
|---|
| 64 | 67 | final Set<int> _seenSeqs = {}; |
|---|
| 65 | 68 | bool _sessionReady = false; |
|---|
| 66 | 69 | final List<Map<String, dynamic>> _pendingMessages = []; |
|---|
| .. | .. |
|---|
| 222 | 225 | debugPrint('MQTT error: $error'); |
|---|
| 223 | 226 | }; |
|---|
| 224 | 227 | |
|---|
| 225 | | - NavigateNotifier.instance = NavigateNotifier( |
|---|
| 228 | + ref.read(navigateNotifierProvider.notifier).state = NavigateNotifier( |
|---|
| 226 | 229 | sendKey: (key, sessionId) { |
|---|
| 227 | 230 | _sendCommand('nav', {'key': key}); |
|---|
| 228 | 231 | }, |
|---|
| .. | .. |
|---|
| 262 | 265 | // Dedup: skip messages we've already processed |
|---|
| 263 | 266 | if (_seenSeqs.contains(seq)) return; |
|---|
| 264 | 267 | _seenSeqs.add(seq); |
|---|
| 265 | | - // Keep set bounded |
|---|
| 266 | | - if (_seenSeqs.length > 500) { |
|---|
| 267 | | - final sorted = _seenSeqs.toList()..sort(); |
|---|
| 268 | | - _seenSeqs.removeAll(sorted.sublist(0, sorted.length - 300)); |
|---|
| 268 | + _seenSeqsList.add(seq); |
|---|
| 269 | + // Keep bounded at 500 with O(1) FIFO eviction (drop oldest first) |
|---|
| 270 | + if (_seenSeqsList.length > 500) { |
|---|
| 271 | + final evict = _seenSeqsList.removeAt(0); |
|---|
| 272 | + _seenSeqs.remove(evict); |
|---|
| 269 | 273 | } |
|---|
| 270 | 274 | if (seq > _lastSeq) { |
|---|
| 271 | 275 | _lastSeq = seq; |
|---|
| .. | .. |
|---|
| 5 | 5 | import 'package:vibration/vibration.dart'; |
|---|
| 6 | 6 | |
|---|
| 7 | 7 | import '../providers/providers.dart'; |
|---|
| 8 | +import '../services/navigate_notifier.dart'; |
|---|
| 8 | 9 | import '../theme/app_theme.dart'; |
|---|
| 9 | 10 | |
|---|
| 10 | 11 | /// Terminal navigation screen with screenshot display and key grid. |
|---|
| .. | .. |
|---|
| 196 | 197 | final activeSessionId = ref.read(activeSessionIdProvider); |
|---|
| 197 | 198 | |
|---|
| 198 | 199 | // Send a key press to the AIBroker daemon via the MQTT service. |
|---|
| 199 | | - // NavigateNotifier bridges the navigate screen to the chat screen's MQTT service. |
|---|
| 200 | + // navigateNotifierProvider bridges the navigate screen to the chat screen's MQTT service. |
|---|
| 200 | 201 | |
|---|
| 201 | | - NavigateNotifier.instance?.sendKey(key, activeSessionId); |
|---|
| 202 | + ref.read(navigateNotifierProvider)?.sendKey(key, activeSessionId); |
|---|
| 202 | 203 | |
|---|
| 203 | 204 | // Request updated screenshot after key |
|---|
| 204 | 205 | Future.delayed(const Duration(milliseconds: 500), _requestScreenshot); |
|---|
| .. | .. |
|---|
| 206 | 207 | |
|---|
| 207 | 208 | void _requestScreenshot() { |
|---|
| 208 | 209 | final activeSessionId = ref.read(activeSessionIdProvider); |
|---|
| 209 | | - NavigateNotifier.instance?.requestScreenshot(activeSessionId); |
|---|
| 210 | + ref.read(navigateNotifierProvider)?.requestScreenshot(activeSessionId); |
|---|
| 210 | 211 | } |
|---|
| 211 | 212 | |
|---|
| 212 | 213 | Future<void> _haptic() async { |
|---|
| .. | .. |
|---|
| 219 | 220 | } |
|---|
| 220 | 221 | } |
|---|
| 221 | 222 | |
|---|
| 222 | | -/// Global notifier to bridge navigate screen to MQTT service. |
|---|
| 223 | | -/// Set by ChatScreen when MQTT is initialized. |
|---|
| 224 | | -class NavigateNotifier { |
|---|
| 225 | | - static NavigateNotifier? instance; |
|---|
| 226 | | - |
|---|
| 227 | | - final void Function(String key, String? sessionId) sendKey; |
|---|
| 228 | | - final void Function(String? sessionId) requestScreenshot; |
|---|
| 229 | | - |
|---|
| 230 | | - NavigateNotifier({ |
|---|
| 231 | | - required this.sendKey, |
|---|
| 232 | | - required this.requestScreenshot, |
|---|
| 233 | | - }); |
|---|
| 234 | | -} |
|---|
| .. | .. |
|---|
| 2 | 2 | import 'dart:convert'; |
|---|
| 3 | 3 | import 'dart:io'; |
|---|
| 4 | 4 | |
|---|
| 5 | +import 'package:flutter/services.dart'; |
|---|
| 5 | 6 | import 'package:path_provider/path_provider.dart'; |
|---|
| 6 | 7 | |
|---|
| 7 | 8 | import '../models/message.dart'; |
|---|
| .. | .. |
|---|
| 14 | 15 | static Timer? _debounceTimer; |
|---|
| 15 | 16 | static final Map<String, List<Message>> _pendingSaves = {}; |
|---|
| 16 | 17 | |
|---|
| 18 | + static const _backupChannel = |
|---|
| 19 | + MethodChannel('com.mnsoft.pailot/backup'); |
|---|
| 20 | + |
|---|
| 17 | 21 | /// Initialize the base directory for message storage. |
|---|
| 22 | + /// On iOS, the directory is excluded from iCloud / iTunes backup so that |
|---|
| 23 | + /// large base64 image attachments do not bloat the user's cloud storage. |
|---|
| 24 | + /// Messages can be re-fetched from the server if needed. |
|---|
| 18 | 25 | static Future<Directory> _getBaseDir() async { |
|---|
| 19 | 26 | if (_baseDir != null) return _baseDir!; |
|---|
| 20 | 27 | final appDir = await getApplicationDocumentsDirectory(); |
|---|
| 21 | 28 | _baseDir = Directory('${appDir.path}/messages'); |
|---|
| 22 | | - if (!await _baseDir!.exists()) { |
|---|
| 29 | + final created = !await _baseDir!.exists(); |
|---|
| 30 | + if (created) { |
|---|
| 23 | 31 | await _baseDir!.create(recursive: true); |
|---|
| 24 | 32 | } |
|---|
| 33 | + // Exclude from iCloud / iTunes backup (best-effort, iOS only). |
|---|
| 34 | + if (Platform.isIOS) { |
|---|
| 35 | + try { |
|---|
| 36 | + await _backupChannel.invokeMethod<void>( |
|---|
| 37 | + 'excludeFromBackup', |
|---|
| 38 | + _baseDir!.path, |
|---|
| 39 | + ); |
|---|
| 40 | + } catch (_) { |
|---|
| 41 | + // Non-fatal: if the channel call fails, backup exclusion is skipped. |
|---|
| 42 | + } |
|---|
| 43 | + } |
|---|
| 25 | 44 | return _baseDir!; |
|---|
| 26 | 45 | } |
|---|
| 27 | 46 | |
|---|
| .. | .. |
|---|
| 1 | +/// Bridge between NavigateScreen and ChatScreen's MQTT service. |
|---|
| 2 | +/// ChatScreen sets the [navigateNotifierProvider] when MQTT is initialized. |
|---|
| 3 | +/// NavigateScreen reads it to send key presses and screenshot requests. |
|---|
| 4 | +class NavigateNotifier { |
|---|
| 5 | + final void Function(String key, String? sessionId) sendKey; |
|---|
| 6 | + final void Function(String? sessionId) requestScreenshot; |
|---|
| 7 | + |
|---|
| 8 | + NavigateNotifier({ |
|---|
| 9 | + required this.sendKey, |
|---|
| 10 | + required this.requestScreenshot, |
|---|
| 11 | + }); |
|---|
| 12 | +} |
|---|