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:
178
wear/README.md
Normal file
178
wear/README.md
Normal 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.
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user