From 0da11b9bf05fa3af78325fe9b3a514e1010b9425 Mon Sep 17 00:00:00 2001 From: jkaczman Date: Sun, 31 May 2026 15:30:50 -0400 Subject: [PATCH] feat(ios): add App Group container access and NSE bridge module for upcoming NotificationService extension --- ios/Runner/AppDelegate.swift | 30 +++ ios/Runner/Runner.entitlements | 4 + ios/Runner/RunnerDebug.entitlements | 4 + lib/main.dart | 4 + lib/notifications/download_task.dart | 9 +- lib/notifications/epoch.dart | 7 +- lib/notifications/epoch_markers.dart | 4 +- lib/notifications/firebase.dart | 4 +- lib/notifications/nse_bridge.dart | 189 +++++++++++++++++ lib/notifications/thumbnails.dart | 49 ++--- lib/routes/activity_page.dart | 6 +- lib/routes/camera/camera_ui_bridge.dart | 4 +- lib/routes/camera/list_cameras.dart | 6 +- lib/routes/camera/new/ip_camera_option.dart | 7 +- .../camera/new/proprietary_camera_option.dart | 8 +- .../new/proprietary_camera_waiting.dart | 4 +- lib/routes/camera/view_camera.dart | 23 +-- lib/routes/camera/view_livestream.dart | 9 +- lib/routes/camera/view_video.dart | 10 +- lib/routes/server_page.dart | 4 + lib/utilities/app_paths.dart | 118 ++++++++++- lib/utilities/http_client.dart | 7 +- lib/utilities/rust_util.dart | 6 +- lib/utilities/storage_manager.dart | 195 +++++++++--------- lib/utilities/video_thumbnail_store.dart | 8 +- pubspec.lock | 38 ++-- test/notifications/nse_bridge_test.dart | 195 ++++++++++++++++++ test/utilities/app_paths_test.dart | 146 +++++++++++++ 28 files changed, 856 insertions(+), 242 deletions(-) create mode 100644 lib/notifications/nse_bridge.dart create mode 100644 test/notifications/nse_bridge_test.dart create mode 100644 test/utilities/app_paths_test.dart diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b02d79c..aa5697d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -86,6 +86,36 @@ import workmanager_apple result(FlutterMethodNotImplemented) } } + + // This is to give Dart access to FileManager.containerURL + // In app_paths.dart, it needs to find the App Group container path on iOS + // (as this is where the NSE and main app share state) + let appGroup = FlutterMethodChannel( + name: "secluso.com/app_group", + binaryMessenger: controller.binaryMessenger + ) + appGroup.setMethodCallHandler { call, result in + switch call.method { + case "getContainerPath": + guard let args = call.arguments as? [String: Any], + let identifier = args["identifier"] as? String, + !identifier.isEmpty + else { + result( + FlutterError( + code: "INVALID_ARGS", + message: "Missing App Group identifier", + details: nil)) + return + } + let url = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: identifier + ) + result(url?.path) + default: + result(FlutterMethodNotImplemented) + } + } let wifi = FlutterMethodChannel( name: "secluso.com/wifi", binaryMessenger: controller.binaryMessenger) diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index ca8bcc7..bdeb613 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -8,5 +8,9 @@ com.apple.developer.networking.wifi-info + com.apple.security.application-groups + + group.com.secluso.shared + diff --git a/ios/Runner/RunnerDebug.entitlements b/ios/Runner/RunnerDebug.entitlements index ea0c25e..42c2424 100644 --- a/ios/Runner/RunnerDebug.entitlements +++ b/ios/Runner/RunnerDebug.entitlements @@ -8,5 +8,9 @@ com.apple.developer.networking.wifi-info + com.apple.security.application-groups + + group.com.secluso.shared + diff --git a/lib/main.dart b/lib/main.dart index f9896a0..b044cee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:secluso_flutter/utilities/rust_api.dart'; import 'package:secluso_flutter/utilities/firebase_init.dart'; import 'package:flutter/services.dart'; import 'package:secluso_flutter/notifications/heartbeat_task.dart'; +import 'package:secluso_flutter/notifications/nse_bridge.dart'; import 'package:secluso_flutter/notifications/scheduler.dart'; import 'package:secluso_flutter/notifications/android_push_transport.dart'; import 'package:secluso_flutter/src/rust/guard.dart'; @@ -867,6 +868,9 @@ Future _invalidateServerCredentials(SharedPreferences prefs) async { await prefs.remove(PrefKeys.serverUsername); await prefs.remove(PrefKeys.serverPassword); await prefs.remove(PrefKeys.fcmConfigJson); + // Keep the App Group credentials file in lockstep + // Tries to make sure iOS NSE doesn't try to call the hub with stale auth + unawaited(NseBridge.clearCredentials()); } Future _clearStoredRelayConnection(SharedPreferences prefs) async { diff --git a/lib/notifications/download_task.dart b/lib/notifications/download_task.dart index 7342696..63c1659 100644 --- a/lib/notifications/download_task.dart +++ b/lib/notifications/download_task.dart @@ -490,13 +490,8 @@ Future retrieveVideos(String cameraName) async { epoch, ); if (markerPayload != null) { - final baseDir = await AppPaths.dataDirectory(); - final decPath = p.join( - baseDir.path, - 'camera_dir_$cameraName', - 'videos', - markerPayload, - ); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final decPath = p.join(cameraDir.path, 'videos', markerPayload); final decFile = File(decPath); if (await decFile.exists()) { await _enqueuePendingVideo(cameraName, markerPayload); diff --git a/lib/notifications/epoch.dart b/lib/notifications/epoch.dart index 12e2699..3e5438b 100644 --- a/lib/notifications/epoch.dart +++ b/lib/notifications/epoch.dart @@ -11,8 +11,8 @@ Future readEpoch( String type, { int defaultValue = 2, }) async { - final dir = await AppPaths.dataDirectory(); - final path = p.join(dir.path, 'camera_dir_$cameraName', 'epoch_$type'); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final path = p.join(cameraDir.path, 'epoch_$type'); final f = File(path); try { @@ -33,8 +33,7 @@ Future readEpoch( /// 3) close /// 4) rename temp -> final (atomic on POSIX filesystems) Future writeEpoch(String cameraName, String type, int value) async { - final dir = await AppPaths.dataDirectory(); - final cameraDir = Directory(p.join(dir.path, 'camera_dir_$cameraName')); + final cameraDir = await AppPaths.cameraDirectory(cameraName); if (!await cameraDir.exists()) { await cameraDir.create(recursive: true); } diff --git a/lib/notifications/epoch_markers.dart b/lib/notifications/epoch_markers.dart index 264b6e3..5b18dd0 100644 --- a/lib/notifications/epoch_markers.dart +++ b/lib/notifications/epoch_markers.dart @@ -9,8 +9,8 @@ String _markerName(String kind, int epoch) { } Future _markerFile(String cameraName, String kind, int epoch) async { - final base = await AppPaths.dataDirectory(); - final dir = p.join(base.path, 'camera_dir_$cameraName', 'videos'); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final dir = p.join(cameraDir.path, 'videos'); return File(p.join(dir, _markerName(kind, epoch))); } diff --git a/lib/notifications/firebase.dart b/lib/notifications/firebase.dart index 4a096de..e291827 100644 --- a/lib/notifications/firebase.dart +++ b/lib/notifications/firebase.dart @@ -781,8 +781,8 @@ class PushNotificationService { ); final docs = await AppPaths.dataDirectory(); - final thumbPath = - '${docs.path}/camera_dir_$cameraName/videos/thumbnail_$timestamp.png'; + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final thumbPath = '${cameraDir.path}/videos/thumbnail_$timestamp.png'; try { final bytes = await File(thumbPath).readAsBytes(); diff --git a/lib/notifications/nse_bridge.dart b/lib/notifications/nse_bridge.dart new file mode 100644 index 0000000..563f277 --- /dev/null +++ b/lib/notifications/nse_bridge.dart @@ -0,0 +1,189 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +// +// "Glue" between the Flutter app and the iOS Notification Service Extension. +// +// The NSE runs in a separate process with no Dart engine. +// The two sides share the App Group container's filesystem. +// This file is rsponsible for the main app's side of that contract. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../keys.dart'; +import '../utilities/app_paths.dart'; +import '../utilities/logger.dart'; + +/// File names + relative paths that the NSE expects. +class NseBridgePaths { + NseBridgePaths._(); + + /// JSON: server_addr, username, password, version + static const String credentialsFile = 'nse_credentials.json'; + + /// Append-only JSONL written by the NSE. Each line is one motion event. + static const String eventsLog = 'nse_events.jsonl'; +} + +class NseEvent { + final String camera; + final String timestamp; + final String? thumbnailFilename; + final List detections; + final int decryptedAtEpochMs; + + const NseEvent({ + required this.camera, + required this.timestamp, + this.thumbnailFilename, + this.detections = const [], + required this.decryptedAtEpochMs, + }); + + factory NseEvent.fromJson(Map json) { + return NseEvent( + camera: (json['camera'] ?? '').toString(), + timestamp: (json['timestamp'] ?? '').toString(), + thumbnailFilename: json['thumbnail_filename']?.toString(), + detections: + (json['detections'] as List?)?.map((e) => e.toString()).toList() ?? + const [], + decryptedAtEpochMs: + (json['decrypted_at_epoch_ms'] is int) + ? json['decrypted_at_epoch_ms'] as int + : int.tryParse(json['decrypted_at_epoch_ms']?.toString() ?? '') ?? + 0, + ); + } +} + +class NseBridge { + NseBridge._(); + + /// Write the current credentials to the App Group so the NSE can call the hub. + static Future exportCredentials() async { + if (!Platform.isIOS) return; + try { + final prefs = await SharedPreferences.getInstance(); + final serverAddr = prefs.getString(PrefKeys.serverAddr); + final username = prefs.getString(PrefKeys.serverUsername); + final password = prefs.getString(PrefKeys.serverPassword); + final dir = await AppPaths.dataDirectory(); + await exportCredentialsTo( + directory: dir, + serverAddr: serverAddr, + username: username, + password: password, + ); + } catch (error, stack) { + Log.w('[nse_bridge] Failed to export credentials: $error\n$stack'); + } + } + + /// Remove the credentials file when the user signs out. iOS-only. + static Future clearCredentials() async { + if (!Platform.isIOS) return; + try { + final dir = await AppPaths.dataDirectory(); + await clearCredentialsIn(dir); + } catch (error) { + Log.w('[nse_bridge] Failed to clear credentials: $error'); + } + } + + /// Read and remove any events the NSE has logged since the last drain. + static Future> drainEvents() async { + if (!Platform.isIOS) return const []; + final dir = await AppPaths.dataDirectory(); + return drainEventsIn(dir); + } + + /// I/O helper for export creds method + @visibleForTesting + static Future exportCredentialsTo({ + required Directory directory, + required String? serverAddr, + required String? username, + required String? password, + }) async { + if (serverAddr == null || + serverAddr.isEmpty || + username == null || + username.isEmpty || + password == null || + password.isEmpty) { + // NSE checks for all-or-nothing, will fail if everything isn't included + return; + } + final file = File(p.join(directory.path, NseBridgePaths.credentialsFile)); + final tmp = File('${file.path}.tmp'); + final payload = jsonEncode({ + 'server_addr': serverAddr, + 'username': username, + 'password': password, + 'version': 1, + }); + await tmp.writeAsString(payload, flush: true); + await tmp.rename(file.path); + } + + /// I/O helper for clearCredentials method + @visibleForTesting + static Future clearCredentialsIn(Directory directory) async { + final file = File(p.join(directory.path, NseBridgePaths.credentialsFile)); + if (await file.exists()) { + await file.delete(); + } + } + + /// I/O helper for drainEvents + @visibleForTesting + static Future> drainEventsIn(Directory directory) async { + final file = File(p.join(directory.path, NseBridgePaths.eventsLog)); + final drain = File('${file.path}.drain'); + + // Atomic-ish drain: rename to a .drain sidecar then read+delete. + // If the sidecar already exists we got here because a previous drain crashed between rename and delete + // We still want to consume it + if (!await drain.exists()) { + if (!await file.exists()) return const []; + try { + await file.rename(drain.path); + } catch (error) { + Log.w('[nse_bridge] Failed to roll events log to drain: $error'); + return const []; + } + } + + final events = []; + try { + final lines = await drain.readAsLines(); + for (final raw in lines) { + final line = raw.trim(); + if (line.isEmpty) continue; + try { + final json = jsonDecode(line); + if (json is Map) { + events.add(NseEvent.fromJson(json)); + } + } catch (error) { + Log.w('[nse_bridge] Ignoring malformed event line: $error'); + } + } + } catch (error) { + Log.w('[nse_bridge] Failed to read events log: $error'); + return const []; + } + + try { + await drain.delete(); + } catch (_) { + // if delete fails the next drain will overwrite (this is best effort) + } + return events; + } +} diff --git a/lib/notifications/thumbnails.dart b/lib/notifications/thumbnails.dart index 77acb16..fa6c08d 100644 --- a/lib/notifications/thumbnails.dart +++ b/lib/notifications/thumbnails.dart @@ -177,11 +177,10 @@ class ThumbnailManager { return false; } // Check if the file already exists in the thumbnails folder - final baseDir = await AppPaths.dataDirectory(); + final cameraDir = await AppPaths.cameraDirectory(camera); final filePath = p.join( - baseDir.path, - 'camera_dir_$camera', + cameraDir.path, 'videos', "thumbnail_$timestamp.png", ); @@ -375,10 +374,9 @@ class ThumbnailManager { ); if (markerPayload != null && _isThumbnailFilename(markerPayload)) { - final baseDir = await AppPaths.dataDirectory(); + final cameraDir = await AppPaths.cameraDirectory(camera); final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', + cameraDir.path, 'videos', markerPayload, ); @@ -460,13 +458,8 @@ class ThumbnailManager { ); if (markerPayload != null && _isThumbnailFilename(markerPayload)) { - final baseDir = await AppPaths.dataDirectory(); - final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', - 'videos', - markerPayload, - ); + final cameraDir = await AppPaths.cameraDirectory(camera); + final decPath = p.join(cameraDir.path, 'videos', markerPayload); if (await File(decPath).exists()) { await _logThumbnailFileState( camera, @@ -496,12 +489,8 @@ class ThumbnailManager { } if (!decFileName.startsWith("Error")) { - final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', - 'videos', - decFileName, - ); + final cameraDir = await AppPaths.cameraDirectory(camera); + final decPath = p.join(cameraDir.path, 'videos', decFileName); final ready = await _waitForStablePng(decPath); if (!ready) { Log.e("Thumbnail file not ready or invalid: $decPath"); @@ -681,10 +670,9 @@ class ThumbnailManager { ); if (markerPayload != null && _isThumbnailFilename(markerPayload)) { - final baseDir = await AppPaths.dataDirectory(); + final cameraDir = await AppPaths.cameraDirectory(camera); final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', + cameraDir.path, 'videos', markerPayload, ); @@ -765,13 +753,8 @@ class ThumbnailManager { ); if (markerPayload != null && _isThumbnailFilename(markerPayload)) { - final baseDir = await AppPaths.dataDirectory(); - final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', - 'videos', - markerPayload, - ); + final cameraDir = await AppPaths.cameraDirectory(camera); + final decPath = p.join(cameraDir.path, 'videos', markerPayload); if (await File(decPath).exists()) { await _logThumbnailFileState( camera, @@ -798,12 +781,8 @@ class ThumbnailManager { } if (!decFileName.startsWith("Error")) { - final decPath = p.join( - baseDir.path, - 'camera_dir_$camera', - 'videos', - decFileName, - ); + final cameraDir = await AppPaths.cameraDirectory(camera); + final decPath = p.join(cameraDir.path, 'videos', decFileName); final ready = await _waitForStablePng(decPath); if (!ready) { Log.e("Thumbnail file not ready or invalid: $decPath"); diff --git a/lib/routes/activity_page.dart b/lib/routes/activity_page.dart index ec11fb1..70ca529 100644 --- a/lib/routes/activity_page.dart +++ b/lib/routes/activity_page.dart @@ -257,10 +257,8 @@ class _ActivityPageState extends State } Future _videoFileExists(String cameraName, String videoFile) async { - final docsDir = await AppPaths.dataDirectory(); - final file = File( - p.join(docsDir.path, 'camera_dir_$cameraName', 'videos', videoFile), - ); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final file = File(p.join(cameraDir.path, 'videos', videoFile)); return file.exists(); } diff --git a/lib/routes/camera/camera_ui_bridge.dart b/lib/routes/camera/camera_ui_bridge.dart index 888f033..ef82116 100644 --- a/lib/routes/camera/camera_ui_bridge.dart +++ b/lib/routes/camera/camera_ui_bridge.dart @@ -70,8 +70,7 @@ class CameraUiBridge { final videos = await videoStore.listByCamera(cameraName); await videoStore.removeMany(videos.map((video) => video.id).toList()); - final docsDir = await AppPaths.dataDirectory(); - final camDir = Directory(p.join(docsDir.path, 'camera_dir_$cameraName')); + final camDir = await AppPaths.cameraDirectory(cameraName); if (await camDir.exists()) { try { await camDir.delete(recursive: true); @@ -81,6 +80,7 @@ class CameraUiBridge { } } + final docsDir = await AppPaths.dataDirectory(); final lock = File( p.join(docsDir.path, 'locks', 'thumbnail$cameraName.lock'), ); diff --git a/lib/routes/camera/list_cameras.dart b/lib/routes/camera/list_cameras.dart index 3f8f063..96f0e3a 100644 --- a/lib/routes/camera/list_cameras.dart +++ b/lib/routes/camera/list_cameras.dart @@ -1242,10 +1242,8 @@ class CamerasPageState extends State } Future _videoFileExists(String cameraName, String videoFile) async { - final docsDir = await AppPaths.dataDirectory(); - final file = File( - p.join(docsDir.path, 'camera_dir_$cameraName', 'videos', videoFile), - ); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final file = File(p.join(cameraDir.path, 'videos', videoFile)); return file.exists(); } diff --git a/lib/routes/camera/new/ip_camera_option.dart b/lib/routes/camera/new/ip_camera_option.dart index 00cd8e6..7109fc9 100644 --- a/lib/routes/camera/new/ip_camera_option.dart +++ b/lib/routes/camera/new/ip_camera_option.dart @@ -12,8 +12,6 @@ import 'package:secluso_flutter/routes/camera/new/ip_camera_waiting.dart'; import 'package:secluso_flutter/keys.dart'; import 'package:secluso_flutter/ui/google_fonts.dart'; import 'package:flutter/services.dart'; -import 'dart:io' show Directory; -import 'package:path/path.dart' as p; class IpCameraDialog extends StatefulWidget { const IpCameraDialog({ @@ -83,10 +81,7 @@ class _IpCameraDialogState extends State { HttpClientService.instance.clearGroupNameCache(lastCameraName); prefs.remove(PrefKeys.lastCameraAdd); - final docsDir = await AppPaths.dataDirectory(); - final camDir = Directory( - p.join(docsDir.path, 'camera_dir_$lastCameraName'), - ); + final camDir = await AppPaths.cameraDirectory(lastCameraName); if (await camDir.exists()) { try { await camDir.delete(recursive: true); diff --git a/lib/routes/camera/new/proprietary_camera_option.dart b/lib/routes/camera/new/proprietary_camera_option.dart index fae9e04..af00c9a 100644 --- a/lib/routes/camera/new/proprietary_camera_option.dart +++ b/lib/routes/camera/new/proprietary_camera_option.dart @@ -1,7 +1,7 @@ //! SPDX-License-Identifier: GPL-3.0-or-later import 'dart:math' as math; -import 'dart:io' show Platform, Directory; +import 'dart:io' show Platform; import 'dart:async'; import 'package:secluso_flutter/constants.dart'; @@ -19,7 +19,6 @@ import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:path/path.dart' as p; import 'package:secluso_flutter/utilities/app_paths.dart'; /// Popup: User connects to camera's Wi-Fi hotspot. @@ -1419,10 +1418,7 @@ class _ProprietaryCameraInfoDialogState HttpClientService.instance.clearGroupNameCache(lastCameraAdd); await sharedPreferences.remove(PrefKeys.lastCameraAdd); - final docsDir = await AppPaths.dataDirectory(); - final camDir = Directory( - p.join(docsDir.path, 'camera_dir_$lastCameraAdd'), - ); + final camDir = await AppPaths.cameraDirectory(lastCameraAdd); if (await camDir.exists()) { try { await camDir.delete(recursive: true); diff --git a/lib/routes/camera/new/proprietary_camera_waiting.dart b/lib/routes/camera/new/proprietary_camera_waiting.dart index e032ca9..e182e05 100644 --- a/lib/routes/camera/new/proprietary_camera_waiting.dart +++ b/lib/routes/camera/new/proprietary_camera_waiting.dart @@ -34,7 +34,6 @@ import 'package:secluso_flutter/utilities/rust_util.dart'; import 'package:secluso_flutter/utilities/result.dart'; import 'package:secluso_flutter/notifications/notification_permissions.dart'; import 'proprietary_camera_option.dart'; -import 'package:path/path.dart' as p; class ProprietaryCameraWaitingDialog extends StatefulWidget { final String cameraName; @@ -490,8 +489,7 @@ class _ProprietaryCameraWaitingDialogState HttpClientService.instance.clearGroupNameCache(cameraName); prefs.remove(PrefKeys.lastCameraAdd); - final docsDir = await AppPaths.dataDirectory(); - final camDir = Directory(p.join(docsDir.path, 'camera_dir_$cameraName')); + final camDir = await AppPaths.cameraDirectory(cameraName); if (await camDir.exists()) { try { await camDir.delete(recursive: true); diff --git a/lib/routes/camera/view_camera.dart b/lib/routes/camera/view_camera.dart index 3e277ec..19e2b31 100644 --- a/lib/routes/camera/view_camera.dart +++ b/lib/routes/camera/view_camera.dart @@ -227,8 +227,8 @@ class _CameraViewPageState extends State with RouteAware { await prefs.reload(); final cameraName = widget.cameraName; final cameraStatus = - prefs.getInt(PrefKeys.cameraStatusPrefix + cameraName) ?? - CameraStatus.online; + prefs.getInt(PrefKeys.cameraStatusPrefix + cameraName) ?? + CameraStatus.online; if (mounted) { setState(() => _cameraStatus = cameraStatus); @@ -357,10 +357,8 @@ class _CameraViewPageState extends State with RouteAware { _dataGeneration++; _isLoading = false; - final dir = await AppPaths.dataDirectory(); - final videoDir = Directory( - '${dir.path}/camera_dir_${widget.cameraName}/videos', - ); + final cameraDir = await AppPaths.cameraDirectory(widget.cameraName); + final videoDir = Directory('${cameraDir.path}/videos'); final nowMs = DateTime.now().millisecondsSinceEpoch; const recentThresholdMs = 15000; @@ -542,10 +540,9 @@ class _CameraViewPageState extends State with RouteAware { } void _deleteOne(Video v, int index) async { - final dir = await AppPaths.dataDirectory(); - if (v.received) { - final videoPath = '${dir.path}/camera_dir_${v.camera}/videos/${v.video}'; + final cameraDir = await AppPaths.cameraDirectory(v.camera); + final videoPath = '${cameraDir.path}/videos/${v.video}'; final file = File(videoPath); if (await file.exists()) { try { @@ -557,8 +554,7 @@ class _CameraViewPageState extends State with RouteAware { final ts = _timestampFromVideo(v.video); if (ts != null) { - final thumbPath = - '${dir.path}/camera_dir_${v.camera}/videos/thumbnail_$ts.png'; + final thumbPath = '${cameraDir.path}/videos/thumbnail_$ts.png'; final thumb = File(thumbPath); if (await thumb.exists()) { try { @@ -1138,7 +1134,10 @@ class _CameraViewPageState extends State with RouteAware { width: metrics.statusDotSize, height: metrics.statusDotSize, decoration: BoxDecoration( - color: isOnline ? const Color(0xFF10B981) : const Color(0xFFEF4444), + color: + isOnline + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), shape: BoxShape.circle, ), ), diff --git a/lib/routes/camera/view_livestream.dart b/lib/routes/camera/view_livestream.dart index f3e4e51..eb472fb 100644 --- a/lib/routes/camera/view_livestream.dart +++ b/lib/routes/camera/view_livestream.dart @@ -368,15 +368,10 @@ class _LivestreamPageState extends State Future _writeArchiveChunk(Uint8List dec) async { if (!_archiveInitialized) { - final baseDir = await AppPaths.dataDirectory(); + final cameraDir = await AppPaths.cameraDirectory(widget.cameraName); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final videoName = 'video_$timestamp.mp4'; - final filePath = p.join( - baseDir.path, - 'camera_dir_${widget.cameraName}', - 'videos', - videoName, - ); + final filePath = p.join(cameraDir.path, 'videos', videoName); final parentDir = Directory(p.dirname(filePath)); if (!await parentDir.exists()) { diff --git a/lib/routes/camera/view_video.dart b/lib/routes/camera/view_video.dart index fec9c69..672af08 100644 --- a/lib/routes/camera/view_video.dart +++ b/lib/routes/camera/view_video.dart @@ -112,14 +112,8 @@ class _VideoViewPageState extends State { Future _initVideo() async { try { - final dir = await AppPaths.dataDirectory(); - var cam = widget.cameraName; - _videoPath = p.join( - dir.path, - "camera_dir_$cam", - 'videos', - widget.videoTitle, - ); + final cameraDir = await AppPaths.cameraDirectory(widget.cameraName); + _videoPath = p.join(cameraDir.path, 'videos', widget.videoTitle); Log.d("Found path: $_videoPath"); final sourcePath = _videoPath!; diff --git a/lib/routes/server_page.dart b/lib/routes/server_page.dart index 714c365..e81908a 100644 --- a/lib/routes/server_page.dart +++ b/lib/routes/server_page.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:secluso_flutter/keys.dart'; import 'package:secluso_flutter/notifications/android_push_transport.dart'; import 'package:secluso_flutter/notifications/firebase.dart'; +import 'package:secluso_flutter/notifications/nse_bridge.dart'; import 'package:secluso_flutter/notifications/unified_push_service.dart'; import 'package:secluso_flutter/database/app_stores.dart'; import 'package:secluso_flutter/database/entities.dart'; @@ -383,6 +384,9 @@ class _ServerPageState extends State { await prefs.setString(PrefKeys.serverUsername, serverUsername); await prefs.setString(PrefKeys.serverPassword, serverPassword); await prefs.setString(PrefKeys.relayConnectionKind, relayConnectionKind); + // The iOS NotificationService extension needs these creds to fetch encrypted thumbnails from the relay + // So we push them into the App Group now + unawaited(NseBridge.exportCredentials()); if (Platform.isAndroid) { await prefs.setString( PrefKeys.androidPushPlatform, diff --git a/lib/utilities/app_paths.dart b/lib/utilities/app_paths.dart index 3094ab5..392b36d 100644 --- a/lib/utilities/app_paths.dart +++ b/lib/utilities/app_paths.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -14,32 +15,139 @@ class AppPaths { static const MethodChannel _storageChannel = MethodChannel( 'secluso.com/storage', ); + static const MethodChannel _appGroupChannel = MethodChannel( + 'secluso.com/app_group', + ); + + /// Identifier shared with the iOS NotificationService extension. + static const String iosAppGroupIdentifier = 'group.com.secluso.shared'; + static const Duration _backupSweepInterval = Duration(minutes: 1); static Future? _dataDirectoryFuture; static DateTime? _lastBackupSweepAt; static Future? _backupSweepFuture; + /// The active data directory, resolved on first call. + /// + /// On iOS this is the App Group container so the NotificationService extension can read the same MLS state the main app writes. + /// Cameras paired before the App Group switch keep their state under the legacy /secluso/ location (only new pairing ends up here) static Future dataDirectory() async { final directory = await (_dataDirectoryFuture ??= _resolveDataDirectory()); await _refreshBackupExclusionIfNeeded(directory); return directory; } + /// Return the directory belonging to a certain camera (whether the current or legacy if iOS) + /// + /// Newly paired cameras (post-App-Group) go under dataDirectory so the iOS NotificationService extension can see them. + /// Cameras paired earlier go under /secluso/camera_dir_ + /// The app reads from there and continues to function normally... the NSE just can't see them (it will fall back to the system text alert for pushes from legacy cameras) + /// When the user reflashes that camera and re-pairs, the new camera ends up in the App Group container and thumbnails-in-notifications start working for it. + static Future cameraDirectory(String cameraName) async { + return resolveCameraDirectory( + cameraName: cameraName, + primaryRoot: await dataDirectory(), + legacyRoot: await _iosLegacyRoot(), + ); + } + + /// All filesystem roots that may contain camera_dir_* subtrees. + /// (iOS: App Group container && legacy ApplicationSupport/secluso/) + /// allows size accounting, temp-file cleanup, orphan detection + static Future> allDataRoots() async { + return resolveDataRoots( + primary: await dataDirectory(), + legacy: await _iosLegacyRoot(), + ); + } + + /// Returns the per-camera dir at the App Group primaryRoot if it exists, + /// otherwise the matching dir under legacyRoot when that exists, + /// otherwise where a fresh pairing would go (primary) + @visibleForTesting + static Future resolveCameraDirectory({ + required String cameraName, + required Directory primaryRoot, + required Directory? legacyRoot, + }) async { + final primary = Directory( + p.join(primaryRoot.path, 'camera_dir_$cameraName'), + ); + if (legacyRoot == null) return primary; + if (await primary.exists()) return primary; + final legacy = Directory(p.join(legacyRoot.path, 'camera_dir_$cameraName')); + if (await legacy.exists()) return legacy; + return primary; + } + + /// Returns the primary root plus the legacy root iff (if and only if) it actually exists on disk and is a distinct path + @visibleForTesting + static Future> resolveDataRoots({ + required Directory primary, + required Directory? legacy, + }) async { + if (legacy == null) return [primary]; + if (!await legacy.exists()) return [primary]; + if (p.canonicalize(legacy.path) == p.canonicalize(primary.path)) { + return [primary]; + } + return [primary, legacy]; + } + + static Future _iosLegacyRoot() async { + if (!Platform.isIOS) return null; + final legacyRoot = await getApplicationSupportDirectory(); + return Directory(p.join(legacyRoot.path, 'secluso')); + } + static Future _resolveDataDirectory() async { - final rootDir = - Platform.isIOS - ? await getApplicationSupportDirectory() - : await getApplicationDocumentsDirectory(); + if (Platform.isIOS) { + return _resolveIosDataDirectory(); + } + final rootDir = await getApplicationDocumentsDirectory(); final dataDir = Directory(p.join(rootDir.path, 'secluso')); await dataDir.create(recursive: true); Log.i( '[storage] Using app data directory ${dataDir.path} (platform=${Platform.operatingSystem})', ); - await _forceRefreshBackupExclusion(dataDir); return dataDir; } + static Future _resolveIosDataDirectory() async { + String? containerPath; + try { + containerPath = await _appGroupChannel.invokeMethod( + 'getContainerPath', + {'identifier': iosAppGroupIdentifier}, + ); + } catch (error) { + Log.w( + '[storage] Failed to resolve App Group container ($iosAppGroupIdentifier): $error', + ); + } + + if (containerPath == null || containerPath.isEmpty) { + // App Group entitlement isn't provisioned on this device yet + // Fall back to the per-app sandbox so the app still launches (but the NSE won't see state in this mode) + final legacyRoot = await getApplicationSupportDirectory(); + final legacyDataDir = Directory(p.join(legacyRoot.path, 'secluso')); + await legacyDataDir.create(recursive: true); + await _forceRefreshBackupExclusion(legacyDataDir); + Log.w( + '[storage] App Group container unavailable; using sandbox path ' + '${legacyDataDir.path} (NSE will be inert).', + ); + return legacyDataDir; + } + + final groupDataDir = Directory(p.join(containerPath, 'secluso')); + await groupDataDir.create(recursive: true); + await _forceRefreshBackupExclusion(groupDataDir); + Log.i('[storage] Using iOS App Group data directory ${groupDataDir.path}'); + return groupDataDir; + } + static Future _refreshBackupExclusionIfNeeded( Directory directory, ) async { diff --git a/lib/utilities/http_client.dart b/lib/utilities/http_client.dart index 3b8d77f..8b5b653 100644 --- a/lib/utilities/http_client.dart +++ b/lib/utilities/http_client.dart @@ -648,8 +648,7 @@ class HttpClientService { final headers = await _basicAuthHeaders(creds.username, creds.password); headers['X-Command-Size'] = command.length.toString(); - final response = await http - .post(url, headers: headers, body: command); + final response = await http.post(url, headers: headers, body: command); await _handleServerVersionHeader(response); @@ -861,8 +860,8 @@ class HttpClientService { } Future _ensureEncryptedDir(String cameraName) async { - final base = await AppPaths.dataDirectory(); - final dir = Directory('${base.path}/camera_dir_$cameraName/encrypted'); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final dir = Directory('${cameraDir.path}/encrypted'); if (!await dir.exists()) { await dir.create(recursive: true); } diff --git a/lib/utilities/rust_util.dart b/lib/utilities/rust_util.dart index 962f7df..9daa74e 100644 --- a/lib/utilities/rust_util.dart +++ b/lib/utilities/rust_util.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'package:secluso_flutter/keys.dart'; import 'package:secluso_flutter/utilities/app_paths.dart'; @@ -220,10 +219,7 @@ Future initializeCore(String cameraName, bool firstTime) async { return false; } - var filesDir = p.join( - (await AppPaths.dataDirectory()).absolute.path, - 'camera_dir_$cameraName', - ); + var filesDir = (await AppPaths.cameraDirectory(cameraName)).absolute.path; var videosDir = "$filesDir/videos"; var encryptedDir = "$filesDir/encrypted"; diff --git a/lib/utilities/storage_manager.dart b/lib/utilities/storage_manager.dart index 639f2cd..dab40d5 100644 --- a/lib/utilities/storage_manager.dart +++ b/lib/utilities/storage_manager.dart @@ -72,8 +72,8 @@ class StorageManager { static const int _keepForeverSentinel = 0; static Future calculateSummary() async { - final docsDir = await AppPaths.dataDirectory(); - if (!await docsDir.exists()) { + final roots = await AppPaths.allDataRoots(); + if (roots.every((d) => !d.existsSync())) { return const StorageSummary( totalBytes: 0, videoBytes: 0, @@ -93,45 +93,48 @@ class StorageManager { var videoCount = 0; var thumbnailCount = 0; - await for (final entity in docsDir.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File) continue; - final stat = await entity.stat(); - final size = stat.size; - totalBytes += size; - final relativePath = p.relative(entity.path, from: docsDir.path); - final parts = p.split(relativePath); - - if (parts.length >= 3 && - parts[0].startsWith('camera_dir_') && - parts[1] == 'videos' && - parts[2].startsWith('video_') && - parts[2].endsWith('.mp4')) { - videoBytes += size; - videoCount += 1; - continue; - } + for (final root in roots) { + if (!await root.exists()) continue; + await for (final entity in root.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final stat = await entity.stat(); + final size = stat.size; + totalBytes += size; + final relativePath = p.relative(entity.path, from: root.path); + final parts = p.split(relativePath); + + if (parts.length >= 3 && + parts[0].startsWith('camera_dir_') && + parts[1] == 'videos' && + parts[2].startsWith('video_') && + parts[2].endsWith('.mp4')) { + videoBytes += size; + videoCount += 1; + continue; + } - if (parts.length >= 3 && - parts[0].startsWith('camera_dir_') && - parts[1] == 'videos' && - parts[2].startsWith('thumbnail_') && - parts[2].endsWith('.png')) { - thumbnailBytes += size; - thumbnailCount += 1; - continue; - } + if (parts.length >= 3 && + parts[0].startsWith('camera_dir_') && + parts[1] == 'videos' && + parts[2].startsWith('thumbnail_') && + parts[2].endsWith('.png')) { + thumbnailBytes += size; + thumbnailCount += 1; + continue; + } - if (parts.length >= 3 && - parts[0].startsWith('camera_dir_') && - parts[1] == 'encrypted') { - encryptedBytes += size; - continue; - } + if (parts.length >= 3 && + parts[0].startsWith('camera_dir_') && + parts[1] == 'encrypted') { + encryptedBytes += size; + continue; + } - otherBytes += size; + otherBytes += size; + } } return StorageSummary( @@ -217,21 +220,24 @@ class StorageManager { } static Future clearAllThumbnails() async { - final docsDir = await AppPaths.dataDirectory(); + final roots = await AppPaths.allDataRoots(); var bytesFreed = 0; var deletedThumbnails = 0; - await for (final entity in docsDir.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File) continue; - final basename = p.basename(entity.path); - if (!basename.startsWith('thumbnail_') || !basename.endsWith('.png')) { - continue; + for (final root in roots) { + if (!await root.exists()) continue; + await for (final entity in root.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final basename = p.basename(entity.path); + if (!basename.startsWith('thumbnail_') || !basename.endsWith('.png')) { + continue; + } + bytesFreed += await _safeDeleteFile(entity); + deletedThumbnails += 1; } - bytesFreed += await _safeDeleteFile(entity); - deletedThumbnails += 1; } await _deleteEpochMarkersReferencing((name) { @@ -249,21 +255,24 @@ class StorageManager { } static Future clearEncryptedTempFiles() async { - final docsDir = await AppPaths.dataDirectory(); + final roots = await AppPaths.allDataRoots(); var bytesFreed = 0; var deletedTempFiles = 0; - await for (final entity in docsDir.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File) continue; - final parts = p.split(p.relative(entity.path, from: docsDir.path)); - if (parts.length >= 3 && - parts[0].startsWith('camera_dir_') && - parts[1] == 'encrypted') { - bytesFreed += await _safeDeleteFile(entity); - deletedTempFiles += 1; + for (final root in roots) { + if (!await root.exists()) continue; + await for (final entity in root.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final parts = p.split(p.relative(entity.path, from: root.path)); + if (parts.length >= 3 && + parts[0].startsWith('camera_dir_') && + parts[1] == 'encrypted') { + bytesFreed += await _safeDeleteFile(entity); + deletedTempFiles += 1; + } } } @@ -285,20 +294,13 @@ class StorageManager { final videoStore = AppStores.instance.videoStore; final detectionStore = AppStores.instance.detectionStore; final videos = await videoStore.getAllAsync(); - final docsDir = await AppPaths.dataDirectory(); final videoIdsToRemove = []; final removedVideoNames = {}; for (final video in videos) { - final file = File( - p.join( - docsDir.path, - 'camera_dir_${video.camera}', - 'videos', - video.video, - ), - ); + final cameraDir = await AppPaths.cameraDirectory(video.camera); + final file = File(p.join(cameraDir.path, 'videos', video.video)); if (!await file.exists()) { videoIdsToRemove.add(video.id); removedVideoNames.add(video.video); @@ -357,7 +359,6 @@ class StorageManager { await AppStores.init(); } - final docsDir = await AppPaths.dataDirectory(); final videoStore = AppStores.instance.videoStore; final detectionStore = AppStores.instance.detectionStore; final videos = await videoStore.getAllAsync(); @@ -374,14 +375,8 @@ class StorageManager { removedVideoIds.add(video.id); removedVideoNames.add(video.video); - final videoFile = File( - p.join( - docsDir.path, - 'camera_dir_${video.camera}', - 'videos', - video.video, - ), - ); + final cameraDir = await AppPaths.cameraDirectory(video.camera); + final videoFile = File(p.join(cameraDir.path, 'videos', video.video)); bytesFreed += await _safeDeleteFile(videoFile); deletedVideos += 1; @@ -389,12 +384,7 @@ class StorageManager { if (timestamp != null) { final thumbnailName = 'thumbnail_$timestamp.png'; final thumbnailFile = File( - p.join( - docsDir.path, - 'camera_dir_${video.camera}', - 'videos', - thumbnailName, - ), + p.join(cameraDir.path, 'videos', thumbnailName), ); final thumbBytes = await _safeDeleteFile(thumbnailFile); if (thumbBytes > 0) { @@ -441,23 +431,26 @@ class StorageManager { static Future _deleteEpochMarkersReferencing( bool Function(String markerPayload) shouldDelete, ) async { - final docsDir = await AppPaths.dataDirectory(); - await for (final entity in docsDir.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File) continue; - final basename = p.basename(entity.path); - if (!basename.startsWith('.epoch_') || !basename.endsWith('.done')) { - continue; - } - try { - final content = (await entity.readAsString()).trim(); - if (content.isNotEmpty && shouldDelete(content)) { - await entity.delete(); + final roots = await AppPaths.allDataRoots(); + for (final root in roots) { + if (!await root.exists()) continue; + await for (final entity in root.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File) continue; + final basename = p.basename(entity.path); + if (!basename.startsWith('.epoch_') || !basename.endsWith('.done')) { + continue; + } + try { + final content = (await entity.readAsString()).trim(); + if (content.isNotEmpty && shouldDelete(content)) { + await entity.delete(); + } + } catch (e) { + Log.w('Failed to inspect epoch marker ${entity.path}: $e'); } - } catch (e) { - Log.w('Failed to inspect epoch marker ${entity.path}: $e'); } } } diff --git a/lib/utilities/video_thumbnail_store.dart b/lib/utilities/video_thumbnail_store.dart index 282dd32..d8baab1 100644 --- a/lib/utilities/video_thumbnail_store.dart +++ b/lib/utilities/video_thumbnail_store.dart @@ -44,12 +44,8 @@ class VideoThumbnailStore { final future = () async { try { - final docsDir = await AppPaths.dataDirectory(); - final videosDir = p.join( - docsDir.path, - 'camera_dir_$cameraName', - 'videos', - ); + final cameraDir = await AppPaths.cameraDirectory(cameraName); + final videosDir = p.join(cameraDir.path, 'videos'); final thumbPath = p.join(videosDir, 'thumbnail_$timestamp.png'); final existingBytes = await _readValidatedImageBytes(thumbPath); diff --git a/pubspec.lock b/pubspec.lock index dfdc4a6..ff29d36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -667,26 +667,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -723,26 +723,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1255,10 +1255,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" timezone: dependency: transitive description: @@ -1391,10 +1391,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" video_player: dependency: "direct main" description: @@ -1590,5 +1590,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/test/notifications/nse_bridge_test.dart b/test/notifications/nse_bridge_test.dart new file mode 100644 index 0000000..af3d8df --- /dev/null +++ b/test/notifications/nse_bridge_test.dart @@ -0,0 +1,195 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +// +// Tests for the App-Group-side I/O contract that NseBridge maintains with the iOS NotificationService extension. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:secluso_flutter/notifications/nse_bridge.dart'; + +void main() { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('secluso_nse_bridge_test_'); + }); + + tearDown(() { + if (tmp.existsSync()) { + tmp.deleteSync(recursive: true); + } + }); + + File credFile() => File(p.join(tmp.path, NseBridgePaths.credentialsFile)); + File eventsFile() => File(p.join(tmp.path, NseBridgePaths.eventsLog)); + File eventsDrainSidecar() => + File(p.join(tmp.path, '${NseBridgePaths.eventsLog}.drain')); + + group('exportCredentialsTo', () { + test('writes JSON with all four fields when creds are present', () async { + await NseBridge.exportCredentialsTo( + directory: tmp, + serverAddr: 'https://dummy-relay.secluso.com', + username: 'alice', + password: 's3cret', + ); + + expect(await credFile().exists(), isTrue); + final parsed = jsonDecode(await credFile().readAsString()); + expect(parsed, { + 'server_addr': 'https://dummy-relay.secluso.com', + 'username': 'alice', + 'password': 's3cret', + 'version': 1, + }); + }); + + test('is a no-op when any credential field is missing', () async { + // Pre-existing file should not be modified. + await credFile().writeAsString('{"server_addr":"old"}'); + + await NseBridge.exportCredentialsTo( + directory: tmp, + serverAddr: 'https://dummy-relay.secluso.com', + username: null, + password: 's3cret', + ); + + expect(await credFile().readAsString(), '{"server_addr":"old"}'); + }); + + test('is a no-op when a credential field is empty string', () async { + await NseBridge.exportCredentialsTo( + directory: tmp, + serverAddr: 'https://dummy-relay.secluso.com', + username: 'alice', + password: '', + ); + + expect(await credFile().exists(), isFalse); + }); + + test('overwrites a previously-written credentials file', () async { + await NseBridge.exportCredentialsTo( + directory: tmp, + serverAddr: 'https://old-dummy-relay.secluso.com', + username: 'alice', + password: 'old', + ); + await NseBridge.exportCredentialsTo( + directory: tmp, + serverAddr: 'https://new-dummy-relay.secluso.com', + username: 'bob', + password: 'new', + ); + + final parsed = jsonDecode(await credFile().readAsString()); + expect(parsed['server_addr'], 'https://new-dummy-relay.secluso.com'); + expect(parsed['username'], 'bob'); + expect(parsed['password'], 'new'); + }); + }); + + group('clearCredentialsIn', () { + test('deletes the credentials file when present', () async { + await credFile().writeAsString('{"server_addr":"x"}'); + expect(await credFile().exists(), isTrue); + + await NseBridge.clearCredentialsIn(tmp); + + expect(await credFile().exists(), isFalse); + }); + + test('is a no-op when the credentials file does not exist', () async { + await NseBridge.clearCredentialsIn(tmp); + expect(await credFile().exists(), isFalse); + }); + }); + + group('drainEventsIn', () { + test('returns empty list when no events log exists', () async { + final drained = await NseBridge.drainEventsIn(tmp); + expect(drained, isEmpty); + }); + + test( + 'parses well-formed JSONL events and removes the drain sidecar', + () async { + final line1 = jsonEncode({ + 'camera': 'frontdoor', + 'timestamp': '1737000000', + 'thumbnail_filename': 'thumbnail_1737000000.png', + 'detections': ['person', 'package'], + 'decrypted_at_epoch_ms': 1737000123456, + }); + final line2 = jsonEncode({ + 'camera': 'backyard', + 'timestamp': '1737000050', + 'decrypted_at_epoch_ms': 1737000150000, + }); + await eventsFile().writeAsString('$line1\n$line2\n'); + + final events = await NseBridge.drainEventsIn(tmp); + + expect(events.length, 2); + expect(events[0].camera, 'frontdoor'); + expect(events[0].timestamp, '1737000000'); + expect(events[0].thumbnailFilename, 'thumbnail_1737000000.png'); + expect(events[0].detections, ['person', 'package']); + expect(events[0].decryptedAtEpochMs, 1737000123456); + expect(events[1].camera, 'backyard'); + expect(events[1].timestamp, '1737000050'); + expect(events[1].thumbnailFilename, isNull); + expect(events[1].detections, isEmpty); + + // Drain removes both the original log and the sidecar so the next drain doesn't double-count. + expect(await eventsFile().exists(), isFalse); + expect(await eventsDrainSidecar().exists(), isFalse); + }, + ); + + test( + 'skips malformed lines but still returns the well-formed ones', + () async { + final good = jsonEncode({ + 'camera': 'driveway', + 'timestamp': '1737000100', + 'decrypted_at_epoch_ms': 1737000200000, + }); + await eventsFile().writeAsString( + '$good\n' + 'this is not json\n' + '\n' + '{"unterminated":\n' + '$good\n', + ); + + final events = await NseBridge.drainEventsIn(tmp); + expect(events.length, 2); + expect(events.every((e) => e.camera == 'driveway'), isTrue); + }, + ); + + test( + 'recovers from a crash mid-drain by re-reading the sidecar on next call', + () async { + // Simulate a previous crash + // The events log was rolled to .drain but the process died before reading/deleting it + // The next drain has to pick it up + final line = jsonEncode({ + 'camera': 'garage', + 'timestamp': '1737000200', + 'decrypted_at_epoch_ms': 1737000300000, + }); + await eventsDrainSidecar().writeAsString('$line\n'); + + final events = await NseBridge.drainEventsIn(tmp); + expect(events.length, 1); + expect(events.first.camera, 'garage'); + expect(await eventsDrainSidecar().exists(), isFalse); + }, + ); + }); +} diff --git a/test/utilities/app_paths_test.dart b/test/utilities/app_paths_test.dart new file mode 100644 index 0000000..b307b9e --- /dev/null +++ b/test/utilities/app_paths_test.dart @@ -0,0 +1,146 @@ +//! SPDX-License-Identifier: GPL-3.0-or-later +// +// Tests for the App Group / per-camera path resolution logic in AppPaths. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:secluso_flutter/utilities/app_paths.dart'; + +void main() { + late Directory tmp; + late Directory primary; + late Directory legacy; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('secluso_app_paths_test_'); + primary = Directory(p.join(tmp.path, 'app_group'))..createSync(); + legacy = Directory(p.join(tmp.path, 'legacy'))..createSync(); + }); + + tearDown(() { + if (tmp.existsSync()) { + tmp.deleteSync(recursive: true); + } + }); + + Directory pairCamera(Directory root, String camera) { + final dir = Directory(p.join(root.path, 'camera_dir_$camera')) + ..createSync(recursive: true); + return dir; + } + + group('resolveCameraDirectory', () { + test('returns primary path when no legacy fallback is provided ' + '(non-iOS case)', () async { + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'frontdoor', + primaryRoot: primary, + legacyRoot: null, + ); + expect(p.basename(dir.path), 'camera_dir_frontdoor'); + expect(p.dirname(dir.path), primary.path); + }); + + test('returns primary path when only the primary dir exists', () async { + pairCamera(primary, 'frontdoor'); + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'frontdoor', + primaryRoot: primary, + legacyRoot: legacy, + ); + expect(dir.path, p.join(primary.path, 'camera_dir_frontdoor')); + }); + + test('returns legacy path when only the legacy dir exists ' + '(existing pre-upgrade camera)', () async { + pairCamera(legacy, 'backyard'); + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'backyard', + primaryRoot: primary, + legacyRoot: legacy, + ); + expect(dir.path, p.join(legacy.path, 'camera_dir_backyard')); + }); + + test('returns primary path when both exist ' + '(post-upgrade camera supersedes legacy)', () async { + pairCamera(primary, 'garage'); + pairCamera(legacy, 'garage'); + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'garage', + primaryRoot: primary, + legacyRoot: legacy, + ); + expect(dir.path, p.join(primary.path, 'camera_dir_garage')); + }); + + test('returns primary path when neither exists ' + '(fresh pairing lands in App Group)', () async { + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'newcam', + primaryRoot: primary, + legacyRoot: legacy, + ); + expect(dir.path, p.join(primary.path, 'camera_dir_newcam')); + }); + + test( + 'returns directory referencing the actual path even before it exists', + () async { + final dir = await AppPaths.resolveCameraDirectory( + cameraName: 'newcam', + primaryRoot: primary, + legacyRoot: legacy, + ); + // The pair-time flow expects to receive a Directory it can `.create()` + // on. Don't assert .exists() here because the resolver intentionally + // does not create directories. + expect(dir, isA()); + expect(dir.path, isNotEmpty); + }, + ); + }); + + group('resolveDataRoots', () { + test('returns [primary] only when no legacy is provided', () async { + final roots = await AppPaths.resolveDataRoots( + primary: primary, + legacy: null, + ); + expect(roots, [primary]); + }); + + test('returns [primary] when legacy does not exist on disk', () async { + legacy.deleteSync(); + final roots = await AppPaths.resolveDataRoots( + primary: primary, + legacy: legacy, + ); + expect(roots.map((d) => d.path), [primary.path]); + }); + + test( + 'returns [primary, legacy] when both exist and are distinct paths', + () async { + final roots = await AppPaths.resolveDataRoots( + primary: primary, + legacy: legacy, + ); + expect(roots.map((d) => d.path), [primary.path, legacy.path]); + }, + ); + + test('returns [primary] when legacy and primary canonicalize to the ' + 'same path (avoids double-scanning the same tree)', () async { + final sameAsPrimary = Directory(primary.path); + final roots = await AppPaths.resolveDataRoots( + primary: primary, + legacy: sameAsPrimary, + ); + expect(roots.length, 1); + expect(roots.first.path, primary.path); + }); + }); +}