Compare commits

...

9 Commits

Author SHA1 Message Date
899e778e49 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>
2026-08-21 01:58:56 +01:00
300bdcc810 Wear SIP: revert to TCP registration (fixes INVITE retransmit timeout)
The UDP switch made the watch register over UDP while Asterisk had
cached a TCP Contact (transport=tcp). Asterisk sent the INVITE over
TCP but got no SIP response (not even 100 Trying) -> 6.4s
retransmission timeout -> immediate voicemail.

Revert to TCP so the watch's registration matches Asterisk's cached
contact. With nat=yes now set, the TCP Contact is made public
(78.141.29.181:<port>), so the INVITE arrives over the persistent TCP
socket and baresip's response reaches Asterisk, letting the call ring
the full configured window instead of timing out.
2026-08-20 23:19:39 +01:00
34661f4ca6 Wear SIP: stop handing incoming calls to system telecom (fixes 2s drop)
The experiment of calling TelecomManager.addNewIncomingCall() for our
COMPANION PhoneAccount caused Samsung Wear's telecom stack to auto-reject
the incoming WearConnection within ~2s; its onReject() then hung up the
native baresip call, so Asterisk dropped to voicemail immediately instead
of ringing for the configured 25s.

Remove the handoff from the 'call incoming' path. The in-app InCallScreen
+ full-screen-intent notification remain the alert UI. The native call now
stays alive and rings for the full window (verified: no more 2s reject +
re-register cycle).
2026-08-20 23:12:23 +01:00
c88bee33d8 Wear SIP: register over UDP (NAT-friendly Contact)
Switch outbound transport from tcp to udp. With Asterisk peer
nat=yes, Asterisk now sends the INVITE to the public source address
(78.141.29.181:<port>) instead of the watch's private Contact, so
incoming calls traverse the NAT and reach the watch.

Verified: external incoming calls now arrive (uaEvent: incoming call
from external caller logged on the watch).
2026-08-20 23:03:15 +01:00
106ef4ed9d Wear SIP: switch account to 01273961147 + NAT-survival settings
- Account line now uses 01273961147 (same credential).
- sipnat=outbound + natpinhole=yes + regint=60 keep the NAT
  mapping/keep-alive fresh so Asterisk's INVITE (sent to the public
  Contact) reaches the watch behind NAT.

Note: sipnat=outbound is ineffective here because the outbound proxy
is the server itself (no edge STUN to learn the watch's public NAT
IP); the actual NAT fix is Asterisk-side (nat=force_rport,comedia on
the peer), which makes Asterisk target the public source address.
2026-08-20 22:40:59 +01:00
bd5b5efa3b Wear SIP: incoming-call alert (full-screen intent + screen wake + vibrate)
- High-importance (IMPORTANCE_MAX) call channel; on incoming call post a
  PRIORITY_MAX / CATEGORY_CALL notification with a full-screen intent that
  launches the InCallScreen over the watch face.
- MainActivity: setShowWhenLocked/setTurnScreenOn + FLAG_SHOW_WHEN_LOCKED
  so the in-call UI presents over the dimmed screen / keyguard.
- Request POST_NOTIFICATIONS at runtime (API 33+ silently drops
  notifications without it) alongside RECORD_AUDIO.
- Vibration ring pattern + wake-lock on incoming call; dismiss the
  heads-up when the call closes.

Note: on Samsung Galaxy Watch the watch notification manager blocks
alerting for apps not in its allowed-notifications list; the device
setting (Settings > Notifications > App notifications > Baresip Wear)
must be enabled for the heads-up to fire. Foreground/app-open case
verified working (call answered, audio -> onboard speaker).
2026-08-20 22:25:42 +01:00
15e266cb43 Wear SIP: audio routing, stock-style dialer, DTMF, incoming-call handling, telecom scaffold
- AudioRouteManager: route to onboard speaker when no BT headset is
  connected, BT SCO when one is; set MODE_IN_COMMUNICATION + focus.
- aaudio: build with AAUDIO_PERFORMANCE_MODE_NONE so Android routing
  (speakerphone) is honoured instead of being bypassed by low-latency.
- DialerScreen: restyled to a stock-dialer layout (large number display,
  3x4 keypad with sub-letters, green call FAB, backspace, recent chips).
- InCallScreen: added DTMF keypad (sendDigit) for voicemail/IVR.
- WearBaresipService: on incoming call, wake screen + vibrate + bring
  activity to foreground so Answer/Decline is visible.
- Fixed dial normalization (bare extension -> full SIP URI with domain)
  and the uap parse in uaEvent so defaultUap is set and calls connect.
- config.static: load g711/opus codecs to resolve 488 Not Acceptable Here.
- Telecom scaffold (experiment): PhoneAccount + ConnectionService handed
  incoming calls via addNewIncomingCall to test stock Wear dialer pickup.
- Submodule libbaresip-android rebuilt with g711/opus/aaudio changes.

Verified: assembleDebug green, installs and registers on Galaxy Watch,
incoming call event fires and is handled without crashing.
2026-08-20 22:06:15 +01:00
a849d98b5e Wear UI: dialer, in-call, debug accounts, baresip: provisioning
- Wear Compose dialer with recent calls and in-call answer/decline/hangup/mute
- Debug accounts screen (scrollable ScalingLazyColumn) for manual SIP config
- baresip:// provisioning intent mirroring phone app (RSA/AES bundle decrypt)
- Physical button (STEM_1/BACK) returns from Accounts via onKeyDown
- Native event handler now emits uap/callp for incoming/registration events
- Writes accounts/auth files in baresip format; verified REGISTER attempt on watch
2026-08-19 04:05:40 +01:00
9f94e72b1e Add Wear OS SIP client module running baresip NDK stack
- New :wear application module (Wear Compose, minSdk 30)
- Second JNI lib 'wearbaresip' reusing distribution static libs
- Watch-side service initializes baresip via full init path
  (libre_init -> conf_configure -> baresip_init -> ua_init)
- FGS started from MainActivity foreground state with mic type
  only (phoneCall requires default-dialer, rejected on watch)
- Runtime-verified: builds, installs, native lib loads and SIP
  stack starts on armeabi-v7a Wear OS device
2026-08-19 03:22:05 +01:00
25 changed files with 3424 additions and 6 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

2
.gitignore vendored
View File

