Files
Warp/apps/mobile/lib/matrix/notifications.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

217 lines
7.0 KiB
Dart

import 'dart:async';
import 'dart:ui' show Color;
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:matrix/matrix.dart';
/// System notifications for new messages: one per room, replaced as more
/// messages arrive, opening the room when tapped.
///
/// They come from two places. While the app runs, from the SDK's
/// [Client.onNotification] (events the user's push rules say notify). While
/// it doesn't, from a push ([Push]), which starts the app in the background
/// just long enough to fetch the event and show it.
class Notifications {
Notifications._();
static final _plugin = FlutterLocalNotificationsPlugin();
static final _opened = StreamController<String>.broadcast();
static bool _ready = false;
/// Room IDs of notifications the user tapped while the app was running.
static Stream<String> get opened => _opened.stream;
/// The room the user is looking at, whose messages don't notify.
static String? activeRoomId;
/// Whether the app is in the foreground. Messages in rooms other than
/// [activeRoomId] still notify then.
static bool foreground = true;
static const _channel = AndroidNotificationChannel(
'messages',
'Messages',
description: 'New messages and invites',
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(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: (response) {
final roomId = response.payload;
if (roomId != null && roomId.isNotEmpty) _opened.add(roomId);
},
);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_channel);
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_callChannel);
_ready = true;
}
/// The room of the notification that launched the app, if one did.
static Future<String?> launchRoomId() async {
final details = await _plugin.getNotificationAppLaunchDetails();
if (details?.didNotificationLaunchApp != true) return null;
return details?.notificationResponse?.payload;
}
/// Asks for the Android 13+ notification permission. Returns whether
/// notifications may be shown.
static Future<bool> requestPermission() async =>
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
/// Shows [event] unless the user is looking at its room.
static Future<void> showEvent(Event event) async {
final room = event.room;
if (foreground && room.id == activeRoomId) return;
final i18n = const MatrixDefaultLocalizations();
final body = room.membership == Membership.invite
? '${event.senderFromMemoryOrFallback.calcDisplayname()} invited you'
: event.calcLocalizedBodyFallback(
i18n,
withSenderNamePrefix: !room.isDirectChat,
hideReply: true,
plaintextBody: false,
removeMarkdown: true,
);
await show(
roomId: room.id,
title: room.getLocalizedDisplayname(i18n),
body: body,
timestamp: event.originServerTs,
);
}
static Future<void> show({
required String roomId,
required String title,
required String body,
DateTime? timestamp,
}) async {
if (!_ready) await init();
try {
await _plugin.show(
id: _idFor(roomId),
title: title,
body: body,
payload: roomId,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channel.id,
_channel.name,
channelDescription: _channel.description,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
when: timestamp?.millisecondsSinceEpoch,
showWhen: timestamp != null,
color: const Color(0xFF00A389),
groupKey: 'messages',
),
),
);
} catch (e, s) {
Logs().w('Could not show a notification', e, s);
}
}
/// Removes the room's notification, when it has been read or opened.
static Future<void> clearRoom(String roomId) async {
if (!_ready) return;
await _plugin.cancel(id: _idFor(roomId));
}
static Future<void> clearAll() async {
if (!_ready) return;
await _plugin.cancelAll();
}
// Notification IDs are 32-bit ints; a room's is a stable hash of its ID so
// a new message replaces the room's previous notification.
static int _idFor(String roomId) {
var h = 0x811c9dc5;
for (final unit in roomId.codeUnits) {
h = ((h ^ unit) * 0x01000193) & 0x7fffffff;
}
return h;
}
/// Listens to [client] for events that notify while the app is running.
static StreamSubscription<Event> watch(Client client) =>
client.onNotification.stream.listen((event) {
if (kDebugMode) debugPrint('notify ${event.eventId}');
showEvent(event);
});
}