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
..

Baresip Wear — SIP client for Wear OS

A native SIP softphone for Wear OS watches, built on the 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 MainActivityInCallScreen. (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/.

# 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:

./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

# 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 loopua_answer_async: donecall 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.