@ -18,6 +18,8 @@ build
.externalNativeBuild
/app/release
/app/.cxx
/wear/.cxx
/wear/build
gradle/wrapper/gradle-wrapper.jar
/distribution/*
/distribution.video/*

View File

@ -8,3 +8,4 @@ android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
android.suppressUnsupportedCompileSdk=37.0

View File

@ -5,9 +5,10 @@ composeBom = "2026.06.01"
coreKtx = "1.19.0"
exifinterface = "1.4.2"
fragmentKtx = "1.8.9"
gradleVersion = "9.3.1"
gradleVersion = "9.1.0"
kotlin = "2.4.10"
kotlinStdlibJdk8 = "2.4.0"
android = "2.4.10"
kotlinSerializationPlugin = "2.4.10"
kotlinxCoroutinesAndroid = "1.11.0"
kotlinxSerializationJson = "1.11.0"
@ -25,6 +26,8 @@ navigationRuntimeAndroid = "2.9.8"
composeMaterialIcons = "1.7.8"
runtime = "1.11.4"
uiText = "1.11.4"
wear = "1.4.0"
wearCompose = "1.4.1"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
@ -35,6 +38,7 @@ androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", vers
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeCompose" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
@ -54,7 +58,12 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "composeMaterialIcons" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", version.ref = "uiText" }
androidx-wear = { module = "androidx.wear:wear", version.ref = "wear" }
androidx-wear-compose = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
androidx-wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
[plugins]
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerializationPlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "android" }

View File

@ -1,5 +1,2 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":app")
include(":wear")

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.

61
wear/build.gradle.kts Normal file
View File

@ -0,0 +1,61 @@
plugins {
id("com.android.application")
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.tutpro.baresip.wear"
compileSdk = 37
ndkVersion = "29.0.14206865"
defaultConfig {
applicationId = "com.tutpro.baresip.wear"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "0.1.0-wear"
externalNativeBuild {
cmake {
cFlags += "-DHAVE_INTTYPES_H -lstdc++"
arguments.addAll(listOf("-DANDROID_STL=c++_shared"))
}
}
ndk {
abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.31.6"
}
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.9.3")
implementation(libs.androidx.wear)
implementation(libs.androidx.wear.compose)
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.navigation)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.VIBRATE" />
<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" />
<uses-feature android:name="android.hardware.type.watch" android:required="true" />
<application
android:name=".WearBaresipApp"
android:allowBackup="true"
android:label="Baresip Wear"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault"
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="Baresip"
android:taskAffinity=""
android:showWhenLocked="true"
android:turnScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="baresip" />
</intent-filter>
</activity>
<service
android:name=".WearBaresipService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<!-- Telecom ConnectionService (experiment): lets the stock Wear dialer
surface our SIP account if it honors third-party PhoneAccounts. -->
<service
android:name=".WearConnectionService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +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;sipnat=outbound;natpinhole=yes

View File

@ -0,0 +1,42 @@
poll_method epoll
call_local_timeout 60
call_max_calls 4
call_hold_other_calls yes
filter_registrar udp,tcp,tls,ws,wss
audio_player aaudio,nil
audio_source aaudio,nil
audio_alert aaudio,nil
audio_level no
ausrc_format s16
auplay_format s16
auenc_format s16
audec_format s16
audio_buffer 20-160
audio_silence -35.0
audio_telev_pt 101
audio_jitter_buffer_type adaptive
audio_jitter_buffer_ms 100-200
audio_jitter_buffer_size 50
rtp_stats no
rtp_timeout 60
rtp_rxmode thread
module aaudio.so
module g711.so
module opus.so
module stun.so
module turn.so
module ice.so
module srtp.so
module dtls_srtp.so
module gzrtp.so
module uuid.so
module_app account.so
module_app debug_cmd.so
module_app mwi.so
opus_samplerate 16000
opus_stereo no
opus_sprop_stereo no
opus_cbr no
opus_inbandfec yes
opus_application voip
dtls_srtp_use_ec prime256v1

View File

@ -0,0 +1,137 @@
cmake_minimum_required(VERSION 3.18...4.0)
project(baresip-wear)
add_link_options("LINKER:--build-id=none")
set(distribution_DIR ${CMAKE_SOURCE_DIR}/../../../../distribution)
add_library(lib_crypto STATIC IMPORTED)
set_target_properties(lib_crypto PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/openssl/lib/${ANDROID_ABI}/libcrypto.a)
add_library(lib_ssl STATIC IMPORTED)
set_target_properties(lib_ssl PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/openssl/lib/${ANDROID_ABI}/libssl.a)
add_library(lib_re STATIC IMPORTED)
set_target_properties(lib_re PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/re/lib/${ANDROID_ABI}/libre.a)
add_library(lib_opus STATIC IMPORTED)
set_target_properties(lib_opus PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/opus/lib/${ANDROID_ABI}/libopus.a)
add_library(lib_g722 STATIC IMPORTED)
set_target_properties(lib_g722 PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/g722/lib/${ANDROID_ABI}/libg722.a)
add_library(lib_g722_1 STATIC IMPORTED)
set_target_properties(lib_g722_1 PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/g7221/lib/${ANDROID_ABI}/libg722_1.a)
add_library(lib_g729 STATIC IMPORTED)
set_target_properties(lib_g729 PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/g729/lib/${ANDROID_ABI}/libbcg729.a)
add_library(lib_ilbc STATIC IMPORTED)
set_target_properties(lib_ilbc PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/ilbc/lib/${ANDROID_ABI}/libilbc.a)
add_library(lib_codec2 STATIC IMPORTED)
set_target_properties(lib_codec2 PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/codec2/lib/${ANDROID_ABI}/libcodec2.a)
add_library(lib_amrnb STATIC IMPORTED)
set_target_properties(lib_amrnb PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/amr/lib/${ANDROID_ABI}/libamrnb.a)
add_library(lib_amrwb STATIC IMPORTED)
set_target_properties(lib_amrwb PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/amr/lib/${ANDROID_ABI}/libamrwb.a)
add_library(lib_amrwbenc STATIC IMPORTED)
set_target_properties(lib_amrwbenc PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/amr/lib/${ANDROID_ABI}/libamrwbenc.a)
add_library(lib_zrtpcppcore STATIC IMPORTED)
set_target_properties(lib_zrtpcppcore PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/gzrtp/lib/${ANDROID_ABI}/libzrtpcppcore.a)
add_library(lib_sndfile STATIC IMPORTED)
set_target_properties(lib_sndfile PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/sndfile/lib/${ANDROID_ABI}/libsndfile.a)
add_library(lib_baresip STATIC IMPORTED)
set_target_properties(lib_baresip PROPERTIES IMPORTED_LOCATION
${distribution_DIR}/baresip/lib/${ANDROID_ABI}/libbaresip.a)
add_library(wearbaresip SHARED ${CMAKE_SOURCE_DIR}/baresip.c)
# Export all symbols from libwearbaresip.so. baresip/re are linked in as
# static archives with hidden visibility, so by default NONE of their
# symbols reach the dynamic symbol table. Loadable application modules
# (account.so) dlopen() against this lib and need ua_alloc/conf_parse/
# uag_list etc. -- without --export-dynamic those are unresolved and the
# module silently fails to load, so no SIP account is ever populated.
target_link_options(wearbaresip PRIVATE -Wl,--export-dynamic)
target_include_directories(wearbaresip PRIVATE
${distribution_DIR}/openssl/include
${distribution_DIR}/re/include
${distribution_DIR}/baresip/include)
add_definitions(-DHAVE_PTHREAD)
target_link_libraries(
wearbaresip
android
aaudio
lib_baresip
lib_re
lib_ssl
lib_crypto
lib_opus
lib_g722
lib_g722_1
lib_g729
lib_ilbc
lib_codec2
lib_amrnb
lib_amrwb
lib_amrwbenc
lib_zrtpcppcore
lib_sndfile
z
log)
# Build the 'account' application module (.so) from the baresip submodule
# source. CRITICAL: it must reference the SAME baresip/re instance that is
# already loaded inside libwearbaresip.so -- NOT link libbaresip.a/libre.a
# statically (that would create a second, independent copy of baresip's
# static state -- separate uag_list/mod registry/global ctors -- and the
# module silently fails to dlopen, so no account is ever populated). Linking
# the MODULE against the SHARED wearbaresip lib makes it resolve every
# baresip/re symbol from the one live instance.
set(account_SRC
${CMAKE_SOURCE_DIR}/../../../../libbaresip-android/baresip/modules/account/account.c)
add_library(account MODULE ${account_SRC})
# baresip's module loader opens "<module_path>/<name>" where <name> comes
# from the config line "module_app account.so". It expects the file to be
# named exactly "account.so" (no "lib" prefix). CMake's default MODULE
# output is "libaccount.so", so force the bare name.
set_target_properties(account PROPERTIES PREFIX "" OUTPUT_NAME account)
target_include_directories(account PRIVATE
${distribution_DIR}/openssl/include
${distribution_DIR}/re/include
${distribution_DIR}/baresip/include
${CMAKE_SOURCE_DIR}/../../../../libbaresip-android/baresip/include)
add_definitions(-DHAVE_PTHREAD)
# Reference the shared baresip lib so there is exactly one baresip instance.
target_link_libraries(account wearbaresip log)
add_dependencies(account wearbaresip)

933
wear/src/main/cpp/baresip.c Normal file
View File

@ -0,0 +1,933 @@
#include <string.h>
#include <pthread.h>
#include <dlfcn.h>
#include <errno.h>
#include <jni.h>
#include <aaudio/AAudio.h>
#include <stdlib.h>
#include <re.h>
#include <baresip.h>
#include "logger.h"
typedef struct baresip_context
{
JavaVM *javaVM;
jclass serviceClz;
jobject serviceObj;
} BaresipContext;
static BaresipContext g_ctx;
enum
{
ASYNC_WORKERS = 4
};
static pthread_key_t g_thread_key;
static void detach_thread(void *env)
{
(void)env;
(*g_ctx.javaVM)->DetachCurrentThread(g_ctx.javaVM);
}
static JNIEnv *get_jni_env(void)
{
JNIEnv *env;
jint res = (*g_ctx.javaVM)->GetEnv(g_ctx.javaVM, (void**)&env, JNI_VERSION_1_6);
if (res != JNI_OK) {
res = (*g_ctx.javaVM)->AttachCurrentThread(g_ctx.javaVM, &env, NULL);
if (res == JNI_OK) {
pthread_setspecific(g_thread_key, env);
} else {
return NULL;
}
}
return env;
}
static void signal_handler(int sig)
{
static bool term = false;
if (term) {
exit(0);
}
term = true;
LOGI("terminated by signal (%d)\n", sig);
ua_stop_all(false);
}
static void ua_exit_handler(void *arg)
{
(void)arg;
LOGD("ua exited -- stopping main runloop\n");
re_cancel();
}
static const char *translate_errorcode(uint16_t scode)
{
switch (scode) {
case 404:
return "";
case 486:
case 603:
return "busy";
case 487:
return "";
default:
return "error";
}
}
static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
{
(void)arg;
const char *prm = bevent_get_text(event);
struct call *call = bevent_get_call(event);
struct ua *ua = bevent_get_ua(event);
const struct sip_msg *msg = bevent_get_msg(event);
struct account *acc = ua_account(bevent_get_ua(event));
const char *tone;
char event_buf[256];
enum sdp_dir ardir;
int len, err;
struct pl module, module_event, data;
switch (ev) {
case BEVENT_CREATE:
len = re_snprintf(event_buf, sizeof event_buf, "create", "");
break;
case BEVENT_REGISTERING:
len = re_snprintf(event_buf, sizeof event_buf, "registering,%ld", (long)ua);
break;
case BEVENT_UNREGISTERING:
len = re_snprintf(event_buf, sizeof event_buf, "unregistering,%ld", (long)ua);
break;
case BEVENT_REGISTER_OK:
case BEVENT_FALLBACK_OK:
len = re_snprintf(event_buf, sizeof event_buf, "registered,%ld", (long)ua);
break;
case BEVENT_REGISTER_FAIL:
case BEVENT_FALLBACK_FAIL:
LOGD("register_event: fail prm='%s' ua=%ld\n", prm ? prm : "", (long)ua);
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, "incoming call,%r",
&msg->from.auri);
break;
case BEVENT_CALL_INCOMING:
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", "");
break;
case BEVENT_CALL_ANSWERED:
len = re_snprintf(event_buf, sizeof event_buf, "call answered", "");
break;
case BEVENT_CALL_REDIRECT:
len = re_snprintf(event_buf, sizeof event_buf, "call redirect,%s", prm + 4);
break;
case BEVENT_CALL_LOCAL_SDP:
if (strcmp(prm, "offer") == 0)
return;
len = re_snprintf(event_buf, sizeof event_buf, "call %sed", prm);
break;
case BEVENT_CALL_RINGING:
len = re_snprintf(event_buf, sizeof event_buf, "call ringing", "");
break;
case BEVENT_CALL_PROGRESS:
ardir = sdp_media_rdir(stream_sdpmedia(audio_strm(call_audio(call))));
len = re_snprintf(event_buf, sizeof event_buf, "call progress,%d", ardir);
break;
case BEVENT_CALL_ESTABLISHED:
len = re_snprintf(event_buf, sizeof event_buf, "call established", "");
break;
case BEVENT_CALL_REMOTE_SDP:
ardir = sdp_media_rdir(stream_sdpmedia(audio_strm(call_audio(call))));
len = re_snprintf(event_buf, sizeof event_buf, "call update,%d,%ld", ardir, (long)event);
break;
case BEVENT_CALL_MENC:
if (prm[0] == '0')
len = re_snprintf(event_buf, sizeof event_buf, "call secure", "");
else if (prm[0] == '1')
len = re_snprintf(event_buf, sizeof event_buf, "call verify,%s", prm + 2);
else if (prm[0] == '2')
len = re_snprintf(event_buf, sizeof event_buf, "call verified,%s", prm + 2);
else
len = re_snprintf(event_buf, sizeof event_buf, "unknown menc event", "");
break;
case BEVENT_CALL_TRANSFER:
len = re_snprintf(event_buf, sizeof event_buf, "call transfer,%s", prm);
break;
case BEVENT_CALL_TRANSFER_FAILED:
call_hold(call, false);
len = re_snprintf(event_buf, sizeof event_buf, "transfer failed,%s", prm);
break;
case BEVENT_CALL_CLOSED:
tone = call_scode(call) ? translate_errorcode(call_scode(call)) : "";
len = re_snprintf(event_buf, sizeof event_buf, "call closed,%s,%s", prm, tone);
break;
case BEVENT_MWI_NOTIFY:
len = re_snprintf(event_buf, sizeof event_buf, "mwi notify,%s", prm);
break;
case BEVENT_MODULE:
err = re_regex(prm, strlen(prm), "[^,]*,[^,]*,[~]*", &module, &module_event, &data);
if (err)
return;
if (!pl_strcmp(&module_event, "dump")) {
len = re_snprintf(event_buf, sizeof event_buf, "sndfile dump,%r", &data);
break;
}
if (!pl_strcmp(&module_event, "recorder sessionid")) {
len = re_snprintf(event_buf, sizeof event_buf, "recorder sessionid,%r", &data);
break;
}
default:
return;
}
if (len == -1) {
return;
}
JNIEnv *env = get_jni_env();
if (!env) return;
if (!g_ctx.serviceClz || !g_ctx.serviceObj) return;
jmethodID methodId =
(*env)->GetMethodID(env, g_ctx.serviceClz, "uaEvent", "(Ljava/lang/String;JJ)V");
jstring jEvent = (*env)->NewStringUTF(env, event_buf);
if (methodId) {
// 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);
}
}
(*env)->DeleteLocalRef(env, jEvent);
}
static void message_handler(
struct ua *ua, const struct pl *peer, const struct pl *ctype, struct mbuf *body, void *arg)
{
(void)arg;
char peer_buf[256];
size_t size;
if (snprintf(peer_buf, 256, "%.*s", (int)peer->l, peer->p) >= 256) {
return;
}
JNIEnv *env = get_jni_env();
if (!env) return;
if (!g_ctx.serviceClz || !g_ctx.serviceObj) return;
jmethodID methodId = (*env)->GetMethodID(env, g_ctx.serviceClz, "messageEvent",
"(JLjava/lang/String;Ljava/lang/String;[B)V");
jstring jPeer = (*env)->NewStringUTF(env, peer_buf);
jbyteArray jMsg;
size = mbuf_get_left(body);
jMsg = (*env)->NewByteArray(env, (jsize)size);
if ((*env)->GetArrayLength(env, jMsg) != size) {
(*env)->DeleteLocalRef(env, jMsg);
jMsg = (*env)->NewByteArray(env, (jsize)size);
}
void *temp = (*env)->GetPrimitiveArrayCritical(env, (jarray)jMsg, 0);
memcpy(temp, mbuf_buf(body), size);
(*env)->ReleasePrimitiveArrayCritical(env, jMsg, temp, 0);
if (methodId) {
(*env)->CallVoidMethod(env, g_ctx.serviceObj, methodId, (jlong)ua, jPeer, NULL, jMsg);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
}
(*env)->DeleteLocalRef(env, jPeer);
(*env)->DeleteLocalRef(env, jMsg);
}
static void send_resp_handler(int err, const struct sip_msg *msg, void *arg)
{
char *native_time = (char *)arg;
char reason_buf[64];
if (err) {
mem_deref(native_time);
return;
}
pl_strcpy(&(msg->reason), reason_buf, 64);
JNIEnv *env = get_jni_env();
if (!env) {
mem_deref(native_time);
return;
}
if (!g_ctx.serviceClz || !g_ctx.serviceObj) {
mem_deref(native_time);
return;
}
jmethodID methodId = (*env)->GetMethodID(env, g_ctx.serviceClz, "messageResponse",
"(ILjava/lang/String;Ljava/lang/String;)V");
jstring javaReason = (*env)->NewStringUTF(env, reason_buf);
jstring javaTime = (*env)->NewStringUTF(env, native_time);
if (methodId) {
(*env)->CallVoidMethod(env, g_ctx.serviceObj, methodId, msg->scode, javaReason,
javaTime);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
}
(*env)->DeleteLocalRef(env, javaReason);
(*env)->DeleteLocalRef(env, javaTime);
mem_deref(native_time);
}
enum
{
ID_UA_STOP_ALL
};
static struct mqueue *mq;
static void mqueue_handler(int id, void *data, void *arg)
{
(void)arg;
if (id == ID_UA_STOP_ALL) {
ua_stop_all((bool)(uintptr_t)data);
}
}
#include <unistd.h>
static int pfd[2];
static pthread_t loggingThread;
static void *loggingFunction(void *arg)
{
(void)arg;
ssize_t readSize;
char buf[128];
while ((readSize = read(pfd[0], buf, sizeof buf - 1)) > 0) {
if (buf[readSize - 1] == '\n') {
--readSize;
}
buf[readSize] = 0;
LOGD("%s", buf);
}
return 0;
}
static int runLoggingThread()
{
setvbuf(stdout, 0, _IOLBF, 0);
setvbuf(stderr, 0, _IONBF, 0);
pipe(pfd);
dup2(pfd[1], 1);
dup2(pfd[1], 2);
int ret = pthread_create(&loggingThread, NULL, loggingFunction, NULL);
if (ret != 0) {
return ret;
}
pthread_detach(loggingThread);
return 0;
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
(void)reserved;
memset(&g_ctx, 0, sizeof(g_ctx));
g_ctx.javaVM = vm;
if (pthread_key_create(&g_thread_key, detach_thread) != 0) {
}
return JNI_VERSION_1_6;
}
static int apply_nameserver(JNIEnv *env, jstring javaServers);
// NOTE: baresip's account.so module (loaded via conf_modules()) already
// auto-allocates and registers the <filesDir>/accounts file at startup, so no
// explicit startup register is needed. A bare AOR such as "sip:user@host"
// passed to ua_alloc is rejected (sip_addr_decode EINVAL); always pass the
// FULL account line (e.g. "<sip:user@host>;auth_pass=...") -- which is exactly
// what the account module does via conf_parse.
static jlong jni_ua_alloc(const char *uri)
{
struct ua *ua = NULL;
int err;
re_thread_enter();
err = ua_alloc(&ua, uri);
re_thread_leave();
return err == 0 ? (jlong)(intptr_t)ua : 0L;
}
static void jni_ua_destroy(jlong uap)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
if (!ua) return;
re_thread_enter();
mem_deref(ua);
re_thread_leave();
}
static jint jni_ua_register(jlong uap)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
int err;
re_thread_enter();
err = ua_register(ua);
re_thread_leave();
return err;
}
static void jni_ua_hangup(jlong uap, jlong callp, jint code, const char *reason)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct call *call = (struct call *)(intptr_t)callp;
re_thread_enter();
ua_hangup(ua, call, code, 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;
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)
{
struct call *call = (struct call *)(intptr_t)callp;
struct pl pl;
int err;
pl_set_str(&pl, peer_uri);
re_thread_enter();
err = call_connect(call, &pl);
re_thread_leave();
return err == 0 ? callp : 0L;
}
static jboolean jni_call_hold(jlong callp, jboolean hold)
{
struct call *call = (struct call *)(intptr_t)callp;
bool ok;
re_thread_enter();
ok = call_hold(call, hold);
re_thread_leave();
return ok ? JNI_TRUE : JNI_FALSE;
}
static jint jni_call_send_digit(jlong callp, jchar digit)
{
struct call *call = (struct call *)(intptr_t)callp;
int err;
re_thread_enter();
err = call_send_digit(call, digit);
re_thread_leave();
return err;
}
static jstring jni_account_aor(JNIEnv *env, jlong acc)
{
struct account *a = (struct account *)(intptr_t)acc;
const char *aor;
jstring jstr = NULL;
re_thread_enter();
aor = account_aor(a);
if (aor) jstr = (*env)->NewStringUTF(env, aor);
re_thread_leave();
return jstr;
}
JNIEXPORT jlong JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1alloc(JNIEnv *env, jclass clazz, jstring jUri)
{
(void)clazz;
const char *uri = (*env)->GetStringUTFChars(env, jUri, NULL);
jlong uap = jni_ua_alloc(uri ? uri : "");
if (uri) (*env)->ReleaseStringUTFChars(env, jUri, uri);
return uap;
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1destroy(JNIEnv *env, jclass clazz, jlong uap)
{
(void)env;
(void)clazz;
jni_ua_destroy(uap);
}
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1register(JNIEnv *env, jclass clazz, jlong uap)
{
(void)env;
(void)clazz;
return jni_ua_register(uap);
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1hangup(JNIEnv *env, jclass clazz,
jlong uap, jlong callp, jint code, jstring jReason)
{
const char *reason = NULL;
if (jReason) reason = (*env)->GetStringUTFChars(env, jReason, NULL);
jni_ua_hangup(uap, callp, code, reason ? reason : "");
if (reason) (*env)->ReleaseStringUTFChars(env, jReason, reason);
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1answer(JNIEnv *env, jclass clazz,
jlong uap, jlong callp, jint video)
{
(void)env;
(void)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)
{
const char *peer = (*env)->GetStringUTFChars(env, jPeer, NULL);
jlong rc = jni_call_connect(callp, peer ? peer : "");
if (peer) (*env)->ReleaseStringUTFChars(env, jPeer, peer);
return rc;
}
JNIEXPORT jboolean JNICALL
Java_com_tutpro_baresip_wear_Api_call_1hold(JNIEnv *env, jclass clazz,
jlong callp, jboolean hold)
{
(void)env;
(void)clazz;
return jni_call_hold(callp, hold);
}
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_call_1send_1digit(JNIEnv *env, jclass clazz,
jlong callp, jchar digit)
{
(void)env;
(void)clazz;
return jni_call_send_digit(callp, digit);
}
JNIEXPORT jstring JNICALL
Java_com_tutpro_baresip_wear_Api_account_1aor(JNIEnv *env, jclass clazz, jlong acc)
{
(void)clazz;
return jni_account_aor(env, acc);
}
JNIEXPORT jlong JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz,
jlong uap, jlong xcallp, jint video)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct call *call = NULL;
int err;
re_thread_enter();
err = ua_call_alloc(&call, ua, (enum vidmode)video, NULL,
(struct call *)(intptr_t)xcallp,
call_localuri((struct call *)(intptr_t)xcallp),
true);
re_thread_leave();
return err == 0 ? (jlong)(intptr_t)call : 0L;
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
JNIEnv *env, jobject instance, jstring jPath, jstring jAddrs, jstring jDns, jint jLogLevel, jstring jSoftware, jstring jNativeLibDir)
{
int err;
jclass clz = (*env)->GetObjectClass(env, instance);
g_ctx.serviceClz = (*env)->NewGlobalRef(env, clz);
g_ctx.serviceObj = (*env)->NewGlobalRef(env, instance);
const char *path = (*env)->GetStringUTFChars(env, jPath, 0);
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
const char *software = (*env)->GetStringUTFChars(env, jSoftware, 0);
const char *nativelib = jNativeLibDir ? (*env)->GetStringUTFChars(env, jNativeLibDir, 0) : NULL;
runLoggingThread();
// baresip does NOT read module_path from the loaded config file; it
// defaults to /usr/local (absent on Android), so point the CWD at the
// extracted native lib dir so module loading resolves .so modules.
if (nativelib) {
if (chdir(nativelib) != 0) {
LOGE("chdir to nativeLibDir '%s' failed: %s\n",
nativelib, strerror(errno));
}
(*env)->ReleaseStringUTFChars(env, jNativeLibDir, nativelib);
}
// Promote libwearbaresip.so to RTLD_GLOBAL so loadable application
// modules (e.g. account.so) can resolve baresip/re symbols. Android's
// System.loadLibrary() uses RTLD_LOCAL, hiding these symbols from a
// later dlopen() of a module .so.
{
void *h = dlopen("libwearbaresip.so", RTLD_GLOBAL | RTLD_NOLOAD);
if (!h) {
LOGE("promote libwearbaresip.so to RTLD_GLOBAL failed: %s\n",
dlerror() ? dlerror() : "unknown");
} else {
dlclose(h);
}
}
err = libre_init();
if (err) {
goto out;
}
if (re_thread_check(true) == 0) {
jint res = (*g_ctx.javaVM)->AttachCurrentThread(g_ctx.javaVM, &env, NULL);
if (JNI_OK != res) {
goto out;
}
} else {
goto out;
}
conf_path_set(path);
log_level_set((enum log_level)jLogLevel);
err = conf_configure();
if (err) {
goto out;
}
re_thread_async_init(ASYNC_WORKERS);
err = baresip_init(conf_config());
if (err) {
goto out;
}
if (jDns) {
err = apply_nameserver(env, jDns);
if (err) {
LOGW("apply_nameserver failed: %d\n", err);
}
}
dnsc_cache_max(net_dnsc(baresip_network()), 0);
if (strlen(addrs) > 0) {
char *addr_list = (char *)malloc(strlen(addrs) + 1);
struct sa temp_sa;
net_flush_addresses(baresip_network());
strcpy(addr_list, addrs);
char *ptr = strtok(addr_list, ";");
while (ptr != NULL) {
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
net_add_address(baresip_network(), &temp_sa);
}
ptr = strtok(NULL, ";");
}
free(addr_list);
}
err = ua_init(software, true, true, true);
if (err) {
goto out;
}
uag_set_exit_handler(ua_exit_handler, NULL);
err = bevent_register(event_handler, NULL);
if (err) {
goto out;
}
err = message_listen(baresip_message(), message_handler, NULL);
if (err) {
goto out;
}
err = conf_modules();
if (err) {
LOGE("conf_modules failed: (%d)\n", err);
goto out;
}
err = mqueue_alloc(&mq, mqueue_handler, NULL);
if (err) {
goto out;
}
LOGI("running main loop ...\n");
err = re_main(signal_handler);
out:
if (err) {
LOGE("stopping UAs due to error: (%d)\n", err);
ua_stop_all(true);
} else {
LOGI("main loop exit\n");
}
mq = mem_deref(mq);
LOGD("closing ...");
ua_close();
module_app_unload();
conf_close();
baresip_close();
bevent_unregister(event_handler);
LOGD("unloading modules ...");
mod_close();
LOGD("closing re thread\n");
re_thread_async_close();
LOGD("closing libre\n");
libre_close();
(*env)->ReleaseStringUTFChars(env, jPath, path);
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
(*env)->ReleaseStringUTFChars(env, jSoftware, software);
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject instance)
{
(void)env;
(void)instance;
ua_stop_all(true);
re_cancel();
baresip_close();
libre_close();
if (g_ctx.serviceClz) {
(*env)->DeleteGlobalRef(env, g_ctx.serviceClz);
g_ctx.serviceClz = NULL;
}
if (g_ctx.serviceObj) {
(*env)->DeleteGlobalRef(env, g_ctx.serviceObj);
g_ctx.serviceObj = NULL;
}
}
// Refresh network context for the running stack. Re-applies DNS and local
// source-address list so rehandshakes use the current interface.
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_WearBaresipService_refreshNetwork(
JNIEnv *env, jobject instance, jstring jAddrs, jstring jDns)
{
(void)instance;
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
const char *dns = (*env)->GetStringUTFChars(env, jDns, 0);
if (dns) {
int err = apply_nameserver(env, jDns);
if (err) {
LOGW("refreshNetwork apply_nameserver failed: %d\n", err);
}
}
if (strlen(addrs) > 0) {
char *addr_list = (char *)malloc(strlen(addrs) + 1);
struct sa temp_sa;
net_flush_addresses(baresip_network());
strcpy(addr_list, addrs);
char *ptr = strtok(addr_list, ";");
while (ptr != NULL) {
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
net_add_address(baresip_network(), &temp_sa);
}
ptr = strtok(NULL, ";");
}
free(addr_list);
}
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
(*env)->ReleaseStringUTFChars(env, jDns, dns);
}
// Apply DNS servers (comma-separated "ip:53") to baresip's resolver. Shared
// by the JNI Api.net_use_nameserver and the startup path below.
static int apply_nameserver(JNIEnv *env, jstring javaServers)
{
if (!javaServers) return 0;
const char *native_servers = (*env)->GetStringUTFChars(env, javaServers, 0);
char servers[256];
char *server;
struct sa nsv[NET_MAX_NS];
uint32_t count = 0;
char *comma;
int res;
int err;
LOGD("Setting DNS servers '%s'\n", native_servers);
if (strlen(native_servers) > 255) {
LOGW("net_use_nameserver: too long (%s)\n", native_servers);
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
return 1;
}
str_ncpy(servers, native_servers, 256);
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
server = &(servers[0]);
while ((count < NET_MAX_NS) && ((comma = strchr(server, ',')) != NULL)) {
*comma = '\0';
err = sa_decode(&(nsv[count]), server, strlen(server));
if (err) {
LOGW("net_use_nameserver: bad '%s' (%u)\n", server, err);
return err;
}
server = ++comma;
count++;
}
if ((count < NET_MAX_NS) && (strlen(server) > 0)) {
err = sa_decode(&(nsv[count]), server, strlen(server));
if (err) {
LOGW("net_use_nameserver: bad `%s' (%u)\n", server, err);
return err;
}
count++;
}
res = net_use_nameserver(baresip_network(), nsv, count);
return res;
}
// Mirror of phone app's Api.net_use_nameserver. Sets the DNS servers used by
// baresip's internal resolver (required on Wear where no dns_server is
// configured in config and dns_getaddrinfo defaults to off).
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_net_1use_1nameserver(
JNIEnv *env, jobject obj, jstring javaServers)
{
(void)obj;
return apply_nameserver(env, javaServers);
}

View File

@ -0,0 +1,13 @@
#include <baresip.h>
#include <android/log.h>
#ifndef BARESIP_LOGGER_H
#define BARESIP_LOGGER_H
#define LOG_TAG "Baresip Wear"
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__))
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
#endif

View File

@ -0,0 +1,26 @@
package com.tutpro.baresip.wear
// Minimal native bindings matching the symbols exported by wearbaresip JNI lib.
// Only the subset needed by the Wear UI is declared here.
class Api private constructor() {
companion object {
const val VIDMODE_OFF = 0
@JvmStatic external fun ua_alloc(uri: String): Long
@JvmStatic external fun ua_destroy(uap: Long)
@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
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
@JvmStatic external fun account_aor(acc: Long): String
@JvmStatic external fun net_use_nameserver(servers: String): Int
}
}

View File

@ -0,0 +1,159 @@
package com.tutpro.baresip.wear
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.media.AudioManager
import android.os.Build
import android.util.Log
/**
* Manages call audio routing for the watch.
*
* Baresip's aaudio module opens AAudio streams with USAGE_VOICE_COMMUNICATION.
* On a watch there is no earpiece, so the default in-call route is useless
* (often silent). We therefore drive routing explicitly through AudioManager:
*
* - If a Bluetooth headset (HFP) is CONNECTED -> route over BT SCO.
* - Otherwise -> route to the onboard speaker.
*
* The aaudio module is built with AAUDIO_PERFORMANCE_MODE_NONE so that the
* stream actually honours AudioManager routing (LOW_LATENCY would bypass it).
*
* NOTE on BT detection: we deliberately do NOT use
* AudioManager.isBluetoothScoAvailableOffCall() -- that returns true whenever
* Bluetooth is merely *enabled* on the watch, not only when a headset is
* actually connected, which wrongly routed every call to BT SCO. Instead we
* query BluetoothAdapter.getProfileConnectionState(HEADSET); it is guarded so
* it never throws (no proxy callback, so no BLUETOOTH_CONNECT crash) and
* falls back to the onboard speaker when BT is unavailable or denied.
*/
object AudioRouteManager {
private const val TAG = "Baresip Wear Audio"
private var am: AudioManager? = null
private var bluetoothAdapter: BluetoothAdapter? = null
private var active = false
fun init(context: Context) {
try {
am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
} catch (e: Exception) {
Log.w(TAG, "init failed (audio routing disabled): ${e.message}")
}
}
/** Called when a call becomes active (outgoing established / incoming answered). */
fun startCallAudio() {
val audioManager = am ?: return
active = true
// Put the device into in-call audio mode so routing policies apply.
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
// Grab audio focus so other streams (music, notifications) duck.
requestFocus(audioManager)
applyRoute()
Log.d(TAG, "startCallAudio mode=${audioManager.mode} btHeadset=${hasConnectedHeadset()}")
}
/** Called when the call ends; restores normal audio. */
fun stopCallAudio() {
val audioManager = am ?: return
active = false
if (audioManager.isBluetoothScoOn) {
audioManager.stopBluetoothSco()
audioManager.isBluetoothScoOn = false
}
audioManager.isSpeakerphoneOn = false
audioManager.mode = AudioManager.MODE_NORMAL
abandonFocus(audioManager)
Log.d(TAG, "stopCallAudio restored normal mode")
}
private fun applyRoute() {
val audioManager = am ?: return
if (hasConnectedHeadset()) {
// A real HFP headset is connected: route to it.
audioManager.isSpeakerphoneOn = false
if (!audioManager.isBluetoothScoOn) {
try {
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
} catch (e: Exception) {
Log.w(TAG, "startBluetoothSco failed: ${e.message}")
}
}
Log.d(TAG, "route -> Bluetooth SCO")
} else {
// No connected headset: force the onboard speaker.
if (audioManager.isBluetoothScoOn) {
audioManager.stopBluetoothSco()
audioManager.isBluetoothScoOn = false
}
audioManager.isSpeakerphoneOn = true
Log.d(TAG, "route -> onboard speaker")
}
}
/**
* Returns true only when an HFP headset is actually CONNECTED. Uses
* getProfileConnectionState (no proxy callback, so it cannot trigger the
* BLUETOOTH_CONNECT SecurityException crash). Returns false on any
* failure so we fall back to the onboard speaker. Never throws.
*/
private fun hasConnectedHeadset(): Boolean {
return try {
val adapter = bluetoothAdapter ?: return false
if (!adapter.isEnabled) return false
adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ==
BluetoothProfile.STATE_CONNECTED
} catch (e: Exception) {
Log.w(TAG, "hasConnectedHeadset failed: ${e.message}")
false
}
}
private fun requestFocus(audioManager: AudioManager) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = android.media.AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
).build()
audioManager.requestAudioFocus(request)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
null, AudioManager.STREAM_VOICE_CALL,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
)
}
} catch (e: Exception) {
Log.w(TAG, "requestAudioFocus failed: ${e.message}")
}
}
private fun abandonFocus(audioManager: AudioManager) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = android.media.AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
).build()
audioManager.abandonAudioFocusRequest(request)
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(null)
}
} catch (e: Exception) {
Log.w(TAG, "abandonAudioFocus failed: ${e.message}")
}
}
fun shutdown() {
stopCallAudio()
}
}

