Files
Warp/apps/mobile/README.md
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

12 KiB

Warp for Android

A Matrix client for Android, written in Flutter on the famedly Matrix Dart SDK with vodozemac for end-to-end encryption. It speaks the plain Client-Server API, so it works against any Matrix homeserver, not only Warp. The same code is meant to ship to iOS later (PLAN.md §8.5); only Android is built and tested today.

It looks like the web client: the same tokens (colours, radii), the Outfit and Figtree fonts, the Warp symbol.

What it does

  • Sign in with a password, on a server name (example.org, found through /.well-known/matrix/client), a homeserver address, or a full user ID alone. Sign up when the server allows it, with a registration token when it asks for one.
  • The session survives restarts: sync state, rooms and keys are in SQLite in the app's private storage, and the access token and cached secrets also in the Android Keystore (flutter_secure_storage). They are excluded from cloud backups and device transfers, since a restored copy would be a second session with this one's keys.
  • Room list: unread and mention counts, invites first with Join and Decline, a search, and filters for unread, people and rooms.
  • Timeline: grouped messages, day separators, room events as one-line notices, older history on scroll, links that open in the browser, images (full screen with zoom), files saved where you choose. Media is fetched with the access token (authenticated media, Matrix 1.11).
  • Sending: text (/me and the SDK's other commands), photos and videos from the gallery or the camera, any file. A message shows at once as Sending, then Sent, then Read once someone else has read it; a 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. 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 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, turning encryption on, leaving.
  • End-to-end encryption, on by default:
    • Set up encryption creates cross-signing keys, secret storage and a key 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 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, sign one out.
  • Notifications while the app runs, one per room, opening the room when tapped, and push notifications through UnifiedPush (below).
  • Settings: display name, avatar, theme (system, light, dark).

Not yet: a per-message trust shield like the web client's, screen 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

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 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, 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

Warp doesn't use Firebase. Push goes through UnifiedPush: the user installs a distributor app, such as ntfy, and turns on Push notifications in Settings. The distributor gives the app an endpoint, which becomes a Matrix pusher on the homeserver. When something notifies, the homeserver posts to the distributor's Matrix gateway, the distributor wakes the app, and the app fetches the event, decrypts it if it can, and shows it.

  • Pushes are event_id_only: the gateway and the distributor never see message content, in encrypted rooms or not.
  • The gateway is found with UnifiedPush's discovery (GET <endpoint origin>/_matrix/push/v1/notify; ntfy has one). A distributor without one goes through UnifiedPush's public gateway, matrix.gateway.unifiedpush.org.
  • The homeserver needs push.enabled (the default); see docs/push.md. It accepts any gateway URL with the path /_matrix/push/v1/notify.
  • Without a distributor the app is still notified, but only while Android keeps it running.

Build and run

You need:

  • Flutter 3.44 or later (Dart 3.12).

  • The Android SDK with an NDK and CMake (Android Studio installs them).

  • Rust, with the Android targets: vodozemac is compiled from source during the build.

    rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
    

Then:

Command What
make mobile release APKs, one per ABI, to bin/warp-android-<abi>.apk
make mobile-run run on a connected phone or emulator (ARGS=--release for a release build)
make mobile-test flutter analyze and the unit tests

The first build compiles the Rust crate and takes a few minutes; later builds reuse it.

Release builds are signed with the key in android/key.properties (storeFile, storePassword, keyAlias, keyPassword; not committed). Without that file they are signed with the debug key, which is fine for trying a release build and not for distributing one.

Against a local homeserver

Debug builds may use plain HTTP, release builds only HTTPS. From the emulator, the host machine is 10.0.2.2:

WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key ./bin/warp generate-keys
WARP_SERVER_NAME=dev.local WARP_PRIVATE_KEY=./keys/dev.key WARP_REGISTRATION_ENABLED=true ./bin/warp serve

and sign up on the server http://10.0.2.2:8008.

Windows notes

  • android/gradle.properties turns off Kotlin's incremental compilation: with the project and the pub cache on different drives, plugins fail to compile with "different roots".
  • flutter test builds the native code of every dependency for the host, including webcrypto (pulled in by UnifiedPush's Linux support). Its build needs CMake 3.13 or later; if Visual Studio's developer environment puts an older CMake first (Visual Studio 2017 ships 3.12), the build fails with "does not appear to contain CMakeLists.txt". Android builds use the Android SDK's CMake and are not affected.

Layout

Path What
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), 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, 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

The SDK's objects change in place, as matrix-js-sdk's do in the web client. Screens watch a counter bumped on every sync (syncProvider in lib/state.dart) and read the client again.

Licences

The Matrix Dart SDK is AGPL-3.0. An APK built from this app includes it, so distributing the APK has to follow the AGPL. The fonts are under the SIL Open Font License.