Wear SIP: stable inbound calls (ring/answer/hangup) + README

- NAT-survival account settings (sipnat=outbound;natpinhole=yes) so INVITE
  replies are not dropped (was going straight to voicemail).
- Native event_handler emits 'incoming call' (BEVENT_SIPSESS_CONN) carrying
  the SIP msg pointer and 'call incoming' (BEVENT_CALL_INCOMING) carrying the
  real struct call*; passes ua/call as separate jlong args (no lossy string
  parse). uaEvent(String, Long, Long) signature updated.
- Answer flow: ua_accept on 'incoming call' (caller hears ringing) then
  ua_answer deferred to the baresip main loop via a one-shot timer
  (ua_answer_async) so the UI-thread Answer tap does not crash in sip_treplyf.
- FGS started from Application.onCreate (decoupled from Activity) holding
  WifiLock + WakeLock so SIP survives screen-off / task removal.
- Ring: vibration + screen wake + high-priority full-screen-intent
  notification; stopRinging() on established/closed + 30s watchdog so the
  ring never loops forever on remote hang-up.
- Lock-screen presentation: manifest showWhenLocked/turnScreenOn +
  USE_FULL_SCREEN_INTENT permission + InCallScreen launch. (Still unreliable
  when fully asleep + backgrounded — tracked in README.)
- wear/README.md: project overview, how answering works, build/install,
  known gaps, and roadmap (SIP MESSAGE, contacts, start-at-boot, UI).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-21 01:58:56 +01:00
parent 300bdcc810
commit 899e778e49
10 changed files with 495 additions and 43 deletions

178
wear/README.md Normal file
View File

@ -0,0 +1,178 @@
# 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) support.** baresip already has a `message` module. Add:
- a native `MESSAGE` event → Kotlin bridge,
- an inbox/thread UI (or at least a toast + unread badge),
- outbound `Api.message_send()` from the dialer.
- **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.
Long-term:
- **Video calls** (baresip supports it; watch camera is the constraint).
- **Encrypted messaging / OMEMO** if the server supports it.
- **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.

View File

@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
@ -29,7 +30,9 @@
android:name=".MainActivity"
android:exported="true"
android:label="Baresip"
android:taskAffinity="">
android:taskAffinity=""
android:showWhenLocked="true"
android:turnScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@ -1,2 +1,2 @@
<sip:01273961147@mail.txt3.net>;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5060;transport=tcp";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;natpinhole=yes
<sip:01273961147@mail.txt3.net>;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5060;transport=tcp";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;sipnat=outbound;natpinhole=yes

View File