View File

@ -0,0 +1,37 @@
package com.tutpro.baresip.wear
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
// Lightweight call model for the watch. Mirrors the essential parts of the
// phone app's Call class without the Telecom/ConnectionService machinery.
data class WearCall(
val callp: Long,
val uap: Long,
val peerUri: String,
var status: String = "",
val dir: String = "in", // "in" or "out"
var onHold: Boolean = false,
var muted: Boolean = false
)
object CallState {
val calls = mutableStateListOf<WearCall>()
val registration = mutableStateOf("")
val status = mutableStateOf("Idle")
val recentPeers = mutableStateListOf<String>()
fun add(call: WearCall) {
if (calls.none { it.callp == call.callp }) calls.add(call)
}
fun remove(callp: Long) {
calls.removeAll { it.callp == callp }
}
fun find(callp: Long) = calls.firstOrNull { it.callp == callp }
fun active() = calls.firstOrNull { it.status == "call established" || it.status == "call outgoing" || it.status == "call ringing" }
fun incoming() = calls.firstOrNull { it.status == "incoming call" }
}

View File

@ -0,0 +1,617 @@
package com.tutpro.baresip.wear
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.material.*
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
class MainActivity : ComponentActivity() {
internal val route = mutableStateOf("dialer")
private val recordAudioPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startBaresipService()
// On API 33+ notifications are silently dropped unless the user
// grants POST_NOTIFICATIONS -- required for the incoming-call
// heads-up (full-screen intent) to wake the watch face.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
postNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
} else {
CallState.status.value = "Mic permission denied"
}
}
private val postNotificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* best-effort; incoming alert still works if already granted */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WearApp(this)
}
// If we were launched by an incoming-call alert (full-screen intent),
// turn the screen on and show over the lock screen, and jump straight
// to the in-call UI.
if (intent?.getBooleanExtra("incoming_call", false) == true) {
android.util.Log.d("Baresip Wear", "onCreate: incoming_call extra seen -> incall")
turnScreenOnForCall()
route.value = "incall"
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
startBaresipService()
} else {
recordAudioPermission.launch(Manifest.permission.RECORD_AUDIO)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
this.intent = intent
if (intent.action == Intent.ACTION_VIEW && intent.data?.scheme == "baresip") {
handleProvisioningIntent(intent)
} else if (intent.getBooleanExtra("incoming_call", false)) {
// Launched from the full-screen call intent while already running.
android.util.Log.d("Baresip Wear", "onNewIntent: incoming_call extra seen -> incall")
turnScreenOnForCall()
route.value = "incall"
}
}
/** Wake the screen and present over the lock screen for an incoming call. */
@Suppress("DEPRECATION")
private fun turnScreenOnForCall() {
try {
// Modern, reliable way to raise an incoming-call UI over the watch
// face / keyguard (API 27+). These are the APIs designed for exactly
// this (alarm / incoming-call) use case.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
val wm = getSystemService(android.view.WindowManager::class.java)
val params = window.attributes
params.flags = params.flags or
android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
window.attributes = params
val pm = getSystemService(android.os.PowerManager::class.java)
if (pm != null && !pm.isInteractive) {
val wl = pm.newWakeLock(
android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP,
"BaresipWear:incomingCallUI"
)
wl.acquire(5000)
}
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "turnScreenOnForCall failed: ${e.message}")
}
}
// Physical side button (STEM_1) / BACK key pops the Accounts screen.
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (route.value == "accounts" &&
(keyCode == KeyEvent.KEYCODE_STEM_1 || keyCode == KeyEvent.KEYCODE_BACK)
) {
route.value = "dialer"
return true
}
return super.onKeyDown(keyCode, event)
}
private fun handleProvisioningIntent(intent: Intent) {
val data = intent.data ?: return
val endpoint = data.getQueryParameter("endpoint") ?: return
val extension = data.getQueryParameter("extension") ?: return
CallState.status.value = "Provisioning..."
lifecycleScope.launch {
try {
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
CallState.registration.value = "Provisioned: $aor"
val svc = Intent(applicationContext, WearBaresipService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(svc)
} else {
startService(svc)
}
CallState.status.value = "Restarting SIP"
} catch (e: Exception) {
CallState.status.value = "Provisioning failed"
}
}
}
private fun startBaresipService() {
val intent = Intent(this, WearBaresipService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
}
@Composable
fun WearApp(activity: MainActivity) {
// Show the in-call screen whenever there is an active or incoming call.
val hasCall by remember { derivedStateOf { CallState.calls.isNotEmpty() } }
MaterialTheme {
Scaffold(timeText = { TimeText() }) {
when {
hasCall -> InCallScreen(onEnd = { hangupCall(CallState.active() ?: CallState.incoming()) })
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
}
}
}
}
@Composable
fun DialerScreen(onAccounts: () -> Unit) {
var number by remember { mutableStateOf("") }
// Stock-dialer keypad layout: digit + the small sub-letters shown on real
// phones (1 has none, 2=ABC, ...). Tapping a key appends the digit.
val keypad = listOf(
"1" to "", "2" to "ABC", "3" to "DEF",
"4" to "GHI", "5" to "JKL", "6" to "MNO",
"7" to "PQRS", "8" to "TUV", "9" to "WXYZ",
"*" to "", "0" to "+", "#" to ""
)
Column(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black)
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Registration status (small, top).
Text(
text = CallState.registration.value.ifEmpty { "Baresip Wear" },
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = androidx.compose.ui.graphics.Color.White
)
Spacer(Modifier.height(10.dp))
// Large centered number display, like the stock dialer.
Text(
text = number.ifEmpty { " " },
style = MaterialTheme.typography.title1,
textAlign = TextAlign.Center,
color = androidx.compose.ui.graphics.Color.White,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
)
Spacer(Modifier.height(10.dp))
// 3-column keypad grid.
keypad.chunked(3).forEach { row ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
row.forEach { (digit, sub) ->
Button(
onClick = { number += digit },
modifier = Modifier
.weight(1f)
.heightIn(min = 46.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(digit, style = MaterialTheme.typography.title3)
if (sub.isNotEmpty()) {
Text(sub, style = MaterialTheme.typography.body2,
fontSize = 8.sp)
}
}
}
}
}
Spacer(Modifier.height(6.dp))
}
Spacer(Modifier.height(8.dp))
// Backspace + Call row, like the stock dialer footer.
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Backspace (only enabled when there is something to delete).
Button(
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
enabled = number.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
)
) {
Icon(Icons.Filled.Backspace, contentDescription = "Delete")
}
// Green call button.
Button(
onClick = {
android.util.Log.d("Baresip Wear", "Call button clicked, number='$number'")
placeCall(number)
},
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color(0xFF007A3D)
),
modifier = Modifier.size(56.dp)
) {
Icon(Icons.Filled.Call, contentDescription = "Call")
}
}
Spacer(Modifier.height(8.dp))
// Recent calls as chips; tap to redial.
if (CallState.recentPeers.isNotEmpty()) {
Text(
text = "Recent",
color = androidx.compose.ui.graphics.Color.Gray,
style = MaterialTheme.typography.body2
)
CallState.recentPeers.take(4).forEach { peer ->
Chip(
onClick = { placeCall(peer) },
label = { Text(peer, maxLines = 1) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(2.dp))
}
}
Spacer(Modifier.height(6.dp))
// Settings / Accounts.
Button(
onClick = onAccounts,
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
)
) {
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
}
}
}
@Composable
fun AccountsScreen(onBack: () -> Unit) {
val registration = CallState.registration.value
val status = CallState.status.value
val recent = CallState.recentPeers.take(5)
val context = LocalContext.current
var aor by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var outbound by remember { mutableStateOf("") }
var regInt by remember { mutableStateOf("") }
var loaded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
try {
val filesDir = context.filesDir
val accounts = File(filesDir, "accounts")
val auth = File(filesDir, "auth")
if (accounts.exists()) {
val text = accounts.readText().trim()
val first = text.lineSequence().firstOrNull() ?: ""
val aorMatch = Regex("^<([^>]+)>").find(first)
aor = aorMatch?.groupValues?.getOrNull(1) ?: ""
val outMatch = Regex("outbound\\s*=\\s*\"([^\"]*)\"").find(first)
outbound = outMatch?.groupValues?.getOrNull(1) ?: ""
val regMatch = Regex("regint\\s*=\\s*(\\d+)").find(first)
regInt = regMatch?.groupValues?.getOrNull(1) ?: ""
}
if (auth.exists()) {
val authLine = auth.readText().trim().lineSequence().firstOrNull() ?: ""
val parts = authLine.split(" ", limit = 2)
if (parts.size == 2) password = parts[1]
}
} catch (_: Exception) {
} finally {
loaded = true
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black)
.verticalScroll(rememberScrollState())
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Account", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.title3)
Spacer(Modifier.height(6.dp))
if (!loaded) {
Text(text = "Loading...", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
} else {
OutlinedTextField(
value = aor,
onValueChange = { aor = it },
label = { Text("AOR", color = androidx.compose.ui.graphics.Color.White) },
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password", color = androidx.compose.ui.graphics.Color.White) },
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = outbound,
onValueChange = { outbound = it },
label = { Text("Outbound proxy", color = androidx.compose.ui.graphics.Color.White) },
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(6.dp))
OutlinedTextField(
value = regInt,
onValueChange = { regInt = it },
label = { Text("Reg interval seconds", color = androidx.compose.ui.graphics.Color.White) },
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(onClick = {
try {
val filesDir = context.filesDir
// Normalize the AOR: ensure a "sip:" scheme. account_alloc()
// rejects a bare "user@host" (returns ENOENT), so we strip any
// existing scheme/brackets and re-wrap as <sip:...>.
val bare = aor.removePrefix("<").removeSuffix(">")
.replaceFirst("(?i)^sip:".toRegex(), "")
val sipAor = "sip:$bare"
val userPart = bare.substringBefore("@")
// Default to the known-working outbound proxy/transport for
// this provider: TCP on :5060. The user may override, but an
// empty field falls back to TCP on :5060.
val out = if (outbound.isNotBlank()) outbound
else "sip:mail.txt3.net:5060;transport=tcp"
val accountLine = buildString {
append("<$sipAor>")
append(";auth_user=\"$userPart\";auth_pass=\"$password\"")
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)
CallState.status.value = "Account saved"
// (Re)register the account against the running native stack.
// Pass the FULL account line (credentials + outbound/transport),
// not just the AOR -- ua_alloc needs the complete line.
WearBaresipService.registerSavedAccount(context, accountLine.toString())
} catch (e: Exception) {
CallState.status.value = "Save failed"
}
}) {
Text("Save")
}
}
Spacer(Modifier.height(8.dp))
Text(text = registration.ifEmpty { "Not registered" }, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
Text(text = status, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
if (recent.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(text = "Recent", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.title3)
recent.forEach { peer ->
Chip(
onClick = {
WearBaresipServiceHelper.dial(peer)
onBack()
},
label = { Text(peer, maxLines = 1) }
)
Spacer(Modifier.height(4.dp))
}
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
val svc = Intent(context, WearBaresipService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(svc)
} else {
context.startService(svc)
}
onBack()
}) {
Text("Restart SIP")
}
Spacer(Modifier.height(4.dp))
Button(onClick = onBack) { Text("Back") }
}
}
@Composable
fun InCallScreen(onEnd: () -> Unit) {
val call = CallState.incoming() ?: CallState.active()
val status = call?.status ?: CallState.status.value
val peer = call?.peerUri ?: ""
// Show a clear "Calling..." indicator while the call is being attempted.
val displayStatus = when {
status == "call outgoing" -> "Calling..."
status.startsWith("call closed") -> "Call ended"
else -> status
}
// Keypad is only useful once the call is established (voicemail/IVR).
var showKeypad by remember { mutableStateOf(false) }
val canSendDigits = call != null && status == "call established"
Column(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black)
.padding(12.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = peer.ifEmpty { "Call" },
style = MaterialTheme.typography.title3,
textAlign = TextAlign.Center,
maxLines = 2
)
Spacer(Modifier.height(4.dp))
Text(text = displayStatus, style = MaterialTheme.typography.body2)
Spacer(Modifier.height(12.dp))
if (call != null && call.status == "incoming call") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { answerCall(call) }) {
Icon(Icons.Filled.Call, contentDescription = "Answer")
Text("Answer", modifier = Modifier.padding(start = 4.dp))
}
Button(onClick = { declineCall(call) }) {
Icon(Icons.Filled.CallEnd, contentDescription = "Decline")
Text("End", modifier = Modifier.padding(start = 4.dp))
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val muted = call?.muted ?: false
Button(onClick = { toggleMute(call, muted) }) {
Icon(
if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription = "Mute"
)
}
Button(onClick = onEnd) {
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
Text("End", modifier = Modifier.padding(start = 4.dp))
}
}
if (canSendDigits) {
Spacer(Modifier.height(8.dp))
Button(onClick = { showKeypad = !showKeypad }) {
Text(if (showKeypad) "Hide keys" else "Keys")
}
}
}
if (showKeypad && canSendDigits && call != null) {
Spacer(Modifier.height(8.dp))
val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#")
// 3 columns, 4 rows of compact digit buttons.
digits.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { d ->
Button(
onClick = { WearBaresipServiceHelper.sendDigit(call, d[0]) },
modifier = Modifier.size(48.dp)
) {
Text(d, style = MaterialTheme.typography.body2)
}
}
}
Spacer(Modifier.height(6.dp))
}
}
}
}
private fun placeCall(uri: String) {
android.util.Log.d("Baresip Wear", "placeCall('$uri')")
if (uri.isBlank()) return
// Pass the raw input to dial(); it normalizes a bare extension to a full
// SIP URI (e.g. "8500" -> "sip:8500@mail.txt3.net"). Do NOT prepend "sip:"
// here -- that would bypass dial()'s domain logic and the native stack
// would emit a domainless "sip:8500" that the proxy can never route.
WearBaresipServiceHelper.dial(uri)
if (!CallState.recentPeers.contains(uri)) {
CallState.recentPeers.add(0, uri)
}
}
private fun answerCall(call: WearCall) {
WearBaresipServiceHelper.answer(call)
}
private fun declineCall(call: WearCall) {
WearBaresipServiceHelper.hangup(call)
}
private fun hangupCall(call: WearCall?) {
if (call != null) WearBaresipServiceHelper.hangup(call)
else CallState.calls.toList().forEach { WearBaresipServiceHelper.hangup(it) }
}
private fun toggleMute(call: WearCall?, muted: Boolean) {
call ?: return
CallState.status.value = "Mute unavailable"
}

