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>
219 lines
11 KiB
Markdown
219 lines
11 KiB
Markdown
# Warp for Android
|
|
|
|
A Matrix client for Android, written in Flutter on the famedly
|
|
[Matrix Dart SDK](https://github.com/famedly/matrix-dart-sdk) with
|
|
[vodozemac](https://github.com/matrix-org/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](../web/README.md): 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.
|
|
- 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](https://unifiedpush.org) (below).
|
|
- 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.
|
|
|
|
## 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](../../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, leave. A "Join" banner in a room
|
|
with a call you're not in; the room list marks rooms with one.
|
|
|
|
## Push notifications
|
|
|
|
Warp doesn't use Firebase. Push goes through UnifiedPush: the user installs
|
|
a *distributor* app, such as [ntfy](https://ntfy.sh), 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](../../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.
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```bash
|
|
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.
|