@ -115,13 +115,18 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
len = re_snprintf(event_buf, sizeof event_buf, "registering failed,%ld", (long)ua);
break;
case BEVENT_SIPSESS_CONN:
// Inbound-INVITE precursor: no call object yet. The `call` slot
// (passed to Kotlin as the 3rd uaEvent arg) holds the SIP message
// pointer, which is the correct handle for ua_accept(). Do NOT
// overwrite it with bevent_get_call() (garbage at this stage).
ua = uag_find_msg(msg);
call = (struct call *)msg;
len = re_snprintf(event_buf, sizeof event_buf, "%s,%r,%ld", prm, &msg->from.auri, (long)event);
len = re_snprintf(event_buf, sizeof event_buf, "incoming call,%r",
&msg->from.auri);
break;
case BEVENT_CALL_INCOMING:
len = re_snprintf(event_buf, sizeof event_buf, "call incoming,%s,%ld,%ld",
prm, (long)ua, (long)call);
len = re_snprintf(event_buf, sizeof event_buf, "call incoming,%s",
prm);
break;
case BEVENT_CALL_OUTGOING:
len = re_snprintf(event_buf, sizeof event_buf, "call outgoing", "");
@ -201,10 +206,15 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
if (!g_ctx.serviceClz || !g_ctx.serviceObj) return;
jmethodID methodId =
(*env)->GetMethodID(env, g_ctx.serviceClz, "uaEvent", "(Ljava/lang/String;)V");
(*env)->GetMethodID(env, g_ctx.serviceClz, "uaEvent", "(Ljava/lang/String;JJ)V");
jstring jEvent = (*env)->NewStringUTF(env, event_buf);
if (methodId) {
(*env)->CallVoidMethod(env, g_ctx.serviceObj, methodId, jEvent);
// Pass ua and call as separate native args (mirrors the upstream
// baresip Android app). At BEVENT_SIPSESS_CONN, `call` holds the SIP
// message pointer (set explicitly above), which is the correct handle
// for ua_accept(). Embedding pointers in the event string is lossy.
(*env)->CallVoidMethod(env, g_ctx.serviceObj, methodId, jEvent,
(jlong)ua, (jlong)call);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
@ -414,14 +424,83 @@ static void jni_ua_hangup(jlong uap, jlong callp, jint code, const char *reason)
re_thread_leave();
}
static void ua_answer_async(void *arg);
static void jni_ua_answer(jlong uap, jlong callp, jint video)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct call *call = (struct call *)(intptr_t)callp;
re_thread_enter();
LOGD("jni_ua_answer: ua=%ld call=%ld video=%d\n", (long)ua, (long)call, video);
// ua_answer() must run on the baresip main-loop thread (it drives SIP
// transactions via sip_treply). Calling it from the UI thread (an Answer
// tap) crashes in sip_treplyf. Hand it off to the main loop via a timer
// scheduled in the immediate past, so re_main() runs it on the right
// thread. We hold the handles with mem_ref and release them afterwards.
struct mbuf *mb = mbuf_alloc(sizeof(uint32_t) * 3);
if (!mb) {
LOGW("jni_ua_answer: mbuf_alloc failed\n");
return;
}
if (ua) mem_ref(ua);
if (call) mem_ref(call);
mbuf_write_u32(mb, (uint32_t)(uintptr_t)ua);
mbuf_write_u32(mb, (uint32_t)(uintptr_t)call);
mbuf_write_u32(mb, (uint32_t)video);
mb->pos = 0;
struct tmr *t = mem_zalloc(sizeof(*t), NULL);
if (!t) {
LOGW("jni_ua_answer: tmr alloc failed\n");
mem_deref(mb);
if (ua) mem_deref(ua);
if (call) mem_deref(call);
return;
}
tmr_start(t, 1, ua_answer_async, mb);
LOGD("jni_ua_answer: deferred to main loop\n");
}
static void ua_answer_async(void *arg)
{
struct mbuf *mb = arg;
struct ua *ua;
struct call *call;
int video;
if (!mb) return;
ua = (struct ua *)(uintptr_t)mbuf_read_u32(mb);
call = (struct call *)(uintptr_t)mbuf_read_u32(mb);
video = (int)mbuf_read_u32(mb);
LOGD("ua_answer_async: ua=%ld call=%ld\n", (long)ua, (long)call);
ua_answer(ua, call, (enum vidmode)video);
LOGD("ua_answer_async: done\n");
if (ua) mem_deref(ua);
if (call) mem_deref(call);
mem_deref(mb);
}
// Accept an inbound call from its SIPSESS_CONN handle (the bevent pointer,
// which holds the sip_msg*). ua_accept() allocates the real call from the
// message, so this is the correct answer entry point for inbound calls --
// ua_answer() requires an already-allocated struct call* and crashes (SIGSEGV
// in call_answer) when handed the precursor message pointer.
static void jni_ua_accept(jlong uap, jlong msgp)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct sip_msg *msg = (struct sip_msg *)(intptr_t)msgp;
int err;
LOGD("jni_ua_accept: ua=%ld msg=%ld\n", (long)ua, (long)msg);
re_thread_enter();
err = ua_accept(ua, msg);
re_thread_leave();
LOGD("jni_ua_accept: result=%d\n", err);
if (err)
LOGW("ua_accept failed: %d\n", err);
}
static jlong jni_call_connect(jlong callp, const char *peer_uri)
@ -517,6 +596,15 @@ Java_com_tutpro_baresip_wear_Api_ua_1answer(JNIEnv *env, jclass clazz,
jni_ua_answer(uap, callp, video);
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1accept(JNIEnv *env, jclass clazz,
jlong uap, jlong msgp)
{
(void)env;
(void)clazz;
jni_ua_accept(uap, msgp);
}
JNIEXPORT jlong JNICALL
Java_com_tutpro_baresip_wear_Api_call_1connect(JNIEnv *env, jclass clazz,
jlong callp, jstring jPeer)

View File

@ -11,6 +11,11 @@ class Api private constructor() {
@JvmStatic external fun ua_register(uap: Long): Int
@JvmStatic external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
@JvmStatic external fun ua_answer(uap: Long, callp: Long, video: Int)
// Accept an inbound call from its SIPSESS_CONN handle (the bevent
// pointer, which holds the SIP message). ua_accept() allocates the
// real call internally -- this is the correct answer entry point for
// inbound calls (ua_answer needs an already-allocated call pointer).
@JvmStatic external fun ua_accept(uap: Long, msgp: Long)
@JvmStatic external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
@JvmStatic external fun call_connect(callp: Long, peerUri: String): Int
@JvmStatic external fun call_hold(callp: Long, hold: Boolean): Boolean

View File

@ -33,5 +33,5 @@ object CallState {
fun active() = calls.firstOrNull { it.status == "call established" || it.status == "call outgoing" || it.status == "call ringing" }
fun incoming() = calls.firstOrNull { it.status == "call incoming" }
fun incoming() = calls.firstOrNull { it.status == "incoming call" }
}

View File

@ -443,6 +443,7 @@ fun AccountsScreen(onBack: () -> Unit) {
append(";outbound=\"$out\"")
append(";stunserver=\"stun:stun.l.google.com:19302\"")
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
append(";sipnat=outbound;natpinhole=yes")
if (regInt.isNotBlank()) append(";regint=$regInt")
}
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
@ -530,7 +531,7 @@ fun InCallScreen(onEnd: () -> Unit) {
Spacer(Modifier.height(12.dp))
if (call != null && call.status == "call incoming") {
if (call != null && call.status == "incoming call") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { answerCall(call) }) {
Icon(Icons.Filled.Call, contentDescription = "Answer")

View File

@ -1,10 +1,31 @@
package com.tutpro.baresip.wear
import android.app.Application
import android.content.Intent
import android.os.Build
class WearBaresipApp : Application() {
// The foreground service is launched from MainActivity (foreground state),
// not from Application.onCreate(), because starting a microphone-type
// foreground service from a non-foreground context is rejected on
// targetSdk 34+.
// Start the SIP foreground service from the Application context so it is
// tied to the PROCESS lifetime, not the MainActivity task. On Wear OS a
// foreground service launched only from an Activity gets recycled when the
// Activity task is removed / screen goes off (the service's onDestroy ->
// baresipStop() tears down registration and we miss incoming calls).
//
// A microphone-type FGS is one of the types explicitly permitted for
// background start on API 34+, so this does not trip the
// "startForegroundService() not allowed in background" SecurityException
// that the old phoneCall-type start hit.
override fun onCreate() {
super.onCreate()
try {
val intent = Intent(this, WearBaresipService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "app start FGS failed: ${e.message}")
}
}
}

View File

@ -24,6 +24,62 @@ import java.net.NetworkInterface
class WearBaresipService : Service() {
// Keep the WiFi radio (and CPU) alive so the SIP stack can send the
// 100/180/200 responses to an inbound INVITE even when the watch screen
// is off / device is in a low-power state. Without these, the INVITE is
// received but the reply is dropped (or delayed past Asterisk's 6.4s
// retransmit timeout) and the call falls through to voicemail.
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
private var wakeLock: android.os.PowerManager.WakeLock? = null
// Held while the call is ringing so we can cancel it the instant the call
// is answered / declined / remotely dropped. Without this the ring tone
// (a looping vibration pattern) never stops.
private var ringVibrator: Vibrator? = null
// Main-thread handler for the ring watchdog (postDelayed).
private val watchdogHandler = Handler(Looper.getMainLooper())
// Tracks the live call's native handle for hangup/decline. Updated on
// incoming/established/closed events. A single call at a time on the watch.
private var activeCallp: Long = 0L
@Suppress("DEPRECATION")
private fun acquireWifiLock() {
try {
val wm = getSystemService(android.net.wifi.WifiManager::class.java)
if (wm != null) {
wifiLock = wm.createWifiLock(
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF,
"BaresipWear:SipWifi"
).apply {
setReferenceCounted(false)
if (!isHeld) acquire()
}
Log.d("Baresip Wear", "wifi lock acquired: ${wifiLock?.isHeld}")
}
} catch (e: Exception) {
Log.w("Baresip Wear", "acquireWifiLock failed: ${e.message}")
}
}
@Suppress("DEPRECATION")
private fun acquireWakeLock(millis: Long) {
try {
val pm = getSystemService(android.os.PowerManager::class.java)
if (pm != null) {
wakeLock = pm.newWakeLock(
android.os.PowerManager.PARTIAL_WAKE_LOCK,
"BaresipWear:CallWake"
).apply {
setReferenceCounted(false)
if (!isHeld) acquire(millis)
}
Log.d("Baresip Wear", "wake lock acquired for ${millis}ms: ${wakeLock?.isHeld}")
}
} catch (e: Exception) {
Log.w("Baresip Wear", "acquireWakeLock failed: ${e.message}")
}
}
override fun onCreate() {
super.onCreate()
appContext = applicationContext
@ -67,8 +123,13 @@ class WearBaresipService : Service() {
.setContentTitle("Baresip Wear")
.setContentText("SIP stack running")
.setSmallIcon(android.R.drawable.sym_call_incoming)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
startForeground(NOTIFICATION_ID, notification)
// Hold the WiFi radio + CPU so inbound INVITEs can be answered even
// when the screen is off.
acquireWifiLock()
val path = applicationContext.filesDir.absolutePath
val nativeLibDir = applicationInfo.nativeLibraryDir
@ -156,7 +217,17 @@ class WearBaresipService : Service() {
} catch (_: Exception) {}
}
override fun onTaskRemoved(rootIntent: Intent?) {
// Keep the SIP stack alive even if the MainActivity task is swiped
// away. Returning (not calling stopSelf) lets the FGS keep running and
// keep the registration / incoming-call path alive. The manifest also
// sets stopWithTask=false as a belt-and-braces measure.
return
}
override fun onDestroy() {
try { wifiLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
try { wakeLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
try { unregisterReceiver(networkReceiver) } catch (_: Exception) {}
AudioRouteManager.shutdown()
baresipStop()
@ -189,22 +260,16 @@ class WearBaresipService : Service() {
return addrs.joinToString(";")
}
fun uaEvent(event: String) {
fun uaEvent(event: String, uap: Long, callp: Long) {
val parts = event.split(",")
val ev = parts[0]
val arg = parts.getOrNull(1) ?: ""
// Event formats differ by event type (see baresip.c event_handler):
// registering/registered/unregistering : "<ev>,<uap>" -> uap in parts[1]
// call incoming/outgoing/established.. : "<ev>,<peer>,<uap>,<callp>" -> uap in parts[2]
// Reading a fixed index breaks registration (uap lands in parts[1]
// but we read parts[2]) -> defaultUap stays 0 -> dial() bails.
val uap = if (ev.startsWith("register") || ev.startsWith("unregister") || ev == "create") {
parts.getOrNull(1)?.toLongOrNull() ?: 0L
} else {
parts.getOrNull(2)?.toLongOrNull() ?: 0L
}
val callp = parts.getOrNull(3)?.toLongOrNull() ?: 0L
android.util.Log.d("Baresip Wear", "uaEvent: $event (uap=$uap)")
// uap/callp are passed as native args by baresip.c (mirrors the
// upstream baresip Android app). `callp` at "incoming call" holds the
// SIP message pointer for ua_accept(); for other events it is the real
// struct call* from bevent_get_call(). Do not re-parse pointers from
// the string -- that is lossy and was crashing ua_answer/ua_accept.
android.util.Log.d("Baresip Wear", "uaEvent: $event (uap=$uap callp=$callp)")
when {
ev.startsWith("registering") -> {
@ -221,35 +286,55 @@ class WearBaresipService : Service() {
}
ev.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
ev == "call incoming" -> {
// "incoming call,<peer>" is emitted at BEVENT_SIPSESS_CONN. Accept
// the session here (ua_accept) so the caller hears ringing
// (100/180). The actual answer (200 OK) happens when the user
// taps the Answer button (WearBaresipServiceHelper.answer), which
// is deferred to the baresip main loop for thread safety.
ev == "incoming call" -> {
val peer = arg.ifEmpty { "unknown" }
val cp = if (callp != 0L) callp else peer.hashCode().toLong()
if (CallState.find(cp) == null) {
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
CallState.add(WearCall(cp, uap, peer, "incoming call", "in"))
}
CallState.status.value = "Incoming call"
// Surface the incoming-call UI even if the app was backgrounded,
// and alert the wearer (the watch screen is usually dimmed).
// NOTE: we deliberately do NOT hand the call to the system
// telecom stack. On Samsung Wear the third-party COMPANION
// PhoneAccount is auto-rejected within ~2s, and that rejection
// propagated back to the native baresip call (hangup) — which
// made Asterisk drop to voicemail immediately instead of ringing
// for the configured 25s. The in-app InCallScreen + the
// full-screen-intent notification below are the real alert UI.
alertIncomingCall(peer)
if (callp != 0L) {
WearBaresipService.activeCallp = callp
Log.d("Baresip Wear", "ua_accept uap=$uap msgp=$callp")
Api.ua_accept(uap, callp)
}
}
// BEVENT_CALL_INCOMING carries the real answerable struct call*.
// Track it so the Answer button can answer it. We do NOT answer
// here -- the user answers via the button (deferred to main loop).
ev == "call incoming" -> {
if (callp != 0L) {
WearBaresipService.activeCallp = callp
Log.d("Baresip Wear", "call incoming tracked callp=$callp")
}
}
ev == "call outgoing" -> CallState.status.value = "Calling"
ev == "call established" -> {
CallState.status.value = "Connected"
CallState.active()?.status = "call established"
if (callp != 0L) WearBaresipService.activeCallp = callp
// Stop the ring vibration/alert; the call is now answered.
stopRinging()
// Sustain the wake lock through the active call so RTP/audio
// keep flowing even if the screen dims.
acquireWakeLock(60_000L)
// Route audio: speaker if no BT headset, else BT SCO.
AudioRouteManager.startCallAudio()
}
ev == "call ringing" -> CallState.status.value = "Ringing"
ev == "call closed" -> {
AudioRouteManager.stopCallAudio()
stopRinging()
dismissIncomingCallNotification()
WearBaresipService.activeCallp = 0L
// Release any call wake lock now that the call is over.
try { wakeLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
// as a toast so the user isn't left guessing why the call died.
if (arg.isNotBlank()) showToast("Call ended: $arg")
@ -259,7 +344,7 @@ class WearBaresipService : Service() {
else -> CallState.status.value = ev
}
if (arg.isNotEmpty() && (ev == "call incoming" || ev == "call outgoing") &&
if (arg.isNotEmpty() && (ev == "incoming call" || ev == "call outgoing") &&
!CallState.recentPeers.contains(arg)
) {
CallState.recentPeers.add(0, arg)
@ -282,6 +367,9 @@ class WearBaresipService : Service() {
* We also wake the (usually dimmed) screen and buzz a ring pattern.
*/
private fun alertIncomingCall(peer: String) {
// Keep the CPU awake long enough to send the SIP 180/200 and present
// the heads-up. Released when the call ends.
acquireWakeLock(60_000L)
// Wake the screen if it's asleep.
try {
val pm = getSystemService(PowerManager::class.java)
@ -344,9 +432,14 @@ class WearBaresipService : Service() {
}
// Ring vibration pattern (vibrate, pause, vibrate, pause, ...).
// Repeat index 0 = loop while the call is incoming. stopRinging()
// cancels it on answer / decline / remote hangup. A watchdog below
// also force-stops it if the call-event never arrives (e.g. a remote
// CANCEL that baresip doesn't surface as "call closed").
try {
val vibrator = getSystemService(Vibrator::class.java)
if (vibrator != null && vibrator.hasVibrator()) {
ringVibrator = vibrator
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val pattern = longArrayOf(0, 400, 200, 400, 200, 400)
vibrator.vibrate(
@ -360,6 +453,41 @@ class WearBaresipService : Service() {
} catch (e: Exception) {
Log.w("Baresip Wear", "vibrate failed: ${e.message}")
}
// Watchdog: if the call is still "incoming call" after 30s (remote
// never answered and no call-closed event arrived), stop the ring and
// clear the heads-up so the watch doesn't buzz forever.
try {
val cp = CallState.incoming()?.callp ?: 0L
watchdogHandler.postDelayed({
val stillIncoming = CallState.incoming() != null &&
CallState.incoming()?.callp == cp
if (stillIncoming) {
Log.w("Baresip Wear", "ring watchdog: no answer/closed in 30s, stopping")
stopRinging()
dismissIncomingCallNotification()
CallState.calls.toList().forEach {
if (it.callp == cp) CallState.remove(cp)
}
CallState.status.value = "Idle"
}
}, 30_000L)
} catch (e: Exception) {
Log.w("Baresip Wear", "ring watchdog failed: ${e.message}")
}
}
/** Stop the ring vibration (called on answer / decline / remote hangup). */
private fun stopRinging() {
try {
ringVibrator?.cancel()
} catch (_: Exception) {}
// Belt-and-braces: also try the system vibrator directly in case the
// stored reference was lost.
try {
val v = getSystemService(Vibrator::class.java)
v?.cancel()
} catch (_: Exception) {}
}
/** Remove the incoming-call heads-up once the call is answered/declined. */
@ -379,6 +507,10 @@ class WearBaresipService : Service() {
const val CALL_CHANNEL_ID = "baresip-wear-call"
const val CALL_NOTIFICATION_ID = 2
// Tracks the live call's native handle for hangup/decline. Updated on
// incoming/established/closed events. A single call at a time on the watch.
@JvmStatic var activeCallp: Long = 0L
// Application context, captured in onCreate, for toasts from non-UI code.
lateinit var appContext: Context
private set

View File

@ -58,14 +58,38 @@ object WearBaresipServiceHelper {
}
fun answer(call: WearCall) {
if (call.uap != 0L) Api.ua_answer(call.uap, call.callp, Api.VIDMODE_OFF)
else if (defaultUap != 0L) Api.ua_answer(defaultUap, call.callp, Api.VIDMODE_OFF)
// Answer the inbound call: send the 200 OK via ua_answer on the real
// struct call* captured at the "call incoming" event. Api.ua_answer
// defers the native call to the baresip main loop (tmr), so invoking
// it from the UI thread is safe (a direct UI-thread call crashes in
// sip_treplyf). The session was already "accepted" (provisional 100/180
// so the caller hears ringing) at the "incoming call" event.
val uap = if (call.uap != 0L) call.uap else defaultUap
val liveCallp = WearBaresipService.activeCallp
val target = if (liveCallp != 0L) liveCallp else call.callp
if (target == 0L || uap == 0L) {
android.util.Log.w("Baresip Wear", "answer: invalid handles uap=$uap target=$target")
return
}
android.util.Log.d("Baresip Wear", "ua_answer uap=$uap callp=$target (deferred to main loop)")
Api.ua_answer(uap, target, Api.VIDMODE_OFF)
}
// Decline/reject the ringing call, or hang up an active one. Uses the
// service's tracked activeCallp (updated on established/closed events),
// because the event-time SIP message pointer is not a valid call* once the
// call is allocated.
fun hangup(call: WearCall) {
val uap = if (call.uap != 0L) call.uap else defaultUap
Api.ua_hangup(uap, call.callp, 0, "")
CallState.remove(call.callp)
val liveCallp = WearBaresipService.activeCallp
val target = if (liveCallp != 0L) liveCallp else call.callp
if (target == 0L || uap == 0L) {
android.util.Log.w("Baresip Wear", "hangup: invalid handles uap=$uap target=$target")
return
}
android.util.Log.d("Baresip Wear", "ua_hangup uap=$uap callp=$target")
Api.ua_hangup(uap, target, 0, "")
CallState.remove(target)
if (CallState.calls.isEmpty()) CallState.status.value = "Idle"
}