Files
jamie prince 100ee241a2 Wear SIP: plaintext SIP MESSAGE (RFC 3428) send/receive + inbox UI
- Native: add message_send() JNI (Api.message_send) and wire the
  existing message_listen receive callback (message_handler) to forward
  inbound MESSAGEs to WearBaresipService.messageEvent.
- Kotlin: WearBaresipServiceHelper.sendMessage (normalizes peer ->
  sip:peer@mail.txt3.net), MessageStore (JSON file persistence, no extra
  deps), messageEvent stores + posts a CATEGORY_MESSAGE notification.
- UI: MessagesScreen (conversation list) + ConversationScreen (thread +
  send field), reached via a new 'Messages' button on the dialer.
- README roadmap: mark SIP MESSAGE done; reword encryption plan to
  asymmetric E2E over SIP MESSAGE with NFC key exchange (OMEMO is
  XMPP-only and does not apply to SIP).

Verified: builds, app starts, registers over TLS, no crash. Live
two-endpoint exchange still to be tested with a real peer.
2026-08-21 03:48:21 +01:00

208 lines
9.9 KiB
Markdown

# Baresip Wear — SIP client for Wear OS
A native SIP softphone for Wear OS watches, built on the
[baresip](https://github.com/baresip/baresip) stack (via the `libbaresip-android`
submodule). It registers a single SIP account, places/receives calls, and
survives the watch's aggressive power management so inbound calls actually ring
while the screen is off.
This app is a slimmed-down port of the full `baresip` Android app
(`app/` in this repo) to the watch form factor. It intentionally does **not**
use the system telecom stack for call UI — the in-app `InCallScreen` plus a
high-priority, full-screen-intent notification are the alert surface.
---
## Current status (as of this commit)
Working:
- **Registration** over UDP with NAT-survival settings
(`sipnat=outbound;natpinhole=yes` in `assets/accounts`). This was the root
cause of the original "call goes straight to voicemail" problem: without the
pinhole the INVITE reply was dropped and Asterisk retransmit-timed-out.
- **Inbound calls ring** whether the app is foreground or backgrounded, with
vibration + screen wake.
- **Answer** connects the call (caller stops hearing ringing; watch shows
"Connected").
- **Remote hang-up** terminates the call promptly whether answered or not
(ring is cancelled, not left looping forever).
- **Foreground service** is started from `WearBaresipApp.onCreate()` (decoupled
from the Activity) and holds a `WifiLock` + `WakeLock` so SIP replies go out
even when the screen is off / device is in a low-power state.
- **Lock-screen presentation** attempted via manifest `showWhenLocked` /
`turnScreenOn` + `USE_FULL_SCREEN_INTENT` + a full-screen-intent notification
that launches `MainActivity``InCallScreen`. (See "Known gaps".)
### How answering actually works (important)
The native baresip library emits two events for an inbound call:
1. `incoming call,<peer>``BEVENT_SIPSESS_CONN`. We call `Api.ua_accept()`
here so the caller hears ringing (100/180 provisional). The `callp` passed
is the SIP message pointer carried by the event.
2. `call incoming,<prm>``BEVENT_CALL_INCOMING`. This carries the real,
answerable `struct call*`. The user's **Answer** button calls
`Api.ua_answer()`, which is **deferred to the baresip main loop** via a
one-shot timer in `baresip.c` (`ua_answer_async`). This deferral is
mandatory: calling `ua_answer()` directly from the UI thread crashes in
`sip_treplyf` (SIGSEGV), because SIP transaction replies must run on the
baresip thread. The native event callback (`uaEvent`) itself runs on the
baresip thread, which is why `ua_accept` is safe there but `ua_answer`
from a tap is not.
Key files:
- `wear/src/main/cpp/baresip.c` — native `event_handler` (event strings +
`jlong` ua/call args), `ua_accept`/`ua_answer` JNI, and the
`ua_answer_async` main-loop hand-off.
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt` — the
SIP event loop, alert/ring logic, `stopRinging()` + 30s ring watchdog,
notification channels, and lock-screen flags.
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt`
UI → native bridge (`dial`, `answer`, `hangup`, `sendDigit`).
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt` — starts the
foreground service from `Application.onCreate()` so it survives task removal.
- `wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt` — Compose UI
(dialer / in-call) + `setShowWhenLocked`/`setTurnScreenOn` for inbound calls.
- `wear/src/main/assets/accounts` — the SIP account line (NAT settings +
credentials).
---
## Build & install
Prereqs: Android SDK, `ndk` (for the native build), and a watch with
`adb` over Wi-Fi (wireless debugging). The native library is built by Gradle's
external-native-build from `wear/src/main/cpp/`.
```bash
# From repo root
./gradlew :wear:assembleDebug
# Install to the watch (wireless ADB; port changes after a reboot/reconnect)
adb connect <watch-ip>:<port>
adb -s <watch-ip>:<port> install -r wear/build/outputs/apk/debug/wear-debug.apk
```
Note: a clean native rebuild is required after editing `baresip.c`:
```bash
./gradlew :wear:clean :wear:assembleDebug
```
### Permissions to grant on-device
- **Microphone** — record audio for calls.
- **Notifications** (API 33+) — required for the incoming-call heads-up /
full-screen intent to wake the watch face.
- **Phone** / `READ_PHONE_STATE` — declared; needed if telecom integration is
re-enabled.
---
## Known gaps / bugs still open
- **Lock-screen Activity sometimes doesn't appear** when the watch is fully
asleep and the app is backgrounded. The vibration fires but the user has to
open the app manually. The full-screen-intent notification + `showWhenLocked`
are in place, but on Android 12+ (this watch runs Android 16) the system
may still suppress the launch. Candidate fixes: ensure
`USE_FULL_SCREEN_INTENT` is granted; raise the call notification to a
dedicated `InCallActivity` (not routed through the LAUNCHER `MainActivity`);
verify `setShowWhenLocked(true)` is applied before `setContent` in every
launch path.
- **No DTMF / keypad verification** end-to-end yet (wired via
`call_send_digit` but not exercised on a real call).
- **No echo cancellation / audio-route tuning** beyond the basic
speaker-vs-BT-SCO switch.
---
## Roadmap
Near-term (core completeness):
- **SIP MESSAGE (IM) — DONE in this branch.** Plaintext RFC 3428 MESSAGE is
implemented end-to-end:
- Native: `message_send()` JNI + a `message_listen` receive callback
(`message_handler` in `baresip.c`) forwarding to `WearBaresipService.messageEvent`.
- Kotlin: `Api.message_send`, `WearBaresipServiceHelper.sendMessage` (normalizes
peer → `sip:peer@mail.txt3.net`), `MessageStore` (JSON file in `filesDir`),
and `MainActivity` `MessagesScreen` / `ConversationScreen` (conversation list +
thread + send field, reached via a "Messages" button on the dialer).
- Incoming messages are persisted and surfaced via a `CATEGORY_MESSAGE` notification.
- **Encryption is NOT yet in this pass** — see the long-term item; the body is
sent in cleartext (protected only by the TLS SIP transport in transit).
- **Encrypted SIP MESSAGE (next phase).** Plan agreed: **asymmetric E2E** using a
per-device X25519/Ed25519 keypair, with public keys exchanged **out-of-band via
NFC tap** (strongest trust — no server in the path), falling back to exchange
over a plaintext MESSAGE if NFC is unavailable (trusts the TLS server). The MESSAGE
body is then encrypted with the peer's public key before `message_send`. Note:
**OMEMO does not apply here** — OMEMO is an XMPP (Jabber) standard and cannot run
over SIP; we implement the same *class* of protection (Signal-style envelope) on
top of SIP MESSAGE instead. OpenSSL (already bundled) provides the crypto.
- **Read device contacts** via the Wearable Data Layer / `ContactsContract`
(the watch mirrors phone contacts through the companion app) so the dialer
can autocomplete instead of requiring typed SIP URIs.
- **Start at boot / on network available.** Register a `BOOT_COMPLETED` +
`CONNECTIVITY_CHANGED` receiver that starts the foreground service so the
watch is always reachable without the user launching the app first.
- **Call history / recent peers** persisted to a small local store
(DataStore/ Room) instead of the in-memory `recentPeers` list.
Medium-term (polish & robustness):
- **Better InCall UI**: larger Answer/Decline hit targets, a proper
"swipe to answer" gesture, mute/hold, and a clear connected timer.
- **Provisioning flow**: ship the account config via a QR/`baresip:` URI or
MDM rather than a hand-edited `assets/accounts` file.
- **Multiple accounts** (currently a single default UA is assumed
throughout `WearBaresipServiceHelper`).
- **Audio quality**: AGC, echo cancellation, and BT-SCO reliability across
headset brands.
- **Re-enable system telecom** as an *optional* path behind a setting, now
that the native answer/hangup path is stable — but keep the in-app UI as
the default to avoid the earlier 2-second auto-drop.
- **SRTP media encryption** — already compiled in (`srtp.so`, `dtls_srtp.so`,
`gzrtp.so` loaded in `config.static`). Verify SRTP actually negotiates on a
TLS:5062 call: capture the SDP and confirm `a=crypto` (SDES) or
`a=fingerprint` (DTLS-SRTP) is offered/answered. Then pair TLS signalling +
DTLS-SRTP for fully encrypted calls (signalling + media). May need the
Asterisk side set `encryption=yes` / `media_encryption=dtls` (or `sdes`).
- **WebSocket (WS/WSS) transport support** — baresip core supports
`transport=ws`/`wss` and the registrar filter already allows `ws,wss`, but
the `websocket` module is **not** compiled into `libbaresip.a` for this
wear build (only `srtp`/`dtls_srtp`/`gzrtp` modules are present; no
`websocket/` module). To enable: add the `websocket` module to the baresip
build, rebuild `libbaresip.a` + `libwearbaresip.so`, load it in
`config.static`, and use `outbound="sip:host:PORT;transport=wss"`. Only
needed if the server fronts SIP behind WebSocket (WebRTC/CPaaS-style), not
typical for an Asterisk PBX.
Long-term:
- **Video calls** (baresip supports it; watch camera is the constraint).
- **Wear companion app** on the phone for config + contact sync.
---
## Debugging tips
```bash
# Live native + Kotlin log during a call
adb -s <watch-ip>:<port> logcat -v time | grep -aE \
'Baresip Wear|jni_ua|ua_answer|ua_accept|ua_hangup|F/DEBUG'
```
Watch for:
- `uaEvent: incoming call,...` then `ua_accept` (caller starts ringing) then
`ua_answer ... deferred to main loop``ua_answer_async: done`
`call established`.
- A `F/DEBUG` SIGSEGV in `sip_treplyf` / `call_answer` means `ua_answer` or
`ua_accept` was called from the wrong thread or with a bad handle.
- `fullScreenIntent=null` in the notification dump means the system stripped
the lock-screen launch — check `USE_FULL_SCREEN_INTENT` + notification
channel importance.