diff --git a/wear/README.md b/wear/README.md new file mode 100644 index 00000000..b0a6fbe8 --- /dev/null +++ b/wear/README.md @@ -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,` — `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,` — `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 : +adb -s : 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 : 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. diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index f245f102..ec419de6 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + @@ -29,7 +30,9 @@ android:name=".MainActivity" android:exported="true" android:label="Baresip" - android:taskAffinity=""> + android:taskAffinity="" + android:showWhenLocked="true" + android:turnScreenOn="true"> diff --git a/wear/src/main/assets/accounts b/wear/src/main/assets/accounts index 13de3254..e8df280b 100644 --- a/wear/src/main/assets/accounts +++ b/wear/src/main/assets/accounts @@ -1,2 +1,2 @@ -;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 +;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 diff --git a/wear/src/main/cpp/baresip.c b/wear/src/main/cpp/baresip.c index 004351a4..f7f319f4 100644 --- a/wear/src/main/cpp/baresip.c +++ b/wear/src/main/cpp/baresip.c @@ -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) diff --git a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt index f6fe62a2..8c299bca 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt @@ -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 diff --git a/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt b/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt index 4d9fe99b..8765d4fd 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt @@ -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" } } diff --git a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt index f4baa9b5..0644aed0 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt @@ -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") diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt index 29bdaef4..052bb82d 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt @@ -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}") + } + } } diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt index bff0963c..05202506 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt @@ -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 : "," -> uap in parts[1] - // call incoming/outgoing/established.. : ",,," -> 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," 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 diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt index a9227de0..20ace56b 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt @@ -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" }