Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
<true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.secluso.shared</string>
</array>
</dict>
</plist>
4 changes: 4 additions & 0 deletions ios/Runner/RunnerDebug.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
<true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.secluso.shared</string>
</array>
</dict>
</plist>
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -867,6 +868,9 @@ Future<void> _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<void> _clearStoredRelayConnection(SharedPreferences prefs) async {
Expand Down
9 changes: 2 additions & 7 deletions lib/notifications/download_task.dart
Original file line number Diff line number Diff line change
Expand Up @@ -490,13 +490,8 @@ Future<bool> 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);
Expand Down
7 changes: 3 additions & 4 deletions lib/notifications/epoch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ Future<int> 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 {
Expand All @@ -33,8 +33,7 @@ Future<int> readEpoch(
/// 3) close
/// 4) rename temp -> final (atomic on POSIX filesystems)
Future<void> 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);
}
Expand Down
4 changes: 2 additions & 2 deletions lib/notifications/epoch_markers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ String _markerName(String kind, int epoch) {
}

Future<File> _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)));
}

Expand Down
4 changes: 2 additions & 2 deletions lib/notifications/firebase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
189 changes: 189 additions & 0 deletions lib/notifications/nse_bridge.dart
Original file line number Diff line number Diff line change
@@ -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<String> detections;
final int decryptedAtEpochMs;

const NseEvent({
required this.camera,
required this.timestamp,
this.thumbnailFilename,
this.detections = const [],
required this.decryptedAtEpochMs,
});

factory NseEvent.fromJson(Map<String, dynamic> 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<void> 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<void> 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<List<NseEvent>> 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<void> 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<void> 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<List<NseEvent>> 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 = <NseEvent>[];
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<String, dynamic>) {
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;
}
}
Loading