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:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+94 -16
View File
@@ -1,29 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
import 'package:pyramid/core/router.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/auth/login_notifier.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';
class PyramidApp extends ConsumerWidget {
class PyramidApp extends ConsumerStatefulWidget {
const PyramidApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final colorScheme = ref.watch(colorSchemeProvider);
ConsumerState<PyramidApp> createState() => _PyramidAppState();
}
return MaterialApp.router(
title: 'Pyramid',
theme: buildTheme(colorScheme, Brightness.light),
darkTheme: buildTheme(colorScheme, Brightness.dark),
themeMode: ThemeMode.system,
routerConfig: router,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
class _PyramidAppState extends ConsumerState<PyramidApp>
with WidgetsBindingObserver {
bool _prefsLoaded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadPersistedPrefs();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
setAppForeground(true);
checkPendingReply(ref);
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// Clears heartbeat so PushService.kt shows FCM notifications immediately.
// Background Matrix syncs must not re-set the heartbeat (see notification_service.dart).
setAppForeground(false);
default:
break;
}
}
Future<void> _loadPersistedPrefs() async {
final saved = await loadThemePrefs();
if (!mounted) return;
ref.read(themeModeProvider.notifier).state = saved.isDark;
ref.read(accentProvider.notifier).state = saved.accentIdx;
ref.read(radiusProvider.notifier).state = saved.radius;
ref.read(motionProvider.notifier).state = saved.motion;
setState(() => _prefsLoaded = true);
}
@override
Widget build(BuildContext context) {
// Block the router from rendering until auth state is known so the
// /server page never flashes before the redirect to /app fires.
final authAsync = ref.watch(isLoggedInProvider);
if (authAsync.isLoading || !_prefsLoaded) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: Color(0xFF1A1B1E),
body: Center(child: PyramidLoader(size: 80)),
),
);
}
final router = ref.watch(routerProvider);
final isDark = ref.watch(themeModeProvider);
final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider);
final motion = ref.watch(motionProvider);
final accent = accentPresets[accentIdx].color;
return PyramidTheme(
isDark: isDark,
accent: accent,
radius: radius,
density: density,
motion: motion,
child: MaterialApp.router(
title: 'Pyramid',
theme: buildPyramidThemeData(false),
darkTheme: buildPyramidThemeData(true),
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
routerConfig: router,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
debugShowCheckedModeBanner: false,
),
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/livekit_call_manager.dart';
import 'package:pyramid/core/voip_manager.dart';
import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── Theme state ──────────────────────────────────────────────────────────────
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
const accentPresets = [
(name: 'amber', color: PyramidColors.accentAmber),
(name: 'coral', color: PyramidColors.accentCoral),
(name: 'mint', color: PyramidColors.accentMint),
(name: 'violet', color: PyramidColors.accentViolet),
(name: 'cobalt', color: PyramidColors.accentCobalt),
(name: 'lime', color: PyramidColors.accentLime),
];
final radiusProvider = StateProvider<double>((ref) => 12);
final densityProvider = StateProvider<double>((ref) => 1);
final motionProvider = StateProvider<double>((ref) => 1);
// ─── Shell layout state ───────────────────────────────────────────────────────
final railCollapsedProvider = StateProvider<bool>((ref) => false);
// True while the app window is the foreground / focused window.
// Set to false when the lifecycle transitions to inactive/paused/detached.
final windowFocusedProvider = StateProvider<bool>((ref) => true);
final membersPanelOpenProvider = StateProvider<bool>((ref) => false);
// Set to an eventId to make the open chat scroll to + briefly highlight that
// message (e.g. when tapping a search result). ChatView consumes and clears it.
final pendingJumpEventProvider = StateProvider<String?>((ref) => null);
// Currently selected room ID (null = no room selected)
final activeRoomIdProvider = StateProvider<String?>((ref) => null);
// ─── Share-Target ("Teilen nach Pyramid", Android) ───────────────────────────
// Von MainActivity.kt via MethodChannel geliefert; AppShell zeigt dann den
// Raum-Picker. paths = bereits in den App-Cache kopierte Dateien.
class PendingShare {
final String? text;
final List<String> paths;
const PendingShare({this.text, this.paths = const []});
bool get isEmpty => (text == null || text!.isEmpty) && paths.isEmpty;
}
final pendingShareProvider = StateProvider<PendingShare?>((ref) => null);
// Space filter: null = all rooms, otherwise space ID
final activeSpaceIdProvider = StateProvider<String?>((ref) => 'dms');
// ─── Modal state ─────────────────────────────────────────────────────────────
enum ModalKind { none, settings, search }
final activeModalProvider = StateProvider<ModalKind>((ref) => ModalKind.none);
// ─── Call state ───────────────────────────────────────────────────────────────
final callStateProvider = ChangeNotifierProvider<LiveKitCallManager>((ref) {
return LiveKitCallManager.instance;
});
final voipStateProvider = ChangeNotifierProvider<PyramidVoipManager>((ref) {
return PyramidVoipManager.instance;
});
final incomingCallProvider = StateProvider<CallSession?>((ref) => null);
// ─── View mode (chat / voice) ─────────────────────────────────────────────────
enum ViewMode { chat, voice }
final viewModeProvider = StateProvider<ViewMode>((ref) => ViewMode.chat);
// ─── Spaces from Matrix ───────────────────────────────────────────────────────
final spacesProvider = StreamProvider<List<Room>>((ref) async* {
final client = await ref.watch(matrixClientProvider.future);
yield _getSpaces(client);
await for (final _ in client.onSync.stream) {
yield _getSpaces(client);
}
});
List<Room> _getSpaces(Client client) {
return client.rooms.where((r) => r.isSpace).toList();
}
// ─── Toast notifications ─────────────────────────────────────────────────────
class ToastMessage {
final String text;
final String icon;
ToastMessage(this.text, this.icon);
}
final toastProvider = StateProvider<ToastMessage?>((ref) => null);
// Remembers the last active room per space section (key = spaceId or null)
final lastRoomPerSpaceProvider = StateProvider<Map<String?, String?>>((ref) => {});
// Space IDs the user just accepted an invite for. The server confirms the join
// immediately, but Continuwuity's incremental /sync sometimes does not flip the
// local membership from invite → join. We treat these as joined optimistically
// so the UI switches to the channel list (channels load via the space hierarchy).
// Persisted so the user does not have to re-accept on every app start.
final forceJoinedSpacesProvider =
StateNotifierProvider<StringSetPref, Set<String>>(
(ref) => StringSetPref('force_joined_spaces'));
// ─── Voice channel state ──────────────────────────────────────────────────────
// ID of the Matrix room the user is currently in as a voice channel (null = none)
final activeVoiceRoomIdProvider = StateProvider<String?>((ref) => null);
const kVoicePresenceType = 'io.pyramid.voice.presence';
// Local override cache: spaceId → bannerMxcUri (set immediately on upload,
// before the server sync delivers the state event back)
final spaceBannerOverrideProvider =
StateProvider<Map<String, String?>>((ref) => {});
// ─── Server-level banner ──────────────────────────────────────────────────────
// Persisted to SharedPreferences keyed by homeserver URL.
// Key in store: homeserverUrl → mxcUri
class ServerBannerNotifier extends AsyncNotifier<Map<String, String>> {
@override
Future<Map<String, String>> build() => loadAllServerBanners();
Future<void> set(String homeserverUrl, String? mxcUri) async {
final current = Map<String, String>.from(state.valueOrNull ?? {});
if (mxcUri == null || mxcUri.isEmpty) {
current.remove(homeserverUrl);
} else {
current[homeserverUrl] = mxcUri;
}
await saveServerBanner(homeserverUrl, mxcUri);
state = AsyncData(current);
}
}
final serverBannerProvider =
AsyncNotifierProvider<ServerBannerNotifier, Map<String, String>>(
ServerBannerNotifier.new,
);
// Active voice participants for a room, read from Matrix state events
final voiceParticipantsProvider =
Provider.family<List<Map<String, dynamic>>, String>((ref, roomId) {
ref.watch(roomListProvider); // Rebuild on every sync so presence updates show
final client = ref.watch(matrixClientProvider).valueOrNull;
if (client == null) return [];
final room = client.getRoomById(roomId);
if (room == null) return [];
final states = room.states[kVoicePresenceType] ?? {};
final now = DateTime.now().millisecondsSinceEpoch;
final result = <Map<String, dynamic>>[];
for (final entry in states.entries) {
final content = entry.value.content;
if (content['active'] != true) continue;
final joinedAt = content['joined_at'] as int? ?? 0;
if (now - joinedAt > 10 * 60 * 1000) continue;
result.add({
'userId': entry.key,
'displayName': content['display_name'] as String? ?? entry.key,
'avatarUrl': content['avatar_url'] as String?,
});
}
// Optimistically insert the local user immediately (state event may not
// have synced back from the server yet after joining)
final activeVoiceRoomId = ref.watch(activeVoiceRoomIdProvider);
if (activeVoiceRoomId == roomId && client.userID != null) {
final userId = client.userID!;
if (!result.any((p) => p['userId'] == userId)) {
String displayName = userId.split(':').first.replaceAll('@', '');
String? avatarUrl;
for (final r in client.rooms) {
try {
final m = r.getParticipants().firstWhere((u) => u.id == userId);
if (m.displayName != null) {
displayName = m.displayName!;
avatarUrl = m.avatarUrl?.toString();
break;
}
} catch (_) {}
}
result.insert(0, {
'userId': userId,
'displayName': displayName,
'avatarUrl': avatarUrl,
});
}
}
return result;
});
+402
View File
@@ -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;
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class E2eeDiagEntry {
final DateTime timestamp;
final String roomId;
final String eventId;
final String sessionId;
final String error;
const E2eeDiagEntry({
required this.timestamp,
required this.roomId,
required this.eventId,
required this.sessionId,
required this.error,
});
Map<String, dynamic> toJson() => {
'ts': timestamp.toIso8601String(),
'room': roomId,
'event': eventId,
'session': sessionId,
'error': error,
};
}
class E2eeDiagnosticsNotifier extends Notifier<List<E2eeDiagEntry>> {
static const int _maxEntries = 500;
@override
List<E2eeDiagEntry> build() => [];
void add(E2eeDiagEntry entry) {
final next = [...state, entry];
state = next.length > _maxEntries ? next.sublist(next.length - _maxEntries) : next;
}
void clear() => state = [];
Future<String> upload({required String serverUrl, required String token}) async {
if (state.isEmpty) return 'Keine Diagnosedaten vorhanden.';
final payload = utf8.encode(jsonEncode({
'client': 'Pyramid',
'uploaded_at': DateTime.now().toIso8601String(),
'entry_count': state.length,
'entries': state.map((e) => e.toJson()).toList(),
}));
final client = HttpClient();
try {
final uri = Uri.parse('$serverUrl/api/e2ee-diagnostics');
final request = await client.postUrl(uri);
request.headers.set('Content-Type', 'application/json; charset=utf-8');
request.headers.set('Authorization', 'Bearer $token');
request.headers.contentLength = payload.length;
request.add(payload);
final response = await request.close();
await response.drain<void>();
if (response.statusCode == 200) {
final count = state.length;
clear();
return '$count Einträge hochgeladen und lokal gelöscht.';
}
return 'Server-Fehler: HTTP ${response.statusCode}';
} on SocketException catch (e) {
return 'Netzwerkfehler: ${e.message}';
} catch (e) {
return 'Fehler: $e';
} finally {
client.close();
}
}
}
final e2eeDiagnosticsProvider =
NotifierProvider<E2eeDiagnosticsNotifier, List<E2eeDiagEntry>>(
E2eeDiagnosticsNotifier.new,
);
+183
View File
@@ -0,0 +1,183 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Self-hosted Sygnal instance on the Pi — must be configured with the
// Firebase service account for the 'chat-pyramid' Firebase project.
// push.element.io only supports Element's own apps and will actively reject
// chat.pyramid.pyramid pushkeys, causing the homeserver to auto-remove the pusher.
const _kGatewayUrl = 'https://push.steggi-matrix.work/_matrix/push/v1/notify';
const _kAppId = 'chat.pyramid.pyramid';
// ── Foreground-Setup & Pusher-Registrierung ───────────────────────────────────
Future<String> fcmDiagnostics(Client matrixClient) async {
if (!Platform.isAndroid) return 'Kein Android';
final buf = StringBuffer();
try {
await Firebase.initializeApp();
buf.writeln('✓ Firebase initialisiert');
} catch (e) {
buf.writeln('✗ Firebase-Init: $e');
return buf.toString();
}
try {
final settings = await FirebaseMessaging.instance.requestPermission();
buf.writeln('✓ Berechtigung: ${settings.authorizationStatus.name}');
} catch (e) {
buf.writeln('✗ Berechtigung: $e');
}
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}');
} else {
buf.writeln('✗ FCM-Token: null');
}
} catch (e) {
buf.writeln('✗ FCM-Token: $e');
}
try {
final pushers = await matrixClient.getPushers() ?? [];
final fcmPushers = pushers.where((p) => p.appId == _kAppId).toList();
if (fcmPushers.isEmpty) {
buf.writeln('✗ Pusher: keiner bei Synapse registriert!');
} else {
buf.writeln('✓ Pusher: ${fcmPushers.length} registriert');
}
} catch (e) {
buf.writeln('✗ Pusher-Abfrage: $e');
}
return buf.toString();
}
Future<void> initFcm(Client matrixClient) async {
if (!Platform.isAndroid) return;
try {
await Firebase.initializeApp();
} catch (_) {
// Bereits initialisiert (main.dart) oder google-services.json fehlt weiter
}
// Background messages are handled by handleBackgroundMessage (background_push.dart)
// via FlutterFirebaseMessagingService. It decrypts E2EE events using the Matrix
// SDK and shows notifications via flutter_local_notifications with
// showsUserInterface:false so no Activity opens when the user replies.
// Notification-Berechtigung anfordern (Android 13+)
await FirebaseMessaging.instance.requestPermission();
// Foreground-Notifications unterdrücken — App zeigt selbst Nachrichten
await FirebaseMessaging.instance
.setForegroundNotificationPresentationOptions(
alert: false,
badge: false,
sound: false,
);
// Re-register pusher if token was refreshed while the app was killed.
final prefs = await SharedPreferences.getInstance();
final pendingToken = prefs.getString('flutter.fcm_pending_token');
if (pendingToken != null && matrixClient.isLogged()) {
await _registerPusher(matrixClient, pendingToken);
await prefs.remove('flutter.fcm_pending_token');
}
final token = pendingToken ?? await _getToken();
if (token != null && matrixClient.isLogged()) {
await _registerPusher(matrixClient, token);
// Save homeserver + token for PushService.kt (Kotlin native notification handler)
await _saveNotifCredentials(matrixClient);
}
// Bei Token-Refresh: Pusher + gespeicherte Credentials aktualisieren
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
if (matrixClient.isLogged()) {
await _registerPusher(matrixClient, newToken);
await _saveNotifCredentials(matrixClient);
}
});
}
Future<void> _saveNotifCredentials(Client client) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_homeserver', client.homeserver.toString());
await prefs.setString('notif_access_token', client.accessToken ?? '');
} catch (_) {}
}
Future<String?> _getToken() async {
try {
return await FirebaseMessaging.instance.getToken();
} catch (_) {
return null;
}
}
Future<void> _registerPusher(Client client, String fcmToken) async {
try {
await client.postPusher(
Pusher(
pushkey: fcmToken,
kind: 'http',
appId: _kAppId,
appDisplayName: 'Pyramid',
deviceDisplayName: client.deviceName ?? 'Android',
lang: 'de',
data: PusherData(
url: Uri.parse(_kGatewayUrl),
format: 'event_id_only',
),
),
append: true,
);
} catch (e) {
debugPrint('[FCM] Pusher-Registrierung fehlgeschlagen: $e');
}
}
/// Tries to register the FCM pusher and returns a human-readable result string.
Future<String> retryPusherRegistration(Client client) async {
if (!Platform.isAndroid) return 'Kein Android';
final buf = StringBuffer();
try {
String? token;
try {
token = await FirebaseMessaging.instance.getToken();
} catch (e) {
return '✗ FCM-Token: $e';
}
if (token == null) {
return '✗ FCM-Token: null (Google Play Services Problem?)';
}
buf.writeln('✓ FCM-Token: ${token.substring(0, 20)}');
buf.writeln(' Gateway: $_kGatewayUrl');
await client.postPusher(
Pusher(
pushkey: token,
kind: 'http',
appId: _kAppId,
appDisplayName: 'Pyramid',
deviceDisplayName: client.deviceName ?? 'Android',
lang: 'de',
data: PusherData(
url: Uri.parse(_kGatewayUrl),
format: 'event_id_only',
),
),
append: true,
);
buf.writeln('✓ Pusher registriert');
await _saveNotifCredentials(client);
buf.writeln('✓ Credentials gespeichert');
} catch (e) {
buf.writeln('✗ Fehler: $e');
}
return buf.toString().trim();
}
+65
View File
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Zentrale Quelle für ICE-Server (STUN/TURN), die in ALLE WebRTC-Verbindungen
/// injiziert werden (Matrix-1:1-Calls und LiveKit).
///
/// Hintergrund: Der Heimserver hängt hinter CGNAT (keine Portfreigabe möglich).
/// Direkte Verbindungen brauchen deshalb ICE-Hole-Punching mit STUN auf beiden
/// Seiten; für harte Fälle (symmetrische Mobilfunk-NAT) können später
/// TURN-Server (z. B. Cloudflare TURN, 1 TB/Monat gratis) ergänzt werden —
/// dafür muss nur die ice.json auf dem Pi aktualisiert werden, ohne App-Update.
class IceServers {
/// Wird vom Pi ausgeliefert und kann dort per Cron mit frischen
/// TURN-Credentials befüllt werden (siehe /home/steggi/matrix/ice.json).
static const _remoteUrl =
'https://steggi-matrix.work/.well-known/pyramid/ice.json';
/// Fallback, falls die ice.json nicht erreichbar ist.
static const List<Map<String, dynamic>> _fallback = [
{
'urls': [
'stun:stun.l.google.com:19302',
'stun:stun.cloudflare.com:3478',
],
},
];
static List<Map<String, dynamic>>? _cached;
static DateTime? _fetchedAt;
/// Liefert die ICE-Server als WebRTC-`iceServers`-Maps
/// (`{'urls': [...], 'username': ..., 'credential': ...}`).
/// Ergebnis wird 30 Minuten gecached; TURN-Credentials in der ice.json
/// sollten deutlich länger gültig sein.
static Future<List<Map<String, dynamic>>> get() async {
final cached = _cached;
if (cached != null &&
_fetchedAt != null &&
DateTime.now().difference(_fetchedAt!) <
const Duration(minutes: 30)) {
return cached;
}
try {
final resp = await http
.get(Uri.parse(_remoteUrl))
.timeout(const Duration(seconds: 4));
if (resp.statusCode == 200) {
final body = jsonDecode(resp.body);
final list = (body['ice_servers'] as List)
.whereType<Map>()
.map((e) => e.cast<String, dynamic>())
.toList();
if (list.isNotEmpty) {
_cached = list;
_fetchedAt = DateTime.now();
return list;
}
}
} catch (_) {
// Offline/Endpoint fehlt — Fallback unten.
}
return _cached ?? _fallback;
}
}
+606
View File
@@ -0,0 +1,606 @@
// ignore_for_file: avoid_print
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:livekit_client/livekit_client.dart';
import 'package:matrix/matrix.dart' hide Room;
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kVoicePresenceType = 'io.pyramid.voice.presence';
class LiveKitCallManager with ChangeNotifier {
static final LiveKitCallManager instance = LiveKitCallManager._();
LiveKitCallManager._();
// ── Publish quality presets (Streamer → Server) ──────────────────────────
static final Map<String, VideoParameters> qualityPresets = {
'sd': VideoParametersPresets.h360_169,
'hd': VideoParametersPresets.h720_169,
'fhd': VideoParametersPresets.h1080_169,
'4k': VideoParametersPresets.h2160_169,
};
static const Map<String, VideoEncoding> _screenShareEncodings = {
'sd': VideoEncoding(maxBitrate: 500_000, maxFramerate: 10),
'hd': VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15),
'fhd': VideoEncoding(maxBitrate: 2_500_000, maxFramerate: 20),
'4k': VideoEncoding(maxBitrate: 4_000_000, maxFramerate: 30),
};
Room? _room;
EventsListener<RoomEvent>? _roomEventListener;
String _displayName = '';
bool isConnecting = false;
String? error;
bool isMuted = false;
bool isCameraOff = false;
bool isScreenSharing = false;
String _localQualityKey = 'hd';
String _subscribeQualityKey = 'auto'; // 'auto' | 'low' | 'medium' | 'high'
String? _currentScreenShareSourceId;
LocalVideoTrack? _screenShareTrack;
bool isDeafened = false;
bool _mutedBeforeDeafen = false;
bool isVoiceChannel = false;
String? _matrixRoomId;
Client? _matrixClient;
Timer? _presenceHeartbeat;
Timer? _statsTimer;
bool get isActive => _room != null || isConnecting;
Room? get room => _room;
String? get currentRoomId => _matrixRoomId;
String get displayName => _displayName;
bool get noiseSuppression => false;
String get currentQualityKey => _localQualityKey;
String get subscribeQualityKey => _subscribeQualityKey;
VideoParameters get _currentQuality =>
qualityPresets[_localQualityKey] ?? VideoParametersPresets.h720_169;
void _onRoomChanged() => notifyListeners();
void _cleanUpAfterDisconnect() {
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
if (_matrixClient != null && _matrixRoomId != null) {
_writePresence(false).catchError((_) {});
}
_roomEventListener?.dispose();
_roomEventListener = null;
_room?.removeListener(_onRoomChanged);
_room = null;
_displayName = '';
isConnecting = false;
error = null;
isMuted = false;
isCameraOff = false;
isScreenSharing = false;
isDeafened = false;
isVoiceChannel = false;
_mutedBeforeDeafen = false;
_screenShareTrack = null;
_currentScreenShareSourceId = null;
_matrixRoomId = null;
_matrixClient = null;
notifyListeners();
}
Future<void> _writePresence(bool active) async {
final client = _matrixClient;
final roomId = _matrixRoomId;
final userId = client?.userID;
if (client == null || roomId == null || userId == null) return;
try {
Map<String, dynamic> content;
if (active) {
// Resolve display name + avatar from any joined room's member state
User? member;
for (final r in client.rooms) {
final participants = r.getParticipants();
try {
member = participants.firstWhere((u) => u.id == userId);
if (member.displayName != null || member.avatarUrl != null) break;
} catch (_) {}
}
content = {
'active': true,
'joined_at': DateTime.now().millisecondsSinceEpoch,
'display_name': member?.displayName ?? userId,
if (member?.avatarUrl != null) 'avatar_url': member!.avatarUrl.toString(),
};
} else {
content = {'active': false};
}
await client.setRoomStateWithKey(roomId, _kVoicePresenceType, userId, content);
} catch (e) {
print('[VoicePresence] $e');
}
}
Future<void> startCall({
required String roomName,
required String roomDisplayName,
required String identity,
bool audioOnly = false,
bool voiceChannel = false,
Client? matrixClient,
String? matrixRoomId,
}) async {
if (isActive) return;
final url = 'wss://livekit.steggi-matrix.work';
isConnecting = true;
isVoiceChannel = voiceChannel;
error = null;
_displayName = roomDisplayName;
isCameraOff = audioOnly;
isMuted = false;
isScreenSharing = false;
isDeafened = false;
_mutedBeforeDeafen = false;
_localQualityKey = 'hd';
_subscribeQualityKey = 'auto';
_matrixClient = matrixClient;
_matrixRoomId = matrixRoomId;
notifyListeners();
try {
final token = LiveKitTokenGenerator.generate(
roomName: roomName,
identity: identity,
displayName: identity,
ttlSeconds: 21600,
);
final room = Room(
roomOptions: const RoomOptions(
adaptiveStream: false, // Matches FluffyChat fix
dynacast: true,
),
);
room.addListener(_onRoomChanged);
// Eigene ICE-Server (STUN/TURN aus ice.json) mitgeben. Client-seitige
// iceServers ERSETZEN die vom LiveKit-Server gelieferten — die zeigten
// bisher auf einen hinter CGNAT unerreichbaren coturn.
RTCConfiguration rtcConfig = const RTCConfiguration();
try {
final ice = await IceServers.get();
rtcConfig = RTCConfiguration(
iceServers: ice
.map((m) => RTCIceServer(
urls: m['urls'] is List
? List<String>.from(m['urls'] as List)
: [m['urls'] as String],
username: m['username'] as String?,
credential: m['credential'] as String?,
))
.toList(),
);
} catch (e) {
print('[LiveKit] ICE server fetch error: $e');
}
await room.connect(
url,
token,
connectOptions: ConnectOptions(rtcConfiguration: rtcConfig),
);
_room = room;
isConnecting = false;
notifyListeners();
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true);
_presenceHeartbeat = Timer.periodic(const Duration(seconds: 30), (_) {
_writePresence(true).catchError((_) {});
});
}
_roomEventListener = room.createListener()
..on<LocalTrackPublishedEvent>((_) => notifyListeners())
..on<LocalTrackUnpublishedEvent>((event) {
if (event.publication.source == TrackSource.screenShareVideo) {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
}
notifyListeners();
})
..on<TrackSubscribedEvent>((event) {
final pub = event.publication;
if (_subscribeQualityKey != 'auto' && pub.kind == TrackType.VIDEO) {
pub.setVideoQuality(_videoQualityFromKey(_subscribeQualityKey));
}
// Ausgabelautstärke aus den Einstellungen auf neue Audio-Tracks.
if (pub.kind == TrackType.AUDIO) {
try {
rtc.Helper.setVolume(
_outputVolume.clamp(0.0, 2.0), event.track.mediaStreamTrack);
} catch (_) {}
}
notifyListeners();
})
..on<TrackUnsubscribedEvent>((_) => notifyListeners())
..on<TrackPublishedEvent>((_) => notifyListeners())
..on<TrackUnpublishedEvent>((_) => notifyListeners())
..on<ParticipantConnectedEvent>((_) => notifyListeners())
..on<ParticipantDisconnectedEvent>((_) => notifyListeners())
..on<RoomDisconnectedEvent>((_) {
_cleanUpAfterDisconnect();
});
// In den Einstellungen gewählte Geräte anwenden (voice_*_device Prefs).
// Stale-IDs (abgestecktes Headset etc.) werden gegen die aktuell
// vorhandenen Geräte validiert und sonst ignoriert.
String? prefMic, prefSpeaker, prefCam;
var deviceIds = <String>{};
try {
final prefs = await SharedPreferences.getInstance();
_outputVolume = prefs.getDouble('voice_output_volume') ?? 1.0;
prefMic = prefs.getString('voice_mic_device');
prefSpeaker = prefs.getString('voice_speaker_device');
prefCam = prefs.getString('voice_camera_device');
final devices = await rtc.navigator.mediaDevices.enumerateDevices();
deviceIds = devices.map((d) => d.deviceId).toSet();
} catch (e) {
print('[LiveKit] Device prefs/enumerate error: $e');
}
if (prefMic != null && !deviceIds.contains(prefMic)) prefMic = null;
if (prefSpeaker != null && !deviceIds.contains(prefSpeaker)) prefSpeaker = null;
if (prefCam != null && !deviceIds.contains(prefCam)) prefCam = null;
await room.localParticipant?.setMicrophoneEnabled(
true,
audioCaptureOptions:
prefMic != null ? AudioCaptureOptions(deviceId: prefMic) : null,
);
if (prefSpeaker != null) {
try {
await rtc.Helper.selectAudioOutput(prefSpeaker);
} catch (e) {
print('[LiveKit] selectAudioOutput error: $e');
}
}
if (!audioOnly) {
try {
await room.localParticipant?.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(
deviceId: prefCam,
params: _currentQuality,
),
);
} catch (e) {
print('[LiveKit] Failed to enable camera: $e');
}
}
_startStatsLogging();
} catch (e) {
print('[VOIP] Start call error: $e');
isConnecting = false;
error = e.toString();
_cleanUpAfterDisconnect();
}
}
VideoQuality _videoQualityFromKey(String key) {
switch (key) {
case 'low': return VideoQuality.LOW;
case 'medium': return VideoQuality.MEDIUM;
case 'high': return VideoQuality.HIGH;
default: return VideoQuality.HIGH; // auto defaults to high
}
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
double _outputVolume = 1.0;
/// Live-Anwendung des Lautstärke-Sliders auf alle Remote-Audio-Tracks.
Future<void> setOutputVolume(double v) async {
_outputVolume = v;
final room = _room;
if (room == null) return;
for (final p in room.remoteParticipants.values) {
for (final pub in p.audioTrackPublications) {
final t = pub.track;
if (t != null) {
try {
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), t.mediaStreamTrack);
} catch (_) {}
}
}
}
}
Future<void> setSubscribeQuality(String key) async {
print('[LiveKit] Viewer changing subscribe quality to: $key');
_subscribeQualityKey = key;
if (_room == null) { notifyListeners(); return; }
final quality = _videoQualityFromKey(key);
for (final p in _room!.remoteParticipants.values) {
for (final pub in p.videoTrackPublications) {
if (pub.kind == TrackType.VIDEO) {
print('[LiveKit] Setting subscription quality for ${p.identity} to $quality');
pub.setVideoQuality(quality);
}
}
}
notifyListeners();
}
Future<void> toggleMute() async {
isMuted = !isMuted;
notifyListeners();
await _room?.localParticipant?.setMicrophoneEnabled(!isMuted);
}
Future<void> toggleDeafen() async {
if (!isDeafened) {
_mutedBeforeDeafen = isMuted;
isDeafened = true;
if (!isMuted) {
await _room?.localParticipant?.setMicrophoneEnabled(false);
isMuted = true;
}
} else {
isDeafened = false;
if (!_mutedBeforeDeafen) {
await _room?.localParticipant?.setMicrophoneEnabled(true);
isMuted = false;
}
}
_applyDeafen();
notifyListeners();
}
void _applyDeafen() {
if (_room == null) return;
for (final p in _room!.remoteParticipants.values) {
for (final trackPub in p.audioTrackPublications) {
final track = trackPub.track;
if (track != null) {
track.mediaStreamTrack.enabled = !isDeafened;
}
}
}
}
Future<void> toggleCamera() async {
if (isCameraOff) {
try {
await _room?.localParticipant?.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(params: _currentQuality),
);
isCameraOff = false;
} catch (e) {
print('[LiveKit] toggleCamera ON error: $e');
}
} else {
try {
await _room?.localParticipant?.setCameraEnabled(false);
} catch (e) {
print('[LiveKit] toggleCamera OFF error: $e');
}
isCameraOff = true;
}
notifyListeners();
}
Future<void> toggleScreenShare() async {
if (isScreenSharing) {
await stopScreenShare();
} else {
try {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows) {
final sources = await rtc.desktopCapturer.getSources(types: [rtc.SourceType.Screen, rtc.SourceType.Window]);
if (sources.isNotEmpty) {
await startScreenShare(sources.first.id);
return;
}
}
await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true;
notifyListeners();
} catch (e) {
print('[ScreenShare] Toggle error: $e');
}
}
}
VideoEncoding _screenShareEncoding() {
return _screenShareEncodings[_localQualityKey] ??
const VideoEncoding(maxBitrate: 1_500_000, maxFramerate: 15);
}
Future<void> startScreenShare(String sourceId) async {
if (isScreenSharing) return;
try {
final encoding = _screenShareEncoding();
final track = await LocalVideoTrack.createScreenShareTrack(
ScreenShareCaptureOptions(
sourceId: sourceId,
maxFrameRate: encoding.maxFramerate.toDouble(),
),
);
if (_room == null || _room!.localParticipant == null) return;
await _room!.localParticipant!.publishVideoTrack(
track,
publishOptions: VideoPublishOptions(
simulcast: true,
degradationPreference: DegradationPreference.balanced,
screenShareEncoding: encoding,
),
);
isScreenSharing = true;
_currentScreenShareSourceId = sourceId;
_screenShareTrack = track;
notifyListeners();
unawaited(_applyFpsToAllSimulcastLayers(track));
} catch (e) {
print('[ScreenShare] ERROR: $e');
}
}
Future<void> _applyFpsToAllSimulcastLayers(LocalVideoTrack track) async {
final targetFps = _screenShareEncoding().maxFramerate;
for (var attempt = 1; attempt <= 5; attempt++) {
await Future.delayed(const Duration(milliseconds: 600));
if (!isScreenSharing || _screenShareTrack != track) return;
final sender = track.transceiver?.sender;
if (sender == null) continue;
final params = sender.parameters;
final encodings = params.encodings;
if (encodings == null || encodings.isEmpty) continue;
var anyUpdated = false;
for (final enc in encodings) {
if ((enc.maxFramerate ?? 0) < targetFps) {
enc.maxFramerate = targetFps;
anyUpdated = true;
}
}
if (anyUpdated) await sender.setParameters(params);
return;
}
}
Future<void> stopScreenShare() async {
final local = _room?.localParticipant;
if (local != null) {
final toStop = <String>[];
for (final pub in local.videoTrackPublications) {
if (pub.source == TrackSource.screenShareVideo) {
toStop.add(pub.sid);
await pub.track?.stop();
}
}
for (final sid in toStop) {
try { await local.removePublishedTrack(sid); } catch (_) {}
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
notifyListeners();
}
Future<void> hangUp() async {
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
if (_matrixClient != null && _matrixRoomId != null) {
await _writePresence(false).catchError((_) {});
}
_roomEventListener?.dispose();
_roomEventListener = null;
_room?.removeListener(_onRoomChanged);
await _room?.disconnect();
_room = null;
_displayName = '';
isConnecting = false;
error = null;
isMuted = false;
isCameraOff = false;
isScreenSharing = false;
isDeafened = false;
isVoiceChannel = false;
_mutedBeforeDeafen = false;
_screenShareTrack = null;
_currentScreenShareSourceId = null;
_matrixRoomId = null;
_matrixClient = null;
notifyListeners();
}
Future<void> changeQuality(String qualityKey) async {
print('[LiveKit] Streamer changing publish quality to: $qualityKey');
_localQualityKey = qualityKey;
final params = qualityPresets[qualityKey];
if (params == null) { notifyListeners(); return; }
if (_room == null) { notifyListeners(); return; }
final local = _room!.localParticipant;
if (local == null) { notifyListeners(); return; }
if (!isCameraOff) {
await local.setCameraEnabled(false);
await Future.delayed(const Duration(milliseconds: 400));
try {
await local.setCameraEnabled(
true,
cameraCaptureOptions: CameraCaptureOptions(params: params),
);
} catch (e) {
print('[LiveKit] Camera restart error: $e');
isCameraOff = true;
}
}
if (isScreenSharing && _currentScreenShareSourceId != null) {
final sid = _currentScreenShareSourceId!;
await stopScreenShare();
await Future.delayed(const Duration(milliseconds: 400));
await startScreenShare(sid);
}
notifyListeners();
}
Future<void> changeAudioInput(String deviceId) async {
final participant = _room?.localParticipant;
if (participant == null) return;
try {
final track = await LocalAudioTrack.create(AudioCaptureOptions(deviceId: deviceId));
for (final pub in List.of(participant.audioTrackPublications)) {
await participant.removePublishedTrack(pub.sid);
}
await participant.publishAudioTrack(track);
if (isMuted) await participant.setMicrophoneEnabled(false);
} catch (e) { print('[Audio] error: $e'); }
}
void setRemoteVolume(double volume) {
if (_room == null) return;
final mute = volume < 0.05;
for (final p in _room!.remoteParticipants.values) {
for (final pub in p.audioTrackPublications) {
pub.track?.mediaStreamTrack.enabled = !mute;
}
}
}
void _applyNoiseSuppression(bool enabled) {}
void _startStatsLogging() {
_stopStatsLogging();
_statsTimer = Timer.periodic(const Duration(seconds: 5), (_) => _logStats());
}
void _stopStatsLogging() {
_statsTimer?.cancel();
_statsTimer = null;
}
Future<void> _logStats() async {
if (_room == null) return;
// Basic stats logging...
print('[Stats] SubQuality: $_subscribeQualityKey | PubQuality: $_localQualityKey');
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
class LiveKitTokenGenerator {
// Your LiveKit Credentials
static const String apiKey = 'LKMatrixPi';
static const String apiSecret = 'rYUT2PRaKLedp5VLQCQE83eZG7fjuBWtFPXGiveBmIE';
static String generate({
required String roomName,
required String identity,
String? displayName,
int ttlSeconds = 3600, // Valid for 1 hour by default
}) {
final jwt = JWT(
{
'video': {
'roomJoin': true,
'room': roomName,
'canPublish': true,
'canSubscribe': true,
'canPublishData': true,
},
'metadata': displayName ?? identity,
},
issuer: apiKey,
subject: identity,
);
// Sign the token with your secret
final token = jwt.sign(
SecretKey(apiSecret),
expiresIn: Duration(seconds: ttlSeconds),
);
return token;
}
}
+58 -5
View File
@@ -3,25 +3,78 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/encryption/utils/key_verification.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart' as sqflite_native;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:pyramid/core/voip_manager.dart';
final matrixClientProvider = FutureProvider<Client>((ref) async {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux)) {
final DatabaseFactory factory;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Use the native sqflite plugin — Android/iOS have system SQLite built-in,
// no .so loading required.
factory = sqflite_native.databaseFactory;
} else if (!kIsWeb) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
factory = databaseFactoryFfi;
} else {
throw UnsupportedError('Web not supported');
}
final appDir = await getApplicationSupportDirectory();
final dbPath = p.join(appDir.path, 'pyramid.sqlite');
final db = await databaseFactoryFfi.openDatabase(dbPath);
final db = await factory.openDatabase(dbPath);
// Optimization for Windows/FFI to prevent "database is locked" errors
try {
await db.execute('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA busy_timeout = 5000');
} catch (e) {
if (kDebugMode) print('Could not set PRAGMAs: $e');
}
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
final client = Client('Pyramid', database: sdkDb);
await client.init();
final client = Client(
'Pyramid',
database: sdkDb,
// Custom State-Events, die beim App-Start mit aus der DB geladen werden
// müssen — sonst sind Space-Banner & Voice-Presence nach Neustart leer,
// bis der jeweilige Raum vollständig nachgeladen wird.
importantStateEvents: {
'io.pyramid.space.banner',
'io.pyramid.voice.presence',
},
verificationMethods: {
KeyVerificationMethod.numbers,
KeyVerificationMethod.emoji,
KeyVerificationMethod.qrShow,
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,
nativeImplementations: NativeImplementationsIsolate(
compute,
vodozemacInit: () => vod.init(),
),
);
await client.init(
waitForFirstSync: false,
waitUntilLoadCompletedLoaded: false,
);
// Re-use or create singleton instance
try {
PyramidVoipManager.instance.client;
} catch (_) {
PyramidVoipManager(client);
}
return client;
});
+139
View File
@@ -0,0 +1,139 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
/// Shared, bounded LRU cache for media bytes (chat images, document previews,
/// avatars). Eviction is driven by a total byte budget plus a hard entry cap,
/// so the most-recently-viewed media (i.e. your recent messages) stays instantly
/// available while older media is dropped and re-fetched on demand.
///
/// In addition to the in-memory layer there is a small disk layer for
/// persistent entries (avatars, banners), so they appear instantly after an
/// app restart instead of popping in once re-downloaded.
class MediaCache {
MediaCache._();
static final MediaCache instance = MediaCache._();
// ~256 MB in memory, capped at 300 entries — comfortably covers the last
// ~100 messages of media per active chat without unbounded growth.
static const int _maxBytes = 256 * 1024 * 1024;
static const int _maxEntries = 300;
// Don't cache very large single files (videos etc.) — they'd evict everything.
static const int _maxEntryBytes = 32 * 1024 * 1024;
// Disk layer: only small entries (avatars, banners), bounded total size.
static const int _maxDiskEntryBytes = 1024 * 1024; // 1 MB pro Eintrag
static const int _maxDiskTotalBytes = 64 * 1024 * 1024; // 64 MB gesamt
final LinkedHashMap<String, Uint8List> _entries = LinkedHashMap();
int _totalBytes = 0;
Directory? _diskDir;
bool _pruneScheduled = false;
Uint8List? get(String key) {
final v = _entries.remove(key);
if (v != null) _entries[key] = v; // move to most-recently-used position
return v;
}
void put(String key, Uint8List bytes) {
if (bytes.lengthInBytes > _maxEntryBytes) return;
final existing = _entries.remove(key);
if (existing != null) _totalBytes -= existing.lengthInBytes;
_entries[key] = bytes;
_totalBytes += bytes.lengthInBytes;
_evict();
}
/// Memory lookup first, then disk. Disk hits are promoted back into memory.
Future<Uint8List?> getPersistent(String key) async {
final mem = get(key);
if (mem != null) return mem;
try {
final file = await _diskFile(key);
if (await file.exists()) {
final bytes = await file.readAsBytes();
if (bytes.isNotEmpty) {
put(key, bytes);
// Touch für LRU-Pruning nach mtime.
file.setLastModified(DateTime.now()).catchError((_) {});
return bytes;
}
}
} catch (_) {}
return null;
}
/// Stores in memory and (for small entries) on disk, surviving restarts.
Future<void> putPersistent(String key, Uint8List bytes) async {
put(key, bytes);
if (bytes.lengthInBytes > _maxDiskEntryBytes) return;
try {
final file = await _diskFile(key);
await file.writeAsBytes(bytes);
} catch (_) {}
}
Future<File> _diskFile(String key) async {
final dir = _diskDir ??= await _initDiskDir();
final name = sha1.convert(utf8.encode(key)).toString();
return File('${dir.path}${Platform.pathSeparator}$name.bin');
}
Future<Directory> _initDiskDir() async {
final support = await getApplicationSupportDirectory();
final dir = Directory(
'${support.path}${Platform.pathSeparator}media_cache');
await dir.create(recursive: true);
_schedulePrune(dir);
return dir;
}
/// Once per session: drop the oldest disk entries when over budget.
void _schedulePrune(Directory dir) {
if (_pruneScheduled) return;
_pruneScheduled = true;
Future(() async {
try {
final files = <File, FileStat>{};
var total = 0;
await for (final e in dir.list()) {
if (e is! File) continue;
final stat = await e.stat();
files[e] = stat;
total += stat.size;
}
if (total <= _maxDiskTotalBytes) return;
final sorted = files.entries.toList()
..sort((a, b) => a.value.modified.compareTo(b.value.modified));
for (final entry in sorted) {
if (total <= _maxDiskTotalBytes) break;
total -= entry.value.size;
await entry.key.delete().catchError((_) => entry.key);
}
} catch (_) {}
});
}
void _evict() {
while ((_totalBytes > _maxBytes || _entries.length > _maxEntries) &&
_entries.isNotEmpty) {
final oldestKey = _entries.keys.first;
final removed = _entries.remove(oldestKey);
if (removed != null) _totalBytes -= removed.lengthInBytes;
}
}
void clear() {
_entries.clear();
_totalBytes = 0;
}
}
+660
View File
@@ -0,0 +1,660 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
const _kInstallChannel = MethodChannel('chat.pyramid.pyramid/install');
// ─── Stable notification ID per room (same algorithm as PushService.kt) ──────
// Dart's String.hashCode is randomised per-process; this gives a deterministic
// integer that matches the Kotlin side so FCM and sync notifications collapse.
int _stableRoomId(String roomId) {
var h = 0;
for (final c in roomId.codeUnits) {
h = ((h * 31) + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
// ─── Android plugin ───────────────────────────────────────────────────────────
final _androidPlugin = FlutterLocalNotificationsPlugin();
bool _androidInitialized = false;
// ─── IsolateNameServer port ───────────────────────────────────────────────────
// Registered on startup so background isolates can forward notification actions
// to the running main isolate instead of starting a new Flutter engine.
ReceivePort? _mainIsolatePort;
// Room → active Windows notification (so we can close it on room-open).
final _windowsNotifs = <String, LocalNotification>{};
// ─── Notification tap payload → navigate to room ──────────────────────────────
final notificationTapProvider = StateProvider<String?>((ref) => null);
// ─── Permission state (Android) ───────────────────────────────────────────────
/// True when the user has permanently denied notification permission on Android.
final notificationsBlockedProvider = StateProvider<bool>((ref) => false);
// ─── Init ─────────────────────────────────────────────────────────────────────
Future<void> initNotifications(WidgetRef ref) async {
if (Platform.isAndroid) {
await _initAndroid(ref);
} else if (Platform.isWindows) {
await _initWindows();
}
}
Future<void> _initAndroid(WidgetRef ref) async {
if (_androidInitialized) return;
_androidInitialized = true;
// Register the IsolateNameServer port so background isolates can forward
// notification actions to us instead of starting a new Flutter engine.
_mainIsolatePort?.close();
_mainIsolatePort = ReceivePort();
IsolateNameServer.removePortNameMapping(kMainIsolateName);
IsolateNameServer.registerPortWithName(
_mainIsolatePort!.sendPort, kMainIsolateName);
_mainIsolatePort!.listen((msg) => _onIsolateMessage(msg, ref));
print('[NOTIF] IsolateNameServer port registered: $kMainIsolateName');
await _androidPlugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
),
onDidReceiveNotificationResponse: (details) {
print('[NOTIF-FG] onDidReceiveNotificationResponse fired');
print('[NOTIF-FG] actionId=${details.actionId} payload=${details.payload} input=${details.input}');
if (details.actionId == 'reply') {
final text = details.input?.trim() ?? '';
final roomId = details.payload;
print('[NOTIF-FG] reply: text="$text" roomId=$roomId');
if (text.isNotEmpty && roomId != null && roomId.isNotEmpty) {
_androidPlugin.cancel(_stableRoomId(roomId));
ref.read(matrixClientProvider.future).then((client) async {
final room = client.getRoomById(roomId);
print('[NOTIF-FG] getRoomById → ${room != null ? 'found' : 'null'}');
if (room != null) {
try {
await room.sendTextEvent(text);
print('[NOTIF-FG] sendTextEvent OK');
} catch (e) {
print('[NOTIF-FG] sendTextEvent failed: $e — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
}
} else {
print('[NOTIF-FG] room null — storing in prefs, waiting for sync');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
try {
await client.onSync.stream.first;
print('[NOTIF-FG] sync arrived — retrying _sendPendingReply');
await _sendPendingReply(ref);
} catch (e) {
print('[NOTIF-FG] sync/send error: $e');
}
}
});
} else {
print('[NOTIF-FG] reply skipped: text empty or roomId null');
}
return;
}
if (details.actionId == 'read') {
final roomId = details.payload;
if (roomId != null) _androidPlugin.cancel(_stableRoomId(roomId));
print('[NOTIF-FG] read action — dismissed');
return;
}
final roomId = details.payload;
print('[NOTIF-FG] tap → opening room $roomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
},
// Registers the background action handler so flutter_local_notifications
// can invoke it in a fresh isolate when the app is killed and the user
// presses Reply or Gelesen on a background notification.
onDidReceiveBackgroundNotificationResponse:
handleBackgroundNotificationResponse,
);
// Handle notification taps from PushService.kt (native FCM notifications).
// "openRoom" arrives when app is already running and user taps a notification.
_kInstallChannel.setMethodCallHandler((call) async {
final args = call.arguments;
if (call.method == 'openRoom') {
final roomId = (args is Map ? args['room_id'] : null) as String?;
print('[NOTIF-FG] openRoom: $roomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
await _sendPendingReply(ref);
} else if (call.method == 'replyFromNotification') {
// Native Android path: ReplyReceiver forwarded the inline reply text via
// MethodChannel — no Activity was opened.
final roomId = (args is Map ? args['room_id'] : null) as String?;
final text = (args is Map ? args['text'] : null) as String?;
print('[NOTIF-FG] replyFromNotification: room=$roomId text=$text');
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
_androidPlugin.cancel(_stableRoomId(roomId));
Future<void> storePending() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
}
try {
final client = await ref.read(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room != null) {
await room.sendTextEvent(text);
print('[NOTIF-FG] replyFromNotification: sent OK');
} else {
print('[NOTIF-FG] replyFromNotification: room null, storing in prefs');
await storePending();
// Retry on the next sync — room list is populated after first sync.
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
} catch (e) {
print('[NOTIF-FG] replyFromNotification error: $e — storing in prefs');
await storePending();
// Resume-based retry (checkPendingReply in app.dart) will pick this up.
}
}
} else if (call.method == 'sharedContent') {
// "Teilen nach Pyramid" während die App läuft — AppShell zeigt den Picker.
final text = (args is Map ? args['text'] : null) as String?;
final paths = (args is Map ? args['paths'] : null);
final share = PendingShare(
text: text,
paths: paths is List ? paths.whereType<String>().toList() : const [],
);
if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share;
}
} else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite
// the notification with the real sender name and message body.
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
final notifId = (args is Map ? args['notif_id'] : null) as int?;
if (roomId != null && roomId.isNotEmpty) {
_decryptAndUpdateNotification(ref, roomId, eventId, notifId).catchError((_) {});
}
}
});
// Cold-start tap: app was killed, user tapped notification → check launch intent.
try {
final roomId = await _kInstallChannel.invokeMethod<String?>('getInitialRoomId');
if (roomId != null && roomId.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = roomId;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
}
} catch (_) {}
// Cold-start reply: app was killed, user pressed Reply → reply text is in the
// launch intent (onCreate fires instead of onNewIntent when app is killed).
try {
final reply = await _kInstallChannel.invokeMethod<Object?>('getInitialReply');
if (reply is Map) {
final roomId = reply['room_id'] as String?;
final text = reply['text'] as String?;
print('[NOTIF-REPLY] getInitialReply: roomId=$roomId text=$text');
if (roomId != null && roomId.isNotEmpty && text != null && text.isNotEmpty) {
// Persist so _sendPendingReply sends it after the first sync.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
// Send the app back to background immediately — the user replied from
// the notification and doesn't want the full UI to appear.
try {
await _kInstallChannel.invokeMethod('minimizeApp');
} catch (_) {}
}
}
} catch (_) {}
// Cold-start share: app was launched via "Teilen nach Pyramid".
try {
final share = await _kInstallChannel.invokeMethod<Object?>('getInitialShare');
if (share is Map) {
final text = share['text'] as String?;
final paths = share['paths'];
final pending = PendingShare(
text: text,
paths: paths is List ? paths.whereType<String>().toList() : const [],
);
if (!pending.isEmpty) {
ref.read(pendingShareProvider.notifier).state = pending;
}
}
} catch (_) {}
final androidPlugin = _androidPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
// Pre-create channel so it exists before the FCM background isolate runs.
await androidPlugin?.createNotificationChannel(
const AndroidNotificationChannel(
'pyramid_messages',
'Nachrichten',
description: 'Neue Nachrichten',
importance: Importance.high,
enableVibration: true,
playSound: true,
),
);
final granted = await androidPlugin?.requestNotificationsPermission();
if (granted == false) {
ref.read(notificationsBlockedProvider.notifier).state = true;
}
// FCM: Firebase initialisieren + Pusher beim Homeserver registrieren.
// Use .future so we wait for the Matrix client to be fully ready
// rather than potentially getting null from .value during startup.
try {
final client = await ref.read(matrixClientProvider.future);
await initFcm(client);
// Rooms are only reliably in memory after the first sync completes.
// Send any reply that was stored while the app was killed.
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
} catch (_) {}
}
// ─── IsolateNameServer message handler ───────────────────────────────────────
// Called when a background isolate (handleBackgroundNotificationResponse)
// forwards a notification action to the running main isolate.
void _onIsolateMessage(dynamic message, WidgetRef ref) {
if (message is! Map) return;
final actionId = message['actionId'] as String?;
final payload = message['payload'] as String?;
final input = message['input'] as String?;
final id = message['id'] as int?;
print('[NOTIF-ISOLATE] action=$actionId payload=$payload input=$input');
switch (actionId) {
case 'reply':
final roomId = payload;
final text = (input ?? '').trim();
if (roomId == null || roomId.isEmpty || text.isEmpty) return;
_androidPlugin.cancel(id ?? _stableRoomId(roomId));
ref.read(matrixClientProvider.future).then((client) async {
final room = client.getRoomById(roomId);
if (room != null) {
try {
await room.sendTextEvent(text);
print('[NOTIF-ISOLATE] reply sent OK');
} catch (e) {
print('[NOTIF-ISOLATE] sendTextEvent failed: $e — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
} else {
print('[NOTIF-ISOLATE] room not in memory — storing in prefs');
final prefs = await SharedPreferences.getInstance();
await prefs.setString('notif_pending_reply_room', roomId);
await prefs.setString('notif_pending_reply_text', text);
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
}
});
case 'read':
if (payload != null) {
_androidPlugin.cancel(_stableRoomId(payload));
} else if (id != null) {
_androidPlugin.cancel(id);
}
print('[NOTIF-ISOLATE] read → notification dismissed');
default:
// Plain notification tap → navigate to room.
if (payload != null && payload.isNotEmpty) {
ref.read(activeRoomIdProvider.notifier).state = payload;
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
print('[NOTIF-ISOLATE] tap → navigated to $payload');
}
}
}
/// Sends a reply stored by the background notification handler.
/// Called on cold start and on every app resume so no reply is ever lost.
Future<void> _sendPendingReply(WidgetRef ref) async {
try {
final prefs = await SharedPreferences.getInstance();
final roomId = prefs.getString('notif_pending_reply_room');
final text = prefs.getString('notif_pending_reply_text');
print('[NOTIF-REPLY] _sendPendingReply: roomId=$roomId text=$text');
if (roomId == null || text == null || text.isEmpty) return;
final client = await ref.read(matrixClientProvider.future);
print('[NOTIF-REPLY] client ready, rooms=${client.rooms.length}');
final room = client.getRoomById(roomId);
print('[NOTIF-REPLY] getRoomById → ${room != null ? 'found' : 'null'}');
if (room == null) {
print('[NOTIF-REPLY] room not in memory yet, retrying after next sync');
client.onSync.stream.first
.then((_) => _sendPendingReply(ref))
.catchError((_) {});
return;
}
await room.sendTextEvent(text);
print('[NOTIF-REPLY] sendTextEvent OK, clearing prefs');
await prefs.remove('notif_pending_reply_room');
await prefs.remove('notif_pending_reply_text');
} catch (e) {
print('[NOTIF-REPLY] error: $e — prefs kept for retry');
}
}
/// Opens the OS notification-settings screen for this app (Android only).
Future<void> openNotificationSettings() async {
if (!Platform.isAndroid) return;
await _kInstallChannel.invokeMethod('openNotificationSettings');
}
/// Opens the system battery optimisation dialog for Pyramid (Android only).
/// Samsung and other OEMs kill FCM background processes unless this is set
/// to "Unrestricted".
Future<void> openBatteryOptimizationSettings() async {
if (!Platform.isAndroid) return;
await _kInstallChannel.invokeMethod('openBatterySettings');
}
Future<void> _initWindows() async {
await localNotifier.setup(appName: 'Pyramid');
}
// ─── Heartbeat — tells the FCM background handler the app is running ──────────
bool _appInForeground = true;
/// Call from the app lifecycle observer in app.dart.
void setAppForeground(bool value) {
_appInForeground = value;
if (!value && Platform.isAndroid) {
// Clear immediately so the FCM background handler runs as soon as the app
// is no longer in the foreground / is killed.
SharedPreferences.getInstance()
.then((p) => p.setInt('notif_app_heartbeat', 0));
}
}
void _pingHeartbeat() {
if (!Platform.isAndroid || !_appInForeground) return;
SharedPreferences.getInstance().then(
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
);
}
// ─── Reset notification timestamp on app resume ───────────────────────────────
// Events that arrived while the app was backgrounded were already notified by
// the FCM background handler. Update _watchStartTime so the notification
// watcher skips those catch-up events when the app comes back to foreground.
void resetNotificationStartTime() {
_watchStartTime = DateTime.now();
}
// ─── Cancel notification for a room (call when the chat is opened) ────────────
Future<void> cancelNotificationForRoom(String roomId) async {
if (Platform.isAndroid && _androidInitialized) {
await _androidPlugin.cancel(_stableRoomId(roomId));
} else if (Platform.isWindows) {
final notif = _windowsNotifs.remove(roomId);
notif?.close();
}
}
// ─── Decrypt & update notification (called by PushService via MethodChannel) ──
// PushService shows a "Neue Nachricht" placeholder immediately. This function
// is called while the app is backgrounded — the Matrix client has the E2EE keys
// so it can decrypt the event and overwrite the placeholder with real content.
Future<void> _decryptAndUpdateNotification(
WidgetRef ref, String roomId, String? eventId, int? notifId) async {
try {
final client = await ref
.read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId);
if (room == null) {
print('[NOTIF] decryptAndUpdate: room $roomId not found');
return;
}
// The event might not have synced yet — wait for the next sync (max 12 s).
// We use lastEvent as a proxy: after sync it will be the event we want.
Event? event = room.lastEvent;
final bool alreadyHave =
eventId == null || event?.eventId == eventId;
if (!alreadyHave || event == null) {
print('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
await client.onSync.stream.first.timeout(const Duration(seconds: 12));
room = client.getRoomById(roomId);
if (room == null) return;
event = room.lastEvent;
}
if (event == null) {
print('[NOTIF] decryptAndUpdate: event still null after sync');
return;
}
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
final rawBody = event.body;
// If decryption failed the body starts with "** Unable to decrypt"
if (rawBody.isEmpty || rawBody.startsWith('**')) {
print('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
return;
}
final notifBody =
rawBody.length > 100 ? '${rawBody.substring(0, 100)}' : rawBody;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = room.isDirectChat
? displayName
: '$displayName · ${room.getLocalizedDisplayname()}';
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
// replaces the existing notification rather than stacking a new one.
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': notifBody,
'roomId': roomId,
'notifId': notifId ?? _stableRoomId(roomId),
});
print('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
} catch (e) {
print('[NOTIF] decryptAndUpdate error: $e');
}
}
// ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({
required String roomId,
String? roomName,
required String senderName,
required String body,
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies.
try {
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': body,
'roomId': roomId,
'notifId': _stableRoomId(roomId),
});
} catch (e) {
print('[NOTIF] showNativeNotification failed: $e');
}
} else if (Platform.isWindows) {
// Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close();
final notif = LocalNotification(
identifier: 'msg_$roomId',
title: title,
body: body,
);
notif.onShow = () {};
_windowsNotifs[roomId] = notif;
await localNotifier.notify(notif);
// Also flash the taskbar button
WindowsTaskbar.setFlashTaskbarAppIcon();
}
}
// ─── Taskbar badge (Windows) ──────────────────────────────────────────────────
bool _badgeVisible = false;
Future<void> _updateTaskbar(int unreadCount) async {
if (!Platform.isWindows) return;
try {
if (unreadCount > 0 && !_badgeVisible) {
_badgeVisible = true;
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
} else if (unreadCount == 0 && _badgeVisible) {
_badgeVisible = false;
await WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
}
} catch (_) {}
}
// Exported so AppShell can call it when the app resumes from background.
Future<void> checkPendingReply(WidgetRef ref) => _sendPendingReply(ref);
// ─── Watcher provider ─────────────────────────────────────────────────────────
final notificationWatcherProvider = Provider<void>((ref) {
ref.watch(matrixClientProvider).whenData((client) {
_watchEvents(ref, client);
_watchBadge(client);
});
});
// Set once on first _watchEvents call so we can skip catchup events from the
// initial SDK sync (they were already shown by the FCM background handler).
DateTime? _watchStartTime;
void _watchEvents(Ref ref, Client client) {
_watchStartTime ??= DateTime.now();
client.onNotification.stream.listen((Event event) {
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
// Skip events that predate this app session — the FCM background handler
// already showed a notification for them while the app was killed.
if (event.originServerTs.isBefore(_watchStartTime!)) return;
// ── Master notification toggle (desktop/Windows setting) ──────────────
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
if (!ref.read(notifDesktopProvider)) return;
}
final roomId = event.room.id;
// Suppress only when the window is focused AND the room is currently open.
// If the window is minimized or in the background, always show the notification.
final windowFocused = ref.read(windowFocusedProvider);
if (windowFocused && roomId == ref.read(activeRoomIdProvider)) return;
final room = event.room;
if (room.membership != Membership.join) return;
// ── Mention-only filter ───────────────────────────────────────────────
if (ref.read(notifMentionOnlyDmProvider)) {
final myId = client.userID ?? '';
// Check MSC3952 m.mentions field first, then fall back to body scan.
final mentionedIds = (event.content
.tryGetMap<String, dynamic>('m.mentions')
?.tryGetList<String>('user_ids')) ??
[];
final bodyMentionsUs = event.body.contains(myId) ||
event.body.contains('@${myId.split(':').first.replaceFirst('@', '')}');
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
}
// ── Build notification body ───────────────────────────────────────────
final rawBody = event.body;
final showPreview = ref.read(notifPreviewProvider);
final notifBody = showPreview
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}' : rawBody)
: 'Neue Nachricht';
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
showMessageNotification(
roomId: roomId,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
senderName: displayName,
body: notifBody,
);
});
}
void _watchBadge(Client client) {
client.onSync.stream.listen(
(_) {
try {
final total = client.rooms
.where((r) => r.membership == Membership.join)
.fold<int>(0, (sum, r) => sum + r.notificationCount);
_updateTaskbar(total);
_pingHeartbeat(); // keep PushService.kt from double-notifying
} catch (_) {}
},
onError: (_) {},
cancelOnError: false,
);
}
+6 -23
View File
@@ -3,17 +3,14 @@ import 'package:go_router/go_router.dart';
import 'package:pyramid/features/auth/login_notifier.dart';
import 'package:pyramid/features/auth/login_page.dart';
import 'package:pyramid/features/auth/server_page.dart';
import 'package:pyramid/features/chat/chat_page.dart';
import 'package:pyramid/features/rooms/rooms_page.dart';
import 'package:pyramid/features/settings/settings_page.dart';
import 'package:pyramid/layout/shell_page.dart';
import 'package:pyramid/layout/app_shell.dart';
final routerProvider = Provider<GoRouter>((ref) {
final isLoggedIn = ref.watch(isLoggedInProvider).valueOrNull ?? false;
final hasServer = ref.watch(homeserverProvider) != null;
return GoRouter(
initialLocation: '/rooms',
initialLocation: '/app',
redirect: (context, state) {
final loc = state.matchedLocation;
final onAuth = loc == '/login' || loc == '/server';
@@ -24,7 +21,7 @@ final routerProvider = Provider<GoRouter>((ref) {
return null;
}
if (isLoggedIn && onAuth) return '/rooms';
if (isLoggedIn && onAuth) return '/app';
return null;
},
routes: [
@@ -36,23 +33,9 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/login',
builder: (context, state) => const LoginPage(),
),
ShellRoute(
builder: (context, state, child) => ShellPage(child: child),
routes: [
GoRoute(
path: '/rooms',
builder: (context, state) => const RoomsPage(),
),
GoRoute(
path: '/chat/:roomId',
builder: (context, state) =>
ChatPage(roomId: state.pathParameters['roomId']!),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
],
GoRoute(
path: '/app',
builder: (context, state) => const AppShell(),
),
],
);
+136
View File
@@ -0,0 +1,136 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
class BoolPref extends StateNotifier<bool> {
final String _key;
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
_load(defaultValue);
}
Future<void> _load(bool def) async {
final p = await SharedPreferences.getInstance();
if (mounted) state = p.getBool(_key) ?? def;
}
Future<void> set(bool v) async {
state = v;
final p = await SharedPreferences.getInstance();
await p.setBool(_key, v);
}
void toggle() => set(!state);
}
// ── Generic SharedPreferences-backed String-Set notifier ───────────────────
class StringSetPref extends StateNotifier<Set<String>> {
final String _key;
StringSetPref(this._key) : super({}) {
_load();
}
Future<void> _load() async {
final p = await SharedPreferences.getInstance();
final list = p.getStringList(_key);
if (mounted && list != null) state = list.toSet();
}
Future<void> add(String v) async {
if (state.contains(v)) return;
state = {...state, v};
final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList());
}
Future<void> remove(String v) async {
if (!state.contains(v)) return;
state = {...state}..remove(v);
final p = await SharedPreferences.getInstance();
await p.setStringList(_key, state.toList());
}
}
// ── Notification preferences ─────────────────────────────────────────────────
final notifDesktopProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_desktop', true));
final notifSoundProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_sound', true));
final notifPreviewProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_preview', true));
final notifMentionOnlyDmProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_mention_dm', false));
// ── Privacy preferences ──────────────────────────────────────────────────────
final privacyPresenceProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
final privacyReadReceiptsProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
final privacyTypingProvider =
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
// ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({
bool? isDark,
int? accentIdx,
double? radius,
double? motion,
}) async {
final p = await SharedPreferences.getInstance();
if (isDark != null) await p.setBool('theme_dark', isDark);
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
if (radius != null) await p.setDouble('theme_radius', radius);
if (motion != null) await p.setDouble('theme_motion', motion);
}
Future<({bool isDark, int accentIdx, double radius, double motion})>
loadThemePrefs() async {
final p = await SharedPreferences.getInstance();
return (
isDark: p.getBool('theme_dark') ?? true,
accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0,
);
}
// ── Server banner persistence ─────────────────────────────────────────────────
// Stored as a single JSON map under 'server_banners': homeserverUrl → mxcUri
const _kServerBannersKey = 'server_banners';
Future<Map<String, String>> loadAllServerBanners() async {
final p = await SharedPreferences.getInstance();
final raw = p.getString(_kServerBannersKey);
if (raw == null) return {};
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return decoded.map((k, v) => MapEntry(k, v as String));
} catch (_) {
return {};
}
}
Future<void> saveServerBanner(String homeserverUrl, String? mxcUri) async {
final p = await SharedPreferences.getInstance();
final current = await loadAllServerBanners();
if (mxcUri == null || mxcUri.isEmpty) {
current.remove(homeserverUrl);
} else {
current[homeserverUrl] = mxcUri;
}
await p.setString(_kServerBannersKey, jsonEncode(current));
}
+143 -16
View File
@@ -1,24 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final colorSchemeProvider = Provider<ColorScheme>((ref) {
return ColorScheme.fromSeed(
seedColor: const Color(0xFF6B4FA0),
brightness: Brightness.light,
);
});
// Pyramid design tokens — matching mockup CSS variables exactly
class PyramidColors {
const PyramidColors._();
ThemeData buildTheme(ColorScheme colorScheme, Brightness brightness) {
final scheme = brightness == Brightness.dark
? ColorScheme.fromSeed(
seedColor: const Color(0xFF6B4FA0),
brightness: Brightness.dark,
)
: colorScheme;
// Dark theme
static const bg0 = Color(0xFF0A0A0C);
static const bg1 = Color(0xFF111114);
static const bg2 = Color(0xFF17171C);
static const bg3 = Color(0xFF1E1E25);
static const bgHover = Color(0xFF232330);
static const bgActive = Color(0xFF2A2A38);
static const border = Color(0xFF25252E);
static const borderStrong = Color(0xFF32323D);
static const fg = Color(0xFFECECF0);
static const fgMuted = Color(0xFFA4A4B0);
static const fgDim = Color(0xFF6F6F7D);
// Light theme
static const bg0Light = Color(0xFFF5F4F0);
static const bg1Light = Color(0xFFFFFFFF);
static const bg2Light = Color(0xFFFAF9F5);
static const bg3Light = Color(0xFFF0EEEA);
static const bgHoverLight = Color(0xFFE9E7E1);
static const bgActiveLight = Color(0xFFDFDCD3);
static const borderLight = Color(0xFFE5E2DC);
static const borderStrongLight = Color(0xFFD3CFC6);
static const fgLight = Color(0xFF1A1A1F);
static const fgMutedLight = Color(0xFF54545E);
static const fgDimLight = Color(0xFF8A8A92);
// Semantic colors (same in both themes)
static const danger = Color(0xFFE5484D);
static const success = Color(0xFF30A46C);
static const online = Color(0xFF4ADE80);
static const away = Color(0xFFFACC15);
static const busy = Color(0xFFF87171);
// Accent presets — default: amber hsl(42 95% 58%)
static const accentAmber = Color(0xFFF5A614);
static const accentCoral = Color(0xFFED6951);
static const accentMint = Color(0xFF3DB88A);
static const accentViolet = Color(0xFFB67EEA);
static const accentCobalt = Color(0xFF5BA3F5);
static const accentLime = Color(0xFF8EC63F);
static const accentFg = Color(0xFF0B0B0D);
}
class PyramidTheme extends InheritedWidget {
final bool isDark;
final Color accent;
final double radius;
final double density;
final double motion;
const PyramidTheme({
super.key,
required super.child,
this.isDark = true,
this.accent = PyramidColors.accentAmber,
this.radius = 12,
this.density = 1,
this.motion = 1,
});
static PyramidTheme of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
}
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
Color get danger => PyramidColors.danger;
Color get online => PyramidColors.online;
Color get away => PyramidColors.away;
Color get busy => PyramidColors.busy;
Color get accentSoft => accent.withAlpha(36); // ~14%
Color get accentGlow => accent.withAlpha(89); // ~35%
Color get accentFg => PyramidColors.accentFg;
double get rSm => radius * 0.6;
double get rBase => radius;
double get rLg => radius * 1.5;
double get rXl => radius * 2;
double get rowPadY => 10 * density;
double get rowPadX => 12 * density;
double get msgGap => 16 * density;
Duration get durationFast => const Duration(milliseconds: 150);
Duration get durationNormal => const Duration(milliseconds: 220);
Curve get springBounce => Curves.easeOutBack;
Curve get springSoft => Curves.easeOutCubic;
@override
bool updateShouldNotify(PyramidTheme oldWidget) =>
isDark != oldWidget.isDark ||
accent != oldWidget.accent ||
radius != oldWidget.radius ||
density != oldWidget.density ||
motion != oldWidget.motion;
}
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
ThemeData buildPyramidThemeData(bool isDark) {
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
final accent = PyramidColors.accentAmber;
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
dividerTheme: const DividerThemeData(space: 1, thickness: 1),
fontFamily: 'Inter',
brightness: isDark ? Brightness.dark : Brightness.light,
scaffoldBackgroundColor: bg1,
colorScheme: ColorScheme(
brightness: isDark ? Brightness.dark : Brightness.light,
primary: accent,
onPrimary: PyramidColors.accentFg,
secondary: accent,
onSecondary: PyramidColors.accentFg,
error: PyramidColors.danger,
onError: Colors.white,
surface: bg1,
onSurface: fg,
),
textTheme: TextTheme(
bodyMedium: _base(fg).copyWith(fontSize: 14),
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
),
dividerTheme: DividerThemeData(
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
thickness: 1,
space: 1,
),
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
);
}
+69 -27
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
@@ -55,7 +56,27 @@ bool _isNewer((int, int, int) remote, (int, int, int) local) {
return remote.$3 > local.$3;
}
String? _findDownloadUrl(List<dynamic> assets) {
// Rewrite the host in a Gitea asset URL to always use the configured base,
// so downloads work from outside the home network once nginx is set up.
String? _rewriteHost(String? url) {
if (url == null) return null;
return url.replaceFirst(RegExp(r'https?://[^/]+'), _kGiteaBase);
}
// Device ABIs in preference order (e.g. [arm64-v8a, armeabi-v7a, armeabi]),
// fetched once from the platform. Used to pick the matching split APK.
Future<List<String>> _deviceAbis() async {
if (!Platform.isAndroid) return const [];
try {
final abis = await const MethodChannel('chat.pyramid.pyramid/install')
.invokeListMethod<String>('getAbis');
return abis?.map((a) => a.toLowerCase()).toList() ?? const [];
} catch (_) {
return const [];
}
}
String? _findDownloadUrl(List<dynamic> assets, List<String> abis) {
if (assets.isEmpty) return null;
final list = assets.cast<Map<String, dynamic>>();
@@ -71,6 +92,12 @@ String? _findDownloadUrl(List<dynamic> assets) {
return pick((n) => n.endsWith('.exe')) ?? pick((n) => n.endsWith('.msix'));
}
if (Platform.isAndroid) {
// Prefer the split APK matching the device's best ABI, then weaker ABIs.
// Fall back to any APK so fat-APK releases (pre-split) keep working.
for (final abi in abis) {
final url = pick((n) => n.endsWith('.apk') && n.contains(abi));
if (url != null) return url;
}
return pick((n) => n.endsWith('.apk'));
}
return null;
@@ -78,38 +105,53 @@ String? _findDownloadUrl(List<dynamic> assets) {
// ─── Fetch ────────────────────────────────────────────────────────────────────
Future<UpdateInfo?> _fetchLatestRelease() async {
Future<UpdateInfo?> _fetchLatestRelease(List<String> abis) async {
// Fetch enough releases to find the highest version even if they were
// created out of chronological order (Gitea sorts by created_at, not semver).
final uri = Uri.parse(
'$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=20',
);
// Let network/timeout exceptions propagate so the provider shows an error
// state instead of silently appearing as "up to date".
final response = await http
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Gitea antwortete mit ${response.statusCode}');
}
try {
final uri = Uri.parse(
'$_kGiteaBase/api/v1/repos/$_kGiteaRepo/releases?limit=1',
);
final response = await http
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) return null;
final list = json.decode(response.body) as List<dynamic>;
if (list.isEmpty) return null;
final data = list.first as Map<String, dynamic>;
final tagName = data['tag_name'] as String? ?? '';
if (tagName.isEmpty) return null;
// Find the release with the highest version number, ignoring drafts and
// pre-releases. This is robust even when releases are created out of order.
UpdateInfo? best;
for (final item in list) {
final data = item as Map<String, dynamic>;
if (data['draft'] == true || data['prerelease'] == true) continue;
final remoteVer = _parseVersion(tagName);
if (remoteVer == null) return null;
final tagName = data['tag_name'] as String? ?? '';
if (tagName.isEmpty) continue;
final assets = (data['assets'] as List<dynamic>?) ?? [];
final downloadUrl = _findDownloadUrl(assets);
final remoteVer = _parseVersion(tagName);
if (remoteVer == null) continue;
return UpdateInfo(
tagName: tagName,
releaseName: data['name'] as String? ?? tagName,
body: data['body'] as String? ?? '',
htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName',
remoteVersion: remoteVer,
downloadUrl: downloadUrl,
);
if (best == null || _isNewer(remoteVer, best.remoteVersion)) {
final assets = (data['assets'] as List<dynamic>?) ?? [];
final downloadUrl = _rewriteHost(_findDownloadUrl(assets, abis));
best = UpdateInfo(
tagName: tagName,
releaseName: data['name'] as String? ?? tagName,
body: data['body'] as String? ?? '',
htmlUrl: '$_kGiteaBase/$_kGiteaRepo/releases/tag/$tagName',
remoteVersion: remoteVer,
downloadUrl: downloadUrl,
);
}
}
return best;
} catch (_) {
return null;
}
@@ -128,7 +170,7 @@ final updateInfoProvider = FutureProvider<UpdateInfo?>((ref) async {
await prefs.setInt(_kPrefLastCheck, DateTime.now().millisecondsSinceEpoch);
final remote = await _fetchLatestRelease();
final remote = await _fetchLatestRelease(await _deviceAbis());
if (remote == null) return null;
final info = await PackageInfo.fromPlatform();
+536
View File
@@ -0,0 +1,536 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/widgets/screen_share_picker.dart';
class PyramidVoipManager extends ChangeNotifier implements WebRTCDelegate {
static PyramidVoipManager? _instance;
static PyramidVoipManager get instance => _instance!;
final Client client;
late final VoIP voip;
CallSession? currentCall;
final rtc.RTCVideoRenderer localRenderer = rtc.RTCVideoRenderer();
final rtc.RTCVideoRenderer remoteRenderer = rtc.RTCVideoRenderer();
bool _renderersInitialized = false;
bool _isMicMuted = false;
bool _isCameraMuted = true;
bool _isScreensharing = false;
bool _isRemoteScreensharing = false;
bool _isDeafened = false;
bool _isUpdating = false;
String? _selectedSourceId;
String _currentQualityKey = 'hd';
bool get isMicMuted => _isMicMuted;
bool get isCameraMuted => _isCameraMuted;
bool get isScreensharing => _isScreensharing;
bool get isRemoteScreensharing => _isRemoteScreensharing;
bool get isDeafened => _isDeafened;
String get currentQualityKey => _currentQualityKey;
PyramidVoipManager(this.client) {
_instance = this;
voip = VoIP(client, this);
// Suppress speakerphone call — not supported on Windows/Desktop
try {
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
} catch (_) {}
_ensureRenderers();
}
Future<void> _ensureRenderers() async {
if (_renderersInitialized) return;
try {
await localRenderer.initialize();
await remoteRenderer.initialize();
_renderersInitialized = true;
} catch (e) {
print('[VOIP] Renderer init error: $e');
}
}
@override
MediaDevices get mediaDevices => MediaDevicesWrapper(rtc.navigator.mediaDevices, this);
@override
bool get isWeb => kIsWeb;
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration, [
Map<String, dynamic> constraints = const {},
]) async {
// Zusätzliche ICE-Server (STUN/TURN aus ice.json) zu den vom Homeserver
// gelieferten mergen — ohne STUN scheitern Calls außerhalb des LANs,
// weil der Server hinter CGNAT hängt.
try {
final extra = await IceServers.get();
final merged = Map<String, dynamic>.from(configuration);
final existing =
(merged['iceServers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
merged['iceServers'] = [...existing, ...extra];
configuration = merged;
} catch (e) {
print('[VOIP] ICE server merge error: $e');
}
return rtc.createPeerConnection(configuration, constraints);
}
@override
Future<void> handleNewCall(CallSession call) async {
print('[VOIP] Incoming call ${call.callId} (type: ${call.type})');
await _ensureRenderers();
currentCall = call;
_isCameraMuted = call.type == CallType.kVoice;
_setupCallListeners(call);
_updateInternalStates();
notifyListeners();
}
Timer? _renderRefreshTimer;
void _setupCallListeners(CallSession call) {
call.onCallStateChanged.stream.listen((state) {
print('[VOIP] State: $state');
_updateInternalStates();
if (state == CallState.kConnected) {
if (!_isCameraMuted) {
print('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false);
}
}
_updateRenderers();
});
_renderRefreshTimer?.cancel();
_renderRefreshTimer = Timer.periodic(
const Duration(milliseconds: 1000), // Faster polling to catch feeds
(_) => _updateRenderers(),
);
_updateRenderers();
}
void _updateInternalStates() {
if (currentCall == null) return;
final dynamic c = currentCall;
try {
_isMicMuted = c.isMicrophoneMuted == true;
_isCameraMuted = c.isLocalVideoMuted == true;
} catch (_) {}
notifyListeners();
}
Future<void> _updateRenderers() async {
if (currentCall == null || _isUpdating) return;
_isUpdating = true;
try {
await _ensureRenderers();
final dynamic c = currentCall;
rtc.MediaStream? remote;
rtc.MediaStream? local;
rtc.MediaStream? extract(dynamic obj) {
if (obj == null) return null;
if (obj is rtc.MediaStream) return obj;
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
return null;
}
final List<dynamic> feeds = [];
try {
final dynamic sdkFeeds = c.getFeeds();
if (sdkFeeds is Iterable) feeds.addAll(sdkFeeds.toList());
} catch (_) {}
bool remoteScreenFound = false;
bool localScreenFound = false;
for (final dynamic f in feeds) {
final s = extract(f);
if (s == null) continue;
bool isLocal = false;
try { isLocal = f.isLocal == true || f.userId == client.userID; } catch (_) {}
String purpose = 'm.usermedia';
try { purpose = f.purpose ?? 'm.usermedia'; } catch (_) {}
if (isLocal) {
if (local == null || purpose == 'm.screenshare') {
local = s;
if (purpose == 'm.screenshare') localScreenFound = true;
}
} else {
if (remote == null || purpose == 'm.screenshare') {
remote = s;
if (purpose == 'm.screenshare') remoteScreenFound = true;
}
}
}
_isScreensharing = localScreenFound;
_isRemoteScreensharing = remoteScreenFound;
if (remote == null) {
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
}
if (remote == null) {
try { remote = extract(c.remoteStream); } catch (_) {}
}
if (local == null) {
try { local = extract(c.localUserMediaStream); } catch (_) {}
}
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
if (remote != null && remote.id != _volumeAppliedStreamId) {
_volumeAppliedStreamId = remote.id;
_remoteAudioStream = remote;
applyOutputVolume();
}
// Final check for tracks before assigning
bool changed = false;
if (remote != null && remote.getVideoTracks().isNotEmpty) {
if (remoteRenderer.srcObject?.id != remote.id) {
print('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
remoteRenderer.srcObject = remote;
changed = true;
}
} else if (remoteRenderer.srcObject != null) {
remoteRenderer.srcObject = null;
changed = true;
}
if (local != null && local.getVideoTracks().isNotEmpty) {
if (localRenderer.srcObject?.id != local.id) {
print('[VOIP] Assigning Local Renderer: ${local.id} (${local.getVideoTracks().length} video tracks)');
localRenderer.srcObject = local;
changed = true;
}
} else if (localRenderer.srcObject != null) {
localRenderer.srcObject = null;
changed = true;
}
if (changed) notifyListeners();
} catch (e) {
print('[VOIP] Error in _updateRenderers: $e');
} finally {
_isUpdating = false;
}
}
@override
Future<void> handleCallEnded(CallSession session) async {
if (currentCall?.callId == session.callId) {
_renderRefreshTimer?.cancel();
_renderRefreshTimer = null;
localRenderer.srcObject = null;
remoteRenderer.srcObject = null;
_remoteAudioStream = null;
_volumeAppliedStreamId = null;
currentCall = null;
_isScreensharing = false;
_isRemoteScreensharing = false;
notifyListeners();
}
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
rtc.MediaStream? _remoteAudioStream;
String? _volumeAppliedStreamId;
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Audio-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future<void> applyOutputVolume([double? value]) async {
var v = value;
if (v == null) {
try {
final prefs = await SharedPreferences.getInstance();
v = prefs.getDouble('voice_output_volume') ?? 1.0;
} catch (_) {
v = 1.0;
}
}
final stream = _remoteAudioStream;
if (stream == null) return;
for (final track in stream.getAudioTracks()) {
try {
await rtc.Helper.setVolume(v.clamp(0.0, 2.0), track);
} catch (_) {}
}
}
@override
Future<void> handleMissedCall(CallSession session) async {
if (currentCall?.callId == session.callId) {
currentCall = null;
notifyListeners();
}
}
@override
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
@override
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
@override
bool get canHandleNewCall => currentCall == null;
@override
Future<void> playRingtone() async {}
@override
Future<void> stopRingtone() async {}
@override
EncryptionKeyProvider? get keyProvider => null;
@override
Future<void> registerListeners(CallSession session) async {}
Future<void> safeAction(Future<dynamic> Function() action) async {
try {
await action();
await Future.delayed(const Duration(milliseconds: 1000));
} catch (e) {
print('[VOIP] Action error: $e');
}
_updateInternalStates();
_updateRenderers();
}
Future<void> toggleMic() async {
if (currentCall == null) return;
_isMicMuted = !_isMicMuted;
notifyListeners();
try {
await currentCall!.setMicrophoneMuted(_isMicMuted);
} catch (e) {
_isMicMuted = !_isMicMuted;
notifyListeners();
}
}
Future<void> toggleCamera() async {
if (currentCall == null) return;
_isCameraMuted = !_isCameraMuted;
notifyListeners();
try {
await currentCall!.setLocalVideoMuted(_isCameraMuted);
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
await _updateRenderers();
}
} catch (e) {
print('[VOIP] toggleCamera error: $e');
_isCameraMuted = !_isCameraMuted;
notifyListeners();
}
_updateInternalStates();
await _updateRenderers();
}
Future<void> toggleScreenSharing([BuildContext? context]) async {
if (currentCall == null) return;
final call = currentCall!;
if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false));
return;
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && context != null) {
final selectedSource = await ScreenSharePicker.show(context);
if (selectedSource == null) return;
_selectedSourceId = selectedSource.id;
} else {
_selectedSourceId = null;
}
await safeAction(() async {
await call.setScreensharingEnabled(true);
});
}
Future<void> toggleDeafen() async {
_isDeafened = !_isDeafened;
notifyListeners();
}
Future<void> hangup() async {
if (currentCall == null) return;
await safeAction(() async {
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
}
Future<void> startCall(String roomId, {bool video = false}) async {
final room = client.getRoomById(roomId);
if (room == null) return;
await _ensureRenderers();
_isCameraMuted = !video;
print('[VOIP] Starting call in room $roomId (video=$video)');
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
currentCall = call;
_setupCallListeners(call);
if (video) {
await Future.delayed(const Duration(milliseconds: 800));
await call.setLocalVideoMuted(false);
await _updateRenderers();
}
_updateInternalStates();
notifyListeners();
}
Future<void> changeQuality(String key) async {
_currentQualityKey = key;
if (currentCall == null) {
notifyListeners();
return;
}
if (!_isCameraMuted) {
await toggleCamera();
await Future.delayed(const Duration(milliseconds: 300));
await toggleCamera();
}
if (_isScreensharing) {
final String? oldSource = _selectedSourceId;
await safeAction(() => currentCall!.setScreensharingEnabled(false));
await Future.delayed(const Duration(milliseconds: 300));
_selectedSourceId = oldSource;
await safeAction(() => currentCall!.setScreensharingEnabled(true));
}
notifyListeners();
}
}
class MediaDevicesWrapper extends MediaDevices {
final MediaDevices _delegate;
final PyramidVoipManager _manager;
MediaDevicesWrapper(this._delegate, this._manager);
@override
Future<rtc.MediaStream> getUserMedia(Map<String, dynamic> constraints) async {
final Map<String, dynamic> improvedConstraints = Map.from(constraints);
final String? sourceId = _manager._selectedSourceId;
if (sourceId != null) {
if (improvedConstraints['video'] is! Map) {
improvedConstraints['video'] = {};
}
final v = Map<String, dynamic>.from(improvedConstraints['video']);
v['mandatory'] ??= {};
v['mandatory']['chromeMediaSource'] = 'desktop';
v['mandatory']['chromeMediaSourceId'] = sourceId;
improvedConstraints['video'] = v;
}
// In den Einstellungen gewählte Geräte (voice_*_device Prefs) anwenden —
// nur wenn kein Screenshare-Source gesetzt ist (sonst nicht anfassen).
try {
final prefs = await SharedPreferences.getInstance();
final mic = prefs.getString('voice_mic_device');
final cam = prefs.getString('voice_camera_device');
final speaker = prefs.getString('voice_speaker_device');
if (mic != null &&
improvedConstraints['audio'] != null &&
improvedConstraints['audio'] != false) {
final a = improvedConstraints['audio'] is Map
? Map<String, dynamic>.from(improvedConstraints['audio'])
: <String, dynamic>{};
a['deviceId'] = mic;
a['optional'] = [
...?(a['optional'] as List?),
{'sourceId': mic},
];
improvedConstraints['audio'] = a;
}
if (cam != null &&
sourceId == null &&
improvedConstraints['video'] != null &&
improvedConstraints['video'] != false) {
final v = improvedConstraints['video'] is Map
? Map<String, dynamic>.from(improvedConstraints['video'])
: <String, dynamic>{};
v['deviceId'] = cam;
improvedConstraints['video'] = v;
}
if (speaker != null) {
rtc.Helper.selectAudioOutput(speaker).catchError((_) {});
}
} catch (_) {}
if (improvedConstraints['video'] != null && improvedConstraints['video'] != false) {
final String q = _manager.currentQualityKey;
int width = 1280;
int height = 720;
if (q == 'sd') { width = 640; height = 360; }
else if (q == 'fhd') { width = 1920; height = 1080; }
else if (q == '4k') { width = 3840; height = 2160; }
if (improvedConstraints['video'] is bool) {
improvedConstraints['video'] = {
'width': {'ideal': width},
'height': {'ideal': height},
'frameRate': {'ideal': 30},
};
} else if (improvedConstraints['video'] is Map) {
final v = Map<String, dynamic>.from(improvedConstraints['video']);
v['width'] = {'ideal': width};
v['height'] = {'ideal': height};
improvedConstraints['video'] = v;
}
}
return _delegate.getUserMedia(improvedConstraints);
}
@override
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = {
'video': {
'mandatory': {
'chromeMediaSource': 'desktop',
'chromeMediaSourceId': sourceId,
},
'optional': [],
},
'audio': false,
};
return _delegate.getDisplayMedia(desktopConstraints);
}
return _delegate.getDisplayMedia(constraints);
}
@override
Future<List<MediaDeviceInfo>> enumerateDevices() => _delegate.enumerateDevices();
@override
MediaTrackSupportedConstraints getSupportedConstraints() => _delegate.getSupportedConstraints();
@override
set ondevicechange(Function(dynamic event)? callback) => _delegate.ondevicechange = callback;
@override
Function(dynamic event)? get ondevicechange => _delegate.ondevicechange;
@override
Future<List<dynamic>> getSources() => _delegate.getSources();
@override
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) => _delegate.selectAudioOutput(options);
}