wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart' as sqflite_native;
|
||||
|
||||
const _kChannel = 'pyramid_messages';
|
||||
|
||||
/// Port name used to locate the main isolate from background isolates.
|
||||
/// Registered by notification_service.dart when the app starts.
|
||||
const kMainIsolateName = 'pyramid_main';
|
||||
|
||||
int _stableId(String roomId) {
|
||||
var h = 0;
|
||||
for (final c in roomId.codeUnits) {
|
||||
h = ((h * 31) + c) & 0x7FFFFFFF;
|
||||
}
|
||||
return h == 0 ? 1 : h;
|
||||
}
|
||||
|
||||
// ── FCM background handler ────────────────────────────────────────────────────
|
||||
// Runs in its own Dart isolate (or fresh process) when the app is killed or
|
||||
// backgrounded. Firebase calls this after Firebase.initializeApp() is done.
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> handleBackgroundMessage(RemoteMessage message) async {
|
||||
// PushService.kt handles all FCM-triggered notifications natively.
|
||||
// This callback is registered for API completeness but never fires because
|
||||
// FlutterFirebaseMessagingService is removed from the manifest.
|
||||
}
|
||||
|
||||
// ── Background engine entrypoint (killed-app E2EE work) ───────────────────────
|
||||
// PushService.kt / ReplyReceiver bootstrap a headless FlutterEngine running this
|
||||
// entrypoint when the app is KILLED (no main engine). It exposes a MethodChannel
|
||||
// so native can ask us to (a) decrypt a push event and update its notification,
|
||||
// or (b) send an encrypted reply. Building a Matrix client here is safe ONLY
|
||||
// because native gates this on the main app being dead — never two clients on
|
||||
// the same olm DB at once.
|
||||
|
||||
const _kBgEngineChannel = 'chat.pyramid.pyramid/bg_engine';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationEngineMain() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel(_kBgEngineChannel);
|
||||
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
final args = (call.arguments as Map?)?.cast<String, dynamic>() ?? {};
|
||||
switch (call.method) {
|
||||
case 'decryptAndShow':
|
||||
return await _bgDecryptAndShow(
|
||||
args['room_id'] as String?,
|
||||
args['event_id'] as String?,
|
||||
(args['notif_id'] as num?)?.toInt(),
|
||||
);
|
||||
case 'sendReply':
|
||||
return await _bgSendReply(
|
||||
args['room_id'] as String?,
|
||||
args['text'] as String?,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] handler error: $e');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Signal native that the channel handler is installed and tasks can be sent.
|
||||
channel.invokeMethod('bgEngineReady');
|
||||
}
|
||||
|
||||
Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) async {
|
||||
if (roomId == null || eventId == null) return false;
|
||||
final client = await _buildClient();
|
||||
if (client == null) return false;
|
||||
try {
|
||||
final event = await client
|
||||
.getEventByPushNotification(
|
||||
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
|
||||
storeInDatabase: false,
|
||||
returnNullIfSeen: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
if (event == null) return false;
|
||||
// Bad-encrypted body still starts with "**" — keep the native placeholder.
|
||||
if (event.body.startsWith('**')) return false;
|
||||
|
||||
final room = client.getRoomById(roomId);
|
||||
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
|
||||
event.senderId.split(':').first.replaceFirst('@', '');
|
||||
final title = (room?.isDirectChat ?? true)
|
||||
? sender
|
||||
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
|
||||
|
||||
// Hand the decrypted content back to native so it re-shows the SAME
|
||||
// notification via NotificationHelper — that keeps the reliable
|
||||
// MainActivity-based reply action (the flutter_local_notifications reply
|
||||
// path doesn't fire dependably when the app is killed).
|
||||
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
|
||||
'title': title,
|
||||
'body': _buildBody(event),
|
||||
'room_id': roomId,
|
||||
'notif_id': notifId ?? _stableId(roomId),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] decryptAndShow error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
// Let the async key-backup upload settle before closing the DB — otherwise
|
||||
// it logs a harmless "database_closed" error mid-flight.
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
await client.dispose().catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _bgSendReply(String? roomId, String? text) async {
|
||||
if (roomId == null || text == null || text.trim().isEmpty) return false;
|
||||
final client = await _buildClient();
|
||||
if (client == null) return false;
|
||||
try {
|
||||
// A one-shot sync ensures the room and recipient device keys are present so
|
||||
// the message can be encrypted. Safe here: main app is dead (native-gated).
|
||||
await client.oneShotSync().timeout(const Duration(seconds: 20)).catchError((_) => null);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
// Fall back to the prefs queue so the main app sends it on next launch.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
return false;
|
||||
}
|
||||
await room.sendTextEvent(text.trim());
|
||||
print('[NOTIF-BGENGINE] encrypted reply sent to $roomId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] sendReply error: $e');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('notif_pending_reply_room', roomId);
|
||||
await prefs.setString('notif_pending_reply_text', text);
|
||||
return false;
|
||||
} finally {
|
||||
// Allow the encrypted send + any key upload to flush before closing the DB.
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
await client.dispose().catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the raw callback handle of [notificationEngineMain] so native code can
|
||||
/// bootstrap the background engine. Call once at app startup.
|
||||
Future<void> registerBgEngineHandle() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
try {
|
||||
final handle = PluginUtilities.getCallbackHandle(notificationEngineMain);
|
||||
if (handle == null) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('bg_engine_handle', handle.toRawHandle());
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] registerBgEngineHandle failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build a short-lived Matrix client for background work ────────────────────
|
||||
// Uses NativeImplementationsDummy so Vodozemac runs synchronously in this
|
||||
// isolate — NativeImplementationsIsolate(compute) would try to spawn a
|
||||
// sub-isolate, which fails inside a Firebase background isolate.
|
||||
|
||||
Future<Client?> _buildClient() async {
|
||||
try {
|
||||
// vodozemac may already be initialised in this isolate (e.g. a decrypt task
|
||||
// ran before this reply task reused the same engine). Calling init() twice
|
||||
// throws — swallow it so it doesn't fail the whole client build.
|
||||
try {
|
||||
await vod.init();
|
||||
} catch (_) {}
|
||||
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
|
||||
|
||||
final db = await sqflite_native.databaseFactory
|
||||
.openDatabase(dbPath)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
// Prevent "database is locked" if the main process still has the DB open.
|
||||
try {
|
||||
await db.execute('PRAGMA busy_timeout = 5000');
|
||||
await db.execute('PRAGMA journal_mode = WAL');
|
||||
} catch (_) {}
|
||||
|
||||
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
|
||||
|
||||
final client = Client(
|
||||
'Pyramid',
|
||||
database: sdkDb,
|
||||
nativeImplementations: NativeImplementationsDummy(),
|
||||
logLevel: Level.error,
|
||||
);
|
||||
|
||||
await client
|
||||
.init(
|
||||
waitForFirstSync: false,
|
||||
waitUntilLoadCompletedLoaded: false,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
return client;
|
||||
} catch (e) {
|
||||
print('[NOTIF-BGENGINE] _buildClient failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildBody(Event event) {
|
||||
final msgType = event.messageType;
|
||||
final rawBody = event.body;
|
||||
return switch (msgType) {
|
||||
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
|
||||
'm.image' => '📷 Bild',
|
||||
'm.video' => '🎥 Video',
|
||||
'm.audio' => '🎵 Audio',
|
||||
'm.file' => '📄 Datei',
|
||||
'm.sticker' => '🏷️ Sticker',
|
||||
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Notification action handler ───────────────────────────────────────────────
|
||||
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
|
||||
// Must return quickly — Android keeps the notification's "sending" spinner
|
||||
// visible until this function returns.
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> handleBackgroundNotificationResponse(
|
||||
NotificationResponse details) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
print('[NOTIF-BG] handleBackgroundNotificationResponse fired');
|
||||
print('[NOTIF-BG] actionId=${details.actionId} payload=${details.payload} input=${details.input} id=${details.id}');
|
||||
|
||||
// ── Fast path: main isolate is running ───────────────────────────────────
|
||||
// If the Pyramid UI process is alive (app is backgrounded but not killed),
|
||||
// forward the action directly via IsolateNameServer. This avoids starting
|
||||
// any new engine and works silently without bringing the app to foreground.
|
||||
final sendPort = IsolateNameServer.lookupPortByName(kMainIsolateName);
|
||||
if (sendPort != null) {
|
||||
print('[NOTIF-BG] main isolate alive → forwarding via IsolateNameServer');
|
||||
sendPort.send(<String, dynamic>{
|
||||
'actionId': details.actionId,
|
||||
'payload': details.payload,
|
||||
'input': details.input,
|
||||
'id': details.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Slow path: app was killed ─────────────────────────────────────────────
|
||||
// Main isolate is gone. Dismiss the notification and, for reply actions,
|
||||
// persist the text in SharedPreferences so _sendPendingReply picks it up
|
||||
// the next time the app opens (via getInitialReply cold-start flow).
|
||||
print('[NOTIF-BG] main isolate not running → SharedPreferences fallback');
|
||||
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
final roomId = details.payload;
|
||||
if (roomId != null) {
|
||||
await plugin.cancel(_stableId(roomId));
|
||||
print('[NOTIF-BG] cancelled notification for room $roomId');
|
||||
} else if (details.id != null) {
|
||||
await plugin.cancel(details.id!);
|
||||
print('[NOTIF-BG] cancelled notification id ${details.id}');
|
||||
}
|
||||
|
||||
if (details.actionId != 'reply') {
|
||||
print('[NOTIF-BG] not a reply action, done');
|
||||
return;
|
||||
}
|
||||
|
||||
final text = details.input?.trim() ?? '';
|
||||
if (text.isEmpty || roomId == null) {
|
||||
print('[NOTIF-BG] text empty or roomId null, aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
// App killed — send the encrypted reply directly from this background isolate.
|
||||
// _bgSendReply falls back to the prefs queue if it can't send (so the main app
|
||||
// delivers it on next launch).
|
||||
final sent = await _bgSendReply(roomId, text);
|
||||
print('[NOTIF-BG] background reply sent=$sent room=$roomId');
|
||||
}
|
||||
|
||||
// ── Notification builder ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _showNotif({
|
||||
required String title,
|
||||
required String body,
|
||||
required String? roomId,
|
||||
required int notifId,
|
||||
}) async {
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
await plugin.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@drawable/ic_notification')),
|
||||
onDidReceiveBackgroundNotificationResponse: handleBackgroundNotificationResponse,
|
||||
);
|
||||
|
||||
await plugin.show(
|
||||
notifId, title, body,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_kChannel, 'Nachrichten',
|
||||
channelDescription: 'Neue Nachrichten',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@drawable/ic_notification',
|
||||
color: const Color(0xFF7B61FF),
|
||||
groupKey: 'pyramid_messages',
|
||||
actions: roomId != null
|
||||
? const [
|
||||
AndroidNotificationAction(
|
||||
'reply', 'Antworten',
|
||||
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
||||
allowGeneratedReplies: true,
|
||||
showsUserInterface: false, // BroadcastReceiver path — app never opens
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'read', 'Gelesen',
|
||||
showsUserInterface: false,
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
),
|
||||
payload: roomId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HTTP fallback — fetch sender / room name without the SDK ──────────────────
|
||||
|
||||
Future<String?> _fetchTitle(
|
||||
String hs, String token, String roomId, String eventId) async {
|
||||
try {
|
||||
final encRoom = Uri.encodeComponent(roomId);
|
||||
final encEvent = Uri.encodeComponent(eventId);
|
||||
|
||||
final rawEvent = await _get(
|
||||
'$hs/_matrix/client/v3/rooms/$encRoom/event/$encEvent', token);
|
||||
if (rawEvent == null) return null;
|
||||
|
||||
final senderId = rawEvent['sender'] as String? ?? '';
|
||||
String senderName = senderId.split(':').first.replaceFirst('@', '');
|
||||
|
||||
if (senderId.isNotEmpty) {
|
||||
final encSender = Uri.encodeComponent(senderId);
|
||||
final memberState = await _get(
|
||||
'$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.member/$encSender',
|
||||
token);
|
||||
final name = memberState?['displayname'] as String?;
|
||||
if (name != null && name.isNotEmpty) senderName = name;
|
||||
}
|
||||
|
||||
final nameState =
|
||||
await _get('$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name', token);
|
||||
final roomName = nameState?['name'] as String?;
|
||||
|
||||
return roomName?.isNotEmpty == true
|
||||
? '$senderName · $roomName'
|
||||
: senderName;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _get(String url, String token) async {
|
||||
try {
|
||||
final req = await HttpClient()
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 8));
|
||||
req.headers.add('Authorization', 'Bearer $token');
|
||||
final res = await req.close();
|
||||
if (res.statusCode != 200) return null;
|
||||
final raw = await res.transform(utf8.decoder).join();
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user