Independent research/development project · technical preview

Android integration

Status: P3 Phase-1 sidecar proxy complete / CRDT UI and native E2EE pending Date: 2026-08-22

This document is the implementation contract for the Android application in apps/android. The Android client is intentionally a native Kotlin application, not an embedded browser and not a second TypeScript runtime. It shares the same e2e-col wire protocol and sidecar contract through executable cross-language fixtures.

1. Current decision

The Android app uses a native Kotlin architecture with a small compatibility layer that mirrors only stable public contracts from the TypeScript monorepo:

apps/android/app                Compose UI + lifecycle/ViewModel
        │
        ├── core:model          platform-neutral join/domain values, SessionTransport
        ├── core:protocol       E2EC ProtocolEnvelope codec + TS golden fixtures
        ├── core:sidecar        loopback-first OkHttp WebSocket transport
        ├── core:identity       Room entities for local/remote identity
        ├── core:storage        Room database, DAOs, atomic transactions
        └── core:session        Automerge DocumentSession lifecycle
                                      │
                                      ▼
                             e2e-col sidecar (Node)
                                      │
                                      ▼
                            signal-cli HTTP JSON-RPC/SSE

The TypeScript packages remain the canonical implementation for browser behavior. They are not Gradle modules and must not appear as implementation(project(":packages:client")) dependencies. Android compatibility is enforced at stable seams instead:

  1. byte-for-byte protocol fixtures;
  2. sidecar request/response contract tests;
  3. CRDT change/snapshot interoperability fixtures;
  4. identity/encryption fixtures before any native encrypted transport is enabled.

This approach avoids two runtimes with subtly different assumptions while still allowing Android-specific lifecycle, storage, security, and background execution.

2. Toolchain baseline

The scaffold deliberately uses stable, current dependencies that remain compatible with Android 16 / API 36:

Component Scaffold baseline Rationale
Android Gradle Plugin 9.1.1 AGP 9 built-in Kotlin model; API 36/37 capable
Gradle wrapper 9.3.1 AGP 9.1 compatibility baseline
JDK 17 Android build toolchain baseline
Kotlin 2.4.10 current stable Kotlin bug-fix line
compileSdk 36 Android 16 APIs
targetSdk 36 required for new Play submissions/updates from 2026-08-31
minSdk 23 current AndroidX default floor and Keystore-capable baseline
Compose BOM 2026.06.00 stable Compose 1.11 line without forcing API 37
Activity Compose 1.13.0 stable
Lifecycle 2.10.0 stable
WorkManager 2.11.2 stable reliable background work line
OkHttp 5.4.0 stable WebSocket/HTTP client

Compose 1.12 / BOM 2026.08.00 moves core Compose to compileSdk 37 and requires a newer AGP point release. The project intentionally stays on the 1.11 BOM until the repository toolchain moves to API 37 as one coordinated change.

Candidate native CRDT dependency: org.automerge:automerge-kotlin:0.0.9 / org.automerge:automerge:0.0.9. It is wired into core:session and DocumentSession uses it for text editing, change encoding, and snapshot persistence. Before enabling CRDT mutation in the Compose UI, Android must prove that Java/Kotlin Automerge changes and snapshots round-trip against the JavaScript implementation used by @e2e-col/core.

Room 2.7.2 is used for persistence in core:storage and core:identity. The schema covers documents, outbound queue, access control, local identity, and remote identity tables with atomic transaction support via StorageTransactionDao.

3. Sidecar mode: first production path

Phase 1 uses the existing sidecar contract rather than trying to run Signal protocol code inside Android. The current sidecar accepts a WebSocket upgrade with the document UUID as a query parameter and binary ProtocolEnvelope frames:

ws://127.0.0.1:43127/?documentId=<uuid>

There is no bearer-token first frame in this sidecar protocol. That authentication handshake belongs to the separate toy MockSignalTransport group backbone. Do not add a token to the sidecar URL or invent an auth frame that the Node sidecar does not understand.

The Android core:sidecar module therefore:

This completes the P3 Phase-1 proxy transport boundary. It deliberately does not make the current Compose text field collaborative: Android does not yet implement the recipient-bound encrypted E2EE wrapper, so emitting native CRDT document material through the production sidecar at this stage would cross the repository’s E2EE boundary. Phase 1 therefore proves and hardens the proxy seam without introducing an Android private-key ownership model or weakening the browser-owned/private-key guarantees.

Development from an emulator or physical device

Prefer ADB reverse so the Android app can still use loopback semantics:

adb reverse tcp:43127 tcp:43127

Then configure the app with ws://127.0.0.1:43127. Do not hard-code emulator-only 10.0.2.2 into production configuration.