View File

@ -0,0 +1,84 @@
package com.tutpro.baresip.wear
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import android.util.Log
/**
* Registers our SIP identity as a telecom PhoneAccount and, on incoming calls,
* optionally hands the call to the system via TelecomManager.addNewIncomingCall
* so the stock Wear incoming-call UI can be used. Whether the Wear dialer
* actually surfaces a third-party PhoneAccount is device/firmware dependent;
* this is wired as an experiment and the in-app InCallScreen remains the
* primary incoming-call UI.
*/
object TelecomHelper {
private const val ACCOUNT_ID = "sip-wear"
const val TELECOM_PERMISSION = android.Manifest.permission.READ_PHONE_STATE
private const val TAG = "Baresip Wear Telecom"
fun accountHandle(context: Context): PhoneAccountHandle {
val component = ComponentName(context, WearConnectionService::class.java)
return PhoneAccountHandle(component, ACCOUNT_ID)
}
/** Register (or re-register) the PhoneAccount with the system telecom stack. */
fun registerAccount(context: Context) {
try {
val tm = context.getSystemService(TelecomManager::class.java) ?: return
val handle = accountHandle(context)
val intent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val account = PhoneAccount.builder(handle, "Baresip Wear (SIP)")
.setCapabilities(
PhoneAccount.CAPABILITY_CALL_PROVIDER or
PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING
)
.setIcon(
android.graphics.drawable.Icon.createWithResource(
context, android.R.drawable.sym_call_incoming
)
)
.build()
tm.registerPhoneAccount(account)
Log.d(TAG, "registered PhoneAccount $ACCOUNT_ID")
} catch (e: Exception) {
Log.w(TAG, "registerAccount failed: ${e.message}")
}
}
/**
* Hands an incoming native call to the system telecom stack. The framework
* will bind WearConnectionService and show the incoming-call UI if the Wear
* dialer honors our account. Safe to call even if telecom ignores it -- the
* in-app InCallScreen is shown independently by the service.
*/
fun addIncomingCall(context: Context, callp: Long, uap: Long, peer: String) {
try {
val tm = context.getSystemService(TelecomManager::class.java) ?: return
val extras = Bundle()
extras.putParcelable(
TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
android.net.Uri.parse(peer)
)
extras.putLong(WearConnectionService.EXTRA_CALLP, callp)
extras.putLong(WearConnectionService.EXTRA_UAP, uap)
extras.putString(WearConnectionService.EXTRA_PEER, peer)
tm.addNewIncomingCall(accountHandle(context), extras)
Log.d(TAG, "addNewIncomingCall callp=$callp peer=$peer")
} catch (e: Exception) {
Log.w(TAG, "addIncomingCall failed: ${e.message}")
}
}
}

