diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index eb6d204..f327c33 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
@@ -26,11 +27,15 @@
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules">
+
+ android:foregroundServiceType="microphone|mediaProjection" />
Unit>()
+
+ fun whenStarted(callback: (Boolean) -> Unit) {
+ waiters.add(callback)
+ }
+
+ private fun notifyWaiters(ok: Boolean) {
+ val list = waiters.toList()
+ waiters.clear()
+ list.forEach { it(ok) }
+ }
+
+ fun start(ctx: Context, title: String, screen: Boolean) {
+ val intent = Intent(ctx, CallService::class.java)
+ .putExtra(EXTRA_TITLE, title)
+ .putExtra(EXTRA_SCREEN, screen)
+ ContextCompat.startForegroundService(ctx, intent)
+ }
+
+ fun stop(ctx: Context) {
+ ctx.stopService(Intent(ctx, CallService::class.java))
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val title = intent?.getStringExtra(EXTRA_TITLE) ?: "Pyramid"
+ val screen = intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false
+ ensureChannel()
+
+ val launch = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ }
+ val open = launch?.let {
+ PendingIntent.getActivity(
+ this, 0, it,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ }
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(if (screen) "Bildschirm wird übertragen" else "Anruf läuft")
+ .setContentText("$title – tippen, um zurückzukehren")
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .setContentIntent(open)
+ .build()
+
+ // Typ „microphone“ nur mit Mikrofon-Berechtigung (sonst verweigert
+ // Android den GANZEN Dienst – auch den für die Übertragung).
+ val micAllowed = ContextCompat.checkSelfPermission(
+ this, Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ val candidates = mutableListOf()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val mic = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ val proj = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
+ if (screen) {
+ if (micAllowed) candidates.add(mic or proj)
+ candidates.add(proj)
+ } else if (micAllowed) {
+ candidates.add(mic)
+ }
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ candidates.add(if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else 0)
+ } else {
+ candidates.add(-1) // ohne Typ
+ }
+
+ var ok = false
+ for (type in candidates) {
+ try {
+ if (type == -1) startForeground(NOTIF_ID, notification)
+ else startForeground(NOTIF_ID, notification, type)
+ ok = true
+ break
+ } catch (e: Exception) {
+ // Nächste, schmalere Variante versuchen – lieber ohne Dienst
+ // weiter als die App abstürzen zu lassen.
+ Log.e(TAG, "startForeground fehlgeschlagen (screen=$screen, type=$type): ${e.message}")
+ }
+ }
+ if (!ok) stopSelf()
+ notifyWaiters(ok)
+ return START_NOT_STICKY
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val nm = getSystemService(NotificationManager::class.java)
+ if (nm.getNotificationChannel(CHANNEL_ID) != null) return
+ nm.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Laufende Anrufe", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Zeigt an, dass ein Anruf oder eine Bildschirmübertragung läuft."
+ setShowBadge(false)
+ }
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
index 200d4d9..c53e75b 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
FlutterEngineCache.getInstance().put("main", flutterEngine)
+ // Anruf-Vordergrunddienst (lib/core/call_foreground.dart ↔ CallService.kt).
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/call")
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "start" -> {
+ // Erst antworten, wenn der Dienst wirklich im Vordergrund
+ // läuft (oder nach 3 s aufgeben) – eine Übertragung vor
+ // dem startForeground würde die App abstürzen lassen.
+ val done = java.util.concurrent.atomic.AtomicBoolean(false)
+ val reply = { ok: Boolean ->
+ if (done.compareAndSet(false, true)) result.success(ok)
+ }
+ try {
+ CallService.whenStarted(reply)
+ CallService.start(
+ this,
+ call.argument("title") ?: "Pyramid",
+ call.argument("screen") ?: false,
+ )
+ android.os.Handler(mainLooper).postDelayed({ reply(false) }, 3000)
+ } catch (e: Exception) {
+ // z. B. Start aus dem Hintergrund verboten (Android 12+).
+ Log.e("PYRAMID-CALL", "CallService-Start abgelehnt: ${e.message}")
+ reply(false)
+ }
+ }
+ "stop" -> {
+ CallService.stop(this)
+ result.success(true)
+ }
+ else -> result.notImplemented()
+ }
+ }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result ->
when (call.method) {
diff --git a/integration_test/android_screen_capture_test.dart b/integration_test/android_screen_capture_test.dart
new file mode 100644
index 0000000..314c38d
--- /dev/null
+++ b/integration_test/android_screen_capture_test.dart
@@ -0,0 +1,52 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:pyramid/core/call_foreground.dart';
+
+/// Android 14+: Bildschirmübertragung darf die App nicht abstürzen lassen
+/// (Bernds Test 2026-10-07: „Teilen“ → App weg → Anruf verloren).
+///
+/// Ablauf wie im Anruf: Zustimmung → Anruf-Dienst mit mediaProjection →
+/// Mitschnitt. Der System-Dialog muss bestätigt werden (auf dem Emulator per
+/// adb, siehe docs/TESTS_UND_BENCHMARKS.md).
+///
+/// flutter test integration_test/android_screen_capture_test.dart -d emulator-5554
+///
+/// PYRAMID_SCREEN_NEGATIVE=1 (per --dart-define) überspringt den Dienst –
+/// Gegenprobe: dann stürzt die App wie früher ab.
+const _negative = bool.fromEnvironment('PYRAMID_SCREEN_NEGATIVE');
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets(
+ 'Bildschirmübertragung startet ohne Absturz',
+ (tester) async {
+ await tester.pumpWidget(const MaterialApp(home: Scaffold()));
+ if (_negative) {
+ expect(await Helper.requestCapturePermission(), isTrue);
+ } else {
+ expect(
+ await CallForeground.prepareScreenCapture('test'),
+ isTrue,
+ reason: 'Zustimmung oder Dienststart fehlgeschlagen',
+ );
+ }
+ final stream = await navigator.mediaDevices
+ .getDisplayMedia({'video': true, 'audio': false});
+ expect(stream.getVideoTracks(), isNotEmpty);
+ await Future.delayed(const Duration(seconds: 3));
+ for (final t in stream.getTracks()) {
+ await t.stop();
+ }
+ await stream.dispose();
+ await CallForeground.screenCaptureStopped('test');
+ await CallForeground.callEnded('test');
+ },
+ skip: !Platform.isAndroid,
+ timeout: const Timeout(Duration(minutes: 3)),
+ );
+}
diff --git a/lib/core/call_foreground.dart b/lib/core/call_foreground.dart
new file mode 100644
index 0000000..3fa212a
--- /dev/null
+++ b/lib/core/call_foreground.dart
@@ -0,0 +1,84 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
+
+/// Anruf-Vordergrunddienst auf Android (android/.../CallService.kt).
+///
+/// * Solange ein Anruf läuft, hält der Dienst das Mikrofon auch im
+/// Hintergrund aktiv (Android schaltet es sonst stumm) und zeigt eine
+/// „Anruf läuft“-Benachrichtigung, über die man zurück in die App kommt.
+/// * Für eine Bildschirmübertragung MUSS ab Android 14 vorher ein Dienst vom
+/// Typ mediaProjection laufen – und der darf erst NACH der Zustimmung des
+/// Nutzers starten. [prepareScreenCapture] erledigt beides in der richtigen
+/// Reihenfolge; flutter_webrtc benutzt danach die gespeicherte Zustimmung.
+///
+/// Beide Anrufwege (1:1 über Matrix, Sprachkanäle über LiveKit) melden sich
+/// mit eigenem Namen an; der Dienst endet erst, wenn keiner mehr läuft.
+/// Auf anderen Plattformen passiert nichts.
+class CallForeground {
+ CallForeground._();
+
+ static const _channel = MethodChannel('chat.pyramid.pyramid/call');
+ static final _owners = {};
+ static final _screenOwners = {};
+ static String _title = 'Pyramid';
+
+ static bool get _android => !kIsWeb && Platform.isAndroid;
+
+ /// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
+ static Future _apply() async {
+ if (!_android) return true;
+ try {
+ if (_owners.isEmpty) {
+ await _channel.invokeMethod('stop');
+ return true;
+ }
+ final ok = await _channel.invokeMethod('start', {
+ 'title': _title,
+ 'screen': _screenOwners.isNotEmpty,
+ });
+ return ok ?? false;
+ } catch (e) {
+ debugPrint('[CallForeground] $e');
+ return false;
+ }
+ }
+
+ /// Anruf von [owner] läuft (z. B. 'voip', 'livekit').
+ static Future callStarted(String owner, {required String title}) async {
+ _owners.add(owner);
+ _title = title;
+ await _apply();
+ }
+
+ /// Anruf von [owner] ist beendet.
+ static Future callEnded(String owner) async {
+ final changed = _owners.remove(owner) | _screenOwners.remove(owner);
+ if (changed) await _apply();
+ }
+
+ /// Vor einer Bildschirmübertragung auf Android: Zustimmung einholen, dann
+ /// den Dienst um „mediaProjection“ erweitern. false = Nutzer hat abgelehnt.
+ static Future prepareScreenCapture(String owner) async {
+ if (!_android) return true;
+ final granted = await rtc.Helper.requestCapturePermission();
+ if (!granted) return false;
+ _owners.add(owner);
+ _screenOwners.add(owner);
+ if (!await _apply()) {
+ // Ohne laufenden mediaProjection-Dienst würde der Mitschnitt die App
+ // abstürzen lassen – dann lieber gar nicht übertragen.
+ _screenOwners.remove(owner);
+ await _apply();
+ return false;
+ }
+ return true;
+ }
+
+ /// Übertragung von [owner] beendet – Dienst wieder ohne mediaProjection.
+ static Future screenCaptureStopped(String owner) async {
+ if (_screenOwners.remove(owner)) await _apply();
+ }
+}
diff --git a/lib/features/call_signaling/call_signaling_service.dart b/lib/features/call_signaling/call_signaling_service.dart
index 3d869a7..d703d13 100644
--- a/lib/features/call_signaling/call_signaling_service.dart
+++ b/lib/features/call_signaling/call_signaling_service.dart
@@ -49,6 +49,11 @@ abstract class CallSignalingService extends ChangeNotifier {
Future changeQuality(String key);
+ /// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
+ /// Auf dem PC ohne Bedeutung.
+ bool get isSpeakerOn => true;
+ Future toggleSpeaker() async {}
+
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
Future applyOutputVolume([double? value]);
diff --git a/lib/features/call_signaling/voip_manager.dart b/lib/features/call_signaling/voip_manager.dart
index 9fd5ed5..20804d1 100644
--- a/lib/features/call_signaling/voip_manager.dart
+++ b/lib/features/call_signaling/voip_manager.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
+import 'package:pyramid/core/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
@@ -35,6 +36,34 @@ class PyramidVoipManager extends CallSignalingService
bool _isDeafened = false;
bool _isUpdating = false;
String? _selectedSourceId;
+
+ // Handy: Lautsprecher statt Hörmuschel (wie Discord). Das SDK schaltet bei
+ // Sprachanrufen auf die Hörmuschel – hält man das Handy vor sich, hört man
+ // dann praktisch nichts (Bernds Test 2026-10-07).
+ bool _speakerOn = true;
+ bool get _isMobile =>
+ !kIsWeb &&
+ (defaultTargetPlatform == TargetPlatform.android ||
+ defaultTargetPlatform == TargetPlatform.iOS);
+
+ @override
+ bool get isSpeakerOn => _speakerOn;
+
+ @override
+ Future toggleSpeaker() async {
+ _speakerOn = !_speakerOn;
+ notifyListeners();
+ await _applySpeaker();
+ }
+
+ Future _applySpeaker() async {
+ if (!_isMobile) return;
+ try {
+ await rtc.Helper.setSpeakerphoneOn(_speakerOn);
+ } catch (e) {
+ debugPrint('[VOIP] Lautsprecher: $e');
+ }
+ }
String _currentQualityKey = 'hd';
@override
@@ -104,6 +133,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
currentCall = call;
_isCameraMuted = call.type == CallType.kVoice;
+ _speakerOn = true;
_setupCallListeners(call);
_updateInternalStates();
notifyListeners();
@@ -120,9 +150,19 @@ class PyramidVoipManager extends CallSignalingService
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
call.setLocalVideoMuted(false);
}
+ _applySpeaker();
+ // Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
+ CallForeground.callStarted('voip',
+ title: call.room.getLocalizedDisplayname());
}
+ if (state == CallState.kEnded) CallForeground.callEnded('voip');
_updateRenderers();
});
+ // Das SDK setzt bei jedem neuen eigenen Medienstrom die Hörmuschel –
+ // danach unsere Wahl erneut anwenden.
+ call.onCallEventChanged.stream.listen((event) {
+ if (event == CallStateChange.kFeedsChanged) _applySpeaker();
+ });
_renderRefreshTimer?.cancel();
_renderRefreshTimer = Timer.periodic(
@@ -259,6 +299,7 @@ class PyramidVoipManager extends CallSignalingService
_isRemoteScreensharing = false;
notifyListeners();
}
+ CallForeground.callEnded('voip');
}
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
@@ -292,6 +333,7 @@ class PyramidVoipManager extends CallSignalingService
currentCall = null;
notifyListeners();
}
+ CallForeground.callEnded('voip');
}
@override
@@ -361,6 +403,7 @@ class PyramidVoipManager extends CallSignalingService
if (_isScreensharing) {
await safeAction(() => call.setScreensharingEnabled(false));
+ await CallForeground.screenCaptureStopped('voip');
return;
}
@@ -390,6 +433,7 @@ class PyramidVoipManager extends CallSignalingService
await currentCall!.hangup(reason: CallErrorCode.userHangup);
currentCall = null;
});
+ await CallForeground.callEnded('voip');
}
@override
@@ -399,6 +443,7 @@ class PyramidVoipManager extends CallSignalingService
await _ensureRenderers();
_isCameraMuted = !video;
+ _speakerOn = true;
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
currentCall = call;
@@ -525,6 +570,13 @@ class MediaDevicesWrapper extends MediaDevices {
@override
Future getDisplayMedia(Map constraints) async {
+ // Android 14+: ohne laufenden mediaProjection-Dienst stürzt die App beim
+ // Mitschnitt ab – erst Zustimmung, dann Dienst, dann Mitschnitt.
+ if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
+ if (!await CallForeground.prepareScreenCapture('voip')) {
+ throw Exception('Bildschirmübertragung wurde nicht erlaubt');
+ }
+ }
final String? sourceId = _manager._selectedSourceId;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
final desktopConstraints = {
diff --git a/lib/features/call_ui/view/avatars_controls.dart b/lib/features/call_ui/view/avatars_controls.dart
index 64ba3d0..c998cd7 100644
--- a/lib/features/call_ui/view/avatars_controls.dart
+++ b/lib/features/call_ui/view/avatars_controls.dart
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
final VoidCallback onToggleScreen;
final VoidCallback onHangUp;
final PyramidTheme pt;
+ // Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
+ final bool isSpeakerOn;
+ final VoidCallback? onToggleSpeaker;
const _CallControls({
required this.isMuted,
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
required this.onToggleScreen,
required this.onHangUp,
required this.pt,
+ this.isSpeakerOn = true,
+ this.onToggleSpeaker,
});
@override
Widget build(BuildContext context) {
+ final platform = Theme.of(context).platform;
+ final showSpeaker = onToggleSpeaker != null &&
+ (platform == TargetPlatform.android || platform == TargetPlatform.iOS);
return SafeArea(
top: false,
child: Container(
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
children: [
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
const SizedBox(width: 16),
- _VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
+ if (showSpeaker)
+ _VoiceCtrlBtn(
+ icon: isSpeakerOn ? Icons.volume_up_rounded : Icons.phone_in_talk_rounded,
+ active: isSpeakerOn,
+ pt: pt,
+ onTap: onToggleSpeaker!,
+ )
+ else
+ _VoiceCtrlBtn(icon: isDeafened ? Icons.headset_off_rounded : Icons.headphones_rounded, danger: isDeafened, pt: pt, onTap: onToggleDeafen),
const SizedBox(width: 16),
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
const SizedBox(width: 16),
diff --git a/lib/features/call_ui/view/call_view.dart b/lib/features/call_ui/view/call_view.dart
index d4f06e2..43eae7f 100644
--- a/lib/features/call_ui/view/call_view.dart
+++ b/lib/features/call_ui/view/call_view.dart
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
final bool isScreenSharing;
final Function(Offset)? onQualityMenu;
final PyramidTheme pt;
+ final bool isSpeakerOn;
+ final VoidCallback? onToggleSpeaker;
const _UnifiedCallView({
required this.participants,
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
required this.isScreenSharing,
this.onQualityMenu,
required this.pt,
+ this.isSpeakerOn = true,
+ this.onToggleSpeaker,
});
@override
@@ -148,6 +152,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
onToggleScreen: widget.onToggleScreen,
onHangUp: widget.onHangUp,
pt: pt,
+ isSpeakerOn: widget.isSpeakerOn,
+ onToggleSpeaker: widget.onToggleSpeaker,
),
],
),
diff --git a/lib/features/call_ui/view/quality_menu.dart b/lib/features/call_ui/view/quality_menu.dart
index b0511da..09eb92b 100644
--- a/lib/features/call_ui/view/quality_menu.dart
+++ b/lib/features/call_ui/view/quality_menu.dart
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
if (call.isScreenSharing) {
await call.stopScreenShare();
+ } else if (Theme.of(context).platform == TargetPlatform.android ||
+ Theme.of(context).platform == TargetPlatform.iOS) {
+ // Handy: ganzer Bildschirm über die System-Zustimmung (kein
+ // Fenster-Auswähler wie am PC).
+ await call.toggleScreenShare();
} else {
final source = await ScreenSharePicker.show(context);
if (source != null) await call.startScreenShare(source.id);
diff --git a/lib/features/call_ui/view/voice_channel_core.dart b/lib/features/call_ui/view/voice_channel_core.dart
index 6c3e308..68a544a 100644
--- a/lib/features/call_ui/view/voice_channel_core.dart
+++ b/lib/features/call_ui/view/voice_channel_core.dart
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: voip.isScreensharing,
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
pt: pt,
+ isSpeakerOn: voip.isSpeakerOn,
+ onToggleSpeaker: voip.toggleSpeaker,
);
}
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
isScreenSharing: call.isScreenSharing,
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
pt: pt,
+ isSpeakerOn: call.isSpeakerOn,
+ onToggleSpeaker: call.toggleSpeaker,
);
}
diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart
index b718c8a..9033f2d 100644
--- a/lib/features/chat/view/chat_misc.dart
+++ b/lib/features/chat/view/chat_misc.dart
@@ -290,3 +290,68 @@ class _InvitePanelState extends ConsumerState<_InvitePanel> {
);
}
}
+
+/// Handy: Läuft ein Anruf, während ein Chat offen ist, zeigt diese Leiste den
+/// Weg zurück (am PC übernimmt das die Anrufleiste in der Seitenleiste).
+/// Vorher war im Chat nirgends zu sehen, dass/wie man zurück in den Anruf
+/// kommt (Bernd 2026-10-07).
+class _ActiveCallBar extends ConsumerWidget {
+ const _ActiveCallBar();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ if (!isMobileLayout(MediaQuery.sizeOf(context))) return const SizedBox.shrink();
+ final voip = ref.watch(callSignalingProvider);
+ final lk = ref.watch(voiceChannelProvider);
+ final voipCall = voip.currentCall;
+ final String? name;
+ if (voipCall != null && voipCall.state != CallState.kEnded) {
+ name = voipCall.room.getLocalizedDisplayname();
+ } else if (lk.isActive || lk.isConnecting) {
+ name = lk.displayName;
+ } else {
+ name = null;
+ }
+ final pt = PyramidTheme.of(context);
+ return AnimatedSize(
+ duration: motionDuration(context, 220),
+ curve: Curves.easeOutCubic,
+ child: name == null
+ ? const SizedBox(width: double.infinity)
+ : Material(
+ color: pt.online.withValues(alpha: 0.16),
+ child: InkWell(
+ onTap: () =>
+ ref.read(viewModeProvider.notifier).state = ViewMode.voice,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+ child: Row(
+ children: [
+ Icon(Icons.call_rounded, size: 18, color: pt.online),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ 'Anruf läuft · $name',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: pt.fg,
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ Text('Zurück',
+ style: TextStyle(
+ color: pt.online,
+ fontSize: 13.5,
+ fontWeight: FontWeight.w700)),
+ Icon(Icons.chevron_right_rounded, size: 20, color: pt.online),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart
index 4270c4b..874462b 100644
--- a/lib/features/chat/view/chat_view_core.dart
+++ b/lib/features/chat/view/chat_view_core.dart
@@ -520,6 +520,7 @@ class _ChatViewState extends ConsumerState {
setState(() => _pinnedOpen = !_pinnedOpen),
),
const _ConnectionBanner(),
+ const _ActiveCallBar(),
Expanded(
child: isInvite
? _InvitePanel(room: room!, pt: pt)
diff --git a/lib/features/voice_channel/livekit_call_manager.dart b/lib/features/voice_channel/livekit_call_manager.dart
index e40aebc..0e1769f 100644
--- a/lib/features/voice_channel/livekit_call_manager.dart
+++ b/lib/features/voice_channel/livekit_call_manager.dart
@@ -4,6 +4,7 @@ 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/call_foreground.dart';
import 'package:pyramid/core/ice_servers.dart';
import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/settings_prefs.dart';
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
void _onRoomChanged() => notifyListeners();
+ // Handy: Lautsprecher statt Hörmuschel (wie Discord).
+ bool _speakerOn = true;
+ bool get _isMobile =>
+ !kIsWeb &&
+ (defaultTargetPlatform == TargetPlatform.android ||
+ defaultTargetPlatform == TargetPlatform.iOS);
+
+ @override
+ bool get isSpeakerOn => _speakerOn;
+
+ @override
+ Future toggleSpeaker() async {
+ _speakerOn = !_speakerOn;
+ notifyListeners();
+ await _applySpeaker();
+ }
+
+ Future _applySpeaker() async {
+ if (!_isMobile) return;
+ try {
+ await rtc.Helper.setSpeakerphoneOn(_speakerOn);
+ } catch (e) {
+ debugPrint('[LiveKit] Lautsprecher: $e');
+ }
+ }
+
void _cleanUpAfterDisconnect() {
_callGeneration++;
+ CallForeground.callEnded('livekit');
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
@@ -254,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
_room = room;
isConnecting = false;
notifyListeners();
+ _speakerOn = true;
+ unawaited(_applySpeaker());
+ // Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
+ unawaited(CallForeground.callStarted('livekit', title: roomDisplayName));
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
await _writePresence(true);
@@ -269,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
+ CallForeground.screenCaptureStopped('livekit');
}
notifyListeners();
})
@@ -487,6 +520,11 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
+ // Android 14+: erst Zustimmung + mediaProjection-Dienst, sonst
+ // stürzt die App beim Mitschnitt ab.
+ if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
+ if (!await CallForeground.prepareScreenCapture('livekit')) return;
+ }
await _room?.localParticipant?.setScreenShareEnabled(true);
isScreenSharing = true;
notifyListeners();
@@ -587,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
}
}
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
+ await CallForeground.screenCaptureStopped('livekit');
isScreenSharing = false;
_currentScreenShareSourceId = null;
_screenShareTrack = null;
@@ -597,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
Future hangUp() async {
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
_callGeneration++;
+ unawaited(CallForeground.callEnded('livekit'));
_stopStatsLogging();
_presenceHeartbeat?.cancel();
_presenceHeartbeat = null;
diff --git a/lib/features/voice_channel/voice_channel_service.dart b/lib/features/voice_channel/voice_channel_service.dart
index c19091b..c90a42b 100644
--- a/lib/features/voice_channel/voice_channel_service.dart
+++ b/lib/features/voice_channel/voice_channel_service.dart
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
Future hangUp();
Future toggleMute();
+ /// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
+ /// Auf dem PC ohne Bedeutung.
+ bool get isSpeakerOn => true;
+ Future toggleSpeaker() async {}
+
Future toggleDeafen();
Future toggleCamera();
Future toggleScreenShare();
diff --git a/lib/widgets/mic_level_test.dart b/lib/widgets/mic_level_test.dart
new file mode 100644
index 0000000..8c018ba
--- /dev/null
+++ b/lib/widgets/mic_level_test.dart
@@ -0,0 +1,214 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:pyramid/core/settings_prefs.dart';
+import 'package:pyramid/core/theme.dart';
+
+/// Mikrofontest mit Pegelanzeige (Einstellungen → Sprache & Video).
+///
+/// Nimmt über DENSELBEN Weg wie ein Anruf auf (gewähltes Gerät aus den
+/// Einstellungen, WebRTC) und zeigt den Pegel live. Anlass: Bernds Test
+/// 2026-10-07 – das Handy hörte nichts vom PC, weil (mit Voicemeeter und
+/// vielen virtuellen Geräten) das Standardgerät nur Stille lieferte. Ohne
+/// Pegelanzeige war das nicht zu erkennen.
+///
+/// Technik: lokale Verbindung zu sich selbst (zwei PeerConnections im selben
+/// Prozess) – erst dann startet libwebrtc die Aufnahme und liefert den Pegel
+/// in den Statistiken. Es wird nichts verschickt oder gespeichert. Nach
+/// 30 Sekunden endet der Test von selbst.
+class MicLevelTest extends StatefulWidget {
+ const MicLevelTest({super.key, required this.pt, this.enabled = true});
+
+ final PyramidTheme pt;
+
+ /// false z. B. während eines Anrufs (zwei Aufnahmen gleichzeitig vermeiden).
+ final bool enabled;
+
+ @override
+ State createState() => _MicLevelTestState();
+}
+
+class _MicLevelTestState extends State {
+ MediaStream? _stream;
+ RTCPeerConnection? _a;
+ RTCPeerConnection? _b;
+ Timer? _poll;
+ Timer? _autoStop;
+ bool _running = false;
+ bool _starting = false;
+ double _level = 0; // 0..1 (geglättet)
+ double _peak = 0;
+ String? _error;
+
+ @override
+ void dispose() {
+ _stop(notify: false);
+ super.dispose();
+ }
+
+ Future _start() async {
+ setState(() {
+ _starting = true;
+ _error = null;
+ _peak = 0;
+ _level = 0;
+ });
+ try {
+ final mic = (await VoicePrefs.loadDevices()).mic;
+ final audio = mic == null
+ ? true
+ : {
+ 'deviceId': mic,
+ 'optional': [
+ {'sourceId': mic},
+ ],
+ };
+ final stream = await navigator.mediaDevices
+ .getUserMedia({'audio': audio, 'video': false});
+ final a = await createPeerConnection({});
+ final b = await createPeerConnection({});
+ a.onIceCandidate = (c) => b.addCandidate(c);
+ b.onIceCandidate = (c) => a.addCandidate(c);
+ await a.addTrack(stream.getAudioTracks().first, stream);
+ final offer = await a.createOffer();
+ await a.setLocalDescription(offer);
+ await b.setRemoteDescription(offer);
+ final answer = await b.createAnswer();
+ await b.setLocalDescription(answer);
+ await a.setRemoteDescription(answer);
+ if (!mounted) {
+ await _release(stream, a, b);
+ return;
+ }
+ _stream = stream;
+ _a = a;
+ _b = b;
+ _poll = Timer.periodic(const Duration(milliseconds: 120), (_) => _read());
+ _autoStop = Timer(const Duration(seconds: 30), _stop);
+ setState(() {
+ _running = true;
+ _starting = false;
+ });
+ } catch (e) {
+ if (mounted) {
+ setState(() {
+ _starting = false;
+ _error = 'Mikrofon ließ sich nicht öffnen: ${e.toString().split('\n').first}';
+ });
+ }
+ }
+ }
+
+ Future _read() async {
+ final a = _a;
+ if (a == null) return;
+ try {
+ for (final r in await a.getStats()) {
+ if (r.type != 'media-source') continue;
+ final raw = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
+ // Wurzel-Skala: leise Sprache soll sichtbar ausschlagen.
+ final shown = math.sqrt(raw.clamp(0.0, 1.0));
+ if (!mounted) return;
+ setState(() {
+ _level = _level * 0.4 + shown * 0.6;
+ _peak = math.max(_peak, shown);
+ });
+ }
+ } catch (_) {}
+ }
+
+ Future _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
+ for (final t in s?.getTracks() ?? []) {
+ try {
+ await t.stop();
+ } catch (_) {}
+ }
+ try {
+ await s?.dispose();
+ } catch (_) {}
+ try {
+ await a?.close();
+ } catch (_) {}
+ try {
+ await b?.close();
+ } catch (_) {}
+ }
+
+ void _stop({bool notify = true}) {
+ _poll?.cancel();
+ _autoStop?.cancel();
+ _poll = null;
+ _autoStop = null;
+ final s = _stream, a = _a, b = _b;
+ _stream = null;
+ _a = null;
+ _b = null;
+ _release(s, a, b);
+ if (notify && mounted) {
+ setState(() {
+ _running = false;
+ _level = 0;
+ });
+ } else {
+ _running = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final pt = widget.pt;
+ final silent = _running && _peak < 0.02;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.mic_rounded, size: 15, color: pt.fgMuted),
+ const SizedBox(width: 8),
+ Text('Mikrofontest',
+ style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
+ const Spacer(),
+ TextButton(
+ onPressed: !widget.enabled || _starting
+ ? null
+ : (_running ? _stop : _start),
+ child: Text(_running ? 'Beenden' : 'Testen'),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: Container(
+ height: 8,
+ color: pt.bg3,
+ alignment: Alignment.centerLeft,
+ child: AnimatedFractionallySizedBox(
+ duration: const Duration(milliseconds: 110),
+ widthFactor: _level.clamp(0.0, 1.0),
+ heightFactor: 1,
+ child: Container(color: _level > 0.85 ? pt.danger : pt.accent),
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ !widget.enabled
+ ? 'Während eines Anrufs nicht möglich.'
+ : _error ??
+ (_running
+ ? (silent
+ ? 'Kein Ton zu hören – sprich etwas. Bleibt der Balken leer, oben ein anderes Mikrofon wählen.'
+ : 'Sprich etwas – der Balken zeigt, was die anderen hören würden.')
+ : 'Prüft das oben gewählte Mikrofon. Es wird nichts gesendet oder gespeichert.'),
+ style: TextStyle(
+ color: _error != null || silent ? pt.away : pt.fgDim,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/widgets/settings/settings_voice.dart b/lib/widgets/settings/settings_voice.dart
index 258ecd8..eb8554f 100644
--- a/lib/widgets/settings/settings_voice.dart
+++ b/lib/widgets/settings/settings_voice.dart
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
]),
const SizedBox(height: 16),
_SettingsGroup(title: 'Pegel', pt: pt, children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ child: Builder(builder: (context) {
+ final voip = ref.watch(callSignalingProvider);
+ final lk = ref.watch(voiceChannelProvider);
+ final inCall = voip.currentCall != null || lk.isActive;
+ // Schlüssel je gewähltem Mikrofon: Gerätewechsel beendet
+ // einen laufenden Test (sonst misst er das alte Gerät).
+ return MicLevelTest(
+ key: ValueKey('mictest:$_selectedMic'),
+ pt: pt,
+ enabled: !inCall,
+ );
+ }),
+ ),
+ _Divider(pt: pt),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Column(
diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart
index 45393e6..84e4771 100644
--- a/lib/widgets/settings_modal.dart
+++ b/lib/widgets/settings_modal.dart
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pyramid/widgets/banner_crop_dialog.dart';
import 'package:pyramid/widgets/hover_region.dart';
+import 'package:pyramid/widgets/mic_level_test.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:pyramid/widgets/pyramid_loader.dart';