# Android Client Architecture The pttclient is an Android application (minSdk 19) written in Java. It implements a PTT radio handset in software. --- ## Module architecture (abstraction layers) PTT **core** behavior—protocol, voice pipeline, crypto, session lifecycle, talkgroup semantics, and the Socket.IO contract—belongs in **shareable modules**, not only inside one app’s UI package. The same logic ships in: - **`:app`** — original rugged `pttclient` (hardware-first; this document’s primary handset) - **`:android`** — touch-first smartphone APK in the same `pttclient` repo (`net.writtenhouse.android.ptttouch`, minSdk 24) - **Java** consumers outside this APK layout where applicable Implement new core features in shared libraries (`:core`, `:core-android`, `:core-socketio`, `:ptt-realtime-service`) and **compose** them from application modules. Avoid duplicating or locking core behavior into a single app’s activities or fragments. Agent-oriented summary: `../CLAUDE.md` → **pttclient: Abstraction Layer and Original-App UX**. ### Gradle modules (`pttclient` repo) | Module | Type | Product | |--------|------|---------| | `:core` | Java library | Protocol, session, identity, voice/UDP planning | | `:core-socketio` | Java library | Socket.IO adapter | | `:core-android` | Android library, minSdk 19 | Keystore, TLS/PAS, SQLite, DeviceProfile | | `:ptt-realtime-service` | Android library, minSdk 19 | `SocketService`, `AudioTask`, Opus | | `:app` | Application | Rugged handset — `net.writtenhouse.android.pttclient`, minSdk **19** | | `:android` | Application | Touch-first phone — `net.writtenhouse.android.ptttouch`, minSdk **24** | | `:java` | Java 17 application | Headless JVM client — `net.writtenhouse.ptt.java`; Linux/macOS/Raspberry Pi | Flagship touch spec: [`PTTCLIENT-TOUCH-FLAGSHIP-SPEC.md`](PTTCLIENT-TOUCH-FLAGSHIP-SPEC.md). Phase A lab steps: [`PHASE-A-CHECKLIST.md`](PHASE-A-CHECKLIST.md). ### Abandoned checkouts (do not use) These local/GitLab trees are **not** the touch-first app. Do not import or continue them: | Tree | Why abandoned | |------|----------------| | GitLab `freeptt/touchclient` | 2024 stub; pointer README then archive | | Local `pttclient-touch` | Imported into `pttclient` `:android` | | Local `ptt-client-android` | Uncommitted rugged rewrite; no GitLab project; not the module split | --- ## Original pttclient UX (rugged radios) The **original `pttclient`** product line targets **rugged devices**. It should **own the experience** and feel like a **true two-radio** handset: PTT, channels, and operational flow are as important as protocol correctness. | Principle | Requirement | |-----------|-------------| | **Hardware button first** | **UI and navigation** are designed around **physical keys** (PTT, d-pad, side buttons, etc.). Do not assume the user will tap the screen to move through the app. | | **Touchscreen** | **Disable touch** as a primary input on **all screens** so gloves, moisture, and accidental contact do not drive the UI. **Exception**: when the **soft keyboard is open** for text entry, touch may be used for the keyboard (and any controls strictly needed for that editing session), consistent with the platform. | | **Muscle memory** | Keep **button mappings** and **screen-to-screen** flows **stable** and **learnable**; avoid touch-only shortcuts or gratuitous reordering on this product line. | Touch-first UI in **`:android`** follows **its own** UX rules; the table above applies to the **original rugged `:app`**, not to `:android`. --- ## Application Lifecycle ``` Application starts │ ▼ PTTClientApplication.onCreate() ├── Install Conscrypt JCE provider at priority 1 └── initializeServerSelection() └── ServerListManager reads active selection from SharedPreferences MainActivity.onCreate() ├── Fast init (main thread): DeviceProfile, handlers, preferences ├── Inflate layout └── backgroundExecutor.execute(() -> initializeKeys()) ├── API 23+: initializeKeysAndroidKeyStore() │ └── AndroidKeyStore RSA keypair (or load existing) └── API 19-22: initializeKeysSoftware() └── Conscrypt RSA keypair (saved to files dir) mainThreadHandler.post(() -> finishCreating()) ├── initHttp() → PlatformTrustManager.getSSLContext() │ ServerTrustManager.init() │ Build OkHttpClient ├── (optional) PasAttestation.runOnce() if no Device Cert ├── initSocket() → build Socket.IO with opts └── startSocketServiceForeground() SocketService (foreground service) ├── Connects socket ├── Handles all socket events ├── Manages audio playback └── Posts broadcasts to MainActivity (`:app`) or RadioFaceActivity (`:android`) ``` --- ## Key Management ### API 23+ (AndroidKeyStore, hardware-backed) ```java KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder( "pttclientMasterKeyPair", KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY ) .setAlgorithmParameterSpec(new RSAKeyGenParameterSpec(2048, F4)) .setDigests(KeyProperties.DIGEST_SHA256) .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) .build(); ``` The private key never leaves the hardware secure enclave. ### API 19–22 (Software-backed, Conscrypt) ```java KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048); KeyPair kp = kpg.generateKeyPair(); // Saved to getApplicationContext().getFilesDir() // "ptt_software_private.key" (PKCS8) and "ptt_software_public.key" (X.509) ``` ### Device Numeric Identity (DNI) The primary connection identifier is the **DNI** — a 16-digit decimal number derived from the device public key: ```java // DeviceNumericIdentity.calculate(publicKey): byte[] keyBytes = publicKey.getEncoded(); // canonical SubjectPublicKeyInfo DER byte[] hash = SHA-256(keyBytes); BigInteger bigInt = new BigInteger(1, hash[0..7]); // first 8 bytes String raw15 = String.format("%015d", bigInt.remainder(new BigInteger("1000000000000000"))); int checkDigit = calculateLuhnWithPlaceholderZero(raw15); return raw15 + checkDigit; // 16 digits ``` The DNI is stored in `PTTClientApplication.myDni` and sent as the `dni` query parameter on every Socket.IO connection. ### Device UUID (Legacy / Client-Side) `PTTClientApplication.deriveUUIDFromPublicKey()` still exists and produces the legacy 16-hex-character UUID (`SHA-256(SPKI DER)[0:8 bytes as hex]`). It is used in some client-side fields and the PAS/CSR subject CN (`device-`), but the **DNI** is what the server uses for routing and authentication. ```java public static String deriveUUIDFromPublicKey(PublicKey publicKey) { // Normalize to canonical SubjectPublicKeyInfo via KeyFactory round-trip KeyFactory kf = KeyFactory.getInstance("RSA"); PublicKey normalised = kf.generatePublic(new X509EncodedKeySpec(publicKey.getEncoded())); byte[] derBytes = normalised.getEncoded(); byte[] hash = MessageDigest.getInstance("SHA-256").digest(derBytes); // First 8 bytes = 16 hex chars StringBuilder sb = new StringBuilder(); for (int i = 0; i < 8; i++) sb.append(String.format("%02x", hash[i] & 0xFF)); return sb.toString(); } ``` The KeyFactory round-trip is important on API 19 where AndroidKeyStore may return certificate-wrapped keys whose `getEncoded()` bytes differ from canonical SubjectPublicKeyInfo DER. --- ## Trust Store Architecture ``` PlatformTrustManager ├── bundleKeyStore: loads all *.pem / *.crt from assets/certs/ │ ├── System CA cert (for Layer 1 TLS to ptt-server) │ ├── Platform Issuing CA cert (for PAS HTTPS) │ └── System CA Signing CA cert (used by SystemCaImporter) │ └── activeSystemCaPem (set at runtime when switching systems) └── systemTmf: Android system trust store (public CAs for reverse proxies) CompositeTrustManager └── checkServerTrusted: try bundle first, then system store (accepts either — whichever chains successfully) ``` ``` ServerTrustManager (Layer 2 only) └── trustedCerts: same assets/certs/ enumeration └── verifyWithCert(certPem, certChainPem, sigB64, socketId): 1. Parse server cert from PEM 2. verifyChainToTrusted: try direct signing, then via intermediates 3. Extract server public key 4. SHA-256(socketId) → verify sig with server public key 5. Returns true only if both chain and sig validate ``` --- ## Hardware Button Handling `MainActivity.onKeyDown()` / `onKeyUp()` dispatches hardware button events: - PTT button down → `startTransmit()` → `SocketService.emitFloorRequest` (ack + 3 s timeout) → on success: short success tone, `AudioTask.execute()`. Private session AES is prepared in `floor_request` and cleared when TX ends. Tones and voice share `STREAM_MUSIC` via `DeviceProfile.getAlarmToneStream()` and `PttTonePlayer` (see rewrite spec AUDIO-009 / §8.6). - PTT button up → `stopTransmit()` → sets `txStatus = false` - **VOX** automatic transmit is **deprecated**; `startVOXTransmit` / `VOXTask` are no-ops. Manual PTT only for this protocol line. Device-specific keycode mappings are in `DeviceProfile.init()`. Supported hardware: RT4, RT5, T310, XP5700, XP3800, TELO_TE390, Ex-Handy 209. Unrecognized devices fall back to using volume buttons as PTT (long-press). For the **original rugged `pttclient`**, non-PTT **navigation** must also follow **hardware-first** rules; **touch** stays off except when the **soft keyboard** is visible for text input. See **Original pttclient UX (rugged radios)** above. --- ## Audio Task Detail `AudioTask` is an `AsyncTask` (runs on a thread pool thread) that: 1. Initializes `OpusEncoder` (8 kHz, 1 channel). 2. Optionally sends a silent pre-roll frame with the session key on `seq: 0` for encrypted calls. 3. Loops reading `AudioRecord` while `txStatus == true`. 4. Each frame: Opus encode → AES-256-GCM encrypt → build v2 envelope JSON → `socket.emit("voice_data", env)`. 5. On completion: stop `AudioRecord`, release encoder, set `txStatus = false`, log call. The session key (password + salt for AES-256 derivation) is generated once per PTT press and included in `seq: 0` only, encrypted with RSA-OAEP to the target talkgroup's public key. Voice RX writes PCM to `AudioTrack` on `STREAM_MUSIC` / `USAGE_MEDIA`. Hardware volume keys adjust `STREAM_MUSIC`. Signalling tones (TX begin/end, denied, RX chirps, alerts) use `ToneGenerator` on the same stream through `DeviceProfile.getAlarmToneStream()` (default `STREAM_MUSIC`) so a PTT keydown beep is not routed separately from voice. --- ## Foreground Service `SocketService` runs as a foreground service so the socket stays alive when the app is backgrounded. The service: - Holds the Socket.IO instance. - Handles all inbound socket events including: `server_challenge`, `register_ok`, `update_talkgroup`, `update_target`, `voice_data`, `ptt_call_setup`, `ptt_interrupt`, `key_share_delivery`, `key_inbox_batch`, `text_data`, `room_event`, `target_unavailable`, `unauthorized`. - Manages AudioTrack for playback. - Posts `Intent` broadcasts to the host UI (`MainActivity` in `:app`, `RadioFaceActivity` in `:android`) for updates. - Started with `STARTFOREGROUND_ACTION`; stopped with `STOPFOREGROUND_ACTION`. - Posts an **ongoing** notification. The tap target is `PttSocketServiceApplication.getMainActivityClass()` — `MainActivity` on `:app`, `RadioFaceActivity` on `:android`. That notification is the touch client's persistent shortcut back to the radio face after Home. On Android 12+ (API 31), `startForegroundService()` cannot be called from the background. Both `MainActivity` and `RadioFaceActivity` defer the start to `onResume()` via `mPendingServiceStart`. On Android 13+ (API 33), stock devices (Pixel) **hide** that FGS notice from the status bar and shade unless `POST_NOTIFICATIONS` is granted. The FGS exemption keeps the service legal; it does not put the icon in the drawer. `:android` (`targetSdk 36`) requests `POST_NOTIFICATIONS` from `RadioFaceActivity` with the mic permission. Denying notifications still starts the radio; the shade shortcut is simply absent. `:android` does not own a second `SocketService`. Do not duplicate the service in the touch module.