View File

@ -0,0 +1,31 @@
package com.tutpro.baresip.wear
import android.app.Application
import android.content.Intent
import android.os.Build
class WearBaresipApp : Application() {
// 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

@ -0,0 +1,623 @@
package com.tutpro.baresip.wear
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.media.AudioManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.app.NotificationCompat
import android.util.Log
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
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Baresip Wear",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
// High-importance channel so the incoming-call heads-up (with a
// full-screen intent) pops over the watch face / dimmed screen.
// Use IMPORTANCE_MAX: on some Wear builds HIGH is still below the
// threshold that lets a full-screen intent take over the clock.
val callChannel = NotificationChannel(
CALL_CHANNEL_ID,
"Incoming call",
NotificationManager.IMPORTANCE_MAX
).apply {
setSound(null, null)
enableLights(false)
enableVibration(false)
}
manager.createNotificationChannel(callChannel)
}
AudioRouteManager.init(this)
// Register our SIP identity with the system telecom stack (experiment:
// lets the stock Wear dialer surface the account if it chooses to).
try { TelecomHelper.registerAccount(this) } catch (e: Exception) {
Log.w("Baresip Wear", "telecom register failed: ${e.message}")
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (::nativeThread.isInitialized && nativeThread.isAlive) {
return START_STICKY
}
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.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
try {
java.io.File(path).mkdirs()
// Only seed accounts from the bundled asset when none exists.
// A user-edited accounts file (via the Accounts screen) must be
// preserved -- account_alloc() requires a "sip:" scheme AOR, so the
// asset is just a sensible default, not an authoritative override.
val accountsFile = java.io.File("$path/accounts")
if (!accountsFile.exists() || accountsFile.length() <= 0) {
applicationContext.assets.open("accounts").use { input ->
accountsFile.writeBytes(input.readBytes())
}
}
// Always refresh config from the bundled asset so module_app entries
// (e.g. account.so) are present and module_path points at the
// extracted native libs on this device. With extractNativeLibs=true
// the .so modules are unpacked to nativeLibraryDir; baresip's
// module loader dlopen()s them from there.
val modulePathLine = "module_path\t\t$nativeLibDir\n"
val static = applicationContext.assets.open("config.static").bufferedReader().use { it.readText() }
val configText = if (static.contains("module_path")) static else "$modulePathLine$static"
java.io.File("$path/config").writeText(configText)
} catch (e: Exception) {
Log.w("Baresip Wear", "asset copy failed: ${e.message}")
}
val addrs = localAddresses()
val dns = collectDnsServers()
Log.d("Baresip Wear", "Starting native stack; addrs=$addrs dns=$dns")
nativeThread = Thread {
try {
baresipStart(path, addrs, dns, 5, "baresip-studio-wear", nativeLibDir)
} catch (e: Exception) {
Log.e("Baresip Wear", "baresipStart failed: ${e.message}")
CallState.status.value = "Native start failed"
}
}
nativeThread.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(networkReceiver, networkFilter())
}
return START_STICKY
}
private fun collectDnsServers(): String {
val servers = mutableListOf<String>()
try {
val cm = getSystemService(ConnectivityManager::class.java) ?: return fallbackDns()
val active = cm.activeNetwork
for (n in cm.allNetworks) {
if (n != active) continue
cm.getLinkProperties(n)?.dnsServers?.forEach {
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
else "${it.hostAddress}:53"
if (!servers.contains(s)) servers.add(s)
}
}
for (n in cm.allNetworks) {
if (n == active) continue
cm.getLinkProperties(n)?.dnsServers?.forEach {
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
else "${it.hostAddress}:53"
if (!servers.contains(s)) servers.add(s)
}
}
} catch (e: Exception) {
Log.w("Baresip Wear", "collectDnsServers failed: ${e.message}")
}
return if (servers.isNotEmpty()) servers.joinToString(",") else fallbackDns()
}
private fun fallbackDns() = "1.1.1.1:53,8.8.8.8:53"
private fun updateDnsServers() {
val dns = collectDnsServers()
try {
val res = Api.net_use_nameserver(dns)
Log.d("Baresip Wear", "runtime net_use_nameserver -> $res")
} 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()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
external fun baresipStart(path: String, addrs: String, dns: String, logLevel: Int, software: String, nativeLibDir: String)
external fun baresipStop()
external fun refreshNetwork(addrs: String, dns: String)
private fun localAddresses(): String {
val addrs = mutableListOf<String>()
try {
val nifs = NetworkInterface.getNetworkInterfaces()
for (nif in nifs) {
if (!nif.isUp || nif.isLoopback) continue
for (addr in nif.inetAddresses) {
val host = addr.hostAddress ?: continue
val bare = host.substringBefore('%')
if (bare == "0.0.0.0" || bare == "::") continue
if (bare.startsWith("fe80:")) continue
if (!addrs.contains(bare)) addrs.add(bare)
}
}
} catch (e: Exception) {
Log.w("Baresip Wear", "localAddresses failed: ${e.message}")
}
return addrs.joinToString(";")
}
fun uaEvent(event: String, uap: Long, callp: Long) {
val parts = event.split(",")
val ev = parts[0]
val arg = parts.getOrNull(1) ?: ""
// 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") -> {
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
CallState.registration.value = "Registering"
}
ev.startsWith("registered") -> {
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
CallState.registration.value = "Registered"
}
ev.startsWith("registering failed") -> {
CallState.registration.value = "Registration failed"
showToast("Registration failed")
}
ev.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
// "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, "incoming call", "in"))
}
CallState.status.value = "Incoming call"
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")
CallState.calls.toList().forEach { CallState.remove(it.callp) }
CallState.status.value = "Idle"
}
else -> CallState.status.value = ev
}
if (arg.isNotEmpty() && (ev == "incoming call" || ev == "call outgoing") &&
!CallState.recentPeers.contains(arg)
) {
CallState.recentPeers.add(0, arg)
}
}
private fun showToast(message: String) {
try {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
} catch (e: Exception) {
Log.w("Baresip Wear", "showToast failed: ${e.message}")
}
}
/**
* Alert the wearer to an incoming call. On Wear OS a bare startActivity
* from a background service does NOT reliably take over the watch face,
* so the robust mechanism is a high-priority notification with a
* full-screen intent that launches the InCallScreen over the clock.
* 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)
if (pm != null && !pm.isInteractive) {
@Suppress("DEPRECATION")
val wl = pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"BaresipWear:incomingCall"
)
wl.acquire(5000)
}
} catch (e: Exception) {
Log.w("Baresip Wear", "wake failed: ${e.message}")
}
// Best-effort: also try to bring the activity forward (helps when the
// app is already the foreground task and the full-screen intent is
// suppressed by the system).
try {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("incoming_call", true)
}
startActivity(intent)
Log.d("Baresip Wear", "alertIncomingCall: startActivity(incoming_call) dispatched")
} catch (e: Exception) {
Log.w("Baresip Wear", "bringToFront failed: ${e.message}")
}
// High-priority heads-up with a full-screen intent -> the InCallScreen
// (Answer/Decline) is shown over the watch face. This is the reliable
// alert path on Wear OS.
try {
val fsIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra("incoming_call", true)
}
val fsPending = PendingIntent.getActivity(
this,
2001,
fsIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
val notification = NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setContentTitle("Incoming call")
.setContentText(label)
.setSmallIcon(android.R.drawable.sym_call_incoming)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setFullScreenIntent(fsPending, true)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000L)
.build()
val nm = getSystemService(NotificationManager::class.java)
nm.notify(CALL_NOTIFICATION_ID, notification)
} catch (e: Exception) {
Log.w("Baresip Wear", "incoming notification failed: ${e.message}")
}
// 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(
VibrationEffect.createWaveform(pattern, 0)
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 400, 200, 400, 200, 400), 0)
}
}
} 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. */
private fun dismissIncomingCallNotification() {
try {
val nm = getSystemService(NotificationManager::class.java)
nm.cancel(CALL_NOTIFICATION_ID)
} catch (e: Exception) {
Log.w("Baresip Wear", "cancel call notification failed: ${e.message}")
}
}
companion object {
const val CHANNEL_ID = "baresip-wear"
const val NOTIFICATION_ID = 1
// Dedicated high-importance channel for incoming-call heads-up alerts.
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
fun registerDefaultAccountIfNeeded(context: Context) {
if (WearBaresipServiceHelper.defaultUa() != 0L) return
// Pass the FULL account line (<sip:...>;auth_pass=...;outbound=...),
// NOT just the AOR. ua_alloc needs the whole line to build an
// account that can complete the digest-challenge REGISTER; a bare
// "<sip:user@host>" AOR has no credentials/proxy and hangs at
// "Registering" (and defaults to UDP:5060, which the server does
// not answer on).
val accountLine = readAccountLine(context) ?: return
try {
val uap = Api.ua_alloc(accountLine)
if (uap == 0L) {
CallState.registration.value = "UA alloc failed"
return
}
WearBaresipServiceHelper.setDefaultUa(uap)
val rc = Api.ua_register(uap)
CallState.registration.value = "Registering rc=$rc"
} catch (e: Exception) {
CallState.registration.value = "Register failed: ${e.message}"
}
}
// Re-register after the user edits/saves the account on the Accounts
// screen. Tears down any previously-allocated UA first so the new
// AOR / credentials take effect, then allocates and registers the
// saved account. CRITICAL: pass the FULL account line (with
// auth_pass + outbound/transport), never just the AOR -- ua_alloc
// needs the complete line or the REGISTER cannot complete its digest
// challenge and the status hangs at "Registering".
fun registerSavedAccount(context: Context, accountLine: String) {
try {
val existing = WearBaresipServiceHelper.defaultUa()
if (existing != 0L) {
Api.ua_destroy(existing)
WearBaresipServiceHelper.clearDefaultUa()
}
val uap = Api.ua_alloc(accountLine.trim())
if (uap == 0L) {
CallState.registration.value = "UA alloc failed"
return
}
WearBaresipServiceHelper.setDefaultUa(uap)
val rc = Api.ua_register(uap)
CallState.registration.value = "Registering rc=$rc"
} catch (e: Exception) {
CallState.registration.value = "Register failed: ${e.message}"
}
}
// Reads the first non-comment, non-blank line of the accounts file --
// the full baresip account line, including <sip:...>;auth_pass=...;
// outbound=.... This is what ua_alloc needs to build a usable UA.
private fun readAccountLine(context: Context): String? {
return try {
val file = java.io.File(context.filesDir, "accounts")
if (!file.exists()) return null
file.readText().lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
} catch (e: Exception) {
null
}
}
}
private lateinit var nativeThread: Thread
private val networkReceiver = object : BroadcastReceiver() {
private val handler = Handler(Looper.getMainLooper())
private var pending = false
override fun onReceive(context: Context, intent: Intent) {
if (pending) return
pending = true
handler.postDelayed({
pending = false
refreshNativeNetwork()
}, 800)
}
}
init {
System.loadLibrary("wearbaresip")
}
private fun networkFilter(): IntentFilter {
return IntentFilter().apply {
addAction("android.net.conn.CONNECTIVITY_CHANGE")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
addAction("android.net.conn.ACTION_RESTRICT_BACKGROUND_CHANGED")
}
addAction("android.net.wifi.STATE_CHANGE")
}
}
private fun refreshNativeNetwork() {
val addrs = localAddresses()
val dns = collectDnsServers()
try {
refreshNetwork(addrs, dns)
Log.d("Baresip Wear", "refreshNetwork addrs=$addrs dns=$dns")
} catch (e: Exception) {
Log.w("Baresip Wear", "refreshNetwork failed: ${e.message}")
}
}
}

