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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 08:43:19 +02:00
1 parent 7d13d4fcfd
commit 41f800ab0b
12 files changed
+1673 -14

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
| [`admin/`](admin/README.md) | admin web panel, served by its own optional `warp-admin` binary | done for the current admin API |
| [`web/`](web/README.md) | web client (Vue SPA on matrix-js-sdk), served by its own optional `warp-web` binary | rooms, timeline, sending text and files, invites; E2EE next |
| `desktop/` | Wails v3 shell reusing the web frontend | next: web has a timeline |
| [`mobile/`](mobile/README.md) | Flutter app on the famedly Matrix SDK, Android first | sign-in, rooms, timeline, files, E2EE with verification, push |
| [`mobile/`](mobile/README.md) | Flutter app on the famedly Matrix SDK, Android first | rooms, timeline, files, replies, reactions, threads, spaces, E2EE with QR and emoji verification, push, calls, sliding sync |
The frontend packages are a pnpm workspace (`pnpm-workspace.yaml` at the
repository root); the Go hosts join `go.work`.
+68 -8
View File
@@ -33,6 +33,18 @@ radii), the Outfit and Figtree fonts, the Warp symbol.
failed one says *Not sent*, with Retry and Delete on a long press.
- Typing notifications, read receipts, deleting messages, editing shown as
*edited*.
- Replies (the message answered is quoted above the reply) and reactions
(six quick ones and any other; chips with counts, tap to add or take
back, long press for who reacted), from a message's long press.
- Threads: *Reply in thread* opens the thread, its root and replies, with
a composer that sends into it. Replies stay out of the main timeline;
the root shows "N replies". Room info lists a room's threads.
- Spaces: the room list's title picks a space, which narrows the list to
its rooms (and its subspaces'). A space's browser (`/hierarchy`) shows
the rooms you're in and the ones you can join; admins add existing or
new rooms. *New space* in the new-chat sheet.
- Voice and video calls, MatrixRTC with a LiveKit SFU like the web client
(see below).
- New rooms (end-to-end encrypted by default, public or not), direct
messages (encrypted), joining by address. Room info: members with their
role and verification shield, invite, rename, topic, notification level,
@@ -42,8 +54,9 @@ radii), the Outfit and Figtree fonts, the Warp symbol.
backup in one step, behind a recovery key shown once.
- A new session verifies itself with the recovery key (which also
restores older messages from the backup) or from another session.
- Verifying people and sessions by comparing emoji, from room info, the
session list, or an incoming request.
- Verifying people and sessions from room info, the session list, or an
incoming request: by QR code (show this device's, or scan the other's
with the camera) when both sides can, else by comparing emoji.
- A message this session can't decrypt says why, not just "encrypted".
- *Reset encryption* for a lost recovery key.
- Sessions: every session of the account, which are verified, verify one,
@@ -53,10 +66,57 @@ radii), the Outfit and Figtree fonts, the Warp symbol.
[UnifiedPush](https://unifiedpush.org) (below).
- Settings: display name, avatar, theme (system, light, dark).
**Not yet:** replies, reactions, threads, spaces, voice and video calls,
QR-code verification, Simplified Sliding Sync (the SDK has no client for
it, so the app uses `/sync`), a per-message trust shield like the web
client's.
**Not yet:** a per-message trust shield like the web client's, screen
sharing in calls, calls that keep going with the app in the background
(Android stops the process; a foreground service would keep it), and
read receipts per thread.
## Sync
When the homeserver advertises `org.matrix.simplified_msc3575`, the app
uses Simplified Sliding Sync (MSC4186), as Element X does; otherwise
`/sync`. The famedly SDK only knows `/sync`, so `WarpClient`
(`lib/matrix/sliding_sync.dart`) answers the SDK's sync call with a
sliding sync request and rewrites the response into the `/sync` shape:
encryption, the database and timelines don't know the difference.
- The first request asks for the 20 most recent rooms with a handful of
state events each, so the room list appears at once; the next ones
widen the window to every room (up to 1000).
- Two lists: rooms, and direct chats with all their members (so a DM's
name is the other person's). Members are otherwise `$LAZY`.
- Extensions: to-device, E2EE, account data, receipts, typing.
- The SDK's token becomes `ss1|<pos>|<to-device since>`. `M_UNKNOWN_POS`
(the server forgot the connection, after a restart or 30 idle minutes)
starts a new one.
## Calls
Every call, between two people or more, is a MatrixRTC call (MSC4143)
with its media through a LiveKit SFU: the model of the web client and
Element Call, with the same events, so the three can share a call. See
[docs/calls.md](../../docs/calls.md) for the server side
(`client_api.rtc`, LiveKit and the JWT service).
- Voice and video buttons in the room header. A room of two rings the
other person (`org.matrix.msc4075.rtc.notification`): an incoming-call
card with Answer, Voice only and Decline
(`org.matrix.msc4310.rtc.decline`), and an insistent ringtone
notification, full screen on a locked phone.
- Who is in a call is `org.matrix.msc3401.call.member` state, one event
per device. Before sending ours the app schedules an empty one with the
homeserver (a delayed event, MSC4140) and pushes it back every five
seconds, so a phone that dies drops out of the call by itself.
- The LiveKit token comes from the JWT service named by the call already
going on, else by the homeserver (`rtc/transports`, or
`org.matrix.msc4143.rtc_foci` in `.well-known`), for an OpenID token.
- In an encrypted room the media is end-to-end encrypted: each device
makes its own key and sends it to the others over encrypted to-device
messages (`io.element.call.encryption_keys`); keys are derived with
HKDF and never ratcheted, like the web client's livekit-client.
- A call of two shows the other person large and you in the corner; more
is a grid. Mute, camera, flip camera, leave. A "Join" banner in a room
with a call you're not in; the room list marks rooms with one.
## Push notifications
@@ -139,11 +199,11 @@ and sign up on the server `http://10.0.2.2:8008`.
|---|---|
| `lib/main.dart` | start-up: notifications, the client, push; a push alone starts the app without UI (`--unifiedpush-bg`) |
| `lib/app.dart` | the app shell: signed in or not, prompts the SDK raises (password, verification requests), opening rooms from notifications |
| `lib/matrix/` | everything around the SDK: the client and homeserver discovery (`client.dart`), the timeline projection, media, encryption setup, notifications, push |
| `lib/matrix/` | everything around the SDK: the client and homeserver discovery (`client.dart`), sliding sync, calls, the timeline projection, media, encryption setup, notifications, push |
| `lib/screens/` | sign-in, sign-up, room list, room, room info, settings, encryption, sessions |
| `lib/widgets/` | message bubbles, avatars, new chat, verification, password prompts |
| `lib/theme.dart` | the design tokens as a Flutter theme |
| `test/` | the timeline projection, formatting |
| `test/` | the timeline projection, the sliding sync conversion, formatting |
| `assets/` | the brand fonts (as TTF: Flutter can't load woff2) and the symbol |
| `tool/icons/` | launcher icon sources; `dart run flutter_launcher_icons` regenerates the icons |
@@ -3,6 +3,15 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Calls: the microphone, the camera (above), audio routing and
Bluetooth headsets. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- An incoming call shows full screen on a locked phone. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Scanning a QR code to verify a session or a person. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
@@ -18,6 +27,8 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+35
View File
@@ -5,7 +5,9 @@ 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';
@@ -15,6 +17,7 @@ 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});
@@ -33,6 +36,7 @@ class WarpApp extends ConsumerWidget {
return MaterialApp(
title: 'Warp',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
debugShowCheckedModeBanner: false,
theme: warpTheme(Brightness.light),
darkTheme: warpTheme(Brightness.dark),
@@ -70,14 +74,45 @@ class _SignedInState extends ConsumerState<_SignedIn>
..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();
+855
View File
@@ -0,0 +1,855 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart';
import 'notifications.dart';
import 'package:webrtc_interface/webrtc_interface.dart' as rtc;
import 'package:flutter_webrtc/flutter_webrtc.dart' show frameCryptorFactory;
/// Voice and video calls: MatrixRTC (MSC4143) with a LiveKit SFU, the way
/// the web client and Element Call do them, so the three can share a call.
///
/// Who is in a room's call is room state: one
/// `org.matrix.msc3401.call.member` event per device, state key
/// `_@user:server_DEVICE_m.call`. Joining sends ours, after scheduling an
/// empty one with the homeserver (a delayed event, MSC4140) that is pushed
/// back every few seconds while the call lasts: if the app dies or loses
/// its connection, the server sends it and the device drops out.
///
/// The media goes through LiveKit, whose token the LiveKit JWT service
/// hands out for an OpenID token from the homeserver. In an encrypted room
/// every member makes its own media key and sends it to the others over
/// encrypted to-device messages (`io.element.call.encryption_keys`); LiveKit
/// encrypts each frame with it.
///
/// Starting a call in a room of two rings the other person
/// (`org.matrix.msc4075.rtc.notification`); declining answers it
/// (`org.matrix.msc4310.rtc.decline`).
///
/// One call at a time.
class Calls extends ChangeNotifier {
Calls(this.client) {
_subs
..add(client.onSync.stream.listen((_) => _refresh()))
..add(client.onTimelineEvent.stream.listen(_onTimelineEvent))
..add(client.onToDeviceEvent.stream.listen(_onToDevice));
_expiryTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _refresh(),
);
_refresh();
}
final Client client;
final _subs = <StreamSubscription>[];
Timer? _expiryTimer;
static const memberType = 'org.matrix.msc3401.call.member';
static const notificationType = 'org.matrix.msc4075.rtc.notification';
static const declineType = 'org.matrix.msc4310.rtc.decline';
static const keysType = 'io.element.call.encryption_keys';
/// How long a call rings, unless the notification says less.
static const ringLifetime = Duration(seconds: 60);
/// The most a received notification may ring for, whatever it says.
static const maxRing = Duration(seconds: 120);
/// A membership lasts this long unless renewed; calls are shorter.
static const membershipExpiry = Duration(hours: 4);
static const _delayedLeave = Duration(seconds: 8);
static const _delayedLeaveRestart = Duration(seconds: 5);
// --- What the screens show --------------------------------------------
/// The room whose call this device is in.
String? roomId;
CallPhase? phase;
bool video = false;
bool encrypted = false;
lk.Room? livekit;
/// We rang someone and they haven't come yet.
bool ringingOut = false;
/// A call ringing for us.
IncomingCall? incoming;
/// When we joined, for the call's length.
DateTime? joinedAt;
/// How many people (not devices) are in each room's call.
final active = <String, int>{};
/// Errors to show once, then cleared.
final errors = StreamController<String>.broadcast();
bool get inCall => roomId != null;
int inCallCount(String roomId) => active[roomId] ?? 0;
// --- Memberships ------------------------------------------------------
/// The live memberships of [room]'s call, one per device.
List<CallMember> memberships(Room room) {
final events = room.states[memberType]?.values ?? const [];
final now = DateTime.now();
final out = <CallMember>[];
for (final e in events) {
final m = CallMember.fromState(e);
if (m != null && m.expiresAt.isAfter(now)) out.add(m);
}
out.sort((a, b) => a.createdAt.compareTo(b.createdAt));
return out;
}
void _refresh() {
var changed = false;
final seen = <String>{};
for (final room in client.rooms) {
if (room.membership != Membership.join) continue;
final users = memberships(room).map((m) => m.userId).toSet();
final n = users.length;
if (n > 0) {
seen.add(room.id);
if (active[room.id] != n) {
active[room.id] = n;
changed = true;
}
}
}
for (final id in active.keys.toList()) {
if (!seen.contains(id)) {
active.remove(id);
changed = true;
}
}
// The caller gave up, or someone else answered for us.
final ring = incoming;
if (ring != null && inCallCount(ring.roomId) == 0) {
_clearIncoming();
changed = true;
}
final rid = roomId;
if (rid != null) {
final room = client.getRoomById(rid);
if (room != null) {
final members = memberships(room);
// Someone came: stop ringing them.
if (ringingOut && members.map((m) => m.userId).toSet().length > 1) {
ringingOut = false;
changed = true;
}
if (encrypted) _shareKeyWithNewDevices(room, members);
}
}
if (changed) notifyListeners();
}
// --- Ringing ------------------------------------------------------------
final _seenEvents = <String>{};
void _onTimelineEvent(Event event) {
if (event.type != notificationType && event.type != declineType) return;
if (!_seenEvents.add(event.eventId)) return;
final me = client.userID;
final room = event.room;
if (event.type == notificationType) {
if (event.senderId == me || roomId == room.id || inCall) return;
final mentions = event.content.tryGetMap<String, Object?>('m.mentions');
final userIds = mentions?.tryGetList<String>('user_ids') ?? const [];
if (mentions?['room'] != true && !userIds.contains(me)) return;
// A ring for a call of two; in a bigger room the banner says enough.
if (event.content['notification_type'] != 'ring' &&
_otherOfTwo(room) == null) {
return;
}
final senderTs = event.content.tryGet<int>('sender_ts');
final sentAt = senderTs == null
? event.originServerTs
: DateTime.fromMillisecondsSinceEpoch(
min(senderTs, event.originServerTs.millisecondsSinceEpoch),
);
final lifetimeMs = min(
event.content.tryGet<int>('lifetime') ?? ringLifetime.inMilliseconds,
maxRing.inMilliseconds,
);
final expiresAt = sentAt.add(Duration(milliseconds: lifetimeMs));
if (!expiresAt.isAfter(DateTime.now())) return;
final caller = event.senderFromMemoryOrFallback;
incoming = IncomingCall(
roomId: room.id,
eventId: event.eventId,
callerId: event.senderId,
callerName: caller.calcDisplayname(),
callerAvatar: caller.avatarUrl,
roomName: room.getLocalizedDisplayname(),
video: event.content['m.call.intent'] != 'audio',
expiresAt: expiresAt,
);
_ringTimer?.cancel();
_ringTimer = Timer(expiresAt.difference(DateTime.now()), () {
_clearIncoming();
notifyListeners();
});
Notifications.ring(
roomId: room.id,
caller: incoming!.callerName,
video: incoming!.video,
);
notifyListeners();
return;
}
// A decline.
final target = event.relationshipEventId;
if (target == null) return;
if (event.senderId == me && incoming?.eventId == target) {
// Declined on another of our devices.
_clearIncoming();
notifyListeners();
} else if (target == _sentNotification && ringingOut && room.id == roomId) {
errors.add(
'${event.senderFromMemoryOrFallback.calcDisplayname()} declined the call.',
);
leave();
}
}
Timer? _ringTimer;
void _clearIncoming() {
_ringTimer?.cancel();
if (incoming != null) Notifications.stopRinging();
incoming = null;
}
/// Answers the call ringing for us.
Future<void> answer({bool withVideo = true}) async {
final call = incoming;
if (call == null) return;
_clearIncoming();
notifyListeners();
await join(call.roomId, video: withVideo && call.video);
}
/// Declines the call ringing for us.
Future<void> decline() async {
final call = incoming;
if (call == null) return;
_clearIncoming();
notifyListeners();
try {
await client.getRoomById(call.roomId)?.sendEvent({
'm.relates_to': {
'rel_type': RelationshipTypes.reference,
'event_id': call.eventId,
},
}, type: declineType);
} catch (e) {
Logs().w('Could not decline the call', e);
}
}
/// The other person in a room of two.
String? _otherOfTwo(Room room) {
if ((room.summary.mJoinedMemberCount ?? room.getParticipants().length) !=
2) {
return null;
}
return room
.getParticipants([Membership.join])
.map((u) => u.id)
.where((id) => id != client.userID)
.firstOrNull;
}
// --- Joining ------------------------------------------------------------
String? _sentNotification;
String? _delayId;
Timer? _delayTimer;
Timer? _ringOutTimer;
lk.BaseKeyProvider? _keyProvider;
lk.EventsListener<lk.RoomEvent>? _lkListener;
/// Our media key, its index, and who has it.
Uint8List? _key;
final _keyIndex = 0;
final _keySentTo = <String>{};
/// Keys that came before LiveKit was ready, by identity.
final _pendingKeys = <String, (Uint8List, int)>{};
String get _deviceId => client.deviceID!;
String get _identity => '${client.userID}:$_deviceId';
String _stateKey(Room room) {
final key = '${client.userID}_${_deviceId}_m.call';
// Room versions with MSC3757/3779 own-state keys take it bare.
final version = room
.getState(EventTypes.RoomCreate)
?.content
.tryGet<String>('room_version');
return version != null &&
RegExp(r'^org\.matrix\.msc(3757|3779)\b').hasMatch(version)
? key
: '_$key';
}
/// Joins the call in [roomId], or starts one. Starting a call in a room
/// of two rings the other person.
Future<void> join(String roomId, {required bool video}) async {
if (this.roomId == roomId) return;
if (inCall) await leave();
final room = client.getRoomById(roomId);
if (room == null) return;
if (incoming?.roomId == roomId) _clearIncoming();
this.roomId = roomId;
this.video = video;
phase = CallPhase.joining;
encrypted = room.encrypted;
_sentNotification = null;
_keySentTo.clear();
notifyListeners();
final others = memberships(room);
final ringing = others.isEmpty && _otherOfTwo(room) != null;
try {
final transport = await _chooseTransport(others);
final (url, jwt) = await _livekitToken(transport, roomId);
if (this.roomId != roomId) return;
if (encrypted) {
_keyProvider = await _makeKeyProvider();
_key = _randomKey();
await _keyProvider!.setRawKey(
_key!,
participantId: _identity,
keyIndex: _keyIndex,
);
for (final e in _pendingKeys.entries) {
await _keyProvider!.setRawKey(
e.value.$1,
participantId: e.key,
keyIndex: e.value.$2,
);
}
_pendingKeys.clear();
}
final lkRoom = lk.Room(
roomOptions: lk.RoomOptions(
adaptiveStream: true,
dynacast: true,
e2eeOptions: _keyProvider == null
? null
: lk.E2EEOptions(keyProvider: _keyProvider!),
),
);
livekit = lkRoom;
lkRoom.addListener(_onLivekit);
_lkListener = lkRoom.createListener()
..on<lk.RoomReconnectingEvent>((_) {
phase = CallPhase.reconnecting;
notifyListeners();
})
..on<lk.RoomReconnectedEvent>((_) {
phase = CallPhase.connected;
notifyListeners();
})
..on<lk.RoomDisconnectedEvent>((event) {
if (livekit != lkRoom) return;
if (event.reason != lk.DisconnectReason.clientInitiated) {
errors.add('You were disconnected from the call.');
}
leave();
});
// The Matrix side: a delayed leave first, then ourselves.
await _scheduleDelayedLeave(room);
final membershipEventId = await client.setRoomStateWithKey(
roomId,
memberType,
_stateKey(room),
_myMembership(transport, video),
);
if (encrypted) _shareKeyWithNewDevices(room, memberships(room));
if (ringing || others.isEmpty) {
_sendNotification(room, membershipEventId, ring: ringing, video: video);
}
if (ringing) {
ringingOut = true;
_ringOutTimer = Timer(ringLifetime, () {
if (!ringingOut || this.roomId != roomId) return;
errors.add('No answer.');
leave();
});
}
await lkRoom.connect(url, jwt);
if (this.roomId != roomId) {
await lkRoom.disconnect();
return;
}
if (_keyProvider != null) await lkRoom.e2eeManager?.setEnabled(true);
phase = CallPhase.connected;
joinedAt = DateTime.now();
notifyListeners();
try {
await lkRoom.localParticipant?.setMicrophoneEnabled(true);
} catch (e) {
errors.add("Can't use the microphone: $e");
}
if (video) {
try {
await lkRoom.localParticipant?.setCameraEnabled(true);
} catch (e) {
errors.add("Can't use the camera: $e");
}
}
notifyListeners();
} catch (e, s) {
Logs().e('Joining the call failed', e, s);
errors.add(e is CallException ? e.message : 'The call failed: $e');
await leave();
}
}
void _onLivekit() => notifyListeners();
/// Leaves the call this device is in.
Future<void> leave() async {
final rid = roomId;
final lkRoom = livekit;
roomId = null;
livekit = null;
phase = null;
joinedAt = null;
ringingOut = false;
_ringOutTimer?.cancel();
_delayTimer?.cancel();
_keyProvider = null;
_key = null;
_keySentTo.clear();
_pendingKeys.clear();
final delayId = _delayId;
_delayId = null;
notifyListeners();
lkRoom?.removeListener(_onLivekit);
await _lkListener?.dispose();
_lkListener = null;
try {
await lkRoom?.disconnect();
} catch (_) {}
if (rid != null) {
final room = client.getRoomById(rid);
if (room != null) {
try {
await client.setRoomStateWithKey(
rid,
memberType,
_stateKey(room),
{},
);
} catch (e) {
Logs().w('Could not send the leave; the delayed one will', e);
}
}
}
if (delayId != null) {
try {
await _delayedEventAction(delayId, 'cancel');
} catch (_) {}
}
_refresh();
}
Map<String, Object?> _myMembership(
Map<String, Object?> transport,
bool video,
) => {
'application': 'm.call',
'call_id': '',
'scope': 'm.room',
'device_id': _deviceId,
'membershipID': _identity,
'expires': membershipExpiry.inMilliseconds,
'm.call.intent': video ? 'video' : 'audio',
'focus_active': {'type': 'livekit', 'focus_selection': 'multi_sfu'},
'foci_preferred': [transport],
};
void _sendNotification(
Room room,
String membershipEventId, {
required bool ring,
required bool video,
}) {
room
.sendEvent({
'slot_id': 'm.call#ROOM',
'm.mentions': {'user_ids': <String>[], 'room': true},
'notification_type': ring ? 'ring' : 'notification',
'm.relates_to': {
'event_id': membershipEventId,
'rel_type': RelationshipTypes.reference,
},
'sender_ts': DateTime.now().millisecondsSinceEpoch,
'lifetime': ringLifetime.inMilliseconds,
'msc4354_sticky_key': 'm.call#ROOM',
'm.call.intent': video ? 'video' : 'audio',
}, type: notificationType)
.then((id) => _sentNotification = id)
.catchError((Object e) {
Logs().w('Could not send the call notification', e);
return null;
});
}
// --- Delayed leave (MSC4140) ----------------------------------------------
Future<void> _scheduleDelayedLeave(Room room) async {
try {
final response = await client.request(
RequestType.PUT,
'/client/v3/rooms/${Uri.encodeComponent(room.id)}/state/'
'${Uri.encodeComponent(memberType)}/${Uri.encodeComponent(_stateKey(room))}',
query: {'org.matrix.msc4140.delay': '${_delayedLeave.inMilliseconds}'},
data: <String, Object?>{},
);
final id = response['delay_id'];
if (id is! String) return;
_delayId = id;
_delayTimer = Timer.periodic(_delayedLeaveRestart, (_) {
final current = _delayId;
if (current == null) return;
_delayedEventAction(current, 'restart').catchError((Object e) {
Logs().w('Could not push back the delayed leave', e);
});
});
} catch (e) {
// Without it, a crash leaves us in the call until our membership
// expires; the call itself still works.
Logs().w('The server has no delayed events', e);
}
}
Future<void> _delayedEventAction(String id, String action) => client.request(
RequestType.POST,
'/client/unstable/org.matrix.msc4140/delayed_events/'
'${Uri.encodeComponent(id)}/$action',
data: <String, Object?>{},
);
// --- LiveKit --------------------------------------------------------------
/// The LiveKit JWT service: the one the call already uses, else this
/// server's.
Future<Map<String, Object?>> _chooseTransport(List<CallMember> others) async {
for (final m in others) {
final t = m.transport;
if (t != null) return {...t, 'livekit_alias': roomId};
}
List? foci;
try {
final response = await client.request(
RequestType.GET,
'/client/unstable/org.matrix.msc4143/rtc/transports',
);
foci = response['rtc_transports'] as List?;
} catch (_) {
try {
final wellKnown = await http.get(
client.homeserver!.resolve('/.well-known/matrix/client'),
);
final json = jsonDecode(wellKnown.body);
foci = json is Map
? json['org.matrix.msc4143.rtc_foci'] as List?
: null;
} catch (_) {}
}
final focus = foci
?.whereType<Map>()
.where(
(f) => f['type'] == 'livekit' && f['livekit_service_url'] is String,
)
.firstOrNull;
if (focus == null) {
throw const CallException("This server doesn't offer calls.");
}
return {
'type': 'livekit',
'livekit_service_url': focus['livekit_service_url'],
'livekit_alias': roomId,
};
}
/// A LiveKit URL and token from the JWT service, for an OpenID token.
Future<(String, String)> _livekitToken(
Map<String, Object?> transport,
String roomId,
) async {
final openid = await client.requestOpenIdToken(client.userID!, {});
final service = (transport['livekit_service_url'] as String).replaceFirst(
RegExp(r'/$'),
'',
);
final response = await http.post(
Uri.parse('$service/sfu/get'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'room': roomId,
'openid_token': openid.toJson(),
'device_id': _deviceId,
}),
);
if (response.statusCode != 200) {
throw CallException(
'The call service refused to let you in (${response.statusCode}).',
);
}
final body = jsonDecode(response.body);
if (body is! Map || body['url'] is! String || body['jwt'] is! String) {
throw const CallException('The call service gave an unusable answer.');
}
return (body['url'] as String, body['jwt'] as String);
}
/// Per-participant keys, no ratcheting, keys derived with HKDF: the
/// settings of the web client and Element Call, whose livekit-client
/// imports keys as HKDF material.
Future<lk.BaseKeyProvider> _makeKeyProvider() async {
final options = rtc.KeyProviderOptions(
sharedKey: false,
ratchetSalt: Uint8List.fromList(utf8.encode('LKFrameEncryptionKey')),
ratchetWindowSize: 0,
uncryptedMagicBytes: Uint8List.fromList(utf8.encode('LK-ROCKS')),
failureTolerance: -1,
keyRingSize: 256,
keyDerivationAlgorithm: rtc.KeyDerivationAlgorithm.kHKDF,
);
final provider = await frameCryptorFactory.createDefaultKeyProvider(
options,
);
return lk.BaseKeyProvider(provider, options);
}
// --- Media keys -----------------------------------------------------------
Uint8List _randomKey() {
final random = Random.secure();
return Uint8List.fromList(List.generate(16, (_) => random.nextInt(256)));
}
/// Sends our media key to every device in the call that hasn't had it.
Future<void> _shareKeyWithNewDevices(
Room room,
List<CallMember> members,
) async {
final key = _key;
if (key == null) return;
final targets = members
.where(
(m) =>
!(m.userId == client.userID && m.deviceId == _deviceId) &&
!_keySentTo.contains('${m.userId}:${m.deviceId}'),
)
.toList();
if (targets.isEmpty) return;
for (final m in targets) {
_keySentTo.add('${m.userId}:${m.deviceId}');
}
try {
final users = targets.map((m) => m.userId).toSet();
if (users.any((u) => client.userDeviceKeys[u] == null)) {
await client.updateUserDeviceKeys(additionalUsers: users);
}
final devices = [
for (final m in targets)
?client.userDeviceKeys[m.userId]?.deviceKeys[m.deviceId],
];
await client.sendToDeviceEncrypted(devices, keysType, {
'keys': {'index': _keyIndex, 'key': base64Encode(key)},
'room_id': room.id,
'member': {'claimed_device_id': _deviceId, 'id': _identity},
'session': {'call_id': '', 'application': 'm.call', 'scope': 'm.room'},
'sent_ts': DateTime.now().millisecondsSinceEpoch,
});
} catch (e) {
Logs().w('Could not share the call key', e);
for (final m in targets) {
_keySentTo.remove('${m.userId}:${m.deviceId}');
}
}
}
/// Someone's media key, over an encrypted to-device message.
void _onToDevice(ToDeviceEvent event) {
if (event.type != keysType) return;
// Keys sent in the clear are refused, as the web client refuses them.
if (event.encryptedContent == null) return;
final content = event.content;
final keys = content.tryGetMap<String, Object?>('keys');
final member = content.tryGetMap<String, Object?>('member');
final key = keys?.tryGet<String>('key');
final index = keys?.tryGet<int>('index');
final device = member?.tryGet<String>('claimed_device_id');
if (key == null || index == null || device == null) return;
final identity = '${event.senderId}:$device';
Uint8List bytes;
try {
bytes = base64Decode(base64.normalize(key));
} catch (_) {
return;
}
final provider = _keyProvider;
if (provider == null || content['room_id'] != roomId) {
// Keys can come before we have joined: keep them for then.
_pendingKeys[identity] = (bytes, index);
return;
}
provider.setRawKey(bytes, participantId: identity, keyIndex: index);
}
// --- Controls ---------------------------------------------------------------
bool get micOn => livekit?.localParticipant?.isMicrophoneEnabled() ?? false;
bool get cameraOn => livekit?.localParticipant?.isCameraEnabled() ?? false;
Future<void> toggleMic() async {
final p = livekit?.localParticipant;
if (p == null) return;
await p.setMicrophoneEnabled(!p.isMicrophoneEnabled());
notifyListeners();
}
Future<void> toggleCamera() async {
final p = livekit?.localParticipant;
if (p == null) return;
await p.setCameraEnabled(!p.isCameraEnabled());
notifyListeners();
}
/// Switches between the front and the back camera.
Future<void> flipCamera() async {
final track = livekit?.localParticipant?.videoTrackPublications
.where((t) => t.source == lk.TrackSource.camera)
.firstOrNull
?.track;
if (track is! lk.LocalVideoTrack) return;
final options = track.currentOptions;
if (options is! lk.CameraCaptureOptions) return;
await track.setCameraPosition(options.cameraPosition.switched());
notifyListeners();
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_expiryTimer?.cancel();
leave();
errors.close();
super.dispose();
}
}
enum CallPhase { joining, connected, reconnecting }
class CallException implements Exception {
const CallException(this.message);
final String message;
@override
String toString() => message;
}
/// A call ringing for us.
class IncomingCall {
const IncomingCall({
required this.roomId,
required this.eventId,
required this.callerId,
required this.callerName,
required this.callerAvatar,
required this.roomName,
required this.video,
required this.expiresAt,
});
final String roomId;
/// The notification event, which a decline refers to.
final String eventId;
final String callerId;
final String callerName;
final Uri? callerAvatar;
final String roomName;
final bool video;
final DateTime expiresAt;
}
/// One device's membership of a room's call.
class CallMember {
CallMember({
required this.userId,
required this.deviceId,
required this.createdAt,
required this.expiresAt,
required this.transport,
});
final String userId;
final String deviceId;
final DateTime createdAt;
final DateTime expiresAt;
/// The LiveKit transport it publishes to.
final Map<String, Object?>? transport;
/// Reads a call member state event; null for an empty one (a leave) or
/// one that isn't a room call.
static CallMember? fromState(StrippedStateEvent state) {
final content = state.content;
if (content.isEmpty) return null;
if (content['application'] != 'm.call') return null;
final deviceId = content.tryGet<String>('device_id');
if (deviceId == null) return null;
final origin = state is Event
? state.originServerTs
: DateTime.fromMillisecondsSinceEpoch(0);
final created = content.tryGet<int>('created_ts');
final createdAt = created == null
? origin
: DateTime.fromMillisecondsSinceEpoch(created);
final expires = content.tryGet<int>('expires') ?? 4 * 60 * 60 * 1000;
final foci = content.tryGetList<Object?>('foci_preferred');
final transport = foci
?.whereType<Map>()
.where(
(f) => f['type'] == 'livekit' && f['livekit_service_url'] is String,
)
.map((f) => Map<String, Object?>.from(f))
.firstOrNull;
return CallMember(
userId: state.senderId,
deviceId: deviceId,
createdAt: createdAt,
expiresAt: createdAt.add(Duration(milliseconds: expires)),
transport: transport,
);
}
}
+59
View File
@@ -36,6 +36,60 @@ class Notifications {
importance: Importance.high,
);
/// Incoming calls: the ringtone, repeating until answered.
static const _callChannel = AndroidNotificationChannel(
'calls',
'Calls',
description: 'Incoming voice and video calls',
importance: Importance.max,
sound: UriAndroidNotificationSound('content://settings/system/ringtone'),
);
static const _ringId = 0x7fff0001;
/// Rings for an incoming call: a call notification with the ringtone,
/// repeating (insistent) until [stopRinging], shown full screen on a
/// locked phone.
static Future<void> ring({
required String roomId,
required String caller,
required bool video,
}) async {
if (!_ready) await init();
try {
await _plugin.show(
id: _ringId,
title: caller,
body: video ? 'Incoming video call' : 'Incoming voice call',
payload: roomId,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_callChannel.id,
_callChannel.name,
channelDescription: _callChannel.description,
importance: Importance.max,
priority: Priority.max,
category: AndroidNotificationCategory.call,
fullScreenIntent: true,
ongoing: true,
autoCancel: false,
sound: _callChannel.sound,
// FLAG_INSISTENT: the sound repeats until the notification goes.
additionalFlags: Int32List.fromList([4]),
color: const Color(0xFF00A389),
),
),
);
} catch (e, s) {
Logs().w('Could not ring', e, s);
}
}
static Future<void> stopRinging() async {
if (!_ready) return;
await _plugin.cancel(id: _ringId);
}
static Future<void> init() async {
if (_ready) return;
await _plugin.initialize(
@@ -52,6 +106,11 @@ class Notifications {
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_channel);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_callChannel);
_ready = true;
}
+527
View File
@@ -0,0 +1,527 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart';
import '../matrix/calls.dart';
import '../state.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
/// The call this device is in: the other person large and you in the
/// corner for a call of two, a grid of tiles for more; mute, camera, flip
/// and leave. "Calling…" while it rings the other person.
class CallScreen extends ConsumerStatefulWidget {
const CallScreen({super.key});
static Route<void> route() => MaterialPageRoute(
settings: const RouteSettings(name: 'call'),
builder: (_) => const CallScreen(),
);
@override
ConsumerState<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends ConsumerState<CallScreen> {
Timer? _clock;
@override
void initState() {
super.initState();
_clock = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_clock?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final calls = ref.watch(callsProvider);
final client = ref.watch(clientProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
final roomId = calls.roomId;
if (roomId == null) {
// The call ended: leave the screen.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const Scaffold(backgroundColor: Colors.black);
}
final room = client.getRoomById(roomId);
final tiles = _tiles(calls, room);
final title = room?.getLocalizedDisplayname() ?? '';
return Scaffold(
backgroundColor: const Color(0xFF0F1213),
body: SafeArea(
child: Column(
children: [
_TopBar(title: title, calls: calls),
Expanded(
child: _Stage(tiles: tiles, calls: calls, room: room),
),
_Controls(calls: calls),
],
),
),
);
},
);
}
List<_Tile> _tiles(Calls calls, Room? room) {
final lkRoom = calls.livekit;
if (lkRoom == null) return [];
final speakers = lkRoom.activeSpeakers.map((p) => p.identity).toSet();
_Tile tile(lk.Participant p, bool local) {
final userId = _identityUser(p.identity);
final user = room?.unsafeGetUserFromMemoryOrFallback(userId);
final camera = p.getTrackPublicationBySource(lk.TrackSource.camera);
final screen = p.getTrackPublicationBySource(
lk.TrackSource.screenShareVideo,
);
final mic = p.getTrackPublicationBySource(lk.TrackSource.microphone);
lk.VideoTrack? video(lk.TrackPublication? pub) =>
pub != null && !pub.muted && pub.track is lk.VideoTrack
? pub.track as lk.VideoTrack
: null;
return _Tile(
identity: p.identity,
userId: userId,
name: user?.calcDisplayname() ?? userId,
avatar: user?.avatarUrl,
local: local,
video: video(screen) ?? video(camera),
micMuted: mic == null || mic.muted,
speaking: speakers.contains(p.identity),
);
}
return [
if (lkRoom.localParticipant != null) tile(lkRoom.localParticipant!, true),
for (final p in lkRoom.remoteParticipants.values) tile(p, false),
];
}
/// The user of a LiveKit identity, "@alice:example.org:DEVICE": the
/// device is after the last colon (a server name may have a port).
static String _identityUser(String identity) {
final i = identity.lastIndexOf(':');
return i > 0 ? identity.substring(0, i) : identity;
}
}
class _Tile {
const _Tile({
required this.identity,
required this.userId,
required this.name,
required this.avatar,
required this.local,
required this.video,
required this.micMuted,
required this.speaking,
});
final String identity;
final String userId;
final String name;
final Uri? avatar;
final bool local;
final lk.VideoTrack? video;
final bool micMuted;
final bool speaking;
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.title, required this.calls});
final String title;
final Calls calls;
@override
Widget build(BuildContext context) {
final since = calls.joinedAt;
final status = switch (calls.phase) {
CallPhase.joining => 'Connecting…',
CallPhase.reconnecting => 'Reconnecting…',
_ when calls.ringingOut => 'Calling…',
_ when since != null => _length(DateTime.now().difference(since)),
_ => '',
};
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 16, 8),
child: Row(
children: [
IconButton(
tooltip: 'Back to the chat',
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontFamily: fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
Text(
status,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
if (calls.encrypted)
const Tooltip(
message: 'End-to-end encrypted',
child: Icon(Icons.lock_outline, color: Colors.white70, size: 18),
),
],
),
);
}
static String _length(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s';
}
}
class _Stage extends StatelessWidget {
const _Stage({required this.tiles, required this.calls, required this.room});
final List<_Tile> tiles;
final Calls calls;
final Room? room;
@override
Widget build(BuildContext context) {
if (tiles.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final remote = tiles.where((t) => !t.local).toList();
final local = tiles.firstWhere((t) => t.local, orElse: () => tiles.first);
// Alone: ringing, or waiting for someone to come.
if (remote.isEmpty) {
return Stack(
children: [
Positioned.fill(child: _TileView(tile: local, large: true)),
if (calls.ringingOut)
const Align(
alignment: Alignment(0, 0.6),
child: Text(
'Ringing…',
style: TextStyle(color: Colors.white70, fontSize: 15),
),
),
],
);
}
// Two: the other person large, you in the corner.
if (remote.length == 1) {
return Stack(
children: [
Positioned.fill(child: _TileView(tile: remote.single, large: true)),
Positioned(
right: 16,
bottom: 16,
width: 110,
height: 160,
child: _TileView(tile: local),
),
],
);
}
// More: a grid.
final columns = tiles.length <= 4 ? 2 : 3;
return GridView.count(
padding: const EdgeInsets.all(8),
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 3 / 4,
children: [for (final t in tiles) _TileView(tile: t)],
);
}
}
class _TileView extends StatelessWidget {
const _TileView({required this.tile, this.large = false});
final _Tile tile;
final bool large;
@override
Widget build(BuildContext context) {
final video = tile.video;
return ClipRRect(
borderRadius: BorderRadius.circular(large ? 0 : 14),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF1B2022),
border: tile.speaking && !large
? Border.all(color: const Color(0xFF22C3A6), width: 3)
: null,
),
child: Stack(
children: [
Positioned.fill(
child: video != null
? lk.VideoTrackRenderer(
video,
fit: large
? lk.VideoViewFit.cover
: lk.VideoViewFit.cover,
mirrorMode: tile.local
? lk.VideoViewMirrorMode.mirror
: lk.VideoViewMirrorMode.off,
)
: Center(
child: Avatar(
name: tile.name,
seed: tile.userId,
mxc: tile.avatar,
size: large ? 112 : 56,
),
),
),
Positioned(
left: 8,
bottom: 8,
right: 8,
child: Row(
children: [
if (tile.micMuted)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.mic_off, color: Colors.white, size: 16),
),
Flexible(
child: Text(
tile.local ? 'You' : tile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
shadows: [Shadow(blurRadius: 4)],
),
),
),
],
),
),
],
),
),
);
}
}
class _Controls extends StatelessWidget {
const _Controls({required this.calls});
final Calls calls;
@override
Widget build(BuildContext context) {
Widget round(
IconData icon,
String label,
VoidCallback? onTap, {
bool active = true,
Color? colour,
}) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: colour ?? (active ? Colors.white24 : Colors.white),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox.square(
dimension: 60,
child: Icon(
icon,
color: colour != null || active ? Colors.white : Colors.black,
),
),
),
),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
);
final connected = calls.phase == CallPhase.connected;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
round(
calls.micOn ? Icons.mic : Icons.mic_off,
calls.micOn ? 'Mute' : 'Unmute',
connected ? calls.toggleMic : null,
active: calls.micOn,
),
round(
calls.cameraOn ? Icons.videocam : Icons.videocam_off,
'Camera',
connected ? calls.toggleCamera : null,
active: calls.cameraOn,
),
if (calls.cameraOn)
round(
Icons.flip_camera_android,
'Flip',
connected ? calls.flipCamera : null,
),
round(
Icons.call_end,
'Leave',
calls.leave,
colour: const Color(0xFFD64545),
),
],
),
);
}
}
/// The incoming call card: who, from which room, answer (with or without
/// video) or decline.
Future<void> showIncomingCall(
BuildContext context,
Calls calls,
NavigatorState navigator,
) => showModalBottomSheet<void>(
context: context,
isDismissible: false,
enableDrag: false,
builder: (sheet) => ListenableBuilder(
listenable: calls,
builder: (context, _) {
final call = calls.incoming;
if (call == null) {
// Answered elsewhere, declined, or it stopped ringing.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Navigator.of(sheet).canPop()) Navigator.of(sheet).pop();
});
return const SizedBox(height: 200);
}
final c = context.warp;
final text = Theme.of(context).textTheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
name: call.callerName,
seed: call.callerId,
mxc: call.callerAvatar,
size: 72,
),
const SizedBox(height: 12),
Text(call.callerName, style: text.titleLarge),
Text(
call.video ? 'Video call' : 'Voice call',
style: text.bodySmall,
),
if (call.roomName != call.callerName)
Text(call.roomName, style: text.bodySmall),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_RoundButton(
icon: Icons.call_end,
label: 'Decline',
colour: c.danger,
onTap: () => calls.decline(),
),
if (call.video)
_RoundButton(
icon: Icons.call,
label: 'Voice only',
colour: c.tealStrong,
onTap: () {
calls.answer(withVideo: false);
navigator.push(CallScreen.route());
},
),
_RoundButton(
icon: call.video ? Icons.videocam : Icons.call,
label: 'Answer',
colour: c.tealStrong,
onTap: () {
calls.answer();
navigator.push(CallScreen.route());
},
),
],
),
],
),
),
);
},
),
);
class _RoundButton extends StatelessWidget {
const _RoundButton({
required this.icon,
required this.label,
required this.colour,
required this.onTap,
});
final IconData icon;
final String label;
final Color colour;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Column(
children: [
Material(
color: colour,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox.square(
dimension: 64,
child: Icon(icon, color: Colors.white),
),
),
),
const SizedBox(height: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
+8 -3
View File
@@ -442,12 +442,13 @@ class _Empty extends StatelessWidget {
}
}
class RoomTile extends StatelessWidget {
class RoomTile extends ConsumerWidget {
const RoomTile({super.key, required this.room});
final Room room;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final inCall = ref.watch(callsProvider).inCallCount(room.id) > 0;
final c = context.warp;
final text = Theme.of(context).textTheme;
final last = room.lastEvent;
@@ -513,9 +514,13 @@ class RoomTile extends StatelessWidget {
Icon(Icons.lock_outline, size: 13, color: c.inkMuted),
const SizedBox(width: 4),
],
if (inCall) ...[
Icon(Icons.call, size: 14, color: c.tealText),
const SizedBox(width: 4),
],
Expanded(
child: Text(
preview,
inCall ? 'Call in progress' : preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.bodySmall?.copyWith(
+95
View File
@@ -13,6 +13,7 @@ import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/composer.dart';
import '../widgets/message.dart';
import 'call_screen.dart';
import 'room_info_screen.dart';
import 'thread_screen.dart';
@@ -150,6 +151,7 @@ class _RoomScreenState extends ConsumerState<RoomScreen>
appBar: _RoomAppBar(room: room),
body: Column(
children: [
_CallBanner(room: room),
Expanded(child: _buildTimeline(room)),
if (room.membership == Membership.invite)
_InviteBar(room: room)
@@ -298,6 +300,7 @@ class _RoomAppBar extends StatelessWidget implements PreferredSizeWidget {
),
),
actions: [
_CallButtons(room: room),
IconButton(
tooltip: 'Room info',
icon: const Icon(Icons.info_outline),
@@ -310,6 +313,98 @@ class _RoomAppBar extends StatelessWidget implements PreferredSizeWidget {
}
}
/// Voice and video call buttons; "back to the call" while in this room's.
class _CallButtons extends ConsumerWidget {
const _CallButtons({required this.room});
final Room room;
@override
Widget build(BuildContext context, WidgetRef ref) {
final calls = ref.watch(callsProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
if (room.membership != Membership.join) return const SizedBox.shrink();
if (calls.roomId == room.id) {
return IconButton(
tooltip: 'Back to the call',
icon: Icon(Icons.call, color: context.warp.tealText),
onPressed: () => Navigator.of(context).push(CallScreen.route()),
);
}
void start(bool video) {
calls.join(room.id, video: video);
Navigator.of(context).push(CallScreen.route());
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voice call',
icon: const Icon(Icons.call_outlined),
onPressed: () => start(false),
),
IconButton(
tooltip: 'Video call',
icon: const Icon(Icons.videocam_outlined),
onPressed: () => start(true),
),
],
);
},
);
}
}
/// "A call is going on" with Join, while this device isn't in it.
class _CallBanner extends ConsumerWidget {
const _CallBanner({required this.room});
final Room room;
@override
Widget build(BuildContext context, WidgetRef ref) {
final calls = ref.watch(callsProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
final count = calls.inCallCount(room.id);
if (count == 0 || calls.roomId == room.id) {
return const SizedBox.shrink();
}
final c = context.warp;
return Material(
color: c.tealSoft,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 8, 6),
child: Row(
children: [
Icon(Icons.call, color: c.tealText, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
count == 1
? '1 person in the call'
: '$count people in the call',
style: TextStyle(color: c.tealText),
),
),
FilledButton(
onPressed: () {
calls.join(room.id, video: false);
Navigator.of(context).push(CallScreen.route());
},
child: const Text('Join'),
),
],
),
),
);
},
);
}
}
class _DaySeparator extends StatelessWidget {
const _DaySeparator({required this.day});
final DateTime day;
+10
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:matrix/matrix.dart';
import 'matrix/calls.dart';
/// The one Matrix client, created in main() before the app starts.
final clientProvider = Provider<Client>(
(ref) => throw UnimplementedError('overridden in main()'),
@@ -72,3 +74,11 @@ class SelectedSpaceNotifier extends Notifier<String?> {
final selectedSpaceProvider = NotifierProvider<SelectedSpaceNotifier, String?>(
SelectedSpaceNotifier.new,
);
/// Calls: one manager for the signed-in client. Screens listen to it
/// directly (it is a ChangeNotifier).
final callsProvider = Provider<Calls>((ref) {
final calls = Calls(ref.watch(clientProvider));
ref.onDispose(calls.dispose);
return calls;
});
+2 -2
View File
@@ -489,7 +489,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: transitive
dependency: "direct main"
description:
name: flutter_webrtc
sha256: ebf5ceef2897caae425ed65a38b2961c29d9d9a626159418ed460e1a65038c00
@@ -1334,7 +1334,7 @@ packages:
source: hosted
version: "1.0.1"
webrtc_interface:
dependency: transitive
dependency: "direct main"
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
+2
View File
@@ -51,6 +51,8 @@ dependencies:
livekit_client: ^2.13.1
qr_flutter: ^4.1.0
mobile_scanner: ^7.4.2
flutter_webrtc: ^1.6.2+hotfix.4
webrtc_interface: ^1.5.1
dev_dependencies:
flutter_test: