Mobile: calls in the background, screen sharing, read receipts per thread

- Calls keep going with the app in the background: a foreground service
  (CallService.kt, driven from lib/matrix/call_service.dart) declares the
  microphone, the camera while it's on and the screen while it's shared.
  Its notification (CallStyle) shows the call's length, opens the call
  and hangs up. Swiping the app away ends the call.
- Screen sharing on Android: consent, then media projection on the
  service, then LiveKit publishes a screen-share track. A shared screen
  replaces the sharer's camera on their tile, shown whole and unmirrored;
  stopping it from Android's controls stops the share.
- Read receipts per thread (MSC3771): an open thread sends receipts with
  thread_id, "Read" under replies comes from receipts in the thread, and
  threads with unread replies get a dot on "N replies" and in the list.

Not tested on a device yet. README updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 09:22:56 +02:00
1 parent b8ddbaacf3
commit 12550c4078
13 files changed
+722 -39

No files matched your search

+21 -5
View File
@@ -39,6 +39,10 @@ radii), the Outfit and Figtree fonts, the Warp symbol.
- 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.
Read receipts per thread (MSC3771): an open thread marks itself read
with a receipt for the thread alone, "Read" under your replies comes
from the others' receipts in the thread, and threads with replies you
haven't read get a dot (on "N replies" and in the thread list).
- 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
@@ -67,9 +71,9 @@ radii), the Outfit and Figtree fonts, the Warp symbol.
- Settings: display name, avatar, theme (system, light, dark).
**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.
sharing on iOS (it needs a broadcast upload extension), and unread counts
per thread from the server (`unread_thread_notifications`); the thread
dots are worked out on the phone from receipts.
## Sync
@@ -115,8 +119,20 @@ Element Call, with the same events, so the three can share a call. See
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.
is a grid. Mute, camera, flip camera, share the screen, leave. A "Join"
banner in a room with a call you're not in; the room list marks rooms
with one.
- Calls keep going with the app in the background: a foreground service
(`android/.../CallService.kt`, driven from `lib/matrix/call_service.dart`)
declares the microphone, the camera while it's on and the screen while
it's shared, which Android requires before it lets a backgrounded app
use them. Its notification shows the call's length; tap it to return to
the call, or hang up from it. Swiping the app away ends the call.
- Screen sharing (Android): Android asks for consent, the service adds
media projection, then LiveKit publishes the screen as a screen-share
track, which the web client and Element Call show as one. A shared
screen replaces its sharer's camera on their tile, shown whole; stopping
it from Android's own controls stops the share.
## Push notifications
+2
View File
@@ -64,6 +64,8 @@ kotlin {
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// The ongoing call notification (CallService).
implementation("androidx.core:core-ktx:1.17.0")
}
// UnifiedPush's connector depends on Tink's JVM artifact and
@@ -10,6 +10,12 @@
<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" />
<!-- A call keeps going in the background (CallService): the microphone,
the camera and screen sharing in a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- 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. -->
@@ -47,6 +53,10 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".CallService"
android:exported="false"
android:foregroundServiceType="microphone|camera|mediaProjection" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -0,0 +1,218 @@
package io.github.dariuszjaniak.warp
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
/**
* Keeps a call going with the app in the background: Android stops a
* backgrounded app's microphone and camera, and in time its process,
* unless a foreground service says they are in use. It shows the ongoing
* call notification (tap to return, or hang up) and nothing else; the
* call itself stays in Dart (lib/matrix/calls.dart), which updates this
* over the `warp/call_service` channel (MainActivity).
*
* Its types follow what the call uses: the microphone, the camera while
* it is on, and the screen (media projection) while it is shared, which
* Android requires before the capture starts.
*/
class CallService : Service() {
companion object {
private const val TAG = "CallService"
private const val CHANNEL = "ongoing_call"
private const val NOTIFICATION_ID = 0x7fff0002
const val ACTION_UPDATE = "io.github.dariuszjaniak.warp.CALL_UPDATE"
const val ACTION_HANG_UP = "io.github.dariuszjaniak.warp.CALL_HANG_UP"
const val ACTION_OPEN = "io.github.dariuszjaniak.warp.CALL_OPEN"
private const val EXTRA_TITLE = "title"
private const val EXTRA_STARTED_AT = "startedAt"
private const val EXTRA_CAMERA = "camera"
private const val EXTRA_SCREEN = "screen"
/** Set by MainActivity: asks the Dart side to leave the call. */
var onHangUp: (() -> Unit)? = null
private var running: CallService? = null
/**
* Starts the service, or updates the running one. False when there
* is nothing to keep (no microphone, camera or screen permission),
* or Android refused.
*/
fun update(
context: Context,
title: String,
startedAt: Long,
camera: Boolean,
screen: Boolean,
): Boolean {
val service = running
if (service != null) {
return service.show(title, startedAt, camera, screen)
}
if (types(context, camera, screen) == 0) return false
val intent = Intent(context, CallService::class.java)
.setAction(ACTION_UPDATE)
.putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_STARTED_AT, startedAt)
.putExtra(EXTRA_CAMERA, camera)
.putExtra(EXTRA_SCREEN, screen)
return try {
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
// Android 12+ refuses one started from the background.
Log.w(TAG, "Could not start the call service", e)
false
}
}
fun stop(context: Context) {
context.stopService(Intent(context, CallService::class.java))
}
/** The foreground service types for what the call uses. */
private fun types(context: Context, camera: Boolean, screen: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// Before Android 11 only media projection is checked.
return if (screen || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
}
}
fun granted(permission: String) = ContextCompat.checkSelfPermission(
context,
permission,
) == PackageManager.PERMISSION_GRANTED
var types = 0
if (granted(Manifest.permission.RECORD_AUDIO)) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (camera && granted(Manifest.permission.CAMERA)) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
if (screen) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
return types
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_HANG_UP) {
onHangUp?.invoke()
if (running == null) stopSelf()
return START_NOT_STICKY
}
running = this
val shown = show(
intent?.getStringExtra(EXTRA_TITLE) ?: "",
intent?.getLongExtra(EXTRA_STARTED_AT, System.currentTimeMillis())
?: System.currentTimeMillis(),
intent?.getBooleanExtra(EXTRA_CAMERA, false) ?: false,
intent?.getBooleanExtra(EXTRA_SCREEN, false) ?: false,
)
if (!shown) stopSelf()
return START_NOT_STICKY
}
/** The app was swiped away: the call goes with it. */
override fun onTaskRemoved(rootIntent: Intent?) {
onHangUp?.invoke()
stopSelf()
}
override fun onDestroy() {
if (running == this) running = null
super.onDestroy()
}
private fun show(title: String, startedAt: Long, camera: Boolean, screen: Boolean): Boolean {
val types = types(this, camera, screen)
if (types == 0) {
stopSelf()
return false
}
createChannel()
return try {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification(title, startedAt, screen),
types,
)
true
} catch (e: Exception) {
// Android 14 refuses camera and microphone types from the
// background, and media projection without consent.
Log.w(TAG, "Could not show the call notification", e)
false
}
}
private fun notification(title: String, startedAt: Long, screen: Boolean) =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setColor(0xFF00A389.toInt())
.setContentTitle(title)
.setContentText(if (screen) "Sharing your screen" else "Call in progress")
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.setAction(ACTION_OPEN)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
)
.setStyle(
NotificationCompat.CallStyle.forOngoingCall(
Person.Builder().setName(title).setImportant(true).build(),
PendingIntent.getService(
this,
1,
Intent(this, CallService::class.java).setAction(ACTION_HANG_UP),
PendingIntent.FLAG_IMMUTABLE,
),
),
)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setWhen(startedAt)
.setUsesChronometer(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
private fun createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL) != null) return
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Ongoing call", NotificationManager.IMPORTANCE_LOW).apply {
description = "The call you are in, while the app is in the background"
setShowBadge(false)
},
)
}
}
@@ -1,5 +1,49 @@
package io.github.dariuszjaniak.warp
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private var calls: MethodChannel? = null
/** The call service's side of `warp/call_service`; see lib/matrix/call_service.dart. */
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "warp/call_service")
channel.setMethodCallHandler { call, result ->
when (call.method) {
"update" -> result.success(
CallService.update(
this,
call.argument<String>("title") ?: "",
call.argument<Number>("startedAt")?.toLong() ?: System.currentTimeMillis(),
call.argument<Boolean>("camera") ?: false,
call.argument<Boolean>("screen") ?: false,
),
)
"stop" -> {
CallService.stop(this)
result.success(null)
}
else -> result.notImplemented()
}
}
CallService.onHangUp = { runOnUiThread { channel.invokeMethod("hangUp", null) } }
calls = channel
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == CallService.ACTION_OPEN) calls?.invokeMethod("openCall", null)
}
override fun onDestroy() {
// The Dart side, and so the call, goes with the activity: so does
// the notification. (Configuration changes don't destroy it.)
CallService.onHangUp = null
CallService.stop(this)
super.onDestroy()
}
}
+13
View File
@@ -76,6 +76,7 @@ class _SignedInState extends ConsumerState<_SignedIn>
..add(client.onKeyVerificationRequest.stream.listen(_onVerification));
final calls = ref.read(callsProvider);
calls.addListener(_onCalls);
_subs.add(calls.openRequests.listen((_) => _openCall()));
_subs.add(
calls.errors.stream.listen(
(message) => messengerKey.currentState?.showSnackBar(
@@ -134,6 +135,18 @@ class _SignedInState extends ConsumerState<_SignedIn>
nav.push(RoomScreen.route(roomId));
}
/// Shows the call screen, unless it is already on top.
void _openCall() {
final nav = navigatorKey.currentState;
if (nav == null || !ref.read(callsProvider).inCall) return;
var onTop = false;
nav.popUntil((route) {
onTop = route.settings.name == 'call';
return true;
});
if (!onTop) nav.push(CallScreen.route());
}
void _onUia(UiaRequest request) {
final context = navigatorKey.currentContext;
if (context == null) return request.cancel();
+61
View File
@@ -0,0 +1,61 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:matrix/matrix.dart';
/// The Android foreground service that keeps a call going with the app in
/// the background (android/.../CallService.kt): without it Android cuts a
/// backgrounded app off from the microphone and camera, and screen sharing
/// can't start at all. Its notification shows the call, opens it and hangs
/// up. Nothing on other platforms.
class CallService {
static const _channel = MethodChannel('warp/call_service');
/// Hang up from the notification, and tapping it.
static void listen({
required void Function() onHangUp,
required void Function() onOpen,
}) {
if (!Platform.isAndroid) return;
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'hangUp':
onHangUp();
case 'openCall':
onOpen();
}
});
}
/// Starts the service, or changes what it declares: the camera while it
/// is on, the screen while it is shared. False if Android refused.
static Future<bool> update({
required String title,
required DateTime startedAt,
required bool camera,
required bool screen,
}) async {
if (!Platform.isAndroid) return true;
try {
return await _channel.invokeMethod<bool>('update', {
'title': title,
'startedAt': startedAt.millisecondsSinceEpoch,
'camera': camera,
'screen': screen,
}) ??
false;
} catch (e) {
Logs().w('Could not start the call service', e);
return false;
}
}
static Future<void> stop() async {
if (!Platform.isAndroid) return;
try {
await _channel.invokeMethod<void>('stop');
} catch (e) {
Logs().w('Could not stop the call service', e);
}
}
}
+117 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
@@ -7,9 +8,11 @@ import 'package:http/http.dart' as http;
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart';
import 'call_service.dart';
import 'notifications.dart';
import 'package:webrtc_interface/webrtc_interface.dart' as rtc;
import 'package:flutter_webrtc/flutter_webrtc.dart' show frameCryptorFactory;
import 'package:flutter_webrtc/flutter_webrtc.dart'
show Helper, 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.
@@ -31,6 +34,10 @@ import 'package:flutter_webrtc/flutter_webrtc.dart' show frameCryptorFactory;
/// (`org.matrix.msc4075.rtc.notification`); declining answers it
/// (`org.matrix.msc4310.rtc.decline`).
///
/// While this device is in a call, a foreground service on Android
/// ([CallService]) keeps it going with the app in the background, and lets
/// it share the screen.
///
/// One call at a time.
class Calls extends ChangeNotifier {
Calls(this.client) {
@@ -38,6 +45,7 @@ class Calls extends ChangeNotifier {
..add(client.onSync.stream.listen((_) => _refresh()))
..add(client.onTimelineEvent.stream.listen(_onTimelineEvent))
..add(client.onToDeviceEvent.stream.listen(_onToDevice));
CallService.listen(onHangUp: leave, onOpen: () => _openRequests.add(null));
_expiryTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _refresh(),
@@ -90,6 +98,11 @@ class Calls extends ChangeNotifier {
/// Errors to show once, then cleared.
final errors = StreamController<String>.broadcast();
final _openRequests = StreamController<void>.broadcast();
/// The ongoing call notification was tapped: show the call.
Stream<void> get openRequests => _openRequests.stream;
bool get inCall => roomId != null;
int inCallCount(String roomId) => active[roomId] ?? 0;
@@ -419,6 +432,9 @@ class Calls extends ChangeNotifier {
errors.add("Can't use the camera: $e");
}
}
// Now that the microphone and camera are asked for, the service can
// declare them.
_mediaStarted = true;
notifyListeners();
} catch (e, s) {
Logs().e('Joining the call failed', e, s);
@@ -438,6 +454,8 @@ class Calls extends ChangeNotifier {
phase = null;
joinedAt = null;
ringingOut = false;
_mediaStarted = false;
_sharingScreen = false;
_ringOutTimer?.cancel();
_delayTimer?.cancel();
_keyProvider = null;
@@ -728,6 +746,11 @@ class Calls extends ChangeNotifier {
bool get micOn => livekit?.localParticipant?.isMicrophoneEnabled() ?? false;
bool get cameraOn => livekit?.localParticipant?.isCameraEnabled() ?? false;
bool get screenSharing =>
livekit?.localParticipant?.isScreenShareEnabled() ?? false;
/// Screen sharing: Android only (iOS needs a broadcast extension).
bool get canShareScreen => Platform.isAndroid;
Future<void> toggleMic() async {
final p = livekit?.localParticipant;
@@ -743,6 +766,53 @@ class Calls extends ChangeNotifier {
notifyListeners();
}
/// Starts or stops sharing the screen. Android asks the user first, and
/// the call service must declare media projection before the capture
/// starts.
Future<void> toggleScreenShare() async {
final p = livekit?.localParticipant;
if (p == null) return;
if (p.isScreenShareEnabled()) {
await _stopScreenShare();
return;
}
if (!canShareScreen) return;
try {
if (!await Helper.requestCapturePermission()) return;
_sharingScreen = true;
if (!await _syncService()) {
_sharingScreen = false;
errors.add("Android didn't let the app share the screen.");
return;
}
final pub = await p.setScreenShareEnabled(true);
// Stopped from Android's own controls: unpublish it too. LiveKit
// listens to the track's end as well, so its handler is kept.
final track = pub?.track?.mediaStreamTrack;
if (track != null) {
final previous = track.onEnded;
track.onEnded = () {
previous?.call();
if (_sharingScreen) _stopScreenShare();
};
}
} catch (e) {
_sharingScreen = false;
errors.add("Can't share the screen: $e");
}
notifyListeners();
}
Future<void> _stopScreenShare() async {
_sharingScreen = false;
try {
await livekit?.localParticipant?.setScreenShareEnabled(false);
} catch (e) {
Logs().w('Could not stop sharing the screen', e);
}
notifyListeners();
}
/// Switches between the front and the back camera.
Future<void> flipCamera() async {
final track = livekit?.localParticipant?.videoTrackPublications
@@ -756,6 +826,50 @@ class Calls extends ChangeNotifier {
notifyListeners();
}
// --- The call service ------------------------------------------------------
/// The microphone and camera have been asked for.
bool _mediaStarted = false;
/// Screen sharing is on, or about to be.
bool _sharingScreen = false;
/// What the service was last told, so it is told only of changes.
String? _serviceState;
@override
void notifyListeners() {
super.notifyListeners();
_syncService();
}
/// Starts, updates or stops the call service to match the call.
Future<bool> _syncService() async {
final rid = roomId;
final since = joinedAt;
if (rid == null || since == null || !_mediaStarted) {
if (_serviceState != null) {
_serviceState = null;
await CallService.stop();
}
return false;
}
final title = client.getRoomById(rid)?.getLocalizedDisplayname() ?? 'Call';
final camera = cameraOn;
final screen = _sharingScreen || screenSharing;
final state = '$rid|$title|$camera|$screen';
if (state == _serviceState) return true;
_serviceState = state;
final ok = await CallService.update(
title: title,
startedAt: since,
camera: camera,
screen: screen,
);
if (!ok) _serviceState = null;
return ok;
}
@override
void dispose() {
for (final s in _subs) {
@@ -763,7 +877,9 @@ class Calls extends ChangeNotifier {
}
_expiryTimer?.cancel();
leave();
CallService.stop();
errors.close();
_openRequests.close();
super.dispose();
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:matrix/matrix.dart';
/// Read receipts per thread (MSC3771, in the spec since v1.4): a receipt
/// with a `thread_id` says how far someone has read in that thread alone,
/// so reading a thread doesn't mark the room read, nor the room a thread.
/// The SDK keeps them apart already (`receiptState.byThread`) but only
/// sends unthreaded ones; the thread screen sends these.
/// Our receipt in the thread under [rootId], public or private, whichever
/// is further.
LatestReceiptStateData? ownThreadReceipt(Room room, String rootId) =>
room.receiptState.byThread[rootId]?.latestOwnReceipt;
/// Marks the thread under [rootId] read up to [eventId], a reply in it.
/// The private receipt always; the public one too unless the user keeps
/// receipts private, as for the room.
Future<void> markThreadRead(Room room, String rootId, String eventId) async {
if (ownThreadReceipt(room, rootId)?.eventId == eventId) return;
final client = room.client;
await client.postReceipt(
room.id,
ReceiptType.mReadPrivate,
eventId,
threadId: rootId,
);
if (client.receiptsPublicByDefault) {
await client.postReceipt(
room.id,
ReceiptType.mRead,
eventId,
threadId: rootId,
);
}
}
/// The other people whose receipt in the thread under [rootId] is on
/// [eventId], with those whose unthreaded receipt is.
Set<String> threadReaders(Room room, String rootId, String eventId) {
final me = room.client.userID;
final state = room.receiptState;
return {
for (final e
in state.byThread[rootId]?.otherUsers.entries ??
const <MapEntry<String, LatestReceiptStateData>>[])
if (e.value.eventId == eventId && e.key != me) e.key,
for (final e in state.global.otherUsers.entries)
if (e.value.eventId == eventId && e.key != me) e.key,
};
}
/// The newest reply in a thread, as far as this device knows: the
/// server's summary on the root, or a newer one that came in since.
({String eventId, String senderId, DateTime time})? latestThreadReply(
Event root, [
Timeline? timeline,
]) {
({String eventId, String senderId, DateTime time})? latest;
final bundled = root.unsigned
?.tryGetMap<String, Object?>('m.relations')
?.tryGetMap<String, Object?>(RelationshipTypes.thread)
?.tryGetMap<String, Object?>('latest_event');
final id = bundled?.tryGet<String>('event_id');
final sender = bundled?.tryGet<String>('sender');
final ts = bundled?.tryGet<int>('origin_server_ts');
if (id != null && sender != null && ts != null) {
latest = (
eventId: id,
senderId: sender,
time: DateTime.fromMillisecondsSinceEpoch(ts),
);
}
if (timeline != null) {
for (final e in root.aggregatedEvents(timeline, RelationshipTypes.thread)) {
if (!e.status.isSynced) continue;
if (latest == null || e.originServerTs.isAfter(latest.time)) {
latest = (
eventId: e.eventId,
senderId: e.senderId,
time: e.originServerTs,
);
}
}
}
return latest;
}
/// Whether the thread under [root] has a reply we haven't read: one from
/// someone else, newer than our receipt in the thread and than our
/// unthreaded one (which covers everything before it).
bool threadUnread(Event root, [Timeline? timeline]) {
final latest = latestThreadReply(root, timeline);
if (latest == null) return false;
final room = root.room;
if (latest.senderId == room.client.userID) return false;
final inThread = ownThreadReceipt(room, root.eventId);
if (inThread?.eventId == latest.eventId) return false;
final unthreaded = room.receiptState.global.latestOwnReceipt;
if (unthreaded?.eventId == latest.eventId) return false;
for (final r in [inThread, unthreaded]) {
if (r != null && !latest.time.isAfter(r.timestamp)) return false;
}
return true;
}
+46 -28
View File
@@ -11,8 +11,9 @@ 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.
/// corner for a call of two, a grid of tiles for more (a shared screen in
/// place of its sharer's camera); mute, camera, flip, share the screen and
/// leave. "Calling…" while it rings the other person.
class CallScreen extends ConsumerStatefulWidget {
const CallScreen({super.key});
@@ -103,6 +104,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
avatar: user?.avatarUrl,
local: local,
video: video(screen) ?? video(camera),
screen: video(screen) != null,
micMuted: mic == null || mic.muted,
speaking: speakers.contains(p.identity),
);
@@ -130,6 +132,7 @@ class _Tile {
required this.avatar,
required this.local,
required this.video,
required this.screen,
required this.micMuted,
required this.speaking,
});
@@ -140,6 +143,9 @@ class _Tile {
final Uri? avatar;
final bool local;
final lk.VideoTrack? video;
/// [video] is a shared screen: shown whole, never mirrored.
final bool screen;
final bool micMuted;
final bool speaking;
}
@@ -288,10 +294,10 @@ class _TileView extends StatelessWidget {
child: video != null
? lk.VideoTrackRenderer(
video,
fit: large
? lk.VideoViewFit.cover
fit: tile.screen
? lk.VideoViewFit.contain
: lk.VideoViewFit.cover,
mirrorMode: tile.local
mirrorMode: tile.local && !tile.screen
? lk.VideoViewMirrorMode.mirror
: lk.VideoViewMirrorMode.off,
)
@@ -349,37 +355,40 @@ class _Controls extends StatelessWidget {
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,
}) => Expanded(
child: 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: 56,
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),
),
],
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
);
final connected = calls.phase == CallPhase.connected;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
round(
calls.micOn ? Icons.mic : Icons.mic_off,
@@ -399,6 +408,15 @@ class _Controls extends StatelessWidget {
'Flip',
connected ? calls.flipCamera : null,
),
if (calls.canShareScreen)
round(
calls.screenSharing
? Icons.stop_screen_share
: Icons.screen_share,
'Screen',
connected ? calls.toggleScreenShare : null,
active: !calls.screenSharing,
),
round(
Icons.call_end,
'Leave',
@@ -3,13 +3,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/thread_receipts.dart';
import '../state.dart';
import '../util/format.dart';
import '../widgets/avatar.dart';
import '../widgets/message_extras.dart';
import 'thread_screen.dart';
/// A room's threads, the most recently active first (`/threads`).
/// A room's threads, the most recently active first (`/threads`); those
/// with replies you haven't read are bold, with a dot.
class ThreadListScreen extends ConsumerStatefulWidget {
const ThreadListScreen({super.key, required this.roomId});
final String roomId;
@@ -41,6 +43,8 @@ class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
@override
Widget build(BuildContext context) {
// Receipts come with syncs: the unread marks follow them.
ref.watch(syncProvider);
return Scaffold(
appBar: AppBar(title: const Text('Threads')),
body: RefreshIndicator(
@@ -97,6 +101,7 @@ class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
final when = latestTs == null
? root.originServerTs
: DateTime.fromMillisecondsSinceEpoch(latestTs);
final unread = threadUnread(root);
return ListTile(
leading: Avatar(
name: sender.calcDisplayname(),
@@ -107,12 +112,23 @@ class _ThreadListScreenState extends ConsumerState<ThreadListScreen> {
quoteText(root),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: unread
? const TextStyle(fontWeight: FontWeight.w700)
: null,
),
subtitle: Text(
'${sender.calcDisplayname()} · '
'${count == 1 ? '1 reply' : '$count replies'} · '
'${formatListTime(when)}',
),
trailing: unread
? Icon(
Icons.circle,
size: 10,
color: Theme.of(context).colorScheme.primary,
semanticLabel: 'Unread',
)
: null,
onTap: () => Navigator.of(
context,
).push(ThreadScreen.route(widget.roomId, root.eventId)),
+57 -2
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/thread_receipts.dart';
import '../matrix/timeline.dart';
import '../state.dart';
import '../widgets/composer.dart';
@@ -15,6 +16,10 @@ import '../widgets/message.dart';
/// (which receives new replies, their sending status, edits and reactions
/// like any event) and adds the older replies it doesn't have from
/// `/relations`.
///
/// While it is on screen and the app in front, it marks the thread read
/// with a receipt for the thread alone; "Read" under your replies comes
/// from the others' receipts in the thread.
class ThreadScreen extends ConsumerStatefulWidget {
const ThreadScreen({super.key, required this.roomId, required this.rootId});
final String roomId;
@@ -29,7 +34,8 @@ class ThreadScreen extends ConsumerStatefulWidget {
ConsumerState<ThreadScreen> createState() => _ThreadScreenState();
}
class _ThreadScreenState extends ConsumerState<ThreadScreen> {
class _ThreadScreenState extends ConsumerState<ThreadScreen>
with WidgetsBindingObserver {
Timeline? _timeline;
Event? _root;
Object? _error;
@@ -42,17 +48,22 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
Event? _replyTo;
final _scroll = ScrollController();
/// The reply we last sent a receipt for, until the server echoes it.
String? _receiptSent;
Room get _room => ref.read(clientProvider).getRoomById(widget.roomId)!;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scroll.addListener(_onScroll);
_load();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timeline?.cancelSubscriptions();
_scroll.dispose();
super.dispose();
@@ -64,6 +75,7 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
final timeline = await room.getTimeline(
onUpdate: () {
if (mounted) setState(() {});
_markRead();
},
);
final root =
@@ -78,11 +90,38 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
_root = root;
});
await _loadOlder();
_markRead();
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _markRead();
}
/// Sends a receipt in the thread for its newest reply, while the thread
/// is on screen and the app is in front.
void _markRead() {
if (!mounted || _timeline == null) return;
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
return;
}
final room = _room;
if (room.membership != Membership.join) return;
final newest = _replies().where((e) => e.status.isSynced).firstOrNull;
if (newest == null || newest.eventId == _receiptSent) return;
if (ownThreadReceipt(room, widget.rootId)?.eventId == newest.eventId) {
return;
}
_receiptSent = newest.eventId;
markThreadRead(room, widget.rootId, newest.eventId).catchError((Object e) {
Logs().w('Could not send a thread read receipt', e);
_receiptSent = null;
});
}
Future<Event> _decrypt(Event event) async {
final encryption = event.room.client.encryption;
if (event.type != EventTypes.Encrypted || encryption == null) return event;
@@ -171,6 +210,7 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
} else {
final replies = _replies();
final entries = [...replies, ?root];
final readUpTo = _readByOthersIndex(entries, room, widget.rootId);
final items = projectTimeline([
for (final e in entries)
(
@@ -217,7 +257,7 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
timeline: timeline,
firstInGroup: firstInGroup,
lastInGroup: lastInGroup,
readByOthers: false,
readByOthers: readUpTo != null && index >= readUpTo,
onReply: (e) => setState(() => _replyTo = e),
),
if (entries[index].eventId == widget.rootId)
@@ -266,6 +306,21 @@ class _ThreadScreenState extends ConsumerState<ThreadScreen> {
}
}
/// The index (newest first) of the newest entry someone else has read,
/// by their receipt in the thread or an unthreaded one, so your replies
/// from there back say "Read".
int? _readByOthersIndex(List<Event> entries, Room room, String rootId) {
for (var i = 0; i < entries.length; i++) {
final e = entries[i];
if (!e.status.isSynced) continue;
if (threadReaders(room, rootId, e.eventId).isNotEmpty ||
e.receipts.any((r) => r.user.id != room.client.userID)) {
return i;
}
}
return null;
}
class _ThreadDivider extends StatelessWidget {
const _ThreadDivider();
+12 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../matrix/client.dart';
import '../matrix/thread_receipts.dart';
import '../theme.dart';
import 'avatar.dart';
@@ -304,7 +305,8 @@ class QuoteBlock extends StatelessWidget {
}
}
/// Under a message that starts a thread: how many replies, opening it.
/// Under a message that starts a thread: how many replies, opening it;
/// a dot when it has replies you haven't read.
class ThreadSummary extends StatelessWidget {
const ThreadSummary({
super.key,
@@ -340,6 +342,15 @@ class ThreadSummary extends StatelessWidget {
fontWeight: FontWeight.w600,
),
),
if (threadUnread(event, timeline)) ...[
const SizedBox(width: 6),
Icon(
Icons.circle,
size: 8,
color: c.tealText,
semanticLabel: 'Unread',
),
],
],
),
),