View File

@ -0,0 +1,106 @@
package com.tutpro.baresip.wear
// Bridges UI intent to the native stack. The watch assumes a single
// pre-configured default account (loaded from accounts.cfg by baresip init).
// The default UA pointer is captured from registration events.
object WearBaresipServiceHelper {
private var defaultUap: Long = 0L
fun setDefaultUa(uap: Long) {
if (uap != 0L) defaultUap = uap
}
fun clearDefaultUa() {
defaultUap = 0L
}
fun defaultUa(): Long = defaultUap
// Outgoing call: allocate a call slot on the default UA and connect.
fun dial(uri: String) {
android.util.Log.d("Baresip Wear", "dial($uri) defaultUap=$defaultUap")
if (defaultUap == 0L) {
CallState.status.value = "No account"
toast("No SIP account - register first")
android.util.Log.w("Baresip Wear", "dial: no default UA")
return
}
// Normalize to a full SIP URI with domain. The proxy needs a domain
// to route the INVITE; a domainless "sip:8500" never leaves the device.
// - already has '@' -> leave as-is (full URI)
// - "sip:8500" (scheme,no '@') -> append "@mail.txt3.net"
// - bare "8500" -> "sip:8500@mail.txt3.net"
val target = when {
uri.contains("@") -> uri
uri.startsWith("sip:", true) -> "$uri@mail.txt3.net"
else -> "sip:${uri}@mail.txt3.net"
}
android.util.Log.d("Baresip Wear", "dial target=$target")
val callp = Api.ua_call_alloc(defaultUap, 0L, Api.VIDMODE_OFF)
android.util.Log.d("Baresip Wear", "ua_call_alloc -> $callp")
if (callp == 0L) {
CallState.status.value = "Call failed"
toast("Call failed to start")
return
}
CallState.add(WearCall(callp, defaultUap, target, "call outgoing", "out"))
Api.call_connect(callp, target)
}
private fun toast(message: String) {
try {
android.widget.Toast.makeText(
WearBaresipService.appContext, message, android.widget.Toast.LENGTH_LONG
).show()
} catch (_: Exception) {
}
}
fun answer(call: WearCall) {
// 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
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"
}
// Send a DTMF digit (RFC 2833 / in-band) on an active call. Used by the
// in-call keypad for voicemail menus, IVRs, etc.
fun sendDigit(call: WearCall, digit: Char) {
try {
Api.call_send_digit(call.callp, digit)
android.util.Log.d("Baresip Wear", "sendDigit '$digit' on ${call.callp}")
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "sendDigit failed: ${e.message}")
}
}
}

