Compare commits
13 Commits
master
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a4fd18cd | |||
| 100ee241a2 | |||
| 0f883c9847 | |||
| a31d6a693c | |||
| 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
|
.externalNativeBuild
|
||||||
/app/release
|
/app/release
|
||||||
/app/.cxx
|
/app/.cxx
|
||||||
|
/wear/.cxx
|
||||||
|
/wear/build
|
||||||
gradle/wrapper/gradle-wrapper.jar
|
gradle/wrapper/gradle-wrapper.jar
|
||||||
/distribution/*
|
/distribution/*
|
||||||
/distribution.video/*
|
/distribution.video/*
|
||||||
|
|||||||
@ -8,3 +8,4 @@ android.r8.strictFullModeForKeepRules=false
|
|||||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
|
||||||
|
android.suppressUnsupportedCompileSdk=37.0
|
||||||
|
|||||||
@ -5,9 +5,10 @@ composeBom = "2026.06.01"
|
|||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
exifinterface = "1.4.2"
|
exifinterface = "1.4.2"
|
||||||
fragmentKtx = "1.8.9"
|
fragmentKtx = "1.8.9"
|
||||||
gradleVersion = "9.3.1"
|
gradleVersion = "9.1.0"
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlinStdlibJdk8 = "2.4.0"
|
kotlinStdlibJdk8 = "2.4.0"
|
||||||
|
android = "2.4.10"
|
||||||
kotlinSerializationPlugin = "2.4.10"
|
kotlinSerializationPlugin = "2.4.10"
|
||||||
kotlinxCoroutinesAndroid = "1.11.0"
|
kotlinxCoroutinesAndroid = "1.11.0"
|
||||||
kotlinxSerializationJson = "1.11.0"
|
kotlinxSerializationJson = "1.11.0"
|
||||||
@ -25,6 +26,8 @@ navigationRuntimeAndroid = "2.9.8"
|
|||||||
composeMaterialIcons = "1.7.8"
|
composeMaterialIcons = "1.7.8"
|
||||||
runtime = "1.11.4"
|
runtime = "1.11.4"
|
||||||
uiText = "1.11.4"
|
uiText = "1.11.4"
|
||||||
|
wear = "1.4.0"
|
||||||
|
wearCompose = "1.4.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
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-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
|
||||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
|
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-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-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
|
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-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-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-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]
|
[plugins]
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerializationPlugin" }
|
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(":app")
|
||||||
|
include(":wear")
|
||||||
|
|||||||
207
wear/README.md
Normal file
207
wear/README.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# 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) — DONE in this branch.** Plaintext RFC 3428 MESSAGE is
|
||||||
|
implemented end-to-end:
|
||||||
|
- Native: `message_send()` JNI + a `message_listen` receive callback
|
||||||
|
(`message_handler` in `baresip.c`) forwarding to `WearBaresipService.messageEvent`.
|
||||||
|
- Kotlin: `Api.message_send`, `WearBaresipServiceHelper.sendMessage` (normalizes
|
||||||
|
peer → `sip:peer@mail.txt3.net`), `MessageStore` (JSON file in `filesDir`),
|
||||||
|
and `MainActivity` `MessagesScreen` / `ConversationScreen` (conversation list +
|
||||||
|
thread + send field, reached via a "Messages" button on the dialer).
|
||||||
|
- Incoming messages are persisted and surfaced via a `CATEGORY_MESSAGE` notification.
|
||||||
|
- **Encryption is NOT yet in this pass** — see the long-term item; the body is
|
||||||
|
sent in cleartext (protected only by the TLS SIP transport in transit).
|
||||||
|
- **Encrypted SIP MESSAGE (next phase).** Plan agreed: **asymmetric E2E** using a
|
||||||
|
per-device X25519/Ed25519 keypair, with public keys exchanged **out-of-band via
|
||||||
|
NFC tap** (strongest trust — no server in the path), falling back to exchange
|
||||||
|
over a plaintext MESSAGE if NFC is unavailable (trusts the TLS server). The MESSAGE
|
||||||
|
body is then encrypted with the peer's public key before `message_send`. Note:
|
||||||
|
**OMEMO does not apply here** — OMEMO is an XMPP (Jabber) standard and cannot run
|
||||||
|
over SIP; we implement the same *class* of protection (Signal-style envelope) on
|
||||||
|
top of SIP MESSAGE instead. OpenSSL (already bundled) provides the crypto.
|
||||||
|
- **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.
|
||||||
|
- **SRTP media encryption** — already compiled in (`srtp.so`, `dtls_srtp.so`,
|
||||||
|
`gzrtp.so` loaded in `config.static`). Verify SRTP actually negotiates on a
|
||||||
|
TLS:5062 call: capture the SDP and confirm `a=crypto` (SDES) or
|
||||||
|
`a=fingerprint` (DTLS-SRTP) is offered/answered. Then pair TLS signalling +
|
||||||
|
DTLS-SRTP for fully encrypted calls (signalling + media). May need the
|
||||||
|
Asterisk side set `encryption=yes` / `media_encryption=dtls` (or `sdes`).
|
||||||
|
- **WebSocket (WS/WSS) transport support** — baresip core supports
|
||||||
|
`transport=ws`/`wss` and the registrar filter already allows `ws,wss`, but
|
||||||
|
the `websocket` module is **not** compiled into `libbaresip.a` for this
|
||||||
|
wear build (only `srtp`/`dtls_srtp`/`gzrtp` modules are present; no
|
||||||
|
`websocket/` module). To enable: add the `websocket` module to the baresip
|
||||||
|
build, rebuild `libbaresip.a` + `libwearbaresip.so`, load it in
|
||||||
|
`config.static`, and use `outbound="sip:host:PORT;transport=wss"`. Only
|
||||||
|
needed if the server fronts SIP behind WebSocket (WebRTC/CPaaS-style), not
|
||||||
|
typical for an Asterisk PBX.
|
||||||
|
|
||||||
|
Long-term:
|
||||||
|
|
||||||
|
- **Video calls** (baresip supports it; watch camera is the constraint).
|
||||||
|
- **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)
|
||||||
|
}
|
||||||
85
wear/src/main/AndroidManifest.xml
Normal file
85
wear/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<?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-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".InCallActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".WearBaresipService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone|phoneCall"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- So RoleManager.isRoleAvailable(ROLE_DIALER) / requestRole resolve on
|
||||||
|
API 29+ even when the role-request activity isn't directly visible. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.app.role.action.REQUEST_ROLE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</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:5062;transport=tls";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;sipnat=outbound;natpinhole=yes
|
||||||
|
|
||||||
47
wear/src/main/assets/config.static
Normal file
47
wear/src/main/assets/config.static
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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
|
||||||
|
# TLS transport (inbound/outbound SIP over TLS). Server cert is an expired
|
||||||
|
# Asterisk Private CA cert, so verification is disabled for now; mTLS client
|
||||||
|
# cert support is planned but not yet enabled.
|
||||||
|
tls_verify no
|
||||||
|
tls_selfsigned yes
|
||||||
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)
|
||||||
958
wear/src/main/cpp/baresip.c
Normal file
958
wear/src/main/cpp/baresip.c
Normal file
@ -0,0 +1,958 @@
|
|||||||
|
#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 jint JNICALL
|
||||||
|
Java_com_tutpro_baresip_wear_Api_message_1send(JNIEnv *env, jclass clazz,
|
||||||
|
jlong uap, jstring jPeer, jstring jMsg)
|
||||||
|
{
|
||||||
|
(void)clazz;
|
||||||
|
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||||
|
const char *peer = (*env)->GetStringUTFChars(env, jPeer, 0);
|
||||||
|
const char *msg = (*env)->GetStringUTFChars(env, jMsg, 0);
|
||||||
|
int err;
|
||||||
|
|
||||||
|
if (!ua || !peer || !msg) {
|
||||||
|
if (peer) (*env)->ReleaseStringUTFChars(env, jPeer, peer);
|
||||||
|
if (msg) (*env)->ReleaseStringUTFChars(env, jMsg, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
err = message_send(ua, peer, msg, NULL, NULL);
|
||||||
|
if (err) {
|
||||||
|
LOGE("message_send failed: %d\n", err);
|
||||||
|
}
|
||||||
|
(*env)->ReleaseStringUTFChars(env, jPeer, peer);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, jMsg, msg);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
28
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal file
28
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
// Send a SIP MESSAGE (RFC 3428) via the given UA. Returns 0 on success.
|
||||||
|
@JvmStatic external fun message_send(uap: Long, peer: String, msg: 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" }
|
||||||
|
}
|
||||||
69
wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
Normal file
69
wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated incoming/active-call Activity, launched by the foreground service
|
||||||
|
* via a full-screen-intent notification (and a best-effort startActivity).
|
||||||
|
*
|
||||||
|
* Why a separate Activity from the LAUNCHER MainActivity: on modern Wear OS
|
||||||
|
* (Android 12+, this watch runs Android 16) a background service's
|
||||||
|
* startActivity / full-screen intent targeting the LAUNCHER activity is
|
||||||
|
* suppressed when that activity is not already the foreground task. A distinct,
|
||||||
|
* non-LAUNCHER Activity with showWhenLocked/turnScreenOn presents reliably over
|
||||||
|
* the lock screen / watch face for an inbound call.
|
||||||
|
*/
|
||||||
|
class InCallActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
applyCallLockScreenFlags()
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
// Auto-finish when there is no call left (answered+ended, or
|
||||||
|
// remote hangup cleared the state). Polls CallState so we don't
|
||||||
|
// need an explicit close from the service.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (CallState.incoming() != null || CallState.active() != null) {
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
InCallScreen(
|
||||||
|
onAnswer = { WearBaresipServiceHelper.answer(it) },
|
||||||
|
onDecline = { WearBaresipServiceHelper.hangup(it) },
|
||||||
|
onEnd = { call -> if (call != null) WearBaresipServiceHelper.hangup(call) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
applyCallLockScreenFlags()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun applyCallLockScreenFlags() {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "InCallActivity flags failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
780
wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
Normal file
780
wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
Normal file
@ -0,0 +1,780 @@
|
|||||||
|
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.BackHandler
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
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.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Send
|
||||||
|
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() } }
|
||||||
|
// Intercept the system back gesture so navigation stays inside the app
|
||||||
|
// (Messages/Conversation/Accounts screens pop to their parent instead of
|
||||||
|
// finishing the Activity and dropping to the clock face). At the root
|
||||||
|
// "dialer" route the handler is disabled, so back behaves normally.
|
||||||
|
BackHandler(enabled = activity.route.value != "dialer") {
|
||||||
|
activity.route.value = when {
|
||||||
|
activity.route.value.startsWith("thread:") -> "messages"
|
||||||
|
activity.route.value == "messages" || activity.route.value == "accounts" -> "dialer"
|
||||||
|
else -> "dialer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialTheme {
|
||||||
|
Scaffold(timeText = { TimeText() }) {
|
||||||
|
when {
|
||||||
|
hasCall -> InCallScreen(
|
||||||
|
onAnswer = { answerCall(it) },
|
||||||
|
onDecline = { declineCall(it) },
|
||||||
|
onEnd = { hangupCall(it) }
|
||||||
|
)
|
||||||
|
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
||||||
|
activity.route.value == "messages" -> MessagesScreen(
|
||||||
|
onBack = { activity.route.value = "dialer" },
|
||||||
|
onOpen = { activity.route.value = "thread:$it" }
|
||||||
|
)
|
||||||
|
activity.route.value.startsWith("thread:") -> {
|
||||||
|
val peer = activity.route.value.removePrefix("thread:")
|
||||||
|
ConversationScreen(
|
||||||
|
peer = peer,
|
||||||
|
onBack = { activity.route.value = "messages" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> DialerScreen(
|
||||||
|
onAccounts = { activity.route.value = "accounts" },
|
||||||
|
onMessages = { activity.route.value = "messages" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialerScreen(onAccounts: () -> Unit, onMessages: () -> 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Messages (SIP MESSAGE / RFC 3428).
|
||||||
|
Button(
|
||||||
|
onClick = onMessages,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Email, contentDescription = "Messages")
|
||||||
|
Text("Messages", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessagesScreen(onBack: () -> Unit, onOpen: (String) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val conversations = remember { MessageStore.conversations(context) }
|
||||||
|
MaterialTheme {
|
||||||
|
Scaffold(timeText = { TimeText() }) {
|
||||||
|
ScalingLazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text("Messages", style = MaterialTheme.typography.title2,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
if (conversations.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("No messages yet", style = MaterialTheme.typography.body2,
|
||||||
|
color = androidx.compose.ui.graphics.Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(conversations.size) { i ->
|
||||||
|
val (peer, last) = conversations[i]
|
||||||
|
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
|
||||||
|
Button(
|
||||||
|
onClick = { onOpen(peer) },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.Start) {
|
||||||
|
Text(label, color = androidx.compose.ui.graphics.Color.White)
|
||||||
|
Text(
|
||||||
|
(if (last.direction == "out") "You: " else "") + last.body.take(32),
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
color = androidx.compose.ui.graphics.Color.Gray,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConversationScreen(peer: String, onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var draft by remember { mutableStateOf("") }
|
||||||
|
val thread = remember { MessageStore.thread(context, peer) }
|
||||||
|
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
|
||||||
|
MaterialTheme {
|
||||||
|
Scaffold(timeText = { TimeText() }) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.title3,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
ScalingLazyColumn(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||||
|
) {
|
||||||
|
items(thread.size) { i ->
|
||||||
|
val m = thread[i]
|
||||||
|
val bg = if (m.direction == "out")
|
||||||
|
androidx.compose.ui.graphics.Color(0xFF1B3A1B)
|
||||||
|
else androidx.compose.ui.graphics.Color.DarkGray
|
||||||
|
Text(
|
||||||
|
m.body,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bg, RoundedCornerShape(6.dp))
|
||||||
|
.padding(6.dp),
|
||||||
|
style = MaterialTheme.typography.body2,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
label = { Text("Message", color = androidx.compose.ui.graphics.Color.White) },
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val text = draft.trim()
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
WearBaresipServiceHelper.sendMessage(context, peer, text)
|
||||||
|
draft = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||||
|
Text("Send", 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(
|
||||||
|
onAnswer: (WearCall) -> Unit,
|
||||||
|
onDecline: (WearCall) -> Unit,
|
||||||
|
onEnd: (WearCall?) -> 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 = { onAnswer(call) }) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = "Answer")
|
||||||
|
Text("Answer", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
Button(onClick = { onDecline(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(call) }) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
104
wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt
Normal file
104
wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single SIP MESSAGE (RFC 3428) — sent or received.
|
||||||
|
* Plaintext for now; encryption is a later phase (NFC key exchange +
|
||||||
|
* asymmetric envelope, see README roadmap).
|
||||||
|
*/
|
||||||
|
data class SipMessage(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val peer: String,
|
||||||
|
val direction: String, // "in" | "out"
|
||||||
|
val body: String,
|
||||||
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny file-backed store for SIP MESSAGE history. Uses a JSON file in
|
||||||
|
* filesDir (no extra dependency). Conversations are keyed by normalized peer.
|
||||||
|
*/
|
||||||
|
object MessageStore {
|
||||||
|
|
||||||
|
private const val FILE = "messages.json"
|
||||||
|
private const val TAG = "Baresip Wear Msg"
|
||||||
|
|
||||||
|
private var cache: MutableList<SipMessage>? = null
|
||||||
|
|
||||||
|
private fun file(context: android.content.Context): File =
|
||||||
|
File(context.filesDir, FILE)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun load(context: android.content.Context): List<SipMessage> {
|
||||||
|
if (cache != null) return cache!!
|
||||||
|
val list = mutableListOf<SipMessage>()
|
||||||
|
try {
|
||||||
|
val f = file(context)
|
||||||
|
if (f.exists()) {
|
||||||
|
val arr = JSONArray(f.readText())
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
list.add(
|
||||||
|
SipMessage(
|
||||||
|
id = o.optString("id", UUID.randomUUID().toString()),
|
||||||
|
peer = o.optString("peer", ""),
|
||||||
|
direction = o.optString("direction", "in"),
|
||||||
|
body = o.optString("body", ""),
|
||||||
|
timestamp = o.optLong("timestamp", 0L)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "load failed: ${e.message}")
|
||||||
|
}
|
||||||
|
cache = list
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(context: android.content.Context, msg: SipMessage) {
|
||||||
|
val list = load(context).toMutableList()
|
||||||
|
list.add(msg)
|
||||||
|
// Cap history to keep the file small on a watch.
|
||||||
|
val trimmed = if (list.size > 500) list.takeLast(500) else list
|
||||||
|
cache = trimmed.toMutableList()
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (m in trimmed) {
|
||||||
|
arr.put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", m.id)
|
||||||
|
put("peer", m.peer)
|
||||||
|
put("direction", m.direction)
|
||||||
|
put("body", m.body)
|
||||||
|
put("timestamp", m.timestamp)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
file(context).writeText(arr.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "save failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversations (one per peer), most-recent first. */
|
||||||
|
fun conversations(context: android.content.Context): List<Pair<String, SipMessage>> {
|
||||||
|
val byPeer = linkedMapOf<String, SipMessage>()
|
||||||
|
for (m in load(context)) {
|
||||||
|
val prev = byPeer[m.peer]
|
||||||
|
if (prev == null || m.timestamp >= prev.timestamp) byPeer[m.peer] = m
|
||||||
|
}
|
||||||
|
return byPeer.entries
|
||||||
|
.sortedByDescending { it.value.timestamp }
|
||||||
|
.map { it.key to it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun thread(context: android.content.Context, peer: String): List<SipMessage> =
|
||||||
|
load(context).filter { it.peer == peer }.sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
86
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal file
86
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
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 SELF-MANAGED telecom PhoneAccount and, on
|
||||||
|
* incoming calls, hands the call to the system via
|
||||||
|
* TelecomManager.addNewIncomingCall. A self-managed account (CAPABILITY_SELF_MANAGED
|
||||||
|
* + MANAGE_OWN_CALLS, a normal permission) is the mechanism apps like WhatsApp
|
||||||
|
* use to show an incoming-call UI over the lock screen WITHOUT becoming the
|
||||||
|
* device's default dialer. It is also exempt from the background-activity-launch
|
||||||
|
* (BAL) restriction, so the system call UI appears even when our app is in the
|
||||||
|
* background / the device is locked.
|
||||||
|
*/
|
||||||
|
object TelecomHelper {
|
||||||
|
|
||||||
|
private const val ACCOUNT_ID = "sip-wear"
|
||||||
|
const val TELECOM_PERMISSION = android.Manifest.permission.MANAGE_OWN_CALLS
|
||||||
|
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 self-managed PhoneAccount. */
|
||||||
|
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_SELF_MANAGED
|
||||||
|
)
|
||||||
|
.setIcon(
|
||||||
|
android.graphics.drawable.Icon.createWithResource(
|
||||||
|
context, android.R.drawable.sym_call_incoming
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
tm.registerPhoneAccount(account)
|
||||||
|
Log.d(TAG, "registered self-managed PhoneAccount $ACCOUNT_ID")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "registerAccount failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands an incoming native call to the system telecom stack as a
|
||||||
|
* self-managed call. The framework shows its incoming-call UI (over the
|
||||||
|
* lock screen) and forwards answer/decline to WearConnectionService. Our
|
||||||
|
* in-app InCallActivity is the fallback if the system UI is suppressed.
|
||||||
|
*/
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
711
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
Normal file
711
wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
Normal file
@ -0,0 +1,711 @@
|
|||||||
|
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()
|
||||||
|
// Start as microphone-only at boot: a phoneCall-type FGS is only valid
|
||||||
|
// once a call is actually active, so declaring phoneCall here (before
|
||||||
|
// any call) throws a SecurityException at startup.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID, notification,
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Register our SIP identity as a telecom PhoneAccount so inbound calls
|
||||||
|
// can be surfaced via the system incoming-call UI (the only reliable
|
||||||
|
// way to show a call prompt over the lock screen from a background FGS
|
||||||
|
// on Android 12+, which blocks ordinary background Activity launches).
|
||||||
|
try {
|
||||||
|
TelecomHelper.registerAccount(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom register failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
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" -> {
|
||||||
|
// The real, answerable struct call* is now available.
|
||||||
|
if (callp != 0L) WearBaresipService.activeCallp = callp
|
||||||
|
Log.d("Baresip Wear", "call incoming tracked callp=$callp")
|
||||||
|
CallState.status.value = "Incoming call"
|
||||||
|
// Hand the call to the system telecom stack so the stock Wear
|
||||||
|
// incoming-call UI is shown over the lock screen (the only
|
||||||
|
// background-launch path Android allows). The in-app UI is a
|
||||||
|
// fallback that is normally blocked by the BAL restriction.
|
||||||
|
try {
|
||||||
|
TelecomHelper.addIncomingCall(this, callp, uap, arg.ifEmpty { "unknown" })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom addIncomingCall failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// Clear the system telecom UI if it was showing this call.
|
||||||
|
try {
|
||||||
|
WearConnectionService.disconnectActive(android.telecom.DisconnectCause.REMOTE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom disconnect failed: ${e.message}")
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the native message_handler (baresip.c) when a SIP MESSAGE
|
||||||
|
* (RFC 3428) arrives. Signature matches the JNI upcall:
|
||||||
|
* (long ua, String peer, String contentType, byte[] body).
|
||||||
|
* Stores the message locally and surfaces a notification.
|
||||||
|
*/
|
||||||
|
fun messageEvent(ua: Long, peer: String, ctype: String?, body: ByteArray?) {
|
||||||
|
val text = try {
|
||||||
|
body?.toString(Charsets.UTF_8) ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "messageEvent decode failed: ${e.message}")
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (text.isBlank()) return
|
||||||
|
android.util.Log.d("Baresip Wear", "messageEvent from $peer: ${text.take(40)}")
|
||||||
|
try {
|
||||||
|
MessageStore.add(this, SipMessage(peer = peer, direction = "in", body = text))
|
||||||
|
notifyIncomingMessage(peer, text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "messageEvent store failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post a notification for an incoming SIP MESSAGE. */
|
||||||
|
private fun notifyIncomingMessage(peer: String, body: String) {
|
||||||
|
try {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
|
||||||
|
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Message from $label")
|
||||||
|
.setContentText(body)
|
||||||
|
.setSmallIcon(android.R.drawable.sym_action_call) // reuse a system icon
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
nm.notify(MESSAGE_NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "notifyIncomingMessage failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 to hand the call to the self-managed telecom
|
||||||
|
* stack (WearConnectionService), which launches InCallActivity over the
|
||||||
|
* lock screen. We also wake the (usually dimmed) screen and buzz a ring
|
||||||
|
* pattern.
|
||||||
|
*/
|
||||||
|
private fun alertIncomingCall(peer: String) {
|
||||||
|
// The InCallActivity is launched from WearConnectionService (self-managed
|
||||||
|
// telecom context) when the system binds us for addNewIncomingCall — that
|
||||||
|
// path is permitted over the lock screen / from the background.
|
||||||
|
// Keep the CPU awake briefly so the SIP 180/200 and the heads-up render.
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: The incoming-call Activity is launched from
|
||||||
|
// WearConnectionService.onCreateIncomingConnection (the self-managed
|
||||||
|
// telecom context), which is the only path Android permits to show UI
|
||||||
|
// over the lock screen / from the background. A plain startActivity
|
||||||
|
// from this foreground service is BAL-blocked on a sleeping device and
|
||||||
|
// must NOT be used here.
|
||||||
|
try {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val pm = getSystemService(android.os.PowerManager::class.java)
|
||||||
|
val wl = pm?.newWakeLock(
|
||||||
|
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
android.os.PowerManager.SCREEN_DIM_WAKE_LOCK,
|
||||||
|
"BaresipWear:incomingWake"
|
||||||
|
)
|
||||||
|
wl?.acquire(5000)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "wake 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. A dedicated InCallActivity (not the LAUNCHER
|
||||||
|
// MainActivity) is used so the launch isn't suppressed when the app is
|
||||||
|
// backgrounded.
|
||||||
|
try {
|
||||||
|
val fsIntent = Intent(this, InCallActivity::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
|
||||||
|
const val MESSAGE_NOTIFICATION_ID = 3
|
||||||
|
|
||||||
|
// 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,143 @@
|
|||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a SIP MESSAGE (RFC 3428) to a peer. Normalizes the peer to a
|
||||||
|
// sip: URI (matching the dial() logic) and stores the outgoing message
|
||||||
|
// locally on success.
|
||||||
|
fun sendMessage(context: android.content.Context, peer: String, body: String) {
|
||||||
|
if (defaultUap == 0L) {
|
||||||
|
toast("No SIP account - register first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = normalizePeer(peer)
|
||||||
|
android.util.Log.d("Baresip Wear", "sendMessage($target) defaultUap=$defaultUap")
|
||||||
|
try {
|
||||||
|
val err = Api.message_send(defaultUap, target, body)
|
||||||
|
if (err == 0) {
|
||||||
|
MessageStore.add(
|
||||||
|
context,
|
||||||
|
SipMessage(peer = target, direction = "out", body = body)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast("Message failed ($err)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "sendMessage failed: ${e.message}")
|
||||||
|
toast("Message failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare peer (no scheme) -> sip:peer@domain; scheme-only -> append @domain;
|
||||||
|
// already has '@' -> leave as-is. Mirrors dial() normalization.
|
||||||
|
private fun normalizePeer(peer: String): String {
|
||||||
|
val domain = "mail.txt3.net" // same default used by dial(); TODO: derive
|
||||||
|
return when {
|
||||||
|
peer.contains("@") -> peer
|
||||||
|
peer.startsWith("sip:") -> "$peer@$domain"
|
||||||
|
else -> "sip:$peer@$domain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
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 — the self-managed call handler.
|
||||||
|
*
|
||||||
|
* When baresip reports "call incoming" we call TelecomManager.addNewIncomingCall
|
||||||
|
* (see WearBaresipService / TelecomHelper). The framework binds this service and
|
||||||
|
* calls onCreateIncomingConnection(). Because our PhoneAccount is SELF_MANAGED,
|
||||||
|
* the system does NOT draw its own incoming UI (letDialerHandleRinging=false);
|
||||||
|
* instead our app must present the call. The canonical self-managed pattern is
|
||||||
|
* to bring our own full-screen Activity forward from here — the active
|
||||||
|
* self-managed call grants the lock-screen / background-launch permission that a
|
||||||
|
* plain foreground-service startActivity lacks.
|
||||||
|
*
|
||||||
|
* The native baresip stack remains the source of truth for the call; this
|
||||||
|
* service only mirrors the incoming-call lifecycle so the system can route
|
||||||
|
* answer / decline to the native stack via 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"
|
||||||
|
|
||||||
|
// The connection currently shown by the system incoming/active UI.
|
||||||
|
// Cleared when the call ends so a later call can replace it.
|
||||||
|
@Volatile
|
||||||
|
var activeConnection: android.telecom.Connection? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Tell the system UI the call ended (remote hangup / local hangup). */
|
||||||
|
fun disconnectActive(cause: Int = DisconnectCause.REMOTE) {
|
||||||
|
val conn = activeConnection ?: return
|
||||||
|
try {
|
||||||
|
conn.setDisconnected(DisconnectCause(cause))
|
||||||
|
conn.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "disconnectActive failed: ${e.message}")
|
||||||
|
}
|
||||||
|
activeConnection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake the screen and bring our InCallActivity to the front. Called from
|
||||||
|
* the self-managed connection context so the active incoming call
|
||||||
|
* grants the lock-screen / background launch permission.
|
||||||
|
*/
|
||||||
|
private fun showInCallUi(context: Context, callp: Long, uap: Long, peer: String) {
|
||||||
|
try {
|
||||||
|
val pm = context.getSystemService(PowerManager::class.java)
|
||||||
|
val wakeLock = pm?.newWakeLock(
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.FULL_WAKE_LOCK,
|
||||||
|
"BaresipWear:incomingCall"
|
||||||
|
)
|
||||||
|
wakeLock?.acquire(60_000L)
|
||||||
|
val intent = Intent(context, InCallActivity::class.java).apply {
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
)
|
||||||
|
putExtra(EXTRA_CALLP, callp)
|
||||||
|
putExtra(EXTRA_UAP, uap)
|
||||||
|
putExtra(EXTRA_PEER, peer)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
Log.d(TAG, "showInCallUi: InCallActivity started (callp=$callp)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "showInCallUi failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
// Self-managed connection: the system expects OUR app to present the UI
|
||||||
|
// (it will not draw its own). CAPABILITY_MUTE lets the user mute.
|
||||||
|
conn.connectionCapabilities = Connection.CAPABILITY_MUTE
|
||||||
|
conn.connectionProperties = Connection.PROPERTY_SELF_MANAGED
|
||||||
|
conn.setRinging()
|
||||||
|
// PRESENTATION_ALLOWED == 1
|
||||||
|
conn.setAddress(Uri.parse(peer), 1)
|
||||||
|
conn.setCallerDisplayName(peer, 1)
|
||||||
|
activeConnection = conn
|
||||||
|
// Bring our incoming-call UI forward (over lock screen / from the
|
||||||
|
// background) while the self-managed call is active.
|
||||||
|
showInCallUi(this, callp, uap, peer)
|
||||||
|
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. */
|
||||||
|
internal 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()
|
||||||
|
activeConnection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
activeConnection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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