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:
+94
-16
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user