Files
Warp/apps/mobile/lib/app.dart
T
tajniak81andClaude Opus 5.5 12550c4078 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>
2026-10-19 09:22:56 +02:00

165 lines
5.1 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'matrix/calls.dart';
import 'matrix/notifications.dart';
import 'screens/call_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'screens/room_screen.dart';
import 'state.dart';
import 'theme.dart';
import 'widgets/uia.dart';
import 'widgets/verification_sheet.dart';
final navigatorKey = GlobalKey<NavigatorState>();
final messengerKey = GlobalKey<ScaffoldMessengerState>();
class WarpApp extends ConsumerWidget {
const WarpApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final loginState = ref.watch(loginStateProvider);
final signedIn = loginState.value == LoginState.loggedIn;
// Signing in from the sign-up screen, or out from settings, leaves those
// screens on top of the new home: close them.
ref.listen(loginStateProvider, (prev, next) {
if (prev?.value != next.value) {
navigatorKey.currentState?.popUntil((route) => route.isFirst);
}
});
return MaterialApp(
title: 'Warp',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
debugShowCheckedModeBanner: false,
theme: warpTheme(Brightness.light),
darkTheme: warpTheme(Brightness.dark),
themeMode: ref.watch(themeModeProvider),
home: loginState.isLoading
? const Scaffold()
: signedIn
? const _SignedIn(key: ValueKey('signed-in'))
: const LoginScreen(key: ValueKey('signed-out')),
);
}
}
/// Everything that runs only while signed in: the notification listener,
/// prompts the SDK raises (a password for an account change, an incoming
/// verification request) and opening rooms from notifications.
class _SignedIn extends ConsumerStatefulWidget {
const _SignedIn({super.key});
@override
ConsumerState<_SignedIn> createState() => _SignedInState();
}
class _SignedInState extends ConsumerState<_SignedIn>
with WidgetsBindingObserver {
final _subs = <StreamSubscription>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final client = ref.read(clientProvider);
_subs
..add(Notifications.watch(client))
..add(Notifications.opened.listen(_openRoom))
..add(client.onUiaRequest.stream.listen(_onUia))
..add(client.onKeyVerificationRequest.stream.listen(_onVerification));
final calls = ref.read(callsProvider);
calls.addListener(_onCalls);
_subs.add(calls.openRequests.listen((_) => _openCall()));
_subs.add(
calls.errors.stream.listen(
(message) => messengerKey.currentState?.showSnackBar(
SnackBar(content: Text(message)),
),
),
);
Notifications.launchRoomId().then((roomId) {
if (roomId != null) _openRoom(roomId);
});
Notifications.requestPermission();
}
IncomingCall? _shownCall;
/// Shows the incoming call card when a call starts ringing.
void _onCalls() {
final calls = ref.read(callsProvider);
final call = calls.incoming;
if (call == null || call == _shownCall) return;
_shownCall = call;
final context = navigatorKey.currentContext;
final navigator = navigatorKey.currentState;
if (context == null || navigator == null) return;
if (!Notifications.foreground) {
Notifications.show(
roomId: call.roomId,
title: call.callerName,
body: call.video ? 'Video call' : 'Voice call',
);
}
showIncomingCall(context, calls, navigator);
}
@override
void dispose() {
ref.read(callsProvider).removeListener(_onCalls);
WidgetsBinding.instance.removeObserver(this);
for (final sub in _subs) {
sub.cancel();
}
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The sync loop keeps running in the background for as long as Android
// lets the process live; pushes cover the time after that.
Notifications.foreground = state == AppLifecycleState.resumed;
}
void _openRoom(String roomId) {
final nav = navigatorKey.currentState;
if (nav == null) return;
nav.popUntil((route) => route.isFirst);
nav.push(RoomScreen.route(roomId));
}
/// 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();
handleUia(context, ref.read(clientProvider), request);
}
void _onVerification(KeyVerification request) {
final context = navigatorKey.currentContext;
if (context == null) return;
showVerificationSheet(context, request);
}
@override
Widget build(BuildContext context) => const HomeScreen();
}