View File

@ -0,0 +1,125 @@
package com.tutpro.baresip.wear
import android.net.Uri
import android.os.Bundle
import android.telecom.Call.Details
import android.telecom.Connection
import android.telecom.ConnectionRequest
import android.telecom.ConnectionService
import android.telecom.DisconnectCause
import android.telecom.PhoneAccountHandle
import android.util.Log
/**
* Telecom ConnectionService scaffold.
*
* This is an EXPERIMENT to see whether the stock Wear dialer surfaces our SIP
* account. The native baresip stack remains the source of truth for the call;
* this service only mirrors telecom's incoming-call lifecycle so the system
* incoming UI can be used if the Wear dialer chooses to honor our PhoneAccount.
*
* Lifecycle:
* - Our service calls TelecomManager.addNewIncomingCall(handle, extras) when
* baresip reports "call incoming"; the framework then binds this service and
* calls onCreateIncomingConnection().
* - onCreateIncomingConnection() builds a WearConnection bound to the native
* callp (passed via extras) and reports the remote address.
* - User actions (answer / disconnect / DTMF) are forwarded to the native
* stack through WearBaresipServiceHelper.
*/
class WearConnectionService : ConnectionService() {
companion object {
const val EXTRA_CALLP = "com.tutpro.baresip.wear.extra.CALLP"
const val EXTRA_UAP = "com.tutpro.baresip.wear.extra.UAP"
const val EXTRA_PEER = "com.tutpro.baresip.wear.extra.PEER"
private const val TAG = "Baresip Wear Telecom"
}
override fun onCreateIncomingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
): Connection {
val extras = request.extras ?: Bundle.EMPTY
val callp = extras.getLong(EXTRA_CALLP, 0L)
val uap = extras.getLong(EXTRA_UAP, 0L)
val peer = extras.getString(EXTRA_PEER, "")
Log.d(TAG, "onCreateIncomingConnection callp=$callp uap=$uap peer=$peer")
val conn = WearConnection(callp, uap, peer)
// Mirror the call for the system incoming UI. We deliberately avoid
// PROPERTY_SELF_MANAGED (requires MANAGE_OWN_CALLS permission and a
// more involved self-managed lifecycle) and video capabilities that
// are absent on this Wear SDK. Plain CAPABILITY_MUTE is enough for the
// call to be presented.
conn.connectionCapabilities = Connection.CAPABILITY_MUTE
conn.setRinging()
// PRESENTATION_ALLOWED == 1
conn.setAddress(Uri.parse(peer), 1)
conn.setCallerDisplayName(peer, 1)
return conn
}
override fun onCreateOutgoingConnection(
connectionManagerPhoneAccount: PhoneAccountHandle?,
request: ConnectionRequest
): Connection {
// We drive outgoing calls directly from the native dialer; telecom
// outgoing is not used yet. Report a failed connection to be safe.
val conn = WearConnection(0L, 0L, "")
conn.setDisconnected(DisconnectCause(DisconnectCause.OTHER, "Not used"))
conn.destroy()
return conn
}
/** A telecom Connection mirroring one native baresip call. */
private class WearConnection(
private val callp: Long,
private val uap: Long,
private val peer: String
) : Connection() {
override fun onAnswer() {
super.onAnswer()
Log.d(TAG, "onAnswer callp=$callp")
val call = CallState.find(callp)
if (call != null) {
WearBaresipServiceHelper.answer(call)
} else {
// Fallback: reconstruct a minimal WearCall to answer.
WearBaresipServiceHelper.answer(
WearCall(callp, uap, peer, "call incoming", "in")
)
}
setActive()
}
override fun onReject() {
super.onReject()
Log.d(TAG, "onReject callp=$callp")
val call = CallState.find(callp)
if (call != null) WearBaresipServiceHelper.hangup(call)
setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
destroy()
}
override fun onDisconnect() {
super.onDisconnect()
Log.d(TAG, "onDisconnect callp=$callp")
val call = CallState.find(callp)
if (call != null) WearBaresipServiceHelper.hangup(call)
setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
destroy()
}
override fun onPlayDtmfTone(c: Char) {
super.onPlayDtmfTone(c)
val call = CallState.find(callp)
if (call != null) WearBaresipServiceHelper.sendDigit(call, c)
}
override fun onStopDtmfTone() {
super.onStopDtmfTone()
}
}
}

