Compare commits
9 Commits
master
...
899e778e49
| Author | SHA1 | Date | |
|---|---|---|---|
| 899e778e49 | |||
| 300bdcc810 | |||
| 34661f4ca6 | |||
| c88bee33d8 | |||
| 106ef4ed9d | |||
| bd5b5efa3b | |||
| 15e266cb43 | |||
| a849d98b5e | |||
| 9f94e72b1e |
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,6 +18,8 @@ build
|
||||
.externalNativeBuild
|
||||
/app/release
|
||||
/app/.cxx
|
||||
/wear/.cxx
|
||||
/wear/build
|
||||
gradle/wrapper/gradle-wrapper.jar
|
||||
/distribution/*
|
||||
/distribution.video/*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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
178
wear/README.md
Normal file
@ -0,0 +1,178 @@
|
||||
# Baresip Wear — SIP client for Wear OS
|
||||
|
||||
A native SIP softphone for Wear OS watches, built on the
|
||||
[baresip](https://github.com/baresip/baresip) stack (via the `libbaresip-android`
|
||||
submodule). It registers a single SIP account, places/receives calls, and
|
||||
survives the watch's aggressive power management so inbound calls actually ring
|
||||
while the screen is off.
|
||||
|
||||
This app is a slimmed-down port of the full `baresip` Android app
|
||||
(`app/` in this repo) to the watch form factor. It intentionally does **not**
|
||||
use the system telecom stack for call UI — the in-app `InCallScreen` plus a
|
||||
high-priority, full-screen-intent notification are the alert surface.
|
||||
|
||||
---
|
||||
|
||||
## Current status (as of this commit)
|
||||
|
||||
Working:
|
||||
|
||||
- **Registration** over UDP with NAT-survival settings
|
||||
(`sipnat=outbound;natpinhole=yes` in `assets/accounts`). This was the root
|
||||
cause of the original "call goes straight to voicemail" problem: without the
|
||||
pinhole the INVITE reply was dropped and Asterisk retransmit-timed-out.
|
||||
- **Inbound calls ring** whether the app is foreground or backgrounded, with
|
||||
vibration + screen wake.
|
||||
- **Answer** connects the call (caller stops hearing ringing; watch shows
|
||||
"Connected").
|
||||
- **Remote hang-up** terminates the call promptly whether answered or not
|
||||
(ring is cancelled, not left looping forever).
|
||||
- **Foreground service** is started from `WearBaresipApp.onCreate()` (decoupled
|
||||
from the Activity) and holds a `WifiLock` + `WakeLock` so SIP replies go out
|
||||
even when the screen is off / device is in a low-power state.
|
||||
- **Lock-screen presentation** attempted via manifest `showWhenLocked` /
|
||||
`turnScreenOn` + `USE_FULL_SCREEN_INTENT` + a full-screen-intent notification
|
||||
that launches `MainActivity` → `InCallScreen`. (See "Known gaps".)
|
||||
|
||||
### How answering actually works (important)
|
||||
|
||||
The native baresip library emits two events for an inbound call:
|
||||
|
||||
1. `incoming call,<peer>` — `BEVENT_SIPSESS_CONN`. We call `Api.ua_accept()`
|
||||
here so the caller hears ringing (100/180 provisional). The `callp` passed
|
||||
is the SIP message pointer carried by the event.
|
||||
2. `call incoming,<prm>` — `BEVENT_CALL_INCOMING`. This carries the real,
|
||||
answerable `struct call*`. The user's **Answer** button calls
|
||||
`Api.ua_answer()`, which is **deferred to the baresip main loop** via a
|
||||
one-shot timer in `baresip.c` (`ua_answer_async`). This deferral is
|
||||
mandatory: calling `ua_answer()` directly from the UI thread crashes in
|
||||
`sip_treplyf` (SIGSEGV), because SIP transaction replies must run on the
|
||||
baresip thread. The native event callback (`uaEvent`) itself runs on the
|
||||
baresip thread, which is why `ua_accept` is safe there but `ua_answer`
|
||||
from a tap is not.
|
||||
|
||||
Key files:
|
||||
|
||||
- `wear/src/main/cpp/baresip.c` — native `event_handler` (event strings +
|
||||
`jlong` ua/call args), `ua_accept`/`ua_answer` JNI, and the
|
||||
`ua_answer_async` main-loop hand-off.
|
||||
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt` — the
|
||||
SIP event loop, alert/ring logic, `stopRinging()` + 30s ring watchdog,
|
||||
notification channels, and lock-screen flags.
|
||||
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt` —
|
||||
UI → native bridge (`dial`, `answer`, `hangup`, `sendDigit`).
|
||||
- `wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt` — starts the
|
||||
foreground service from `Application.onCreate()` so it survives task removal.
|
||||
- `wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt` — Compose UI
|
||||
(dialer / in-call) + `setShowWhenLocked`/`setTurnScreenOn` for inbound calls.
|
||||
- `wear/src/main/assets/accounts` — the SIP account line (NAT settings +
|
||||
credentials).
|
||||
|
||||
---
|
||||
|
||||
## Build & install
|
||||
|
||||
Prereqs: Android SDK, `ndk` (for the native build), and a watch with
|
||||
`adb` over Wi-Fi (wireless debugging). The native library is built by Gradle's
|
||||
external-native-build from `wear/src/main/cpp/`.
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
./gradlew :wear:assembleDebug
|
||||
|
||||
# Install to the watch (wireless ADB; port changes after a reboot/reconnect)
|
||||
adb connect <watch-ip>:<port>
|
||||
adb -s <watch-ip>:<port> install -r wear/build/outputs/apk/debug/wear-debug.apk
|
||||
```
|
||||
|
||||
Note: a clean native rebuild is required after editing `baresip.c`:
|
||||
|
||||
```bash
|
||||
./gradlew :wear:clean :wear:assembleDebug
|
||||
```
|
||||
|
||||
### Permissions to grant on-device
|
||||
|
||||
- **Microphone** — record audio for calls.
|
||||
- **Notifications** (API 33+) — required for the incoming-call heads-up /
|
||||
full-screen intent to wake the watch face.
|
||||
- **Phone** / `READ_PHONE_STATE` — declared; needed if telecom integration is
|
||||
re-enabled.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps / bugs still open
|
||||
|
||||
- **Lock-screen Activity sometimes doesn't appear** when the watch is fully
|
||||
asleep and the app is backgrounded. The vibration fires but the user has to
|
||||
open the app manually. The full-screen-intent notification + `showWhenLocked`
|
||||
are in place, but on Android 12+ (this watch runs Android 16) the system
|
||||
may still suppress the launch. Candidate fixes: ensure
|
||||
`USE_FULL_SCREEN_INTENT` is granted; raise the call notification to a
|
||||
dedicated `InCallActivity` (not routed through the LAUNCHER `MainActivity`);
|
||||
verify `setShowWhenLocked(true)` is applied before `setContent` in every
|
||||
launch path.
|
||||
- **No DTMF / keypad verification** end-to-end yet (wired via
|
||||
`call_send_digit` but not exercised on a real call).
|
||||
- **No echo cancellation / audio-route tuning** beyond the basic
|
||||
speaker-vs-BT-SCO switch.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
Near-term (core completeness):
|
||||
|
||||
- **SIP MESSAGE (IM) support.** baresip already has a `message` module. Add:
|
||||
- a native `MESSAGE` event → Kotlin bridge,
|
||||
- an inbox/thread UI (or at least a toast + unread badge),
|
||||
- outbound `Api.message_send()` from the dialer.
|
||||
- **Read device contacts** via the Wearable Data Layer / `ContactsContract`
|
||||
(the watch mirrors phone contacts through the companion app) so the dialer
|
||||
can autocomplete instead of requiring typed SIP URIs.
|
||||
- **Start at boot / on network available.** Register a `BOOT_COMPLETED` +
|
||||
`CONNECTIVITY_CHANGED` receiver that starts the foreground service so the
|
||||
watch is always reachable without the user launching the app first.
|
||||
- **Call history / recent peers** persisted to a small local store
|
||||
(DataStore/ Room) instead of the in-memory `recentPeers` list.
|
||||
|
||||
Medium-term (polish & robustness):
|
||||
|
||||
- **Better InCall UI**: larger Answer/Decline hit targets, a proper
|
||||
"swipe to answer" gesture, mute/hold, and a clear connected timer.
|
||||
- **Provisioning flow**: ship the account config via a QR/`baresip:` URI or
|
||||
MDM rather than a hand-edited `assets/accounts` file.
|
||||
- **Multiple accounts** (currently a single default UA is assumed
|
||||
throughout `WearBaresipServiceHelper`).
|
||||
- **Audio quality**: AGC, echo cancellation, and BT-SCO reliability across
|
||||
headset brands.
|
||||
- **Re-enable system telecom** as an *optional* path behind a setting, now
|
||||
that the native answer/hangup path is stable — but keep the in-app UI as
|
||||
the default to avoid the earlier 2-second auto-drop.
|
||||
|
||||
Long-term:
|
||||
|
||||
- **Video calls** (baresip supports it; watch camera is the constraint).
|
||||
- **Encrypted messaging / OMEMO** if the server supports it.
|
||||
- **Wear companion app** on the phone for config + contact sync.
|
||||
|
||||
---
|
||||
|
||||
## Debugging tips
|
||||
|
||||
```bash
|
||||
# Live native + Kotlin log during a call
|
||||
adb -s <watch-ip>:<port> logcat -v time | grep -aE \
|
||||
'Baresip Wear|jni_ua|ua_answer|ua_accept|ua_hangup|F/DEBUG'
|
||||
```
|
||||
|
||||
Watch for:
|
||||
|
||||
- `uaEvent: incoming call,...` then `ua_accept` (caller starts ringing) then
|
||||
`ua_answer ... deferred to main loop` → `ua_answer_async: done` →
|
||||
`call established`.
|
||||
- A `F/DEBUG` SIGSEGV in `sip_treplyf` / `call_answer` means `ua_answer` or
|
||||
`ua_accept` was called from the wrong thread or with a bad handle.
|
||||
- `fullScreenIntent=null` in the notification dump means the system stripped
|
||||
the lock-screen launch — check `USE_FULL_SCREEN_INTENT` + notification
|
||||
channel importance.
|
||||
61
wear/build.gradle.kts
Normal file
61
wear/build.gradle.kts
Normal 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)
|
||||
}
|
||||
67
wear/src/main/AndroidManifest.xml
Normal file
67
wear/src/main/AndroidManifest.xml
Normal 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>
|
||||
2
wear/src/main/assets/accounts
Normal file
2
wear/src/main/assets/accounts
Normal 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
|
||||
|
||||
42
wear/src/main/assets/config.static
Normal file
42
wear/src/main/assets/config.static
Normal 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
|
||||
137
wear/src/main/cpp/CMakeLists.txt
Normal file
137
wear/src/main/cpp/CMakeLists.txt
Normal 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
933
wear/src/main/cpp/baresip.c
Normal 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);
|
||||
}
|
||||
13
wear/src/main/cpp/logger.h
Normal file
13
wear/src/main/cpp/logger.h
Normal 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
|
||||
26
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal file
26
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal 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
|
||||
}
|
||||
}
|
||||
159
wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
Normal file
159
wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
37
wear/src/main/java/com/tutpro/baresip/wear/CallState.kt
Normal file
37
wear/src/main/java/com/tutpro/baresip/wear/CallState.kt
Normal 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" }
|
||||
}
|
||||
617
wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
Normal file
617
wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
Normal 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"
|
||||
}
|
||||
84
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal file
84
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
31
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt
Normal file
31
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipApp.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
623
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
Normal file
623
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
168
wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
Normal file
168
wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user