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>
217 lines
7.0 KiB
Dart
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);
|
|
});
|
|
}
|