View File

@ -0,0 +1,168 @@
package com.tutpro.baresip.wear
import android.content.Context
import android.net.Uri
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.net.URL
import javax.net.ssl.HttpsURLConnection
// Slimmed port of the phone app's Provisioning for the watch. Fetches and
// decrypts the provisioning bundle and writes an 'accounts' file that
// baresip loads on (re)start.
object WearProvisioning {
private const val RSA_KEY_SIZE = 2048
private const val ALIAS = "baresip_wear_provisioning"
private const val SERVER_TIMEOUT_MS = 20_000
data class Bundle(
val connectString: String,
val username: String? = null,
val password: String? = null,
val displayName: String? = null,
val outbound1: String? = null,
val outbound2: String? = null,
val regInt: Int? = null
)
// Minimal RSA keypair in AndroidKeyStore (reuses same shape as phone app).
private fun ensurePublicKey(): java.security.PublicKey {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
if (ks.containsAlias(ALIAS)) {
return ks.getCertificate(ALIAS).publicKey
}
val kpg = java.security.KeyPairGenerator.getInstance("RSA")
val spec = android.security.keystore.KeyGenParameterSpec.Builder(
ALIAS,
android.security.keystore.KeyProperties.PURPOSE_DECRYPT or
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
)
.setAlgorithmParameterSpec(
java.security.spec.RSAKeyGenParameterSpec(
RSA_KEY_SIZE, java.security.spec.RSAKeyGenParameterSpec.F4
)
)
.setDigests(
android.security.keystore.KeyProperties.DIGEST_SHA256,
android.security.keystore.KeyProperties.DIGEST_SHA512
)
.setEncryptionPaddings(
android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_OAEP
)
.build()
kpg.initialize(spec)
return kpg.generateKeyPair().public
}
suspend fun fetchBundle(endpoint: String, extension: String): Bundle =
withContext(Dispatchers.IO) {
val pub = ensurePublicKey()
val enrollment = JSONObject().apply {
put("extension", extension)
put("public_key", Base64.encodeToString(pub.encoded, Base64.NO_WRAP))
}
val url = Uri.parse(endpoint).buildUpon().appendPath("bundle").build().toString()
val conn = URL(url).openConnection() as HttpsURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
conn.connectTimeout = SERVER_TIMEOUT_MS
conn.readTimeout = SERVER_TIMEOUT_MS
conn.outputStream.use { it.write(enrollment.toString().toByteArray(Charsets.UTF_8)); it.flush() }
val code = conn.responseCode
if (code !in 200..299) {
throw Exception("Provisioning HTTP $code")
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
val root = JSONObject(body)
val encKey = Base64.decode(root.getString("encrypted_key"), Base64.NO_WRAP)
val iv = Base64.decode(root.getString("iv"), Base64.NO_WRAP)
val ct = Base64.decode(root.getString("ciphertext"), Base64.NO_WRAP)
val tag = Base64.decode(root.getString("tag"), Base64.NO_WRAP)
val aesKey = rsaDecrypt(encKey)
val plain = aesGcmDecrypt(aesKey, iv, ct, tag)
val p = JSONObject(String(plain, Charsets.UTF_8))
Bundle(
connectString = p.getString("connect_string"),
username = p.optString("username", null),
password = p.optString("password", null),
displayName = p.optString("display_name", null),
outbound1 = p.optString("outbound1", null),
outbound2 = p.optString("outbound2", null),
regInt = if (p.has("reg_int")) p.getInt("reg_int") else null
)
}
// Writes accounts file so baresip picks it up on next (re)start.
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
fun writeAccount(ctx: Context, bundle: Bundle): String {
val filesDir = ctx.filesDir
val aor = bundle.connectString.removePrefix("sip:")
val accountLine = buildString {
append("<$aor>")
if (!bundle.outbound1.isNullOrBlank()) {
val hostPort = bundle.outbound1.removePrefix("sip:")
append(";outbound=\"$hostPort\"")
}
if (!bundle.outbound2.isNullOrBlank()) {
val hostPort = bundle.outbound2.removePrefix("sip:")
append(";outbound2=\"$hostPort\"")
}
if (bundle.regInt != null) append(";regint=${bundle.regInt}")
append(";stunserver=\"stun:stun.l.google.com:19302\"")
append(";regq=0.5;pubint=0;check_origin=no;mwi=no;transport=TCP")
}
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
if (!bundle.password.isNullOrBlank()) {
val authLine = "${bundle.username ?: aor} ${bundle.password}\n"
File(filesDir, "auth").writeText(authLine, Charsets.UTF_8)
}
Log.i("Baresip Wear", "Provisioned account $aor")
return aor
}
// Writes accounts from manual debug input.
fun writeManualAccount(ctx: Context, aor: String, password: String, outbound: String) {
val filesDir = ctx.filesDir
val cleanAor = aor.removePrefix("sip:")
val accountLine = buildString {
append("<$cleanAor>")
val hostPort = outbound.removePrefix("sip:")
if (hostPort.isNotBlank()) append(";outbound=\"$hostPort\"")
append(";stunserver=\"stun:stun.l.google.com:19302\"")
append(";regq=0.5;pubint=0;check_origin=no;mwi=no;transport=TCP")
}
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
if (password.isNotBlank()) {
File(filesDir, "auth").writeText("$cleanAor $password\n", Charsets.UTF_8)
}
Log.i("Baresip Wear", "Manual account saved: $cleanAor")
}
private fun rsaDecrypt(encrypted: ByteArray): ByteArray {
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = ks.getKey(ALIAS, null) as java.security.PrivateKey
val cipher = javax.crypto.Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key)
return cipher.doFinal(encrypted)
}
private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ct: ByteArray, tag: ByteArray): ByteArray {
val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
javax.crypto.Cipher.DECRYPT_MODE,
javax.crypto.spec.SecretKeySpec(key, "AES"),
javax.crypto.spec.GCMParameterSpec(128, iv)
)
val combined = ByteArray(ct.size + tag.size)
System.arraycopy(ct, 0, combined, 0, ct.size)
System.arraycopy(tag, 0, combined, ct.size, tag.size)
return cipher.doFinal(combined)
}
}