Calls the way the web client and Element Call do them, with the same events, so the three can share a call. The famedly SDK's own group calls use com.famedly.call.member events, which the others don't read, so this is written against the matrix-js-sdk wire format instead (lib/matrix/ calls.dart): - Membership: org.matrix.msc3401.call.member state, key _@user:server_DEVICE_m.call, with the legacy session fields the js-sdk sends (application m.call, call_id "", membershipID, expires, focus_active, foci_preferred). An empty delayed copy (MSC4140) is scheduled first and pushed back every 5 s; leaving sends the empty state and cancels it. - Media: a LiveKit token from the JWT service (the call's, else the homeserver's rtc/transports or .well-known rtc_foci) for an OpenID token; livekit_client connects. - Encrypted rooms: a random 16-byte key per device, sent to every other device in the call over encrypted to-device io.element.call.encryption_keys, keys from others applied per LiveKit identity (@user:server:DEVICE); per-participant keys, no ratcheting, HKDF, like the web client's livekit-client. - Ringing: a call started in a room of two sends a ring notification (MSC4075); the other side gets an incoming-call card and an insistent ringtone notification (full screen on a locked phone), declines with MSC4310 or answers by joining. The caller gives up after a minute or on a decline. - UI: voice and video buttons in the room header, a call screen (the other person large and you in the corner, or a grid; mute, camera, flip, leave), a Join banner and a room-list marker for calls going on. Not tested end to end yet: it needs a LiveKit SFU and JWT service. README: replies, reactions, threads, spaces, QR verification, sliding sync and calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
4.7 KiB
Dart
152 lines
4.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/encryption.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import 'matrix/calls.dart';
|
|
import 'matrix/notifications.dart';
|
|
import 'screens/call_screen.dart';
|
|
import 'screens/home_screen.dart';
|
|
import 'screens/login_screen.dart';
|
|
import 'screens/room_screen.dart';
|
|
import 'state.dart';
|
|
import 'theme.dart';
|
|
import 'widgets/uia.dart';
|
|
import 'widgets/verification_sheet.dart';
|
|
|
|
final navigatorKey = GlobalKey<NavigatorState>();
|
|
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
|
|
|
class WarpApp extends ConsumerWidget {
|
|
const WarpApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final loginState = ref.watch(loginStateProvider);
|
|
final signedIn = loginState.value == LoginState.loggedIn;
|
|
// Signing in from the sign-up screen, or out from settings, leaves those
|
|
// screens on top of the new home: close them.
|
|
ref.listen(loginStateProvider, (prev, next) {
|
|
if (prev?.value != next.value) {
|
|
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
}
|
|
});
|
|
return MaterialApp(
|
|
title: 'Warp',
|
|
navigatorKey: navigatorKey,
|
|
scaffoldMessengerKey: messengerKey,
|
|
debugShowCheckedModeBanner: false,
|
|
theme: warpTheme(Brightness.light),
|
|
darkTheme: warpTheme(Brightness.dark),
|
|
themeMode: ref.watch(themeModeProvider),
|
|
home: loginState.isLoading
|
|
? const Scaffold()
|
|
: signedIn
|
|
? const _SignedIn(key: ValueKey('signed-in'))
|
|
: const LoginScreen(key: ValueKey('signed-out')),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Everything that runs only while signed in: the notification listener,
|
|
/// prompts the SDK raises (a password for an account change, an incoming
|
|
/// verification request) and opening rooms from notifications.
|
|
class _SignedIn extends ConsumerStatefulWidget {
|
|
const _SignedIn({super.key});
|
|
|
|
@override
|
|
ConsumerState<_SignedIn> createState() => _SignedInState();
|
|
}
|
|
|
|
class _SignedInState extends ConsumerState<_SignedIn>
|
|
with WidgetsBindingObserver {
|
|
final _subs = <StreamSubscription>[];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
final client = ref.read(clientProvider);
|
|
_subs
|
|
..add(Notifications.watch(client))
|
|
..add(Notifications.opened.listen(_openRoom))
|
|
..add(client.onUiaRequest.stream.listen(_onUia))
|
|
..add(client.onKeyVerificationRequest.stream.listen(_onVerification));
|
|
final calls = ref.read(callsProvider);
|
|
calls.addListener(_onCalls);
|
|
_subs.add(
|
|
calls.errors.stream.listen(
|
|
(message) => messengerKey.currentState?.showSnackBar(
|
|
SnackBar(content: Text(message)),
|
|
),
|
|
),
|
|
);
|
|
Notifications.launchRoomId().then((roomId) {
|
|
if (roomId != null) _openRoom(roomId);
|
|
});
|
|
Notifications.requestPermission();
|
|
}
|
|
|
|
IncomingCall? _shownCall;
|
|
|
|
/// Shows the incoming call card when a call starts ringing.
|
|
void _onCalls() {
|
|
final calls = ref.read(callsProvider);
|
|
final call = calls.incoming;
|
|
if (call == null || call == _shownCall) return;
|
|
_shownCall = call;
|
|
final context = navigatorKey.currentContext;
|
|
final navigator = navigatorKey.currentState;
|
|
if (context == null || navigator == null) return;
|
|
if (!Notifications.foreground) {
|
|
Notifications.show(
|
|
roomId: call.roomId,
|
|
title: call.callerName,
|
|
body: call.video ? 'Video call' : 'Voice call',
|
|
);
|
|
}
|
|
showIncomingCall(context, calls, navigator);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
ref.read(callsProvider).removeListener(_onCalls);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
for (final sub in _subs) {
|
|
sub.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// The sync loop keeps running in the background for as long as Android
|
|
// lets the process live; pushes cover the time after that.
|
|
Notifications.foreground = state == AppLifecycleState.resumed;
|
|
}
|
|
|
|
void _openRoom(String roomId) {
|
|
final nav = navigatorKey.currentState;
|
|
if (nav == null) return;
|
|
nav.popUntil((route) => route.isFirst);
|
|
nav.push(RoomScreen.route(roomId));
|
|
}
|
|
|
|
void _onUia(UiaRequest request) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) return request.cancel();
|
|
handleUia(context, ref.read(clientProvider), request);
|
|
}
|
|
|
|
void _onVerification(KeyVerification request) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) return;
|
|
showVerificationSheet(context, request);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const HomeScreen();
|
|
}
|