Skip to main content

The Android app

The Android app is the desktop app’s Tauri shell built for Android: the same React client (web/dist), bundled into an APK that talks to the home node its user picks, with a small Kotlin side for what only Android can do. It shares the shell’s crate with the desktop app (apps/desktop/src-tauri) and keeps its own identifier, org.tavrn.app (the desktop app is org.tavrn.desktop), so both can exist side by side and the Android signing key never mixes with the desktop’s.

Read DESKTOP.md first: the node picker, the platform layer of the client and the sign-in on the node’s page work as described there. This document covers what is different on Android.

WhereWhat
apps/desktop/src-tauri/tauri.android.conf.jsonthe Android overrides of tauri.conf.json: identifier, minimum SDK
apps/desktop/src-tauri/src/android.rsthe Android commands of the shell, each a checked call into the Kotlin side
apps/desktop/src-tauri/src/mobile.rsthe checks of that bridge in both directions (builds and is tested on every platform)
apps/desktop/src-tauri/src/release.rsthe check for a newer release (the release feed)
apps/desktop/src-tauri/capabilities/mobile.jsonthe commands the bundled client may call on Android
apps/desktop/src-tauri/gen/android/the Android project (generated by tauri android init, then adapted, committed; build output ignored)
.../gen/android/app/src/main/java/org/tavrn/app/ShellPlugin.kt (the Kotlin side) with Shell.kt (the state the plugin, services and receivers share), Links.kt (the first filter of tavrn:// links), BiometricUnlock.kt with UnlockVault.kt (the unlock with fingerprint, face or PIN), VoiceService.kt, ConnectionService.kt, PushReceiver.kt with PushRules.kt, Notifier.kt with NotificationTrampoline.kt and AppPaths.kt (taps on notifications), ActionReceiver.kt, Fullscreen.kt (the webview’s Fullscreen API), AudioRouter.kt with AudioRoutes.kt (the call’s audio output), ScreenShare.kt with ScreenShareRules.kt (screen sharing), Passkeys.kt with AppOrigin.kt (passkeys in the app), MainActivity.kt
web/src/platform/tauriAndroid.ts, androidShell.ts, unlock.tsthe client’s Android members and its wiring (back gesture, system bars, the biometric unlock); links: deepLinks.ts, components/DeepLinkPrompt.tsx
web/src/voice/nativeScreen.tsthe client’s side of the screen share (the screen publisher’s join, the keys, stop)

Android 8.0 (API 26) and newer, target SDK 36. The reasons for 26:

  • The client needs a current Chromium webview (WebAssembly for the end-to-end encryption, encoded transforms for encrypted voice, WebRTC). Android updates the system webview independently of the system from Android 7 on, so the floor is not the webview but the platform APIs the Kotlin side uses without compatibility branches: notification channels (the user can silence messages, calls and the voice notification separately), audio focus requests for calls (AudioFocusRequest) and adaptive launcher icons all start at API 26.
  • Tauri 2’s own floor is API 24; nothing gained from 24 and 25 is worth two more code paths. Phones on Android 7 are now a fraction of a percent of active devices and get no security updates.
  • Everything newer is handled where it matters: runtime permission for notifications (13), foreground service types (10, 14), the nearby-devices permission for headsets (12), passkeys through the Credential Manager (14), edge-to-edge (15).

Only 64-bit ARM (arm64-v8a) is released: every phone of the last years. The emulator uses an x86_64 debug build.

The rules of the desktop shell hold (DESKTOP.md “Security of the shell”), adapted:

WhatWhere
The webview shows only the bundled client (https://tauri.localhost); other navigations are refused and web links open in the browser (the same navigation guard as the desktop).src/guard.rs, src/lib.rs
One capability for the Android app: the shared commands (settings, node picker, sign-in, shell events, notifications) and the android_* commands; no plugin, core or remote permission. The Kotlin plugin (tavrn-android) has no permission at all, so the webview can never call it: only Rust reaches it, after mobile.rs checked the input (texts and lengths, permission names, settings pages, the VAPID key, save names, the screen share’s media server URL, token and 32-byte key). Its events (back gesture, links, taps, voice buttons, insets, push endpoints, screen share states) are checked there again. Unit tests keep both capability files and the manifest’s promises from eroding.capabilities/mobile.json, src/conf_tests.rs
Plain http only to localhost and 127.0.0.1 (a development node on the device), as on the desktop; debug builds allow it everywhere for tauri android dev.res/xml/network_security_config.xml
No backups and no device transfer of the app’s data: the local key store and the session stay on the device.android:allowBackup="false", data_extraction_rules.xml
Every service and receiver of the app is private; pushes come in only through the UnifiedPush connector, and only decrypted ones are shown.AndroidManifest.xml
A tapped notification opens its view through a private activity (NotificationTrampoline): only the app’s own pending intents reach it, and it passes on only the app’s views (start page, rooms, conversations, settings, the report inbox; never /connect, /sso/… or /verify-email). The exported main activity takes no path from an intent, so another app cannot make the client open a page; mobile.rs and the client check the same list again. Unit tests: AppPathsTest.kt, mobile.rs, conf_tests.rs.NotificationTrampoline.kt, AppPaths.kt, src/mobile.rs
No Google services: no Firebase, no Play services; a test fails if they appear in the Android project.src/conf_tests.rs
The node discovery (address first) checks certificates against Mozilla’s root store (webpki-roots): reqwest’s platform verifier needs a Kotlin component on Android. A node with a certificate from a private CA installed on the phone cannot be found by address; its URL in “Advanced: node URL” still works (the webview trusts the system’s store).src/resolve.rs

The local key store is in IndexedDB as in a browser; the key that opens it is sealed behind fingerprint, face or PIN once the user turns that on (“Unlock with fingerprint, face or PIN” below), else it is kept as in a browser, which Settings → App says. If the key store does not survive while the session does (the device or emulator dies before the webview’s storage reached the disk), the device’s key is gone for good: the app says so and offers “Sign in again” (a new device, unlocked with the recovery key), see CLIENT.md “Accounts and sign-in”.

PermissionWhen it is askedWithout it
Microphone (RECORD_AUDIO)joining a voice channel or a call, right beforelistening only; the error names Android’s settings when Android no longer asks
Cameraturning the camera onno camera
Nearby devices (BLUETOOTH_CONNECT, Android 12+)after the microphone, at the first jointhe call’s audio stays off Bluetooth headsets
Notifications (POST_NOTIFICATIONS, Android 13+)turning push on; once per start at the first join (the voice notification)no push and no voice notification (voice still runs)
Foreground service (microphone, camera, media playback, special use), wake lockgranted at install—
Full-screen intentgranted at install (Android 14 limits it to calling apps; the user can turn it off)a ringing call shows as a notification only
Battery optimisation exemptiononly from Settings → Notifications, with an explanation, through the system’s dialogAndroid may pause the app in the background sooner
Biometric prompt (USE_BIOMETRIC)granted at install; the prompt shows when the user turns the unlock with fingerprint, face or PIN on and at each unlockthe recovery key or a passkey
Screen capture (MediaProjection)Android’s consent dialog at every screen share (no permission to keep); the foreground service type mediaProjection (FOREGROUND_SERVICE_MEDIA_PROJECTION) is granted at installno screen sharing
The sound of other apps (AudioPlaybackCapture, Android 10+)the microphone permission covers it; only with “The sound of other apps” chosen in the share dialogthe share has no sound

The client asks through the shell (android_permission), which answers granted, denied or blocked (Android no longer shows its dialog); for blocked the client says where to allow it (Settings → Apps → tavrn → Permissions) or opens the page (android_open_settings). Requests go out one at a time (Android shows one at a time); a join waits for the microphone’s answer only, the nearby-devices and notification dialogs that follow it do not hold the call.

The node picker, password and second-factor sign-in and the recovery key work as in the desktop app. What needs the node’s own page (passkeys, single sign-on, the captcha) uses the connect flow (DESKTOP.md “Signing in”) with the page in the browser (a Custom Tab; Android has no second window for the app): the app creates the app session, opens <web>/connect?session=…, polls while the user signs in there, and brings itself to the front once it has its session (close_connect). The page in the browser keeps its own session, as in a browser tab. The device appears as “Android app” in the account’s devices.

The app runs passkeys natively, through Android’s Credential Manager (androidx.credentials 1.6, the framework’s own on Android 14 and newer; Google’s companion library for older systems is not included), as any native app does (Passkeys.kt, reached through passkey_create / passkey_get in src-tauri/src/passkey.rs, the same commands and checks as the Windows app’s). Android lets the app use the passkeys of a relying party whose domain lists the app in its assetlinks.json with delegate_permission/common.get_login_creds, and clientDataJSON names the app’s origin android:apk-key-hash:<base64url of the SHA-256 of the signing certificate>, not the node’s web origin (PROTOCOL.md §5a.4). The shell reads that origin from the installed APK’s signer at runtime (AppOrigin.kt, checked again in Rust) and reports it with android_info; it passes on only a relying party id that the connected node’s web origin is equal to or a subdomain of. The PRF output (the key store unlock) comes back apart from the credential and never reaches the node; clientExtensionResults.prf.enabled of a registration stays in it, so the node’s prf_supported says whether the credential provider (Google Password Manager, or another passkey provider) enabled the PRF, and prf.results of a registration (providers that evaluate it at creation) stores the passkey’s secret storage entry without a second prompt. A provider without the PRF: the passkey signs in, Settings → Security says “cannot unlock messages” (CLIENT.md “Accounts and sign-in”).

The webview’s own WebAuthn (WEB_AUTHENTICATION_SUPPORT_FOR_APP, which the shell still turns on) cannot be used for this: measured on Android 16 with WebView 133, it checks the relying party id against the page’s origin (https://tauri.localhost) and refuses tavrn.org with SecurityError.

A node accepts the app’s origin when its operator lists it (TAVRN_WEBAUTHN_APP_ORIGINS, tavrn-node HANDBOOK “Passkeys in the apps”); it publishes every accepted origin as webauthn.origins in GET /node. The client asks its home node once and compares (passkeyBlocker in web/src/lib/passkeys.ts):

  • the node lists the app’s origin: registering a passkey (Settings → Security, and at registration), signing in with it, re-authentication and unlocking the encrypted messages with its PRF all run in the app;
  • the node does not list it (or is older and publishes no list): “Sign in with a passkey” opens the node’s page in the browser as before, and the login page and Settings → Security say “This node does not accept the app’s passkeys yet”;
  • below Android 14, or when the app has several signers: the browser, without that note.

Every signing key is its own origin: a debug build (the debug keystore of the build machine) has another origin than the release APK, so a development node needs the debug origin in TAVRN_WEBAUTHN_APP_ORIGINS (and the relying party’s assetlinks.json the debug fingerprint) to test passkeys in a debug build. The debug key’s values:

Terminal window
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA256
fp='<the SHA256 line's hex>'
printf '%s' "$fp" | tr -d ': ' | xxd -r -p | base64 -w0 | tr '+/' '-_' | tr -d '='; echo # → android:apk-key-hash:<this>

On Android 11 and newer the app can keep the key that opens the local key store behind the phone’s own authentication, as the desktop app keeps it in the OS keychain (DESKTOP.md “Keys in the OS keychain”):

  • Turning it on. After every unlock that needed the recovery key or a passkey (a new device, registration, an identity reset, an invalidated key, “Require the recovery key or a passkey every time” off), the unlock screen asks “Unlock faster next time?” when the phone has a screen lock; Turn on shows the system’s prompt (the key cannot be sealed without it), Not now goes on. Settings → App has the same switch (“Unlock with fingerprint, face or PIN”) with the reason when it cannot be turned on (no screen lock, older than Android 11, the store is locked).
  • Unlocking. On the next start the store is locked and the unlock screen opens the system’s prompt by itself: a fingerprint, a face (only a class 3 “strong” one; phones whose face unlock is weaker offer the PIN instead) or the phone’s PIN, pattern or password. Closing the prompt leaves the screen with Unlock with fingerprint, face or PIN and the recovery key (or a passkey) as the alternative; the alternative does not turn the biometric unlock off.
  • Turning it off (Settings → App, or “Require the recovery key or a passkey every time” in Settings → Security) deletes the sealed key and its keystore key at once; the key of the store goes back to the app’s storage (as in a browser), or, with “every time”, nowhere. Signing out, “Sign in again” of a lost device, an identity reset and the stale data of an older device delete it too.
  • A removed screen lock, an invalidated key. Android deletes or invalidates the keystore key when the screen lock is removed (and, on systems that apply setInvalidatedByBiometricEnrollment to such keys, when a biometric is enrolled). The app then deletes the sealed key, the unlock screen says why (“The fingerprints, face or screen lock of this phone changed…”) and asks for the recovery key or a passkey once, and offers to turn the unlock on again with a fresh key once the phone has a screen lock again.
  • A newly enrolled fingerprint keeps working. Measured on Android 16 (emulator): a key that also accepts the device credential is not invalidated by a new enrollment, although the app asks for it; Android applies that invalidation only to keys bound to biometrics alone. This takes nothing away: enrolling a finger needs the screen lock’s PIN, pattern or password, which opens the key by itself anyway.

How it works (BiometricUnlock.kt, UnlockVault.kt; the bridge android_unlock_status, android_unlock_store, android_unlock_get, android_unlock_clear in src/android.rs, checked in src/mobile.rs; the client in web/src/platform/unlock.ts and web/src/crypto/store.ts):

  • What is sealed: the account’s 32-byte local storage key (the AES-256 key of the IndexedDB store, derived from the master secret and the device id), nothing else. While the unlock is on, IndexedDB holds ciphertext only.
  • The key that seals it: an AES-256-GCM key in the Android Keystore per account entry, setUserAuthenticationRequired(true) with setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG | AUTH_DEVICE_CREDENTIAL) (every single encryption or decryption needs a fresh authentication through the prompt’s CryptoObject, no time window), setInvalidatedByBiometricEnrollment(true), in StrongBox when the phone has one (else the TEE, which every phone with Google’s certification has; the emulator’s keystore is software). A new key is made each time the unlock is turned on. Android 8 to 10 cannot combine a key of one use with the device credential, so they do not offer the unlock (Settings → App says “Needs Android 11 or newer”).
  • The sealed blob: version | IV (12 bytes) | ciphertext and tag (48 bytes) in noBackupFilesDir/unlock/<SHA-256 of the entry>.bin (private to the app, never in a backup: the app allows none). The GCM associated data names the entry, so a blob copied to another account’s place does not open.
  • Scoping: the entry is unlock/<home node origin>/<account address>; the shell takes the node from its settings (never from the page) and accepts only plain account addresses, 32-byte secrets and short plain prompt texts, as for the desktop’s keychain. The commands are in capabilities/mobile.json only (the bundled client of the main window); the Kotlin plugin itself has no permission.

What it protects against, and what not:

  • It protects the key store at rest: a copy of the app’s files (a forensic image, a backup tool with root, a lost phone read out without its screen lock) opens nothing, because the key lives in the keystore’s hardware and the keystore releases its use only after the user’s fingerprint, face or credential. Other apps cannot use the key (keystore keys belong to the app’s uid). The node is not involved and never sees the key or the blob.
  • It does not protect against anyone who knows the phone’s PIN, pattern or password (the device credential is an accepted alternative by design, so the unlock is as strong as the screen lock; whoever knows it can also enrol a finger of their own), against code running in the app while it is unlocked (the key is in the webview’s memory then, as in the browser), against a compromised system (root, a malicious ROM, a keystore without hardware backing such as the emulator’s), or against forced use of a finger. A weak face unlock is not accepted, the PIN is asked instead. Without a screen lock the unlock cannot be turned on at all.
  • Not a replacement for the recovery key: the sealed key opens only this phone’s local store. A new phone, a reinstall, cleared app data or an invalidated key need the recovery key or a passkey; keep the recovery key.

The system’s prompt is a secure window: screenshots show it black, uiautomator dump lists its texts. The emulator check: set a screen lock (adb shell locksettings set-pin 1234), enrol a fingerprint in Settings → Security & privacy → Device unlock → Fingerprint and touch the sensor with adb -e emu finger touch 1 (also in the prompt); adb -e emu finger touch 2 is a finger that does not match.

While the client is connected to a voice channel or a call, the voice store drives the shell’s voice service (the tray interface of the desktop, android_voice):

  • a foreground service of type microphone (media playback when listening only), plus camera while the camera is on, with a persistent notification: “In voice channel Lounge (Gaming)” or “In a call with Bob”, the state, and the buttons Mute/Unmute microphone and Leave voice channel, which act through the client like the tray menu;
  • a partial wake lock: the webview keeps running with the screen off (at most 12 hours as a safety net);
  • the audio focus for the call (voice communication), abandoned when voice ends.

Leaving voice stops the service, the wake lock and the focus. Closing the app from the recent apps ends voice (the webview is gone). In the app the microphone’s capture stays open while muted (web/src/voice/capture.ts): the notification can unmute while the app is in the background, where the webview would not open a new capture until the app is on screen again.

Doze: the app asks for no battery-optimisation exemption by itself. Settings → Notifications explains it and opens the system’s dialog on request (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS; Google Play allows this permission only for some app categories, so a Play build may have to switch to the settings list instead).

Android’s webview has no getDisplayMedia, and its WebRTC cannot take a video source from the app, so the app shares the screen natively, as the device’s screen publisher (protocol VOICE.md §4b): a second LiveKit participant of the same device, <user>/<device>#screen, that publishes the screen share (and optionally the sound of other apps) through the LiveKit Android SDK (io.livekit:livekit-android), next to the webview’s own connection that carries the voice.

  1. The screen share button opens the client’s dialog (resolution and frame rate within the node’s limits, the sound: “No sound” or “The sound of other apps”, the latter only on Android 10+). The client asks the node for the screen publisher’s token (voice/join or call/join with { "role": "screen" }: publish-only, screen share sources only, no seat of its own) and hands it to the shell with the device’s frame key of the current epoch (android_screen_share_start, checked in src/mobile.rs: a wss URL, or ws only for a development node on the device itself, a JWT, exactly 32 key bytes, sizes and short plain texts).
  2. ScreenShare.kt shows Android’s consent dialog (MediaProjectionManager.createScreenCaptureIntent(); declining ends quietly), starts the SDK’s foreground service of type mediaProjection (required since Android 14) with the app’s notification “Sharing your screen” and Stop sharing, connects with the token (no subscriptions, no microphone; LiveKit’s audio handling replaced by a no-op so AudioRouter keeps the call’s audio) and publishes the screen as VP8 in the resolution’s 16:9 box, turned with the display (ScreenShareRules.captureBox). The capture follows the phone’s orientation while the share runs: turned to landscape, the viewers get a landscape frame that fills their 16:9 tile, and a fold or unfold gets a box of the new shape (the running capture is resized in place, no new consent dialog, no new publication; frame rate and bitrate stay as chosen).
  3. Encryption (ENCRYPTION.md §5a): the publisher encrypts exactly like the client, one shared key per MLS epoch, HKDF with LiveKit’s salt, a ring of 256 keys at epoch mod 256, no ratcheting, no failure limit. The publisher is no MLS member: the client hands it every epoch’s 32 bytes over the bridge right after deriving them (android_screen_share_key), the Kotlin side sets them as the sending key of the published tracks and wipes its copy; the key never reaches the node, LiveKit, a log or a file. Nothing is published before the first key is set. The SDK sets a track’s sending key index only when the track is published, so ScreenShare.setKey moves the index of the running tracks’ frame cryptors itself (the SDK’s E2EEManager.frameCryptors, kept by the R8 rule in proguard-rules.pro); an SDK without that field keeps the first epoch’s key, which receivers still hold.
  4. Stop: the “You are streaming” bar or the voice controls (android_screen_share_stop), Stop sharing in the notification, Android ending the projection (the system’s own stop), a broken connection, or leaving voice. The shell reports every stop it did not get from the client as screen_share stopped with the reason; the client then tells the node (voice/leave with { "role": "screen" }). The node also removes a screen publisher together with its device.
  5. Others see the share as the sharer’s stream, joined on demand like every screen share; the sharer’s own app shows the bar but no tile of its own publisher. android_screen_share_status answers whether a share runs and whether the sound of other apps can be shared.

The sound of other apps: Android’s AudioPlaybackCapture (only apps that allow capture; Android 10+) through the SDK’s ScreenAudioCapturer, published as the share’s audio track. The SDK mixes it into a recorded buffer, so the app clears the microphone’s samples first: the microphone never goes out through the share. The call itself is never part of it either: Android’s playback capture takes the media, game and unknown usages only, and the webview plays the call as voice communication (CLIENT.md “Screen share audio”).

The fullscreen button of streams, camera tiles, the whiteboard and the stage uses the standard Fullscreen API (web/src/voice/ui/fullscreen.tsx, unchanged for the app). A webview shows an element in fullscreen through its chrome client: WebChromeClient.onShowCustomView receives a view with the fullscreen content and onHideCustomView takes it back. Tauri’s webview (wry 0.57, RustWebChromeClient, generated into generated/ at build time and final) implements onShowCustomView by calling onCustomViewHidden() at once, so the page entered and left fullscreen in the same moment and the button seemed to do nothing (issue #74).

Fullscreen.kt wraps wry’s chrome client when the plugin loads (webView.webChromeClient, after wry set it): every other callback (camera and microphone permissions, file choosers, JavaScript dialogs, the console, the title) goes to wry’s client unchanged; only the custom view is the app’s own:

  • Enter: the custom view is laid over the whole activity (on black), the system bars are hidden in immersive mode (a swipe from the edge shows them for a moment; Android explains this once with its own hint).
  • Orientation: Android’s webview does not implement the Screen Orientation API (screen.orientation.lock() rejects with NotSupportedError: screen.orientation.lock() is not available on this device). The client’s shell wiring (routeOrientationLock in web/src/platform/androidShell.ts) gives the page a lock()/unlock() that tries the webview first and then asks the app (android_orientation): the activity locks to the orientation the page asked for (landscape → sensor landscape, so the phone can still be turned over), only while an element is in fullscreen, as browsers do. So fullscreen.tsx keeps its one code path: a wide video turns the phone to landscape.
  • Back gesture: leaves fullscreen first (the page sees fullscreenchange), the next one goes back in the client.
  • Exit (the client’s button, Escape, the back gesture): the view goes back to the webview, the bars come back, and the orientation the activity had before fullscreen is restored.

While voice runs (the voice notification’s lifetime, ShellPlugin.voice), AudioRouter.kt owns the call’s audio output; the voice controls show a route button with the route in use and a menu of the connected outputs (web/src/voice/ui/AudioRouteButton.tsx, commands android_audio_routes and android_audio_route, the event audio_routes when headsets come and go):

RouteAndroid device types
Speakerbuilt-in speaker
Earpiecebuilt-in earpiece (phones only)
Wired headsetwired headset or headphones, USB headset
Bluetooth headsetBluetooth SCO (headsets with a microphone), Bluetooth LE Audio headsets
  • Defaults (AudioRoutes.kt, unit-tested): a wired headset, else a Bluetooth headset, when connected; otherwise the speaker, for voice channels and calls alike. A voice channel is a group conversation and video calls are watched, not held to the ear (a phone call app would use the earpiece; one tap in the menu does that here).
  • The user’s choice holds while its device stays connected; a headset that connects later takes over (as in a phone call), and a choice whose device went away falls back to the defaults.
  • Android 12 and newer: AudioManager.setCommunicationDevice (it connects Bluetooth SCO by itself); older systems: the speakerphone switch and startBluetoothSco. While voice runs the app holds MODE_IN_COMMUNICATION (the webview’s WebRTC audio follows the communication route); leaving voice clears the communication device and gives the mode back.
  • The webview picks a route of its own when devices change; the router answers each change a moment later (300 ms) with its rules, and shows a route the system moved to.
  • The route lives in the app process, not in the activity: it stays with the screen off and the app in the background (the voice service keeps the process). Bluetooth needs the nearby-devices permission (asked at the first join).

The camera button’s quick menu (long press) offers Front camera and Back camera (docs/CLIENT.md “Quick menus”); the webview opens the camera with the facingMode constraint, a switch during a call restarts the camera track with the other camera (a phone cannot open both at once). The foreground service gains the camera type while the camera is on.

Push needs no Google services: the app uses UnifiedPush with the user’s distributor app (ntfy, NextPush, Sunup, …), which keeps one connection for all its apps. The connector is org.unifiedpush.android:connector 3.3.5 (pinned), whose WebPush support (since 3.0) generates the RFC 8291 key pair (P-256 and the auth secret, kept by the connector in the app’s private storage) and decrypts each push in the app.

  1. The client (Settings → Notifications → Push notifications, after the system’s permission prompt) asks the node for its VAPID key (GET /push/vapid, PROTOCOL.md §6d) and the shell registers at the distributor with it (UnifiedPush.register(…, vapid), the distributor’s endpoint is then tied to that key, RFC 8292).
  2. The distributor hands the endpoint with the public keys (PushEndpoint.pubKeySet); the client registers { endpoint, keys: { p256dh, auth } } at the node through the existing push API (PUT /users/me/push-subscriptions), exactly as a browser subscription. The node needs no change: it encrypts every push with RFC 8291 (aes128gcm) for that endpoint.
  3. A push reaches the distributor, the distributor hands it to the app, the connector decrypts it (PushMessage.decrypted; anything it could not decrypt is dropped) and PushReceiver shows it with the service worker’s rules (PushRules.kt, a port of notificationFor and targetUrl of web/public/sw.js, with the same texts in the user’s language that the client hands over, android_push_config): messages and mentions, direct messages (never their text), reports, and call rings with vibration and a full-screen intent on a locked screen. As in the service worker, a ring is not shown while the client is on screen (it rings by itself). A tap opens the conversation.
  4. Renewals: a new endpoint while the app runs goes to the node at once; the client registers again at every start (idempotent at the node), which also catches an endpoint that changed while the app was closed. A distributor that drops the registration turns push off.

Without a distributor the settings say “No UnifiedPush app found” with links to the list of UnifiedPush apps and to ntfy on F-Droid. As an alternative they offer Keep a connection for notifications: a foreground service (type special use) keeps the app and its WebSocket running in the background, and the client shows its own notifications natively (notify), with a permanent notification and a battery warning in the text. Closing the app from the recent apps ends it; it starts again with the app.

  • App Links for the project’s node: https://app.tavrn.org/invite/<code> and https://app.tavrn.org/add/… (an intent filter with autoVerify); Android opens them in the app once app.tavrn.org publishes the app’s assetlinks.json (below).
  • tavrn:// links of any node, as on the desktop (DESKTOP.md “Links into the app”): tavrn://invite/<node>/<code>, tavrn://room/<node>/<room>[/<channel>], tavrn://add/<node>/… and tavrn://connect?session=…&state=…, the way back from the sign-in in the browser (Custom Tab) to the app. An intent filter of its own (android:scheme="tavrn", no autoVerify).

Links.kt hands only VIEW intents with an https or tavrn link to the Rust side, which accepts App Links only on these paths of a host of mobile::APP_LINK_HOSTS without port or credentials (mobile::app_link) and tavrn:// links by the desktop’s rules (deeplink.rs). The client shows the target and opens it only when the user confirms; it routes the home node’s links as they are and another node’s invite as /invite/<code>?node=<its identity domain> (the invite page joins through the federated session, as the “Join a room” dialog does).

Try a link on the emulator: adb shell am start -a android.intent.action.VIEW -d 'tavrn://invite/chat.example/code1234' org.tavrn.app.

The file names the app and the SHA-256 fingerprint of its release signing certificate (below, “Releases”):

[
{
"relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "org.tavrn.app",
"sha256_cert_fingerprints": ["<SHA-256 OF THE RELEASE CERTIFICATE, AA:BB:CC:…>"]
}
}
]
  • https://app.tavrn.org/.well-known/assetlinks.json: the host of the App Links (the node’s host; served by the reverse proxy in front of the node until the node serves it itself);
  • https://tavrn.org/.well-known/assetlinks.json: the relying party of the passkeys (the website answers it once its variable TAVRN_ANDROID_CERT_SHA256 holds the fingerprint; tavrn-website deploy/README.md “Android app”).

Both with Content-Type: application/json, status 200, no redirect. Android checks them at install; adb shell pm get-app-links org.tavrn.app shows the result.

  • Downloads (attachments, exports, the recovery key) go through Android’s document picker (“Save to”), the equivalent of the desktop’s save dialog: the client streams the file in 1 MiB pieces (base64: Android’s IPC carries JSON only; android_save_*), the Kotlin side writes it to the document the user picked; the webview never names a place.
  • Updates: Tauri’s updater does not support Android. At start and in Settings → App the shell reads the release list of tavrn-web through the website’s proxy (https://tavrn.org/gh/releases/web) and names the newest release with an APK newer than the app in the update channel of Settings → App (stable: releases only, beta: pre-releases too; beta by default for a pre-release app; only GitHub’s download URL of that very asset). A failure names its reason like the desktop’s: the server unreachable (update.unreachable), no release with an APK in the channel (update.no_release) or another failure (update.failed). “Download the update” opens the APK in the browser; Android installs it over the app (same signing key). The Android version code follows the version (major·10⁶ + minor·10³ + patch), so a release candidate and its final release share a code and install over each other.
  • System bars and keyboard: the app draws edge to edge (light icons on transparent bars); the shell reports the bars in CSS pixels and the client sets --safe-top, --safe-right, --safe-bottom, --safe-left on the root element (web/src/styles.css uses them everywhere it used env(safe-area-inset-*), which they fall back to in a browser). The on-screen keyboard shortens the webview, so the composer stays above it.
  • Back gesture: closes the open dialog, menu or drawer first, then goes back in the client’s history; on the first page it sends the app to the background instead of closing it.

Toolchain: Android SDK with platform 36 and build-tools 36 (ANDROID_HOME), NDK 28.2.13676358 (NDK_HOME), Java 21, the Rust targets aarch64-linux-android and x86_64-linux-android.

Terminal window
just build # web/dist
just android-build # debug APK for the emulator (x86_64); `just android-build aarch64` for a phone
just android-test # JUnit tests of the Kotlin side (after one android-build)
just desktop-check # the shell's Rust tests run on the desktop, the Android checks included

A development node on the computer: adb reverse tcp:<port> tcp:<port> makes the emulator’s localhost:<port> reach it (plus the LiveKit port for voice), and “Advanced: node URL” takes http://localhost:<port>. The emulator can run headless (emulator -avd <name> -no-window -no-audio); adb exec-out screencap -p takes screenshots. -camera-front emulated -camera-back emulated gives it both cameras for the camera menu. Playwright reaches the app’s webview in a debug build (_android.devices(), device.webView({ pkg: "org.tavrn.app" })); connectOverCDP does not work with a webview. The x86_64 emulator images have a speaker but no earpiece and no headsets, so the audio output menu lists the speaker only there; earpiece, wired and Bluetooth headsets need a phone.

Every library the app declares in gen/android/app/build.gradle.kts, and what the LiveKit SDK brings along (a test in src/conf_tests.rs keeps the declared ones and this list together):

LibraryLicenceWhy
androidx.webkit:webkit, androidx.appcompat:appcompat, androidx.activity:activity-ktx, androidx.lifecycle:lifecycle-process, androidx.browser:browser, androidx.credentials:credentialsApache-2.0webview settings, the activity, the lifecycle, Custom Tabs, passkeys
com.google.android.material:materialApache-2.0the theme of the generated activity
org.unifiedpush.android:connectorApache-2.0push without Google; its Tink dependency is replaced by Tink’s Android build (com.google.crypto.tink:tink-android, Apache-2.0, protobuf shaded), which has the same API without clashing with LiveKit’s protobuf
io.livekit:livekit-androidApache-2.0screen sharing
org.jetbrains.kotlinx:kotlinx-coroutines-androidApache-2.0the SDK’s suspending calls

What the LiveKit SDK brings along: io.github.webrtc-sdk:android-prefixed (BSD-3-Clause), com.squareup.okhttp3:okhttp (Apache-2.0), com.github.davidliu:audioswitch (Apache-2.0, from JitPack, which the build allows for this one group only), com.vdurmont:semver4j (MIT), org.jetbrains.kotlinx:kotlinx-serialization-json (Apache-2.0), io.livekit:livekit-uniffi-android (Apache-2.0) with net.java.dev.jna:jna (Apache-2.0 or LGPL-2.1, used under Apache-2.0), com.auth0.android:jwtdecode (MIT), com.google.protobuf:protobuf-javalite (BSD-3-Clause), javax.sip:android-jain-sip-ri (public domain, its SDP parser), com.google.dagger:dagger (Apache-2.0).

LiveKit’s manifest adds ACCESS_NETWORK_STATE (it watches the network to reconnect) and its screen capture service (type mediaProjection, not exported, stopped with the app’s task).

desktop.yml’s job android builds the release APK for arm64 on ubuntu-latest from the tag, with no secret, and runs the Kotlin tests; release-assets signs it as Tavrn_<version>_android-arm64.apk with apksigner, writes its .sha256 and attaches both to the release (DESKTOP.md “Builds and releases”, GITHUB.md).

Owner steps, once:

  1. Create the release key (keep the keystore and its passwords safe and backed up: an update must be signed with the same key, a lost key means users reinstall):

    Terminal window
    keytool -genkeypair -v -keystore tavrn-release.jks -alias tavrn -keyalg RSA -keysize 4096 -validity 10000
    keytool -list -v -keystore tavrn-release.jks -alias tavrn | grep SHA256 # the certificate's fingerprint
    base64 -w0 tavrn-release.jks > tavrn-release.jks.b64
  2. Secrets of the environment release (never repository secrets): ANDROID_KEYSTORE_B64 (the content of tavrn-release.jks.b64), ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS (tavrn), ANDROID_KEY_PASSWORD. Optional, recommended: the repository variable ANDROID_CERT_SHA256 with the fingerprint, so a release signed with another key fails.

  3. Publish assetlinks.json with that fingerprint: TAVRN_ANDROID_CERT_SHA256 in the website’s stack (tavrn.org), and the same document on app.tavrn.org from the reverse proxy in front of the node (above).

  4. Let the node accept the app’s passkeys: the same fingerprint as the app’s origin in the node’s environment, then restart the node (GET /api/v1/node then lists it in webauthn.origins):

    Terminal window
    printf '%s' "$ANDROID_CERT_SHA256" | tr -d ': ' | xxd -r -p | base64 -w0 | tr '+/' '-_' | tr -d '='; echo
    # TAVRN_WEBAUTHN_APP_ORIGINS=android:apk-key-hash:<the output>
  5. Later, for Google Play: Play App Signing with this key as the upload key or the app signing key; the fingerprint in assetlinks.json and the node’s app origin must be the one Play signs with.

  • Screen sharing shares the whole screen (Android’s dialog may offer a single app on newer systems), stops when the app is closed from the recent apps, and ends with a voice reconnect (the client starts it again on request). The sound of other apps was not checked on a phone yet (the emulator has no audio); apps that forbid capture stay silent.
  • The unlock with fingerprint, face or PIN needs Android 11 or newer and a screen lock; otherwise the key of the local key store is kept as in a browser (or not at all, with “Require the recovery key or a passkey every time”). No global hotkeys, no tray.
  • Passkeys in the app need Android 14+ and a node that accepts the app’s origin (above); otherwise they run on the node’s page in the browser.
  • Closing the app from the recent apps ends voice and the connection service; push through UnifiedPush keeps working.
  • The UnifiedPush endpoint is renewed at the node only while the app runs or at its next start.

This page is generated from tavrn-web/docs/ANDROID.md (Apache-2.0) when the website is built; its links were adapted for this site.