Files
Warp/apps/mobile/lib/app.dart
T
tajniak81andClaude Opus 5.5 41f800ab0b Mobile: voice and video calls (MatrixRTC with LiveKit)
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>
2026-10-19 08:43:19 +02:00

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();
}