- 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.
208 lines
9.9 KiB
Markdown
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.
|