Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064d730f33 | ||
|
|
8074928505 | ||
|
|
5485b6d449 | ||
|
|
775cf71b77 | ||
|
|
441cc6ef54 | ||
|
|
c817de924a | ||
|
|
a6461fe23c |
+52
@@ -13,6 +13,58 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-10-08 – Bernds Anruf-/Chat-Test mit PC-Testinstanz → Test-Version 0.1.39
|
||||||
|
|
||||||
|
**Erledigt (je ein Commit, alles gepusht):**
|
||||||
|
- **PC-Testinstanz** (a6461fe + außerhalb des Repos): `C:\Users\nordm\pyramid-testinstanz\`
|
||||||
|
(Release-Build, `Pyramid-Testinstanz.cmd`, Desktop-Verknüpfung), Profil
|
||||||
|
`profile2` = pyramidtest2, Fenstertitel „Pyramid - TESTINSTANZ“,
|
||||||
|
Doppelstart-Schutz, übernimmt bereitgelegte Updates (`app-neu`) beim
|
||||||
|
nächsten Start. DM pyramidtest2 ↔ Bernd angelegt.
|
||||||
|
- **Haken live / Einladungen / Trenner** (c817de9): ✓✓ zeichnet nach jedem
|
||||||
|
Sync neu, wenn sich Lesepositionen ändern (Raum-Update kommt im SDK VOR der
|
||||||
|
Bestätigung); Einladung im Chat annehmen, Verlauf baut sich bei
|
||||||
|
invite→join neu auf; Trenner blendet 6 s nach Erreichen der neuesten
|
||||||
|
Nachrichten aus, auch ohne Scrollen. Live-Test mit beiden Testkonten
|
||||||
|
(`integration_test/live_receipts_invite_test.dart`, Gegenprobe rot ohne
|
||||||
|
Fix), in `scripts/test.ps1 -Ui`.
|
||||||
|
- **Anrufe** (441cc6e): Bildschirm teilen auf Android 14+ stürzte ab
|
||||||
|
(kein mediaProjection-Vordergrunddienst; Manifest verwies auf nicht
|
||||||
|
existierende flutter_webrtc-Klasse) → `CallService.kt` +
|
||||||
|
`lib/core/call_foreground.dart` (Zustimmung → Dienst, wartet auf echten
|
||||||
|
startForeground → Mitschnitt). Emulator-Test Android 16
|
||||||
|
(`integration_test/android_screen_capture_test.dart`): mit Fix läuft der
|
||||||
|
Mitschnitt, Gegenprobe wirft die erwartete SecurityException. Dienst hält
|
||||||
|
während jedes Anrufs das Mikrofon im Hintergrund aktiv + „Anruf läuft“-
|
||||||
|
Benachrichtigung. Handy: Lautsprecher statt Hörmuschel (SDK schaltet
|
||||||
|
Sprachanrufe auf Hörmuschel) + Umschalter. „Anruf läuft · Zurück“-Leiste im
|
||||||
|
Chat. LiveKit-Teilen am Handy ohne PC-Quellen-Auswähler. Mikrofontest mit
|
||||||
|
Pegel in den Einstellungen.
|
||||||
|
- **Version 0.1.39+40** (775cf71) als Gitea-Vorabversion.
|
||||||
|
|
||||||
|
**Befund PC-Mikrofon:** Lokale Pegelmessung über WebRTC lieferte auf ALLEN
|
||||||
|
10 Aufnahmegeräten (Voicemeeter-Ausgänge, Steam, Razer Barracuda X) nur
|
||||||
|
digitale Stille (audioLevel 1/32767). Entweder war das Headset aus, oder das
|
||||||
|
Mikrofon läuft über Voicemeeter und Pyramid nutzt das falsche Gerät – das
|
||||||
|
erklärt „Handy hört den PC nicht“. Bernd prüft mit dem neuen Mikrofontest.
|
||||||
|
|
||||||
|
**Offen/Nächster Schritt:** Bernd testet 0.1.39 + Testinstanz: Teilen am
|
||||||
|
Handy (System-Dialog „Gesamten Bildschirm teilen“), Ton beide Richtungen
|
||||||
|
(PC-Mikrofon im Mikrofontest wählen), Teilen vom PC, Verbindungsdauer
|
||||||
|
außerhalb des Heimnetzes (ICE/TURN ist ok: TURN-Daten werden alle 4 h
|
||||||
|
erneuert; Verzögerung vermutlich SDK-Kandidaten-Bündelung 2 s + verschlüsselte
|
||||||
|
Signalisierung – erst messen, dann ändern).
|
||||||
|
|
||||||
|
**Stolperfallen:** (1) Git-Bash schreibt `/sdcard/...` in Windows-Pfade um →
|
||||||
|
`MSYS_NO_PATHCONV=1` für adb. (2) Android 16 schlägt beim Teilen „eine App“
|
||||||
|
vor und wechselt in diese App – das sah wie „App verkleinert sich“ aus.
|
||||||
|
(3) Ein Dienst mit Typ microphone ohne RECORD_AUDIO-Berechtigung wird KOMPLETT
|
||||||
|
verweigert – Typen nur mit Berechtigung anfordern. (4) Python-urllib wird von
|
||||||
|
Cloudflare ohne User-Agent abgewiesen (Aufräumskript). (5) Testkonten:
|
||||||
|
Schlüssel in test-accounts.txt heißen user1/pass1/user2/pass2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
## 2026-10-07 (Teil 4) – Handy-UI-Feinschliff, Animationen, Lesebestätigung, LiveKit ohne Secret → Test-Version 0.1.38
|
||||||
|
|
||||||
**Erledigt (je ein Commit, alles gepusht):**
|
**Erledigt (je ein Commit, alles gepusht):**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
@@ -26,11 +27,15 @@
|
|||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:fullBackupContent="false"
|
android:fullBackupContent="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||||
|
<!-- Laufender Anruf: hält das Mikrofon im Hintergrund am Leben und
|
||||||
|
ist Pflicht für Bildschirmübertragung ab Android 14 (CallService.kt).
|
||||||
|
Der frühere Eintrag com.cloudwebrtc...FlutterForegroundService
|
||||||
|
verwies auf eine Klasse, die flutter_webrtc 1.x nicht mehr hat. -->
|
||||||
<service
|
<service
|
||||||
android:name="com.cloudwebrtc.webrtc.FlutterForegroundService"
|
android:name=".CallService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaProjection" />
|
android:foregroundServiceType="microphone|mediaProjection" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package chat.pyramid.pyramid
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vordergrunddienst für laufende Anrufe (1:1 und Sprachkanäle).
|
||||||
|
*
|
||||||
|
* Warum: Android schaltet das Mikrofon einer App im Hintergrund stumm, und
|
||||||
|
* seit Android 14 stürzt jede Bildschirmübertragung ab, wenn vorher kein
|
||||||
|
* Dienst vom Typ „mediaProjection“ läuft (flutter_webrtc startet keinen).
|
||||||
|
* Genau das war Bernds Fehlerbild 2026-10-07: Teilen gestartet → App weg →
|
||||||
|
* beim Öffnen kein Anruf mehr.
|
||||||
|
*
|
||||||
|
* Typen: immer „microphone“ (ab Android 11), bei laufender Übertragung
|
||||||
|
* zusätzlich „mediaProjection“. Der Dienst darf mit mediaProjection erst
|
||||||
|
* NACH der Zustimmung des Nutzers gestartet werden – das stellt die
|
||||||
|
* Dart-Seite sicher (lib/core/call_foreground.dart).
|
||||||
|
*/
|
||||||
|
class CallService : Service() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PYRAMID-CALL"
|
||||||
|
private const val CHANNEL_ID = "pyramid_call"
|
||||||
|
private const val NOTIF_ID = 7301
|
||||||
|
private const val EXTRA_TITLE = "title"
|
||||||
|
private const val EXTRA_SCREEN = "screen"
|
||||||
|
|
||||||
|
// Wer auf den tatsächlichen startForeground wartet (Hauptthread).
|
||||||
|
// Die Bildschirmübertragung darf erst danach beginnen.
|
||||||
|
private val waiters = mutableListOf<(Boolean) -> 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<Int>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,39 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
// Cache so ReplyReceiver can call back into Dart without starting a new engine.
|
||||||
FlutterEngineCache.getInstance().put("main", flutterEngine)
|
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<String>("title") ?: "Pyramid",
|
||||||
|
call.argument<Boolean>("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)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:webrtc_interface/webrtc_interface.dart';
|
||||||
|
|
||||||
|
/// Echter 1:1-Anruf zwischen der App (pyramidtest1, Testprofil) und einem
|
||||||
|
/// zweiten Matrix-Client im selben Prozess (pyramidtest2, kurzlebiges Gerät,
|
||||||
|
/// wird am Ende abgemeldet). Bernds Fehlerbericht 2026-10-08: Ton kam nicht
|
||||||
|
/// an, Bildschirm teilen ging weder am PC noch am Handy.
|
||||||
|
///
|
||||||
|
/// Geprüft wird: Verbindung steht, Ton fließt in BEIDE Richtungen (Bytes),
|
||||||
|
/// Bildschirm der Gegenseite wird in der App angezeigt, und die App kann
|
||||||
|
/// über den echten Quellen-Auswähler ihren Bildschirm teilen, der bei der
|
||||||
|
/// Gegenseite ankommt. (Ob das Mikrofon hörbaren Ton liefert, kann ein
|
||||||
|
/// Automat nicht prüfen – dafür gibt es den Mikrofontest und den Sprech-Ring.)
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_call_test.dart -d windows
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = _loadAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'1:1-Anruf: Ton beide Richtungen, Bildschirm teilen beide Richtungen',
|
||||||
|
(tester) async {
|
||||||
|
Logs().level = Level.verbose;
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final me = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(me.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
|
||||||
|
final partner = (await tester.runAsync(
|
||||||
|
() => _Partner.start(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
))!;
|
||||||
|
String? roomId;
|
||||||
|
addTearDown(() => tester.runAsync(() async {
|
||||||
|
await partner.dispose(roomId);
|
||||||
|
final r = roomId == null ? null : me.getRoomById(roomId);
|
||||||
|
await r?.leave().catchError((_) {});
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Eigener, unverschlüsselter Testraum (nur für diesen Lauf).
|
||||||
|
roomId = await tester.runAsync(() => partner.client.createRoom(
|
||||||
|
invite: [me.userID!],
|
||||||
|
preset: CreateRoomPreset.privateChat,
|
||||||
|
name: 'Anruftest (automatisch)',
|
||||||
|
));
|
||||||
|
_log('Raum angelegt: $roomId');
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => me.getRoomById(roomId!)?.membership == Membership.invite,
|
||||||
|
what: 'Einladung kommt bei der App an',
|
||||||
|
);
|
||||||
|
await tester.runAsync(() => me.getRoomById(roomId!)!.join());
|
||||||
|
_log('App ist beigetreten');
|
||||||
|
// Die Gegenseite ist Ersteller und damit im Raum; ihr Mitglieder-Cache
|
||||||
|
// wird vom SDK nur bei Bedarf geladen – kurz warten genügt.
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
final voip = container.read(callSignalingProvider);
|
||||||
|
|
||||||
|
// ═══ Teil 1: Gegenseite ruft an, App nimmt über den echten Knopf an ═══
|
||||||
|
// (prüft die Kandidaten-Pufferung der App mit einem ungepatchten
|
||||||
|
// Anrufer – genau Bernds Fall „lange/kein Verbindungsaufbau“)
|
||||||
|
_log('Teil 1: Gegenseite ruft an');
|
||||||
|
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
|
||||||
|
partner.client.getRoomById(roomId!)!,
|
||||||
|
CallType.kVoice,
|
||||||
|
)));
|
||||||
|
final answerBtn = find.byWidgetPredicate(
|
||||||
|
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty,
|
||||||
|
what: 'Annehmen-Knopf erscheint');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 2)); // klingeln lassen
|
||||||
|
await tester.tap(answerBtn.first);
|
||||||
|
await _waitConnected(tester, voip, partner);
|
||||||
|
|
||||||
|
await _checkAudio(tester, voip, partner);
|
||||||
|
|
||||||
|
// Gegenseite teilt Bildschirm → App zeigt ihn
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(true)),
|
||||||
|
);
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.isRemoteScreensharing &&
|
||||||
|
(voip.remoteRenderer.srcObject?.getVideoTracks().isNotEmpty ?? false),
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der Gegenseite erscheint in der App',
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final appVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
_log('BILD bei der App (von der Gegenseite): $appVideoIn');
|
||||||
|
expect(appVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der Gegenseite kommt nicht an');
|
||||||
|
await tester.runAsync(
|
||||||
|
() => partner.run(() => partner.delegate.call!.setScreensharingEnabled(false)),
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => !voip.isRemoteScreensharing,
|
||||||
|
what: 'Bildschirm der Gegenseite verschwindet wieder');
|
||||||
|
|
||||||
|
// App teilt ihren Bildschirm über den echten Auswähler
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() => find.byIcon(Icons.screen_share_rounded).evaluate().isNotEmpty,
|
||||||
|
what: 'Teilen-Knopf in der Anrufleiste',
|
||||||
|
);
|
||||||
|
await tester.tap(find.byIcon(Icons.screen_share_rounded).first);
|
||||||
|
final tile = find.byWidgetPredicate(
|
||||||
|
(w) => w.runtimeType.toString() == '_SourceTile',
|
||||||
|
);
|
||||||
|
await _pumpUntil(tester, () => tile.evaluate().isNotEmpty,
|
||||||
|
timeout: const Duration(seconds: 20), what: 'Quellen-Auswähler');
|
||||||
|
await tester.tap(tile.first);
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() {
|
||||||
|
final s = partner.delegate.call?.remoteScreenSharingStream?.stream;
|
||||||
|
return voip.isScreensharing &&
|
||||||
|
s != null &&
|
||||||
|
s.getVideoTracks().isNotEmpty;
|
||||||
|
},
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
what: 'Bildschirm der App kommt bei der Gegenseite an',
|
||||||
|
);
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
final partnerVideoIn = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'video'),
|
||||||
|
))!;
|
||||||
|
_log('BILD bei der Gegenseite (von der App): $partnerVideoIn');
|
||||||
|
expect(partnerVideoIn.received, greaterThan(0),
|
||||||
|
reason: 'Bildschirm der App kommt nicht an');
|
||||||
|
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 1)');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
|
||||||
|
// ═══ Teil 2: App ruft an, Gegenseite nimmt an ═══
|
||||||
|
_log('Teil 2: App ruft an');
|
||||||
|
partner.fixCandidates = true; // Gegenseite wie eine gepatchte Pyramid
|
||||||
|
await tester.runAsync(() => voip.startCall(roomId!, video: false));
|
||||||
|
await _waitConnected(tester, voip, partner);
|
||||||
|
await _checkAudio(tester, voip, partner);
|
||||||
|
await tester.runAsync(() => voip.hangup());
|
||||||
|
await _pumpUntil(tester, () => voip.currentCall == null,
|
||||||
|
what: 'aufgelegt (Teil 2)');
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _waitConnected(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
_Partner partner,
|
||||||
|
) async {
|
||||||
|
final t0 = DateTime.now();
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
() =>
|
||||||
|
voip.currentCall?.state == CallState.kConnected &&
|
||||||
|
partner.delegate.call?.state == CallState.kConnected,
|
||||||
|
timeout: const Duration(seconds: 60),
|
||||||
|
what: 'Anruf verbunden',
|
||||||
|
every5s: () {
|
||||||
|
final a = voip.currentCall;
|
||||||
|
final b = partner.delegate.call;
|
||||||
|
_log('App: ${a?.state} ice=${a?.pc?.iceConnectionState} | '
|
||||||
|
'Gegenseite: ${b?.state} ice=${b?.pc?.iceConnectionState}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_log('verbunden nach ${DateTime.now().difference(t0).inMilliseconds} ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAudio(
|
||||||
|
WidgetTester tester,
|
||||||
|
CallSignalingService voip,
|
||||||
|
_Partner partner,
|
||||||
|
) async {
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
final appAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(voip.currentCall!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
final partnerAudio = (await tester.runAsync(
|
||||||
|
() => _rtp(partner.delegate.call!.pc!, 'audio'),
|
||||||
|
))!;
|
||||||
|
_log('TON App: $appAudio | Gegenseite: $partnerAudio');
|
||||||
|
expect(appAudio.sent, greaterThan(0), reason: 'App sendet keinen Ton');
|
||||||
|
expect(appAudio.received, greaterThan(0), reason: 'App empfängt keinen Ton');
|
||||||
|
expect(partnerAudio.received, greaterThan(0),
|
||||||
|
reason: 'Gegenseite empfängt keinen Ton der App');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Rtp {
|
||||||
|
_Rtp(this.sent, this.received);
|
||||||
|
final num sent;
|
||||||
|
final num received;
|
||||||
|
@override
|
||||||
|
String toString() => 'gesendet=$sent B, empfangen=$received B';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_Rtp> _rtp(rtc.RTCPeerConnection pc, String kind) async {
|
||||||
|
num sent = 0;
|
||||||
|
num received = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != kind) continue;
|
||||||
|
if (r.type == 'outbound-rtp') sent += (r.values['bytesSent'] as num?) ?? 0;
|
||||||
|
if (r.type == 'inbound-rtp') received += (r.values['bytesReceived'] as num?) ?? 0;
|
||||||
|
}
|
||||||
|
return _Rtp(sent, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zweiter Teilnehmer: eigener Matrix-Client + VoIP im selben Prozess.
|
||||||
|
class _Partner {
|
||||||
|
_Partner(this.client, this.delegate, this.voip, this.zone) {
|
||||||
|
_sub = client.onCallEvents.stream.listen((events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (list != null) _buffer.addAll(list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||||
|
final pc = delegate.call?.pc;
|
||||||
|
if (!fixCandidates || pc == null) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
for (final c in List.of(_buffer)) {
|
||||||
|
final cand = c['candidate'] as String?;
|
||||||
|
if (cand == null || !_applied.add(cand)) continue;
|
||||||
|
await pc.addCandidate(RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final Client client;
|
||||||
|
final _PartnerDelegate delegate;
|
||||||
|
final VoIP voip;
|
||||||
|
final Zone zone;
|
||||||
|
|
||||||
|
/// Aktion der Gegenseite in ihrer Zone ausführen (Fehler über Completer).
|
||||||
|
Future<T> run<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
zone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gegenseite reicht Kandidaten nach wie die gepatchte App.
|
||||||
|
bool fixCandidates = false;
|
||||||
|
final _buffer = <Map<String, dynamic>>[];
|
||||||
|
final _applied = <String>{};
|
||||||
|
StreamSubscription? _sub;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
static Future<_Partner> start(String user, String pass) async {
|
||||||
|
final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||||
|
final client = Client(
|
||||||
|
'pyramid-call-partner',
|
||||||
|
database: await MatrixSdkDatabase.init('pyramid-call-partner', database: db),
|
||||||
|
);
|
||||||
|
await client.init(waitForFirstSync: false, waitUntilLoadCompletedLoaded: false);
|
||||||
|
await client.checkHomeserver(Uri.parse('https://steggi-matrix.work'));
|
||||||
|
await client.login(
|
||||||
|
LoginType.mLoginPassword,
|
||||||
|
identifier: AuthenticationUserIdentifier(user: user),
|
||||||
|
password: pass,
|
||||||
|
initialDeviceDisplayName: 'Pyramid-Anruftest (kurzlebig)',
|
||||||
|
);
|
||||||
|
final delegate = _PartnerDelegate();
|
||||||
|
// Eigene Zone: Fehler der Gegenseite sichtbar protokollieren statt sie
|
||||||
|
// im Testrahmen zu verlieren.
|
||||||
|
final zone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, z, e, s) =>
|
||||||
|
_log('Gegenseite (abgefangen): ${e.toString().split('\n').first}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final voip = zone.run(() => VoIP(client, delegate));
|
||||||
|
return _Partner(client, delegate, voip, zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose(String? roomId) async {
|
||||||
|
_timer?.cancel();
|
||||||
|
await _sub?.cancel();
|
||||||
|
try {
|
||||||
|
await delegate.call?.hangup(reason: CallErrorCode.userHangup);
|
||||||
|
} catch (_) {}
|
||||||
|
if (roomId != null) {
|
||||||
|
try {
|
||||||
|
await client.leaveRoom(roomId);
|
||||||
|
await client.forgetRoom(roomId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
client.backgroundSync = false;
|
||||||
|
await client.logout();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PartnerDelegate implements WebRTCDelegate {
|
||||||
|
CallSession? call;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MediaDevices get mediaDevices => _PartnerMedia(rtc.navigator.mediaDevices);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RTCPeerConnection> createPeerConnection(
|
||||||
|
Map<String, dynamic> configuration, [
|
||||||
|
Map<String, dynamic> constraints = const {},
|
||||||
|
]) =>
|
||||||
|
rtc.createPeerConnection(configuration, constraints);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleNewCall(CallSession session) async {
|
||||||
|
call = session;
|
||||||
|
if (session.direction == CallDirection.kIncoming) {
|
||||||
|
// Wie ein Mensch nach kurzem Klingeln annehmen. Sofortiges Annehmen
|
||||||
|
// trifft eine Lücke im SDK: Kandidaten, die WÄHREND answer()
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird (CallSession.onCandidatesReceived vs. answer()).
|
||||||
|
unawaited(Future.delayed(const Duration(seconds: 2), () async {
|
||||||
|
try {
|
||||||
|
await session.answer();
|
||||||
|
} catch (e) {
|
||||||
|
_log('Gegenseite: Annehmen fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> handleCallEnded(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleMissedCall(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleNewGroupCall(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> handleGroupCallEnded(GroupCallSession groupCall) async {}
|
||||||
|
@override
|
||||||
|
Future<void> playRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> stopRingtone() async {}
|
||||||
|
@override
|
||||||
|
Future<void> registerListeners(CallSession session) async {}
|
||||||
|
@override
|
||||||
|
bool get isWeb => false;
|
||||||
|
@override
|
||||||
|
bool get canHandleNewCall => true;
|
||||||
|
@override
|
||||||
|
EncryptionKeyProvider? get keyProvider => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie die App am PC: Bildschirm über eine Desktop-Quelle (erster Monitor).
|
||||||
|
class _PartnerMedia extends MediaDevices {
|
||||||
|
_PartnerMedia(this._d);
|
||||||
|
final MediaDevices _d;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getUserMedia(Map<String, dynamic> c) => _d.getUserMedia(c);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MediaStream> getDisplayMedia(Map<String, dynamic> c) async {
|
||||||
|
final sources = await rtc.desktopCapturer
|
||||||
|
.getSources(types: [rtc.SourceType.Screen]);
|
||||||
|
return _d.getDisplayMedia({
|
||||||
|
'video': {
|
||||||
|
'mandatory': {
|
||||||
|
'chromeMediaSource': 'desktop',
|
||||||
|
'chromeMediaSourceId': sources.first.id,
|
||||||
|
},
|
||||||
|
'optional': [],
|
||||||
|
},
|
||||||
|
'audio': false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MediaDeviceInfo>> enumerateDevices() => _d.enumerateDevices();
|
||||||
|
@override
|
||||||
|
MediaTrackSupportedConstraints getSupportedConstraints() =>
|
||||||
|
_d.getSupportedConstraints();
|
||||||
|
@override
|
||||||
|
set ondevicechange(Function(dynamic event)? callback) =>
|
||||||
|
_d.ondevicechange = callback;
|
||||||
|
@override
|
||||||
|
Function(dynamic event)? get ondevicechange => _d.ondevicechange;
|
||||||
|
@override
|
||||||
|
// ignore: deprecated_member_use
|
||||||
|
Future<List<dynamic>> getSources() => _d.getSources();
|
||||||
|
@override
|
||||||
|
Future<MediaDeviceInfo> selectAudioOutput([AudioOutputOptions? options]) =>
|
||||||
|
_d.selectAudioOutput(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String m) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('SCHRITT ${DateTime.now().toIso8601String().substring(11, 19)} $m');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
bool Function() condition, {
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
String what = '',
|
||||||
|
void Function()? onTimeout,
|
||||||
|
void Function()? every5s,
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
var next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (condition()) return;
|
||||||
|
if (every5s != null && DateTime.now().isAfter(next)) {
|
||||||
|
every5s();
|
||||||
|
next = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_log('ZEITÜBERSCHREITUNG: $what');
|
||||||
|
onTimeout?.call();
|
||||||
|
fail('Zeitüberschreitung: $what');
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
|
/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07):
|
||||||
|
/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu
|
||||||
|
/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu).
|
||||||
|
/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf
|
||||||
|
/// sofort da (vorher leerer Chat bis zum App-Neustart).
|
||||||
|
///
|
||||||
|
/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1).
|
||||||
|
/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät
|
||||||
|
/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds
|
||||||
|
/// Testinstanz mit profile2) bleiben unberührt.
|
||||||
|
///
|
||||||
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
|
/// flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
|
const _hs = 'https://steggi-matrix.work';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
final accounts = _loadAccounts();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Lesebestätigung live + Einladung im Chat annehmen',
|
||||||
|
(tester) async {
|
||||||
|
tester.view
|
||||||
|
..physicalSize = const Size(1080, 2340)
|
||||||
|
..devicePixelRatio = 2.625;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
final partner = await tester.runAsync(
|
||||||
|
() => _RestUser.login(accounts!['user2']!, accounts['pass2']!),
|
||||||
|
);
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.logout()));
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await _pumpUntil(tester, find.byType(MaterialApp));
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
|
final client = (await tester.runAsync(
|
||||||
|
() => container.read(matrixClientProvider.future),
|
||||||
|
))!;
|
||||||
|
expect(client.userID, '@pyramidtest1:steggi-matrix.work');
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
|
// ── 1. Lesebestätigung live ──
|
||||||
|
final dm = client.rooms.firstWhere(
|
||||||
|
(r) =>
|
||||||
|
r.membership == Membership.join &&
|
||||||
|
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work',
|
||||||
|
);
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = dm.id;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 4));
|
||||||
|
|
||||||
|
final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final eventId = await tester.runAsync(() => dm.sendTextEvent(text));
|
||||||
|
await _pumpUntil(tester, find.textContaining(text));
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 3));
|
||||||
|
expect(
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!));
|
||||||
|
// Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.byIcon(Icons.done_rounded),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
final msgY = tester.getCenter(find.textContaining(text).first).dy;
|
||||||
|
final readIcons = find.byIcon(Icons.done_all_rounded).evaluate();
|
||||||
|
expect(
|
||||||
|
readIcons.any((e) {
|
||||||
|
final box = e.renderObject as RenderBox;
|
||||||
|
final y = box.localToGlobal(box.size.center(Offset.zero)).dy;
|
||||||
|
return y > msgY && y - msgY < 120;
|
||||||
|
}),
|
||||||
|
isTrue,
|
||||||
|
reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 2. Einladung im Chat annehmen ──
|
||||||
|
final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final inviteRoomId = await tester.runAsync(() async {
|
||||||
|
final id = await partner!.createRoom('Einladungstest');
|
||||||
|
await partner.sendText(id, welcome);
|
||||||
|
await partner.invite(id, client.userID!);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!)));
|
||||||
|
|
||||||
|
// Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung).
|
||||||
|
final end = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite &&
|
||||||
|
DateTime.now().isBefore(end)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Annehmen'));
|
||||||
|
await tester.tap(find.text('Annehmen'));
|
||||||
|
// Ohne App-Neustart: Verlauf mit der Begrüßung erscheint.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.textContaining(welcome),
|
||||||
|
timeout: const Duration(seconds: 40),
|
||||||
|
);
|
||||||
|
expect(find.text('Annehmen'), findsNothing);
|
||||||
|
|
||||||
|
// ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ──
|
||||||
|
// (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten
|
||||||
|
// desselben Absenders hintereinander, gelesen bis zur ersten.)
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
final ids = (await tester.runAsync(() async => [
|
||||||
|
await partner!.sendText(inviteRoomId, 'Gruppe 1'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 2'),
|
||||||
|
await partner.sendText(inviteRoomId, 'Gruppe 3'),
|
||||||
|
]))!;
|
||||||
|
final room = client.getRoomById(inviteRoomId)!;
|
||||||
|
// Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden.
|
||||||
|
final tEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0]));
|
||||||
|
final fEnd = DateTime.now().add(const Duration(seconds: 30));
|
||||||
|
while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) {
|
||||||
|
await _pumpFor(tester, const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an');
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = inviteRoomId;
|
||||||
|
await _pumpUntil(tester, find.text('Neue Nachrichten'),
|
||||||
|
timeout: const Duration(seconds: 20));
|
||||||
|
// Nach der Verweildauer (6 s) + Ausblenden muss er weg sein.
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.text('Neue Nachrichten'),
|
||||||
|
gone: true,
|
||||||
|
timeout: const Duration(seconds: 15),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aufräumen: Testraum verlassen + vergessen.
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final r = client.getRoomById(inviteRoomId);
|
||||||
|
await r?.leave();
|
||||||
|
await r?.forget().catchError((_) {});
|
||||||
|
});
|
||||||
|
container.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
},
|
||||||
|
skip: profile == null || accounts == null,
|
||||||
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _loadAccounts() {
|
||||||
|
final home = Platform.environment['USERPROFILE'] ?? '';
|
||||||
|
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
|
||||||
|
if (!file.existsSync()) return null;
|
||||||
|
final map = <String, String>{};
|
||||||
|
for (final line in file.readAsLinesSync()) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty || t.startsWith('#')) continue;
|
||||||
|
final i = t.indexOf('=');
|
||||||
|
if (i > 0) map[t.substring(0, i)] = t.substring(i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät).
|
||||||
|
class _RestUser {
|
||||||
|
_RestUser(this._token);
|
||||||
|
final String _token;
|
||||||
|
int _txn = 0;
|
||||||
|
|
||||||
|
static Future<_RestUser> login(String user, String pass) async {
|
||||||
|
final res = await http.post(
|
||||||
|
Uri.parse('$_hs/_matrix/client/v3/login'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'type': 'm.login.password',
|
||||||
|
'identifier': {'type': 'm.id.user', 'user': user},
|
||||||
|
'password': pass,
|
||||||
|
'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (res.statusCode != 200) throw StateError('Login ${res.statusCode}');
|
||||||
|
return _RestUser(jsonDecode(res.body)['access_token'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _call(String method, String path,
|
||||||
|
[Object? body]) async {
|
||||||
|
final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path'))
|
||||||
|
..headers['Authorization'] = 'Bearer $_token'
|
||||||
|
..headers['Content-Type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body ?? {});
|
||||||
|
final res = await http.Response.fromStream(await req.send());
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('$method $path → ${res.statusCode}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enc(String s) => Uri.encodeComponent(s);
|
||||||
|
|
||||||
|
Future<void> readReceipt(String roomId, String eventId) => _call(
|
||||||
|
'POST',
|
||||||
|
'/rooms/${_enc(roomId)}/read_markers',
|
||||||
|
{'m.fully_read': eventId, 'm.read': eventId},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> createRoom(String name) async =>
|
||||||
|
(await _call('POST', '/createRoom', {
|
||||||
|
'name': name,
|
||||||
|
'preset': 'private_chat',
|
||||||
|
}))['room_id'] as String;
|
||||||
|
|
||||||
|
Future<String> sendText(String roomId, String text) async =>
|
||||||
|
(await _call(
|
||||||
|
'PUT',
|
||||||
|
'/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}',
|
||||||
|
{'msgtype': 'm.text', 'body': text},
|
||||||
|
))['event_id'] as String;
|
||||||
|
|
||||||
|
Future<void> invite(String roomId, String userId) =>
|
||||||
|
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
|
||||||
|
|
||||||
|
Future<void> leave(String roomId) async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/leave');
|
||||||
|
await _call('POST', '/rooms/${_enc(roomId)}/forget');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _call('POST', '/logout');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFor(WidgetTester tester, Duration d) async {
|
||||||
|
final end = DateTime.now().add(d);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
bool gone = false,
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
final found = finder.evaluate().isNotEmpty;
|
||||||
|
if (found != gone) return;
|
||||||
|
}
|
||||||
|
fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}');
|
||||||
|
}
|
||||||
@@ -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 = <String>{};
|
||||||
|
static final _screenOwners = <String>{};
|
||||||
|
static String _title = 'Pyramid';
|
||||||
|
|
||||||
|
static bool get _android => !kIsWeb && Platform.isAndroid;
|
||||||
|
|
||||||
|
/// true = Dienst läuft (bzw. wurde beendet) wie gewünscht.
|
||||||
|
static Future<bool> _apply() async {
|
||||||
|
if (!_android) return true;
|
||||||
|
try {
|
||||||
|
if (_owners.isEmpty) {
|
||||||
|
await _channel.invokeMethod('stop');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final ok = await _channel.invokeMethod<bool>('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<void> callStarted(String owner, {required String title}) async {
|
||||||
|
_owners.add(owner);
|
||||||
|
_title = title;
|
||||||
|
await _apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anruf von [owner] ist beendet.
|
||||||
|
static Future<void> 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<bool> 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<void> screenCaptureStopped(String owner) async {
|
||||||
|
if (_screenOwners.remove(owner)) await _apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,16 @@ abstract class CallSignalingService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> changeQuality(String key);
|
Future<void> changeQuality(String key);
|
||||||
|
|
||||||
|
/// Spricht gerade jemand? (Pegel aus den WebRTC-Statistiken, für den
|
||||||
|
/// grünen Sprech-Ring.)
|
||||||
|
bool get isLocalSpeaking => false;
|
||||||
|
bool get isRemoteSpeaking => false;
|
||||||
|
|
||||||
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
|
/// Auf dem PC ohne Bedeutung.
|
||||||
|
bool get isSpeakerOn => true;
|
||||||
|
Future<void> toggleSpeaker() async {}
|
||||||
|
|
||||||
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
/// Wendet die in den Einstellungen gewählte Ausgabelautstärke auf den
|
||||||
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
/// Remote-Stream an. Ohne [value] wird der Pref-Wert gelesen.
|
||||||
Future<void> applyOutputVolume([double? value]);
|
Future<void> applyOutputVolume([double? value]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:webrtc_interface/webrtc_interface.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/ice_servers.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
@@ -19,6 +20,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
final Client client;
|
final Client client;
|
||||||
late final VoIP voip;
|
late final VoIP voip;
|
||||||
|
late final Zone _sdkZone;
|
||||||
@override
|
@override
|
||||||
CallSession? currentCall;
|
CallSession? currentCall;
|
||||||
|
|
||||||
@@ -35,6 +37,34 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
bool _isDeafened = false;
|
bool _isDeafened = false;
|
||||||
bool _isUpdating = false;
|
bool _isUpdating = false;
|
||||||
String? _selectedSourceId;
|
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<void> toggleSpeaker() async {
|
||||||
|
_speakerOn = !_speakerOn;
|
||||||
|
notifyListeners();
|
||||||
|
await _applySpeaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpeaker() async {
|
||||||
|
if (!_isMobile) return;
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Lautsprecher: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
String _currentQualityKey = 'hd';
|
String _currentQualityKey = 'hd';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -52,7 +82,21 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
PyramidVoipManager(this.client) {
|
PyramidVoipManager(this.client) {
|
||||||
_instance = this;
|
_instance = this;
|
||||||
voip = VoIP(client, this);
|
// VOR dem SDK anmelden: Kandidaten der Gegenseite puffern (siehe
|
||||||
|
// _bufferRemoteCandidates).
|
||||||
|
_callEventSub = client.onCallEvents.stream.listen(_bufferRemoteCandidates);
|
||||||
|
// Alles, was das Anruf-SDK anstößt, läuft in einer eigenen Zone: Fehler,
|
||||||
|
// die SDK/WebRTC-Plugin ins Leere werfen (z. B. Lautsprecher-Umschalten
|
||||||
|
// unter Windows, getRemoteDescription wirft statt null), werden
|
||||||
|
// protokolliert statt als „unbehandelt“ durchzuschlagen.
|
||||||
|
_sdkZone = Zone.current.fork(
|
||||||
|
specification: ZoneSpecification(
|
||||||
|
handleUncaughtError: (self, parent, zone, error, stack) {
|
||||||
|
debugPrint('[VOIP] SDK-Fehler (abgefangen): $error');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
voip = _sdkZone.run(() => VoIP(client, this));
|
||||||
// Suppress speakerphone call — not supported on Windows/Desktop
|
// Suppress speakerphone call — not supported on Windows/Desktop
|
||||||
try {
|
try {
|
||||||
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
(voip as dynamic).onConfigSpeakerphone = (bool _) async {};
|
||||||
@@ -60,6 +104,29 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_ensureRenderers();
|
_ensureRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt [f] in der SDK-Zone aus. Ergebnis UND Fehler kommen über einen
|
||||||
|
/// Completer von außen zurück – Fehler können Zonengrenzen sonst nicht
|
||||||
|
/// überqueren, und der Aufrufer würde ewig warten.
|
||||||
|
Future<T> _inSdkZone<T>(Future<T> Function() f) {
|
||||||
|
final done = Completer<T>();
|
||||||
|
_sdkZone.run(() async {
|
||||||
|
try {
|
||||||
|
done.complete(await f());
|
||||||
|
} catch (e, s) {
|
||||||
|
done.completeError(e, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return done.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_callEventSub?.cancel();
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_renderRefreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ensureRenderers() async {
|
Future<void> _ensureRenderers() async {
|
||||||
if (_renderersInitialized) return;
|
if (_renderersInitialized) return;
|
||||||
try {
|
try {
|
||||||
@@ -104,13 +171,86 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
_isCameraMuted = call.type == CallType.kVoice;
|
_isCameraMuted = call.type == CallType.kVoice;
|
||||||
|
_speakerOn = true;
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Netzwerk-Kandidaten der Gegenseite puffern ───────────────────────────
|
||||||
|
// Lücke im matrix-SDK (6.2): Kommen die Kandidaten des Anrufers an, bevor
|
||||||
|
// die Einladung fertig verarbeitet ist, verwirft das SDK sie endgültig
|
||||||
|
// („we do not have the call“); Kandidaten, die WÄHREND des Annehmens
|
||||||
|
// eintreffen, landen in einer Warteschlange, die nie mehr abgearbeitet
|
||||||
|
// wird. Dann kennt man keinen Weg zur Gegenseite – im Heimnetz rettet das
|
||||||
|
// oft noch ein direkter Prüfversuch der anderen Seite, über Mobilfunk/NAT
|
||||||
|
// dauert der Aufbau ewig oder scheitert (Bernd 2026-10-08, im Anruftest
|
||||||
|
// nachgestellt). Deshalb: alle Kandidaten mitschreiben und direkt an die
|
||||||
|
// Verbindung geben, sobald sie die Gegenseite kennt. Doppelte ignoriert
|
||||||
|
// WebRTC.
|
||||||
|
StreamSubscription<List<BasicEventWithSender>>? _callEventSub;
|
||||||
|
final _candidateBuffer = <String, List<Map<String, dynamic>>>{};
|
||||||
|
final _appliedCandidates = <String>{};
|
||||||
|
|
||||||
|
void _bufferRemoteCandidates(List<BasicEventWithSender> events) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.CallCandidates || e.senderId == client.userID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final callId = e.content.tryGet<String>('call_id');
|
||||||
|
final list = e.content.tryGetList<Map<String, dynamic>>('candidates');
|
||||||
|
if (callId == null || list == null) continue;
|
||||||
|
final party = e.content.tryGet<String>('party_id');
|
||||||
|
(_candidateBuffer[callId] ??= []).addAll(
|
||||||
|
list.map((c) => {...c, '_party': party}),
|
||||||
|
);
|
||||||
|
// Nur die letzten Anrufe behalten.
|
||||||
|
while (_candidateBuffer.length > 8) {
|
||||||
|
_candidateBuffer.remove(_candidateBuffer.keys.first);
|
||||||
|
}
|
||||||
|
final call = currentCall;
|
||||||
|
if (call != null && call.callId == callId) {
|
||||||
|
unawaited(_applyBufferedCandidates(call));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyBufferedCandidates(CallSession call) async {
|
||||||
|
final pc = call.pc;
|
||||||
|
final list = _candidateBuffer[call.callId];
|
||||||
|
if (pc == null || list == null || list.isEmpty) return;
|
||||||
|
try {
|
||||||
|
if (await pc.getRemoteDescription() == null) return;
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final c in List.of(list)) {
|
||||||
|
final cand = c['candidate'];
|
||||||
|
if (cand is! String || cand.isEmpty) continue;
|
||||||
|
final party = c['_party'];
|
||||||
|
final remoteParty = call.remotePartyId;
|
||||||
|
if (remoteParty != null && party != null && party != remoteParty) continue;
|
||||||
|
if (!_appliedCandidates.add('${call.callId}|$cand')) continue;
|
||||||
|
try {
|
||||||
|
await pc.addCandidate(rtc.RTCIceCandidate(
|
||||||
|
cand,
|
||||||
|
c['sdpMid']?.toString() ?? '',
|
||||||
|
(c['sdpMLineIndex'] as num?)?.round() ?? 0,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[VOIP] Kandidat nachreichen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _renderRefreshTimer;
|
Timer? _renderRefreshTimer;
|
||||||
|
String? _listenedCallId;
|
||||||
void _setupCallListeners(CallSession call) {
|
void _setupCallListeners(CallSession call) {
|
||||||
|
// Ausgehende Anrufe meldet das SDK zusätzlich über handleNewCall –
|
||||||
|
// Listener nur einmal je Anruf anhängen.
|
||||||
|
if (_listenedCallId == call.callId) return;
|
||||||
|
_listenedCallId = call.callId;
|
||||||
call.onCallStateChanged.stream.listen((state) {
|
call.onCallStateChanged.stream.listen((state) {
|
||||||
debugPrint('[VOIP] State: $state');
|
debugPrint('[VOIP] State: $state');
|
||||||
_updateInternalStates();
|
_updateInternalStates();
|
||||||
@@ -120,14 +260,36 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
debugPrint('[VOIP] Ensuring camera is unmuted on connect');
|
||||||
call.setLocalVideoMuted(false);
|
call.setLocalVideoMuted(false);
|
||||||
}
|
}
|
||||||
|
_applySpeaker();
|
||||||
|
_startLevelPolling();
|
||||||
|
// Android: Mikrofon im Hintergrund + „Anruf läuft“-Benachrichtigung.
|
||||||
|
CallForeground.callStarted('voip',
|
||||||
|
title: call.room.getLocalizedDisplayname());
|
||||||
|
}
|
||||||
|
if (state == CallState.kEnded) {
|
||||||
|
_stopLevelPolling();
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
_updateRenderers();
|
_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?.cancel();
|
||||||
_renderRefreshTimer = Timer.periodic(
|
_renderRefreshTimer = Timer.periodic(
|
||||||
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
const Duration(milliseconds: 1000), // Faster polling to catch feeds
|
||||||
(_) => _updateRenderers(),
|
(_) {
|
||||||
|
_updateRenderers();
|
||||||
|
// Gepufferte Kandidaten nachreichen, sobald die Verbindung die
|
||||||
|
// Gegenseite kennt (nach Annehmen bzw. Antwort).
|
||||||
|
final c = currentCall;
|
||||||
|
if (c != null && c.state != CallState.kConnected) {
|
||||||
|
_applyBufferedCandidates(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
_updateRenderers();
|
_updateRenderers();
|
||||||
@@ -144,96 +306,63 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateRenderers() async {
|
Future<void> _updateRenderers() async {
|
||||||
if (currentCall == null || _isUpdating) return;
|
final call = currentCall;
|
||||||
|
if (call == null || _isUpdating) return;
|
||||||
_isUpdating = true;
|
_isUpdating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
final dynamic c = currentCall;
|
// Typisierte SDK-Zugriffe. Früher lief das über ein dynamisches
|
||||||
rtc.MediaStream? remote;
|
// `getFeeds()` – das gibt es im SDK gar nicht; der Fehler wurde
|
||||||
rtc.MediaStream? local;
|
// geschluckt, und Bildschirmübertragungen wurden weder beim Empfänger
|
||||||
|
// angezeigt noch beim Sender als aktiv erkannt (Bernd 2026-10-08).
|
||||||
rtc.MediaStream? extract(dynamic obj) {
|
rtc.MediaStream? video(WrappedMediaStream? w, {bool respectMute = false}) {
|
||||||
if (obj == null) return null;
|
final s = w?.stream;
|
||||||
if (obj is rtc.MediaStream) return obj;
|
// stopped: Gegenseite hat die Übertragung beendet (das SDK lässt den
|
||||||
try { return obj.stream as rtc.MediaStream?; } catch (_) {}
|
// Strom stehen und markiert ihn nur).
|
||||||
return null;
|
if (w == null || w.stopped || s == null || s.getVideoTracks().isEmpty) {
|
||||||
}
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (respectMute && w.videoMuted) return null;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
_isScreensharing = localScreenFound;
|
final remoteScreen = video(call.remoteScreenSharingStream);
|
||||||
_isRemoteScreensharing = remoteScreenFound;
|
final localScreen = video(call.localScreenSharingStream);
|
||||||
|
final remoteUser = call.remoteUserMediaStream?.stream;
|
||||||
|
|
||||||
if (remote == null) {
|
var changed = false;
|
||||||
try { remote = extract(c.remoteUserMediaStream); } catch (_) {}
|
final remoteSharing = remoteScreen != null;
|
||||||
}
|
final localSharing = call.localScreenSharingStream != null;
|
||||||
if (remote == null) {
|
if (remoteSharing != _isRemoteScreensharing ||
|
||||||
try { remote = extract(c.remoteStream); } catch (_) {}
|
localSharing != _isScreensharing) {
|
||||||
}
|
changed = true;
|
||||||
if (local == null) {
|
|
||||||
try { local = extract(c.localUserMediaStream); } catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
_isRemoteScreensharing = remoteSharing;
|
||||||
|
_isScreensharing = localSharing;
|
||||||
|
|
||||||
// Ausgabelautstärke (Settings-Slider) auf den Remote-Stream anwenden —
|
// Ausgabelautstärke (Settings-Slider) auf den Ton der Gegenseite –
|
||||||
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
// nur einmal pro Stream, _updateRenderers läuft auch per Timer.
|
||||||
if (remote != null && remote.id != _volumeAppliedStreamId) {
|
if (remoteUser != null && remoteUser.id != _volumeAppliedStreamId) {
|
||||||
_volumeAppliedStreamId = remote.id;
|
_volumeAppliedStreamId = remoteUser.id;
|
||||||
_remoteAudioStream = remote;
|
_remoteAudioStream = remoteUser;
|
||||||
applyOutputVolume();
|
applyOutputVolume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final check for tracks before assigning
|
// Bildschirm hat Vorrang vor der Kamera.
|
||||||
bool changed = false;
|
final remoteVideo =
|
||||||
if (remote != null && remote.getVideoTracks().isNotEmpty) {
|
remoteScreen ?? video(call.remoteUserMediaStream, respectMute: true);
|
||||||
if (remoteRenderer.srcObject?.id != remote.id) {
|
final localVideo =
|
||||||
debugPrint('[VOIP] Assigning Remote Renderer: ${remote.id} (${remote.getVideoTracks().length} video tracks)');
|
localScreen ?? video(call.localUserMediaStream, respectMute: true);
|
||||||
remoteRenderer.srcObject = remote;
|
|
||||||
changed = true;
|
if (remoteRenderer.srcObject?.id != remoteVideo?.id) {
|
||||||
}
|
debugPrint('[VOIP] Remote-Video: ${remoteVideo?.id} (Bildschirm: $remoteSharing)');
|
||||||
} else if (remoteRenderer.srcObject != null) {
|
remoteRenderer.srcObject = remoteVideo;
|
||||||
remoteRenderer.srcObject = null;
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (localRenderer.srcObject?.id != localVideo?.id) {
|
||||||
if (local != null && local.getVideoTracks().isNotEmpty) {
|
debugPrint('[VOIP] Lokales Video: ${localVideo?.id} (Bildschirm: $localSharing)');
|
||||||
if (localRenderer.srcObject?.id != local.id) {
|
localRenderer.srcObject = localVideo;
|
||||||
debugPrint('[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;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +374,64 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Wer spricht gerade? (Pegel aus den WebRTC-Statistiken) ───────────────
|
||||||
|
// Zeigt den grünen Sprech-Ring wie bei Discord – und macht sichtbar, wo
|
||||||
|
// Ton verloren geht: leuchtet „Du“ beim Sprechen nicht, kommt vom eigenen
|
||||||
|
// Mikrofon nichts an; leuchtet die Gegenseite nicht, kommt nichts an.
|
||||||
|
Timer? _levelTimer;
|
||||||
|
bool _localSpeaking = false;
|
||||||
|
bool _remoteSpeaking = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLocalSpeaking => _localSpeaking;
|
||||||
|
@override
|
||||||
|
bool get isRemoteSpeaking => _remoteSpeaking;
|
||||||
|
|
||||||
|
void _startLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = Timer.periodic(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
(_) => _pollLevels(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopLevelPolling() {
|
||||||
|
_levelTimer?.cancel();
|
||||||
|
_levelTimer = null;
|
||||||
|
_localSpeaking = false;
|
||||||
|
_remoteSpeaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollLevels() async {
|
||||||
|
final pc = currentCall?.pc;
|
||||||
|
if (pc == null) return;
|
||||||
|
try {
|
||||||
|
double local = 0;
|
||||||
|
double remote = 0;
|
||||||
|
for (final r in await pc.getStats()) {
|
||||||
|
if (r.values['kind'] != 'audio') continue;
|
||||||
|
final level = (r.values['audioLevel'] as num?)?.toDouble() ?? 0;
|
||||||
|
if (r.type == 'media-source' && level > local) local = level;
|
||||||
|
if (r.type == 'inbound-rtp' && level > remote) remote = level;
|
||||||
|
}
|
||||||
|
// Hysterese: an ab 0,03, aus unter 0,01 – kein Flackern.
|
||||||
|
bool speaking(bool was, double level) => was ? level > 0.01 : level > 0.03;
|
||||||
|
final ls = speaking(_localSpeaking, _isMicMuted ? 0 : local);
|
||||||
|
final rs = speaking(_remoteSpeaking, remote);
|
||||||
|
if (ls != _localSpeaking || rs != _remoteSpeaking) {
|
||||||
|
_localSpeaking = ls;
|
||||||
|
_remoteSpeaking = rs;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> handleCallEnded(CallSession session) async {
|
Future<void> handleCallEnded(CallSession session) async {
|
||||||
if (currentCall?.callId == session.callId) {
|
if (currentCall?.callId == session.callId) {
|
||||||
_renderRefreshTimer?.cancel();
|
_renderRefreshTimer?.cancel();
|
||||||
_renderRefreshTimer = null;
|
_renderRefreshTimer = null;
|
||||||
|
_stopLevelPolling();
|
||||||
localRenderer.srcObject = null;
|
localRenderer.srcObject = null;
|
||||||
remoteRenderer.srcObject = null;
|
remoteRenderer.srcObject = null;
|
||||||
_remoteAudioStream = null;
|
_remoteAudioStream = null;
|
||||||
@@ -259,6 +441,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_isRemoteScreensharing = false;
|
_isRemoteScreensharing = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
// ── Ausgabelautstärke (Settings-Slider "voice_output_volume") ─────────────
|
||||||
@@ -292,6 +475,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
currentCall = null;
|
currentCall = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -312,7 +496,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
@override
|
@override
|
||||||
Future<void> safeAction(Future<dynamic> Function() action) async {
|
Future<void> safeAction(Future<dynamic> Function() action) async {
|
||||||
try {
|
try {
|
||||||
await action();
|
await _inSdkZone(action);
|
||||||
await Future.delayed(const Duration(milliseconds: 1000));
|
await Future.delayed(const Duration(milliseconds: 1000));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VOIP] Action error: $e');
|
debugPrint('[VOIP] Action error: $e');
|
||||||
@@ -327,7 +511,8 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_isMicMuted = !_isMicMuted;
|
_isMicMuted = !_isMicMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await currentCall!.setMicrophoneMuted(_isMicMuted);
|
final c = currentCall!;
|
||||||
|
await _inSdkZone(() => c.setMicrophoneMuted(_isMicMuted));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_isMicMuted = !_isMicMuted;
|
_isMicMuted = !_isMicMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -340,7 +525,8 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
_isCameraMuted = !_isCameraMuted;
|
_isCameraMuted = !_isCameraMuted;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await currentCall!.setLocalVideoMuted(_isCameraMuted);
|
final c = currentCall!;
|
||||||
|
await _inSdkZone(() => c.setLocalVideoMuted(_isCameraMuted));
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
await Future.delayed(Duration(milliseconds: 300 + (i * 200)));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
@@ -361,6 +547,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
|
|
||||||
if (_isScreensharing) {
|
if (_isScreensharing) {
|
||||||
await safeAction(() => call.setScreensharingEnabled(false));
|
await safeAction(() => call.setScreensharingEnabled(false));
|
||||||
|
await CallForeground.screenCaptureStopped('voip');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +577,7 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
await currentCall!.hangup(reason: CallErrorCode.userHangup);
|
||||||
currentCall = null;
|
currentCall = null;
|
||||||
});
|
});
|
||||||
|
await CallForeground.callEnded('voip');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,15 +587,17 @@ class PyramidVoipManager extends CallSignalingService
|
|||||||
await _ensureRenderers();
|
await _ensureRenderers();
|
||||||
_isCameraMuted = !video;
|
_isCameraMuted = !video;
|
||||||
|
|
||||||
|
_speakerOn = true;
|
||||||
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
debugPrint('[VOIP] Starting call in room $roomId (video=$video)');
|
||||||
final call = await voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice);
|
final call = await _inSdkZone(
|
||||||
|
() => voip.inviteToCall(room, video ? CallType.kVideo : CallType.kVoice));
|
||||||
currentCall = call;
|
currentCall = call;
|
||||||
|
|
||||||
_setupCallListeners(call);
|
_setupCallListeners(call);
|
||||||
|
|
||||||
if (video) {
|
if (video) {
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
await call.setLocalVideoMuted(false);
|
await _inSdkZone(() => call.setLocalVideoMuted(false));
|
||||||
await _updateRenderers();
|
await _updateRenderers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +715,13 @@ class MediaDevicesWrapper extends MediaDevices {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> constraints) async {
|
Future<rtc.MediaStream> getDisplayMedia(Map<String, dynamic> 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;
|
final String? sourceId = _manager._selectedSourceId;
|
||||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.windows && sourceId != null) {
|
||||||
final desktopConstraints = {
|
final desktopConstraints = {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ class _CallControls extends StatelessWidget {
|
|||||||
final VoidCallback onToggleScreen;
|
final VoidCallback onToggleScreen;
|
||||||
final VoidCallback onHangUp;
|
final VoidCallback onHangUp;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
// Handy: Lautsprecher/Hörmuschel statt „Ton aus“ (Platz in der Leiste).
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
const _CallControls({
|
const _CallControls({
|
||||||
required this.isMuted,
|
required this.isMuted,
|
||||||
@@ -63,10 +66,15 @@ class _CallControls extends StatelessWidget {
|
|||||||
required this.onToggleScreen,
|
required this.onToggleScreen,
|
||||||
required this.onHangUp,
|
required this.onHangUp,
|
||||||
required this.pt,
|
required this.pt,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final platform = Theme.of(context).platform;
|
||||||
|
final showSpeaker = onToggleSpeaker != null &&
|
||||||
|
(platform == TargetPlatform.android || platform == TargetPlatform.iOS);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -77,7 +85,15 @@ class _CallControls extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
_VoiceCtrlBtn(icon: isMuted ? Icons.mic_off_rounded : Icons.mic_rounded, danger: isMuted, pt: pt, onTap: onToggleMic),
|
||||||
const SizedBox(width: 16),
|
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),
|
const SizedBox(width: 16),
|
||||||
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
_VoiceCtrlBtn(icon: isCameraOff ? Icons.videocam_off_rounded : Icons.videocam_rounded, active: !isCameraOff, pt: pt, onTap: onToggleCam),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
final bool isScreenSharing;
|
final bool isScreenSharing;
|
||||||
final Function(Offset)? onQualityMenu;
|
final Function(Offset)? onQualityMenu;
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
|
final bool isSpeakerOn;
|
||||||
|
final VoidCallback? onToggleSpeaker;
|
||||||
|
|
||||||
const _UnifiedCallView({
|
const _UnifiedCallView({
|
||||||
required this.participants,
|
required this.participants,
|
||||||
@@ -35,6 +37,8 @@ class _UnifiedCallView extends StatefulWidget {
|
|||||||
required this.isScreenSharing,
|
required this.isScreenSharing,
|
||||||
this.onQualityMenu,
|
this.onQualityMenu,
|
||||||
required this.pt,
|
required this.pt,
|
||||||
|
this.isSpeakerOn = true,
|
||||||
|
this.onToggleSpeaker,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -83,7 +87,14 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = widget.pt;
|
final pt = widget.pt;
|
||||||
final all = _sorted(widget.participants);
|
final all = _sorted(widget.participants);
|
||||||
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ?? (all.isNotEmpty ? all.first : null);
|
// Ohne gewählten Hero nicht nach „spricht gerade“ wählen – sonst springt
|
||||||
|
// das große Bild bei jedem Sprechwechsel hin und her.
|
||||||
|
final stable = [...widget.participants]..sort((a, b) {
|
||||||
|
int r(_ParticipantData p) => p.isScreenShare ? 0 : (p.hasVideo ? 1 : 2);
|
||||||
|
return r(a).compareTo(r(b));
|
||||||
|
});
|
||||||
|
final hero = all.firstWhereOrNull((p) => p.id == _heroId) ??
|
||||||
|
(stable.isNotEmpty ? stable.first : null);
|
||||||
|
|
||||||
if (widget.isConnecting) {
|
if (widget.isConnecting) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -148,6 +159,8 @@ class _UnifiedCallViewState extends State<_UnifiedCallView> {
|
|||||||
onToggleScreen: widget.onToggleScreen,
|
onToggleScreen: widget.onToggleScreen,
|
||||||
onHangUp: widget.onHangUp,
|
onHangUp: widget.onHangUp,
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: widget.isSpeakerOn,
|
||||||
|
onToggleSpeaker: widget.onToggleSpeaker,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ part of '../voice_channel.dart';
|
|||||||
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
void _handleLiveKitScreenShare(BuildContext context, VoiceChannelService call) async {
|
||||||
if (call.isScreenSharing) {
|
if (call.isScreenSharing) {
|
||||||
await call.stopScreenShare();
|
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 {
|
} else {
|
||||||
final source = await ScreenSharePicker.show(context);
|
final source = await ScreenSharePicker.show(context);
|
||||||
if (source != null) await call.startScreenShare(source.id);
|
if (source != null) await call.startScreenShare(source.id);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
isScreenSharing: voip.isScreensharing,
|
isScreenSharing: voip.isScreensharing,
|
||||||
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
onQualityMenu: (pos) => _showVoipQualityMenu(context, voip, pos),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: voip.isSpeakerOn,
|
||||||
|
onToggleSpeaker: voip.toggleSpeaker,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
isScreenSharing: call.isScreenSharing,
|
isScreenSharing: call.isScreenSharing,
|
||||||
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
onQualityMenu: (pos) => _showLiveKitQualityMenu(context, call, pos),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
|
isSpeakerOn: call.isSpeakerOn,
|
||||||
|
onToggleSpeaker: call.toggleSpeaker,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +151,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
name: voip.currentCall?.room.getLocalizedDisplayname() ?? 'Remote',
|
||||||
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
videoWidget: hasRemoteVideo ? rtc.RTCVideoView(voip.remoteRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
|
isSpeaking: voip.isRemoteSpeaking,
|
||||||
hasVideo: hasRemoteVideo,
|
hasVideo: hasRemoteVideo,
|
||||||
isScreenShare: isRemoteScreen,
|
isScreenShare: isRemoteScreen,
|
||||||
avatarColor: _avatarColors[1],
|
avatarColor: _avatarColors[1],
|
||||||
@@ -161,6 +166,7 @@ class VoiceChannelView extends ConsumerWidget {
|
|||||||
name: 'Du',
|
name: 'Du',
|
||||||
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
videoWidget: hasLocalVideo ? rtc.RTCVideoView(voip.localRenderer, objectFit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitContain) : null,
|
||||||
isMuted: voip.isMicMuted,
|
isMuted: voip.isMicMuted,
|
||||||
|
isSpeaking: voip.isLocalSpeaking,
|
||||||
hasVideo: hasLocalVideo,
|
hasVideo: hasLocalVideo,
|
||||||
isScreenShare: isLocalScreen,
|
isScreenShare: isLocalScreen,
|
||||||
avatarColor: _avatarColors[0],
|
avatarColor: _avatarColors[0],
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart';
|
|||||||
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
||||||
Timeline? _timeline;
|
Timeline? _timeline;
|
||||||
StreamSubscription<String>? _roomUpdateSub;
|
StreamSubscription<String>? _roomUpdateSub;
|
||||||
|
StreamSubscription<SyncUpdate>? _syncSub;
|
||||||
Timer? _redecryptTimer;
|
Timer? _redecryptTimer;
|
||||||
|
String _receiptSig = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Timeline> build(String arg) async {
|
Future<Timeline> build(String arg) async {
|
||||||
@@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
|
|
||||||
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
|
||||||
@@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das
|
||||||
|
// Raum-Update kommt im SDK VORHER):
|
||||||
|
// • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim
|
||||||
|
// Einladen gebaute blieb sonst leer, bis man die App neu startete.
|
||||||
|
// • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit
|
||||||
|
// ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus,
|
||||||
|
// eine Bestätigung ohne neue Nachricht bisher nicht.
|
||||||
|
final membershipAtBuild = room.membership;
|
||||||
|
_receiptSig = _receiptSignature(room);
|
||||||
|
_syncSub = client.onSync.stream.listen((_) {
|
||||||
|
if (membershipAtBuild != Membership.join &&
|
||||||
|
room.membership == Membership.join) {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final sig = _receiptSignature(room);
|
||||||
|
if (sig != _receiptSig) {
|
||||||
|
_receiptSig = sig;
|
||||||
|
_onUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure device keys are downloaded for all room members so outbound
|
// Ensure device keys are downloaded for all room members so outbound
|
||||||
// Megolm session key sharing works on the first send.
|
// Megolm session key sharing works on the first send.
|
||||||
if (room.encrypted && client.encryptionEnabled) {
|
if (room.encrypted && client.encryptionEnabled) {
|
||||||
@@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_roomUpdateSub?.cancel();
|
_roomUpdateSub?.cancel();
|
||||||
|
_syncSub?.cancel();
|
||||||
_redecryptTimer?.cancel();
|
_redecryptTimer?.cancel();
|
||||||
_timeline?.cancelSubscriptions();
|
_timeline?.cancelSubscriptions();
|
||||||
_timeline = null;
|
_timeline = null;
|
||||||
@@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder.
|
||||||
|
String _receiptSignature(Room room) {
|
||||||
|
final b = StringBuffer();
|
||||||
|
for (final users in [
|
||||||
|
room.receiptState.global.otherUsers,
|
||||||
|
room.receiptState.mainThread?.otherUsers ?? const {},
|
||||||
|
]) {
|
||||||
|
users.forEach((userId, data) => b
|
||||||
|
..write(userId)
|
||||||
|
..write('=')
|
||||||
|
..write(data.eventId)
|
||||||
|
..write(';'));
|
||||||
|
}
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einladung annehmen und warten, bis der Raum als beigetreten
|
||||||
|
/// synchronisiert ist – erst dann hat der Verlauf Inhalt.
|
||||||
|
Future<void> acceptInvite(WidgetRef ref, Room room) async {
|
||||||
|
final client = room.client;
|
||||||
|
// Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen.
|
||||||
|
final synced = client
|
||||||
|
.waitForRoomInSync(room.id, join: true)
|
||||||
|
.timeout(const Duration(seconds: 20))
|
||||||
|
.then<void>((_) {}, onError: (_) {});
|
||||||
|
await room.join();
|
||||||
|
await synced;
|
||||||
|
ref.invalidate(timelineProvider(room.id));
|
||||||
|
}
|
||||||
|
|
||||||
final timelineProvider =
|
final timelineProvider =
|
||||||
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
|
||||||
TimelineNotifier.new,
|
TimelineNotifier.new,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ extension _BubbleBuilder on _MessageGroupState {
|
|||||||
rows.add(_ReadMarker(pt: pt));
|
rows.add(_ReadMarker(pt: pt));
|
||||||
}
|
}
|
||||||
if (widget.fullyReadId == event.eventId && !isLast) {
|
if (widget.fullyReadId == event.eventId && !isLast) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class MessageGroup extends StatefulWidget {
|
|||||||
/// When non-null and pointing to a non-last event in this group, an inline
|
/// When non-null and pointing to a non-last event in this group, an inline
|
||||||
/// "Neue Nachrichten" divider is rendered after that event's row.
|
/// "Neue Nachrichten" divider is rendered after that event's row.
|
||||||
final String? fullyReadId;
|
final String? fullyReadId;
|
||||||
|
/// Ausblenden des Inline-Trenners + Schlüssel für den Sprung zu ihm.
|
||||||
|
final bool unreadFading;
|
||||||
|
final GlobalKey? unreadDividerKey;
|
||||||
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
/// Jump-to-message (from search): the row matching [jumpEventId] gets
|
||||||
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
|
||||||
final String? jumpEventId;
|
final String? jumpEventId;
|
||||||
@@ -55,6 +58,8 @@ class MessageGroup extends StatefulWidget {
|
|||||||
this.showStatusIndicator = false,
|
this.showStatusIndicator = false,
|
||||||
this.readEventIds = const {},
|
this.readEventIds = const {},
|
||||||
this.fullyReadId,
|
this.fullyReadId,
|
||||||
|
this.unreadFading = false,
|
||||||
|
this.unreadDividerKey,
|
||||||
this.jumpEventId,
|
this.jumpEventId,
|
||||||
this.jumpKey,
|
this.jumpKey,
|
||||||
this.highlightEventId,
|
this.highlightEventId,
|
||||||
@@ -155,7 +160,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
// Inline unread divider after the first event when it's the fullyRead
|
// Inline unread divider after the first event when it's the fullyRead
|
||||||
// marker and there are more events in this group.
|
// marker and there are more events in this group.
|
||||||
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Continuation event rows (siblings, same indentation) ──
|
// ── Continuation event rows (siblings, same indentation) ──
|
||||||
@@ -174,7 +179,7 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
|
||||||
// Inline divider after this event if it's the fullyRead and not the last.
|
// Inline divider after this event if it's the fullyRead and not the last.
|
||||||
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
|
||||||
rows.add(_InlineUnreadDivider(pt: pt));
|
rows.add(_InlineUnreadDivider(key: widget.unreadDividerKey, pt: pt, fading: widget.unreadFading));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,19 @@ class _InitialBox extends StatelessWidget {
|
|||||||
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
// fullyRead event is in the middle of the group (same sender, <5 min window).
|
||||||
class _InlineUnreadDivider extends StatelessWidget {
|
class _InlineUnreadDivider extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
const _InlineUnreadDivider({required this.pt});
|
final bool fading;
|
||||||
|
const _InlineUnreadDivider({super.key, required this.pt, this.fading = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
// Blendet aus wie der Gruppen-Trenner (vorher blieb dieser „Neue
|
||||||
|
// Nachrichten“-Trenner mitten in einer Gruppe bis zum erneuten Öffnen
|
||||||
|
// stehen – Bernd 2026-10-08).
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: fading ? 0.0 : 1.0,
|
||||||
|
duration: const Duration(milliseconds: 700),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -112,6 +120,7 @@ class _InlineUnreadDivider extends StatelessWidget {
|
|||||||
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,10 +216,11 @@ class _MessageList extends StatelessWidget {
|
|||||||
// Pass fullyReadId to the group only when the event is mid-group
|
// Pass fullyReadId to the group only when the event is mid-group
|
||||||
// (not the last event) so MessageGroup can render an inline divider.
|
// (not the last event) so MessageGroup can render an inline divider.
|
||||||
// groupContainsFullyRead already guarantees fullyReadId != null.
|
// groupContainsFullyRead already guarantees fullyReadId != null.
|
||||||
final passedFullyReadId =
|
final passedFullyReadId = (hasUnread &&
|
||||||
(groupContainsFullyRead && group.last.eventId != fullyReadId)
|
groupContainsFullyRead &&
|
||||||
? fullyReadId
|
group.last.eventId != fullyReadId)
|
||||||
: null;
|
? fullyReadId
|
||||||
|
: null;
|
||||||
itemFullyReadIds.add(passedFullyReadId);
|
itemFullyReadIds.add(passedFullyReadId);
|
||||||
|
|
||||||
if (isLastOfDay) {
|
if (isLastOfDay) {
|
||||||
@@ -292,6 +293,9 @@ class _MessageList extends StatelessWidget {
|
|||||||
showStatusIndicator: showStatus,
|
showStatusIndicator: showStatus,
|
||||||
readEventIds: readEventIds,
|
readEventIds: readEventIds,
|
||||||
fullyReadId: itemFullyReadIds[i],
|
fullyReadId: itemFullyReadIds[i],
|
||||||
|
unreadFading: unreadFading,
|
||||||
|
unreadDividerKey:
|
||||||
|
itemFullyReadIds[i] != null ? unreadDividerKey : null,
|
||||||
jumpEventId: jumpEventId,
|
jumpEventId: jumpEventId,
|
||||||
jumpKey: jumpKey,
|
jumpKey: jumpKey,
|
||||||
highlightEventId: highlightEventId,
|
highlightEventId: highlightEventId,
|
||||||
|
|||||||
@@ -151,3 +151,210 @@ class _FloatingComposerArea extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
|
||||||
|
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
|
||||||
|
/// das nur in der Chatliste (Bernd 2026-10-07).
|
||||||
|
class _InvitePanel extends ConsumerStatefulWidget {
|
||||||
|
final Room room;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
const _InvitePanel({required this.room, required this.pt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvitePanelState extends ConsumerState<_InvitePanel> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
String get _inviter {
|
||||||
|
final me = widget.room.client.userID;
|
||||||
|
final sender = me == null
|
||||||
|
? null
|
||||||
|
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
|
||||||
|
if (sender == null) return 'Jemand';
|
||||||
|
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await acceptInvite(ref, widget.room);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decline() async {
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await widget.room.leave();
|
||||||
|
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final pt = widget.pt;
|
||||||
|
final isDm = widget.room.isDirectChat;
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: pt.accent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isDm
|
||||||
|
? '$_inviter möchte mit dir chatten'
|
||||||
|
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.fg,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _decline,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: pt.fgMuted,
|
||||||
|
side: BorderSide(color: pt.border),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Ablehnen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _accept,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: pt.accent,
|
||||||
|
foregroundColor: pt.accentFg,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('Annehmen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: pt.danger, fontSize: 13)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
// Klingeln zählt nicht als „läuft“ – dafür gibt es das Annehmen-Fenster.
|
||||||
|
if (voipCall != null &&
|
||||||
|
voipCall.state != CallState.kEnded &&
|
||||||
|
voipCall.state != CallState.kRinging) {
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
// Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
|
||||||
bool _unreadFading = false;
|
bool _unreadFading = false;
|
||||||
Timer? _unreadFadeTimer;
|
Timer? _unreadFadeTimer;
|
||||||
|
// So lange bleibt der Trenner stehen, nachdem man bei den neuesten
|
||||||
|
// Nachrichten angekommen ist (Bernd 2026-10-07: verschwand beim Öffnen aus
|
||||||
|
// der Benachrichtigung sofort).
|
||||||
|
static const _unreadDwell = Duration(seconds: 6);
|
||||||
|
Timer? _unreadDwellTimer;
|
||||||
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
|
// Zuletzt von hier als gelesen gemeldetes Event. Verhindert, dass jeder
|
||||||
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
// Neuaufbau erneut eine Lesebestätigung schickt, und markiert nach dem
|
||||||
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
// Zurückkehren in die App, ab wo „Neue Nachrichten“ beginnen.
|
||||||
@@ -162,6 +167,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
_historyExhausted = false;
|
_historyExhausted = false;
|
||||||
_unreadFading = false;
|
_unreadFading = false;
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
_lastMarkedEventId = null;
|
_lastMarkedEventId = null;
|
||||||
_popInAfter = null;
|
_popInAfter = null;
|
||||||
_jumpEventId = null;
|
_jumpEventId = null;
|
||||||
@@ -177,6 +183,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -247,10 +254,12 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
);
|
);
|
||||||
|
_scheduleUnreadFadeIfAtNewest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (attempt >= 24) {
|
if (attempt >= 24) {
|
||||||
_didScrollToUnread = true; // give up — stay at the newest messages
|
_didScrollToUnread = true; // give up — stay at the newest messages
|
||||||
|
_scheduleUnreadFadeIfAtNewest();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
|
||||||
@@ -275,7 +284,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
cancelNotificationForRoom(widget.roomId);
|
cancelNotificationForRoom(widget.roomId);
|
||||||
|
|
||||||
final room = ref.read(roomProvider(widget.roomId));
|
final room = ref.read(roomProvider(widget.roomId));
|
||||||
if (room == null) return;
|
if (room == null || room.membership != Membership.join) return;
|
||||||
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
if (room.markedUnread) room.markUnread(false).catchError((_) {});
|
||||||
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
|
||||||
final latest = timeline?.events
|
final latest = timeline?.events
|
||||||
@@ -311,6 +320,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
|
.any((e) => _MessageList._isVisible(e) && e.senderId != me);
|
||||||
if (hasNew) {
|
if (hasNew) {
|
||||||
_unreadFadeTimer?.cancel();
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadDwellTimer?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
_sessionFullyReadId = since;
|
_sessionFullyReadId = since;
|
||||||
_sessionHasUnread = true;
|
_sessionHasUnread = true;
|
||||||
@@ -318,26 +328,57 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
|
||||||
_markRead(timeline);
|
_markRead(timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
|
||||||
|
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
|
||||||
|
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
|
||||||
|
// Trenner dann für immer stehen.)
|
||||||
|
bool get _atNewest {
|
||||||
|
if (!_scrollCtrl.hasClients) return true;
|
||||||
|
final pos = _scrollCtrl.position;
|
||||||
|
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
|
||||||
|
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
|
||||||
|
/// hochscrollt, behält ihn; unten angekommen beginnt die Wartezeit neu.
|
||||||
|
void _scheduleUnreadFade() {
|
||||||
|
if (!_sessionHasUnread || _unreadFading) return;
|
||||||
|
if (_unreadDwellTimer?.isActive ?? false) return;
|
||||||
|
_unreadDwellTimer = Timer(_unreadDwell, () {
|
||||||
|
if (!mounted || !_sessionHasUnread) return;
|
||||||
|
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
|
||||||
|
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
|
||||||
|
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
|
||||||
|
setState(() => _unreadFading = true);
|
||||||
|
_unreadFadeTimer?.cancel();
|
||||||
|
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach dem Sprung zum Trenner (Animation ~250 ms) prüfen: Passt alles
|
||||||
|
// Ungelesene auf den Bildschirm, scrollt niemand mehr – dann läuft die
|
||||||
|
// Verweildauer trotzdem an.
|
||||||
|
void _scheduleUnreadFadeIfAtNewest() {
|
||||||
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (mounted && _atNewest) _scheduleUnreadFade();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onScroll() async {
|
Future<void> _onScroll() async {
|
||||||
if (!_scrollCtrl.hasClients) return;
|
if (!_scrollCtrl.hasClients) return;
|
||||||
final pixels = _scrollCtrl.position.pixels;
|
final pixels = _scrollCtrl.position.pixels;
|
||||||
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
final maxExtent = _scrollCtrl.position.maxScrollExtent;
|
||||||
|
|
||||||
if (pixels < 60) {
|
if (pixels < 60) _markRead(null);
|
||||||
_markRead(null);
|
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
|
||||||
// Reached the newest messages → the "Neue Nachrichten" divider is no
|
// Verweildauer ausblenden.
|
||||||
// longer useful. Fade it out, then remove it.
|
if (_atNewest) _scheduleUnreadFade();
|
||||||
if (_sessionHasUnread && !_unreadFading) {
|
|
||||||
setState(() => _unreadFading = true);
|
|
||||||
_unreadFadeTimer?.cancel();
|
|
||||||
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
|
|
||||||
if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final shouldShow = pixels > 400;
|
final shouldShow = pixels > 400;
|
||||||
if (shouldShow != _showScrollDown) {
|
if (shouldShow != _showScrollDown) {
|
||||||
setState(() => _showScrollDown = shouldShow);
|
setState(() => _showScrollDown = shouldShow);
|
||||||
@@ -406,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||||
|
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
|
||||||
|
// hier Annehmen/Ablehnen anbieten.
|
||||||
|
final isInvite = room?.membership == Membership.invite;
|
||||||
final body = Stack(
|
final body = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -476,8 +520,11 @@ class _ChatViewState extends ConsumerState<ChatView> {
|
|||||||
setState(() => _pinnedOpen = !_pinnedOpen),
|
setState(() => _pinnedOpen = !_pinnedOpen),
|
||||||
),
|
),
|
||||||
const _ConnectionBanner(),
|
const _ConnectionBanner(),
|
||||||
|
const _ActiveCallBar(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: isInvite
|
||||||
|
? _InvitePanel(room: room!, pt: pt)
|
||||||
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
timelineAsync.when(
|
timelineAsync.when(
|
||||||
loading: () => Center(
|
loading: () => Center(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> {
|
|||||||
Future<void> _accept() async {
|
Future<void> _accept() async {
|
||||||
setState(() => _accepting = true);
|
setState(() => _accepting = true);
|
||||||
try {
|
try {
|
||||||
await widget.room.join();
|
await acceptInvite(ref, widget.room);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
ref.read(activeRoomIdProvider.notifier).state = widget.room.id;
|
||||||
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
ref.read(viewModeProvider.notifier).state = ViewMode.chat;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart';
|
|||||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||||
|
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||||
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
import 'package:pyramid/features/call_ui/mini_call_widget.dart';
|
||||||
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
|
||||||
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
import 'package:matrix/matrix.dart' hide Room;
|
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/ice_servers.dart';
|
||||||
import 'package:pyramid/core/livekit_token.dart';
|
import 'package:pyramid/core/livekit_token.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
@@ -98,8 +99,35 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
|
|
||||||
void _onRoomChanged() => notifyListeners();
|
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<void> toggleSpeaker() async {
|
||||||
|
_speakerOn = !_speakerOn;
|
||||||
|
notifyListeners();
|
||||||
|
await _applySpeaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applySpeaker() async {
|
||||||
|
if (!_isMobile) return;
|
||||||
|
try {
|
||||||
|
await rtc.Helper.setSpeakerphoneOn(_speakerOn);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[LiveKit] Lautsprecher: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _cleanUpAfterDisconnect() {
|
void _cleanUpAfterDisconnect() {
|
||||||
_callGeneration++;
|
_callGeneration++;
|
||||||
|
CallForeground.callEnded('livekit');
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
@@ -254,6 +282,10 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
_room = room;
|
_room = room;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
notifyListeners();
|
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) {
|
if (voiceChannel && matrixClient != null && matrixRoomId != null) {
|
||||||
await _writePresence(true);
|
await _writePresence(true);
|
||||||
@@ -269,6 +301,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
isScreenSharing = false;
|
isScreenSharing = false;
|
||||||
_currentScreenShareSourceId = null;
|
_currentScreenShareSourceId = null;
|
||||||
_screenShareTrack = null;
|
_screenShareTrack = null;
|
||||||
|
CallForeground.screenCaptureStopped('livekit');
|
||||||
}
|
}
|
||||||
notifyListeners();
|
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);
|
await _room?.localParticipant?.setScreenShareEnabled(true);
|
||||||
isScreenSharing = true;
|
isScreenSharing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -587,6 +625,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
try { await _room?.localParticipant?.setScreenShareEnabled(false); } catch (_) {}
|
||||||
|
await CallForeground.screenCaptureStopped('livekit');
|
||||||
isScreenSharing = false;
|
isScreenSharing = false;
|
||||||
_currentScreenShareSourceId = null;
|
_currentScreenShareSourceId = null;
|
||||||
_screenShareTrack = null;
|
_screenShareTrack = null;
|
||||||
@@ -597,6 +636,7 @@ class LiveKitCallManager extends VoiceChannelService {
|
|||||||
Future<void> hangUp() async {
|
Future<void> hangUp() async {
|
||||||
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
// Entwertet einen evtl. noch laufenden Verbindungsaufbau (s. _callGeneration).
|
||||||
_callGeneration++;
|
_callGeneration++;
|
||||||
|
unawaited(CallForeground.callEnded('livekit'));
|
||||||
_stopStatsLogging();
|
_stopStatsLogging();
|
||||||
_presenceHeartbeat?.cancel();
|
_presenceHeartbeat?.cancel();
|
||||||
_presenceHeartbeat = null;
|
_presenceHeartbeat = null;
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ abstract class VoiceChannelService extends ChangeNotifier {
|
|||||||
Future<void> hangUp();
|
Future<void> hangUp();
|
||||||
|
|
||||||
Future<void> toggleMute();
|
Future<void> toggleMute();
|
||||||
|
/// Handy: Ton über den Lautsprecher (true) oder die Hörmuschel (false).
|
||||||
|
/// Auf dem PC ohne Bedeutung.
|
||||||
|
bool get isSpeakerOn => true;
|
||||||
|
Future<void> toggleSpeaker() async {}
|
||||||
|
|
||||||
Future<void> toggleDeafen();
|
Future<void> toggleDeafen();
|
||||||
Future<void> toggleCamera();
|
Future<void> toggleCamera();
|
||||||
Future<void> toggleScreenShare();
|
Future<void> toggleScreenShare();
|
||||||
|
|||||||
@@ -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<MicLevelTest> createState() => _MicLevelTestState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MicLevelTestState extends State<MicLevelTest> {
|
||||||
|
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<void> _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<void> _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<void> _release(MediaStream? s, RTCPeerConnection? a, RTCPeerConnection? b) async {
|
||||||
|
for (final t in s?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -270,6 +270,22 @@ class _VoiceSectionState extends ConsumerState<_VoiceSection> {
|
|||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SettingsGroup(title: 'Pegel', pt: pt, children: [
|
_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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import 'package:pyramid/widgets/update_download_dialog.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:pyramid/widgets/banner_crop_dialog.dart';
|
import 'package:pyramid/widgets/banner_crop_dialog.dart';
|
||||||
import 'package:pyramid/widgets/hover_region.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/mxc_image.dart';
|
||||||
import 'package:pyramid/widgets/pyramid_loader.dart';
|
import 'package:pyramid/widgets/pyramid_loader.dart';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pyramid
|
name: pyramid
|
||||||
description: A flexible Matrix client with resizable panel layout.
|
description: A flexible Matrix client with resizable panel layout.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.38+39
|
version: 0.1.40+41
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.11.1 <4.0.0"
|
sdk: ">=3.11.1 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -109,15 +109,30 @@ if ($Ui) {
|
|||||||
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
$uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1"
|
||||||
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) {
|
||||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile"
|
||||||
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue) {
|
} elseif (Get-Process pyramid -ErrorAction SilentlyContinue |
|
||||||
# Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören.
|
Where-Object { $_.Path -like "*\build\windows\*" }) {
|
||||||
Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden"
|
# Nie zwei Matrix-Clients auf derselben DB: ein noch laufender Testlauf
|
||||||
|
# (Build-Ordner) nutzt profile1. Bernds echte Pyramid und seine
|
||||||
|
# Testinstanz (profile2, C:\Users\nordm\pyramid-testinstanz) stören nicht.
|
||||||
|
Add-Skipped "UI-Durchlauf (Handy-Format)" "Ein Testlauf läuft noch – erst beenden"
|
||||||
} else {
|
} else {
|
||||||
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
$env:PYRAMID_PROFILE_DIR = $uiProfile
|
||||||
try {
|
try {
|
||||||
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" {
|
||||||
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
}
|
}
|
||||||
|
$accounts = Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt"
|
||||||
|
if (Test-Path $accounts) {
|
||||||
|
Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" {
|
||||||
|
flutter test integration_test/live_receipts_invite_test.dart -d windows
|
||||||
|
}
|
||||||
|
# Nutzt kurz Mikrofon + Bildschirm (nur lokal zwischen zwei Testkonten).
|
||||||
|
Invoke-Step "Live: 1:1-Anruf, Ton + Bildschirm teilen (2 Testkonten)" {
|
||||||
|
flutter test integration_test/live_call_test.dart -d windows
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt"
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"Pyramid", origin, size)) {
|
// Testprofil (PYRAMID_PROFILE_DIR, z. B. die Testinstanz mit einem
|
||||||
|
// Testkonto) im Fenstertitel kenntlich machen, damit sie nicht mit der
|
||||||
|
// echten Pyramid verwechselt wird. Ohne die Variable bleibt alles wie immer.
|
||||||
|
wchar_t profile_probe[2] = {0};
|
||||||
|
const bool test_profile =
|
||||||
|
::GetEnvironmentVariableW(L"PYRAMID_PROFILE_DIR", profile_probe, 2) > 0;
|
||||||
|
if (!window.Create(test_profile ? L"Pyramid - TESTINSTANZ" : L"Pyramid",
|
||||||
|
origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user