The manifest disables general cleartext networking and grants a network-security exception only to local sidecar hosts. Remote sidecars must use TLS (wss://) and require a separate threat-model/configuration change.

4. Protocol compatibility

core:protocol is a native implementation of the stable v1 binary envelope codec. The test suite contains the exact canonical fixture from packages/protocol/src/protocol.test.ts:

E2EC magic
version = 1
kind automerge-change = 1
flags: sequence present
createdAt = 1787159200000
sequence = 7
document = 11111111-1111-4111-8111-111111111111
message  = 22222222-2222-4222-8222-222222222222
sender   = device-A
payload  = 01 02 03 04

The Kotlin encoder must produce the same full hex fixture as TypeScript, and Kotlin must decode the same bytes. This is the minimum acceptance gate for every Android protocol change.

The encrypted E2EE wrapper is intentionally not mirrored yet. Native encryption must not ship until Android has equivalent Ed25519/X25519/HKDF/AES-GCM fixtures, key-selector semantics, and recipient-binding validation.

5. App architecture

The Android application follows unidirectional state flow:

Compose UI
   │ events
   ▼
EditorViewModel ─── StateFlow<EditorUiState> ───► Compose UI
   │
   ├── JoinRequestParser
   ├── SidecarWebSocketTransport
   └── future AndroidCollaborationRepository

The current scaffold can:

The text field is deliberately labeled a local scaffold draft. It does not emit CRDT changes yet; pretending plain text is an Automerge change would create a wire-compatible-looking but semantically broken client.

Next repository boundary

core:session provides a DocumentSession that owns:

What remains before UI integration:

The UI must never directly manipulate Automerge, Room, OkHttp, or key material.

The documented development link remains:

e2e-col://join?document=<uuid>&group=<uuid>&inviter=<display-name>

JoinRequestParser validates the scheme/host and UUIDs before state mutation. A production public release should additionally provide verified HTTPS Android App Links; custom schemes can be claimed by another installed application and must not be treated as proof of inviter identity.

A join link contains identifiers only. It must never contain bearer tokens, private keys, one-time prekeys, or plaintext document material.

7. Identity and key storage

The Android identity implementation is still pending. Required rules:

The app currently sets android:allowBackup="false" until a reviewed backup/restore story exists.

8. Persistence and offline behavior

Android reproduces the semantics already implemented in @e2e-col/storage / @e2e-col/client. The Room schema in core:storage contains:

document(documentId PK, snapshot BLOB, updatedAt, title, archived)
outbound(id PK, documentId, payload BLOB, createdAt, state, attempts, kind)
access_control(documentId PK, selfRole, participants, archived, deleted, revision)

core:identity adds:

identity(userId PK, phoneNumber, displayName, identityKeyPublic, signedPrekeyPublic, sessionToken, createdAt)
remote_identity(userId PK, phoneNumber, identityKeyPublic, verified, fetchedAt)

Still missing before M1 completion:

seen_messages(document_id, message_id, seen_at, PK(document_id,message_id))

Required transaction boundaries:

A process crash must never leave a visible local edit without its corresponding outbound work.

9. Background work

Use foreground execution only for genuinely user-visible continuous synchronization. Use WorkManager for deferrable retry/recovery work. Do not keep an immortal service merely to maintain a socket.

When background sync is implemented:

Running a desktop signal-cli JVM child process inside an ordinary Android app is not the default Phase-1 design. A standalone on-device Signal backend remains a separate feasibility/security milestone because packaging a JVM/Rust/native Signal stack on Android has materially different lifecycle and ABI constraints.

10. Build and verification

The repository exposes:

pnpm run android:check       # pure JVM model/protocol/sidecar/identity/storage/session tests
pnpm run android:assemble    # Android debug APK; requires API 36 SDK + build tools

Use a JDK 17 Gradle runtime. The checked-in wrapper is pinned to Gradle 9.3.1.

Required local Android SDK packages:

platforms;android-36
build-tools;36.0.0

Dedicated GitHub Actions workflows run Android CI independently:

The main ci.yml workflow also includes an Android job that runs android:check and android:assemble as part of the release gate. Both gates should be required for changes under shared protocol/contract fixtures.

11. Milestones

M0 — buildable compatibility scaffold

M1 — local-first document core

M2 — identity and encrypted envelopes

M3 — sharing and recovery UX

M4 — production hardening

M5 — optional direct/on-device Signal backend

Only pursue after M1–M4. It requires a dedicated feasibility review for Signal protocol library licensing/API stability, Android ABI packaging, background execution, account linking, and key ownership. The public AndroidCollaborationRepository/transport boundary must remain stable if this backend is swapped in later.