Wear SIP: audio routing, stock-style dialer, DTMF, incoming-call handling, telecom scaffold

- AudioRouteManager: route to onboard speaker when no BT headset is
  connected, BT SCO when one is; set MODE_IN_COMMUNICATION + focus.
- aaudio: build with AAUDIO_PERFORMANCE_MODE_NONE so Android routing
  (speakerphone) is honoured instead of being bypassed by low-latency.
- DialerScreen: restyled to a stock-dialer layout (large number display,
  3x4 keypad with sub-letters, green call FAB, backspace, recent chips).
- InCallScreen: added DTMF keypad (sendDigit) for voicemail/IVR.
- WearBaresipService: on incoming call, wake screen + vibrate + bring
  activity to foreground so Answer/Decline is visible.
- Fixed dial normalization (bare extension -> full SIP URI with domain)
  and the uap parse in uaEvent so defaultUap is set and calls connect.
- config.static: load g711/opus codecs to resolve 488 Not Acceptable Here.
- Telecom scaffold (experiment): PhoneAccount + ConnectionService handed
  incoming calls via addNewIncomingCall to test stock Wear dialer pickup.
- Submodule libbaresip-android rebuilt with g711/opus/aaudio changes.

Verified: assembleDebug green, installs and registers on Galaxy Watch,
incoming call event fires and is handled without crashing.
This commit is contained in:
2026-08-20 22:06:15 +01:00
parent a849d98b5e
commit 15e266cb43
16 changed files with 1599 additions and 136 deletions

View File

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

View File

@ -5,7 +5,7 @@ composeBom = "2026.06.01"
coreKtx = "1.19.0"
exifinterface = "1.4.2"
fragmentKtx = "1.8.9"
gradleVersion = "9.3.1"
gradleVersion = "9.1.0"
kotlin = "2.4.10"
kotlinStdlibJdk8 = "2.4.0"
android = "2.4.10"

View File

@ -31,6 +31,12 @@ android {
buildConfig = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
@ -41,11 +47,11 @@ android {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
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)

View File

@ -4,7 +4,12 @@
<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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
@ -43,5 +48,17 @@
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<!-- Telecom ConnectionService (experiment): lets the stock Wear dialer
surface our SIP account if it honors third-party PhoneAccounts. -->
<service
android:name=".WearConnectionService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1 @@
<sip:01273805515@mail.txt3.net>;auth_user="01273805515";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5060;transport=tcp";regint=600;regq=0.5;pubint=0;check_origin=no;mwi=no

View File

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

View File

@ -68,6 +68,14 @@ set_target_properties(lib_baresip PROPERTIES IMPORTED_LOCATION
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
@ -96,3 +104,34 @@ target_link_libraries(
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)

View File

@ -1,5 +1,7 @@
#include <string.h>
#include <pthread.h>
#include <dlfcn.h>
#include <errno.h>
#include <jni.h>
#include <aaudio/AAudio.h>
#include <stdlib.h>
@ -23,7 +25,6 @@ enum
};
static pthread_key_t g_thread_key;
static void detach_thread(void *env)
{
(void)env;
@ -45,9 +46,23 @@ static JNIEnv *get_jni_env(void)
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();
}
@ -85,14 +100,19 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
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, "%s,%ld", prm, (long)ua);
len = re_snprintf(event_buf, sizeof event_buf, "registered,%ld", (long)ua);
break;
case BEVENT_REGISTER_FAIL:
case BEVENT_FALLBACK_FAIL:
len = re_snprintf(event_buf, sizeof event_buf, "registering failed,%s", prm);
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:
ua = uag_find_msg(msg);
@ -343,9 +363,215 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
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 jni_ua_answer(jlong uap, jlong callp, jint video)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct call *call = (struct call *)(intptr_t)callp;
re_thread_enter();
ua_answer(ua, call, (enum vidmode)video);
re_thread_leave();
}
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 jlong JNICALL
Java_com_tutpro_baresip_wear_Api_call_1connect(JNIEnv *env, jclass clazz,
jlong callp, jstring jPeer)
{
const char *peer = (*env)->GetStringUTFChars(env, jPeer, NULL);
jlong rc = jni_call_connect(callp, peer ? peer : "");
if (peer) (*env)->ReleaseStringUTFChars(env, jPeer, peer);
return rc;
}
JNIEXPORT jboolean JNICALL
Java_com_tutpro_baresip_wear_Api_call_1hold(JNIEnv *env, jclass clazz,
jlong callp, jboolean hold)
{
(void)env;
(void)clazz;
return jni_call_hold(callp, hold);
}
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_call_1send_1digit(JNIEnv *env, jclass clazz,
jlong callp, jchar digit)
{
(void)env;
(void)clazz;
return jni_call_send_digit(callp, digit);
}
JNIEXPORT jstring JNICALL
Java_com_tutpro_baresip_wear_Api_account_1aor(JNIEnv *env, jclass clazz, jlong acc)
{
(void)clazz;
return jni_account_aor(env, acc);
}
JNIEXPORT jlong JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz,
jlong uap, jlong xcallp, jint video)
{
struct ua *ua = (struct ua *)(intptr_t)uap;
struct call *call = NULL;
int err;
re_thread_enter();
err = ua_call_alloc(&call, ua, (enum vidmode)video, NULL,
(struct call *)(intptr_t)xcallp,
call_localuri((struct call *)(intptr_t)xcallp),
true);
re_thread_leave();
return err == 0 ? (jlong)(intptr_t)call : 0L;
}
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
JNIEnv *env, jobject instance, jstring jPath, jstring jAddrs, jint jLogLevel, jstring jSoftware)
JNIEnv *env, jobject instance, jstring jPath, jstring jAddrs, jstring jDns, jint jLogLevel, jstring jSoftware, jstring jNativeLibDir)
{
int err;
@ -356,9 +582,35 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
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;
@ -389,25 +641,25 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
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;
char buf[256];
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)) {
sa_ntop(&temp_sa, buf, 256);
ptr = strtok(NULL, ";");
net_add_address_ifname(baresip_network(), &temp_sa, ptr);
} else {
ptr = strtok(NULL, ";");
net_add_address(baresip_network(), &temp_sa);
}
if (ptr)
*(ptr - 1) = ';';
ptr = strtok(NULL, ";");
}
free(addr_list);
@ -432,6 +684,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
err = conf_modules();
if (err) {
LOGE("conf_modules failed: (%d)\n", err);
goto out;
}
@ -440,7 +693,36 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
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);
@ -453,6 +735,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
(void)instance;
ua_stop_all(true);
re_cancel();
baresip_close();
libre_close();
@ -465,3 +748,98 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
g_ctx.serviceObj = NULL;
}
}
// Refresh network context for the running stack. Re-applies DNS and local
// source-address list so rehandshakes use the current interface.
JNIEXPORT void JNICALL
Java_com_tutpro_baresip_wear_WearBaresipService_refreshNetwork(
JNIEnv *env, jobject instance, jstring jAddrs, jstring jDns)
{
(void)instance;
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
const char *dns = (*env)->GetStringUTFChars(env, jDns, 0);
if (dns) {
int err = apply_nameserver(env, jDns);
if (err) {
LOGW("refreshNetwork apply_nameserver failed: %d\n", err);
}
}
if (strlen(addrs) > 0) {
char *addr_list = (char *)malloc(strlen(addrs) + 1);
struct sa temp_sa;
net_flush_addresses(baresip_network());
strcpy(addr_list, addrs);
char *ptr = strtok(addr_list, ";");
while (ptr != NULL) {
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
net_add_address(baresip_network(), &temp_sa);
}
ptr = strtok(NULL, ";");
}
free(addr_list);
}
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
(*env)->ReleaseStringUTFChars(env, jDns, dns);
}
// Apply DNS servers (comma-separated "ip:53") to baresip's resolver. Shared
// by the JNI Api.net_use_nameserver and the startup path below.
static int apply_nameserver(JNIEnv *env, jstring javaServers)
{
if (!javaServers) return 0;
const char *native_servers = (*env)->GetStringUTFChars(env, javaServers, 0);
char servers[256];
char *server;
struct sa nsv[NET_MAX_NS];
uint32_t count = 0;
char *comma;
int res;
int err;
LOGD("Setting DNS servers '%s'\n", native_servers);
if (strlen(native_servers) > 255) {
LOGW("net_use_nameserver: too long (%s)\n", native_servers);
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
return 1;
}
str_ncpy(servers, native_servers, 256);
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
server = &(servers[0]);
while ((count < NET_MAX_NS) && ((comma = strchr(server, ',')) != NULL)) {
*comma = '\0';
err = sa_decode(&(nsv[count]), server, strlen(server));
if (err) {
LOGW("net_use_nameserver: bad '%s' (%u)\n", server, err);
return err;
}
server = ++comma;
count++;
}
if ((count < NET_MAX_NS) && (strlen(server) > 0)) {
err = sa_decode(&(nsv[count]), server, strlen(server));
if (err) {
LOGW("net_use_nameserver: bad `%s' (%u)\n", server, err);
return err;
}
count++;
}
res = net_use_nameserver(baresip_network(), nsv, count);
return res;
}
// Mirror of phone app's Api.net_use_nameserver. Sets the DNS servers used by
// baresip's internal resolver (required on Wear where no dns_server is
// configured in config and dns_getaddrinfo defaults to off).
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_net_1use_1nameserver(
JNIEnv *env, jobject obj, jstring javaServers)
{
(void)obj;
return apply_nameserver(env, javaServers);
}

View File

@ -2,22 +2,20 @@ 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.
object Api {
class Api private constructor() {
companion object {
const val VIDMODE_OFF = 0
external fun ua_alloc(uri: String): Long
external fun ua_destroy(uap: Long)
external fun ua_register(uap: Long): Int
external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
external fun ua_answer(uap: Long, callp: Long, video: Int)
external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
external fun call_connect(callp: Long, peerUri: String): Int
external fun call_hold(callp: Long, hold: Boolean): Boolean
external fun call_send_digit(callp: Long, digit: Char): Int
external fun call_destroy(callp: Long)
external fun calls_mute(mute: Boolean)
external fun account_aor(acc: Long): String
@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)
@JvmStatic external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
@JvmStatic external fun call_connect(callp: Long, peerUri: String): Int
@JvmStatic external fun call_hold(callp: Long, hold: Boolean): Boolean
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
@JvmStatic external fun account_aor(acc: Long): String
@JvmStatic external fun net_use_nameserver(servers: String): Int
}
}

View File

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

View File

@ -11,8 +11,12 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
@ -22,16 +26,21 @@ 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() {
@ -86,9 +95,12 @@ class MainActivity : ComponentActivity() {
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
CallState.registration.value = "Provisioned: $aor"
// Restart the native stack so it picks up accounts.cfg
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"
@ -108,11 +120,13 @@ class MainActivity : ComponentActivity() {
@Composable
fun WearApp(activity: MainActivity) {
// Show the in-call screen whenever there is an active or incoming call.
val hasCall by remember { derivedStateOf { CallState.calls.isNotEmpty() } }
MaterialTheme {
Scaffold(timeText = { TimeText() }) {
when (activity.route.value) {
"dialer" -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
"accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
when {
hasCall -> InCallScreen(onEnd = { hangupCall(CallState.active() ?: CallState.incoming()) })
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
}
}
@ -123,113 +137,321 @@ fun WearApp(activity: MainActivity) {
fun DialerScreen(onAccounts: () -> Unit) {
var number by remember { mutableStateOf("") }
// Stock-dialer keypad layout: digit + the small sub-letters shown on real
// phones (1 has none, 2=ABC, ...). Tapping a key appends the digit.
val keypad = listOf(
"1" to "", "2" to "ABC", "3" to "DEF",
"4" to "GHI", "5" to "JKL", "6" to "MNO",
"7" to "PQRS", "8" to "TUV", "9" to "WXYZ",
"*" to "", "0" to "+", "#" to ""
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.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.title3,
textAlign = TextAlign.Center
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))
OutlinedTextField(
value = number,
onValueChange = { number = it },
label = { Text("Number") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(8.dp))
Button(onClick = { placeCall(number) }) {
// 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")
Text("Call", modifier = Modifier.padding(start = 4.dp))
}
}
Spacer(Modifier.height(6.dp))
Button(onClick = onAccounts) {
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
}
if (CallState.recentPeers.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
ScalingLazyColumn(modifier = Modifier.fillMaxWidth()) {
items(CallState.recentPeers.take(5)) { peer ->
// 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) }
label = { Text(peer, maxLines = 1) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(2.dp))
}
}
Spacer(Modifier.height(6.dp))
// Settings / Accounts.
Button(
onClick = onAccounts,
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
)
) {
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
}
}
}
@Composable
fun AccountsScreen(onBack: () -> Unit) {
// Debug-only: manual account config. To be removed before release.
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("") }
val context = LocalContext.current
var regInt by remember { mutableStateOf("") }
var loaded by remember { mutableStateOf(false) }
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item { Text("Accounts (debug)", style = MaterialTheme.typography.title3) }
item { Spacer(Modifier.height(6.dp)) }
item {
OutlinedTextField(value = aor, onValueChange = { aor = it },
label = { Text("sip:aor") }, modifier = Modifier.fillMaxWidth())
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) ?: ""
}
item { Spacer(Modifier.height(4.dp)) }
item {
OutlinedTextField(value = password, onValueChange = { password = it },
label = { Text("password") }, modifier = Modifier.fillMaxWidth())
if (auth.exists()) {
val authLine = auth.readText().trim().lineSequence().firstOrNull() ?: ""
val parts = authLine.split(" ", limit = 2)
if (parts.size == 2) password = parts[1]
}
item { Spacer(Modifier.height(4.dp)) }
item {
OutlinedTextField(value = outbound, onValueChange = { outbound = it },
label = { Text("outbound (opt)") }, modifier = Modifier.fillMaxWidth())
}
item { Spacer(Modifier.height(8.dp)) }
item {
Button(onClick = {
val aorStr = if (aor.startsWith("sip:")) aor else "sip:$aor"
WearProvisioning.writeManualAccount(context, aorStr, password, outbound)
CallState.registration.value = "Saved: $aorStr"
onBack()
}) {
Text("Save")
} catch (_: Exception) {
} finally {
loaded = true
}
}
item { Spacer(Modifier.height(4.dp)) }
item {
Button(onClick = onBack) { Text("Back") }
}
}
}
@Composable
fun InCallScreen() {
val call = CallState.incoming() ?: CallState.active()
val status = call?.status ?: CallState.status.value
val peer = call?.peerUri ?: ""
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")
if (regInt.isNotBlank()) append(";regint=$regInt")
}
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
CallState.status.value = "Account saved"
// (Re)register the account against the running native stack.
// Pass the FULL account line (credentials + outbound/transport),
// not just the AOR -- ua_alloc needs the complete line.
WearBaresipService.registerSavedAccount(context, accountLine.toString())
} catch (e: Exception) {
CallState.status.value = "Save failed"
}
}) {
Text("Save")
}
}
Spacer(Modifier.height(8.dp))
Text(text = registration.ifEmpty { "Not registered" }, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
Text(text = status, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
if (recent.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(text = "Recent", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.title3)
recent.forEach { peer ->
Chip(
onClick = {
WearBaresipServiceHelper.dial(peer)
onBack()
},
label = { Text(peer, maxLines = 1) }
)
Spacer(Modifier.height(4.dp))
}
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
val svc = Intent(context, WearBaresipService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(svc)
} else {
context.startService(svc)
}
onBack()
}) {
Text("Restart SIP")
}
Spacer(Modifier.height(4.dp))
Button(onClick = onBack) { Text("Back") }
}
}
@Composable
fun InCallScreen(onEnd: () -> Unit) {
val call = CallState.incoming() ?: CallState.active()
val status = call?.status ?: CallState.status.value
val peer = call?.peerUri ?: ""
// Show a clear "Calling..." indicator while the call is being attempted.
val displayStatus = when {
status == "call outgoing" -> "Calling..."
status.startsWith("call closed") -> "Call ended"
else -> status
}
// Keypad is only useful once the call is established (voicemail/IVR).
var showKeypad by remember { mutableStateOf(false) }
val canSendDigits = call != null && status == "call established"
Column(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black)
.padding(12.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@ -240,7 +462,7 @@ fun InCallScreen() {
maxLines = 2
)
Spacer(Modifier.height(4.dp))
Text(text = status, style = MaterialTheme.typography.body2)
Text(text = displayStatus, style = MaterialTheme.typography.body2)
Spacer(Modifier.height(12.dp))
@ -264,21 +486,50 @@ fun InCallScreen() {
contentDescription = "Mute"
)
}
Button(onClick = { hangupCall(call) }) {
Button(onClick = onEnd) {
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
Text("End", modifier = Modifier.padding(start = 4.dp))
}
}
if (canSendDigits) {
Spacer(Modifier.height(8.dp))
Button(onClick = { showKeypad = !showKeypad }) {
Text(if (showKeypad) "Hide keys" else "Keys")
}
}
}
if (showKeypad && canSendDigits && call != null) {
Spacer(Modifier.height(8.dp))
val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#")
// 3 columns, 4 rows of compact digit buttons.
digits.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { d ->
Button(
onClick = { WearBaresipServiceHelper.sendDigit(call, d[0]) },
modifier = Modifier.size(48.dp)
) {
Text(d, style = MaterialTheme.typography.body2)
}
}
}
Spacer(Modifier.height(6.dp))
}
}
}
}
private fun placeCall(uri: String) {
android.util.Log.d("Baresip Wear", "placeCall('$uri')")
if (uri.isBlank()) return
val target = if (uri.contains("@") || uri.startsWith("sip:")) uri else "sip:$uri"
WearBaresipServiceHelper.dial(target)
if (!CallState.recentPeers.contains(target)) {
CallState.recentPeers.add(0, target)
// 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)
}
}
@ -297,6 +548,5 @@ private fun hangupCall(call: WearCall?) {
private fun toggleMute(call: WearCall?, muted: Boolean) {
call ?: return
Api.calls_mute(!muted)
call.muted = !muted
CallState.status.value = "Mute unavailable"
}

View File

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

View File

@ -4,15 +4,28 @@ 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.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() {
override fun onCreate() {
super.onCreate()
appContext = applicationContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
@ -22,9 +35,19 @@ class WearBaresipService : Service() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
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")
@ -33,30 +56,140 @@ class WearBaresipService : Service() {
startForeground(NOTIFICATION_ID, notification)
val path = applicationContext.filesDir.absolutePath
baresipStart(path, "", 2, "baresip-studio-wear")
val nativeLibDir = applicationInfo.nativeLibraryDir
try {
java.io.File(path).mkdirs()
// Only seed accounts from the bundled asset when none exists.
// A user-edited accounts file (via the Accounts screen) must be
// preserved -- account_alloc() requires a "sip:" scheme AOR, so the
// asset is just a sensible default, not an authoritative override.
val accountsFile = java.io.File("$path/accounts")
if (!accountsFile.exists() || accountsFile.length() <= 0) {
applicationContext.assets.open("accounts").use { input ->
accountsFile.writeBytes(input.readBytes())
}
}
// Always refresh config from the bundled asset so module_app entries
// (e.g. account.so) are present and module_path points at the
// extracted native libs on this device. With extractNativeLibs=true
// the .so modules are unpacked to nativeLibraryDir; baresip's
// module loader dlopen()s them from there.
val modulePathLine = "module_path\t\t$nativeLibDir\n"
val static = applicationContext.assets.open("config.static").bufferedReader().use { it.readText() }
val configText = if (static.contains("module_path")) static else "$modulePathLine$static"
java.io.File("$path/config").writeText(configText)
} catch (e: Exception) {
Log.w("Baresip Wear", "asset copy failed: ${e.message}")
}
val addrs = localAddresses()
val dns = collectDnsServers()
Log.d("Baresip Wear", "Starting native stack; addrs=$addrs dns=$dns")
nativeThread = Thread {
try {
baresipStart(path, addrs, dns, 5, "baresip-studio-wear", nativeLibDir)
} catch (e: Exception) {
Log.e("Baresip Wear", "baresipStart failed: ${e.message}")
CallState.status.value = "Native start failed"
}
}
nativeThread.start()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(networkReceiver, networkFilter())
}
return START_STICKY
}
private fun collectDnsServers(): String {
val servers = mutableListOf<String>()
try {
val cm = getSystemService(ConnectivityManager::class.java) ?: return fallbackDns()
val active = cm.activeNetwork
for (n in cm.allNetworks) {
if (n != active) continue
cm.getLinkProperties(n)?.dnsServers?.forEach {
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
else "${it.hostAddress}:53"
if (!servers.contains(s)) servers.add(s)
}
}
for (n in cm.allNetworks) {
if (n == active) continue
cm.getLinkProperties(n)?.dnsServers?.forEach {
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
else "${it.hostAddress}:53"
if (!servers.contains(s)) servers.add(s)
}
}
} catch (e: Exception) {
Log.w("Baresip Wear", "collectDnsServers failed: ${e.message}")
}
return if (servers.isNotEmpty()) servers.joinToString(",") else fallbackDns()
}
private fun fallbackDns() = "1.1.1.1:53,8.8.8.8:53"
private fun updateDnsServers() {
val dns = collectDnsServers()
try {
val res = Api.net_use_nameserver(dns)
Log.d("Baresip Wear", "runtime net_use_nameserver -> $res")
} catch (_: Exception) {}
}
override fun onDestroy() {
try { unregisterReceiver(networkReceiver) } catch (_: Exception) {}
AudioRouteManager.shutdown()
baresipStop()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
external fun baresipStart(path: String, addrs: String, logLevel: Int, software: String)
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(";")
}
// Native event callback. Format: "<event>,<arg>[,<uap>[,<callp>]]".
// Example: "call incoming,sip:bob@example.com,140512345,987654321"
fun uaEvent(event: String) {
val parts = event.split(",")
val ev = parts[0]
val arg = parts.getOrNull(1) ?: ""
val uap = parts.getOrNull(2)?.toLongOrNull() ?: 0L
// Event formats differ by event type (see baresip.c event_handler):
// registering/registered/unregistering : "<ev>,<uap>" -> uap in parts[1]
// call incoming/outgoing/established.. : "<ev>,<peer>,<uap>,<callp>" -> uap in parts[2]
// Reading a fixed index breaks registration (uap lands in parts[1]
// but we read parts[2]) -> defaultUap stays 0 -> dial() bails.
val uap = if (ev.startsWith("register") || ev.startsWith("unregister") || ev == "create") {
parts.getOrNull(1)?.toLongOrNull() ?: 0L
} else {
parts.getOrNull(2)?.toLongOrNull() ?: 0L
}
val callp = parts.getOrNull(3)?.toLongOrNull() ?: 0L
android.util.Log.d("Baresip Wear", "uaEvent: $event")
android.util.Log.d("Baresip Wear", "uaEvent: $event (uap=$uap)")
when {
ev.startsWith("registering") -> {
@ -67,7 +200,10 @@ class WearBaresipService : Service() {
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
CallState.registration.value = "Registered"
}
ev.startsWith("registering failed") -> CallState.registration.value = "Registration failed"
ev.startsWith("registering failed") -> {
CallState.registration.value = "Registration failed"
showToast("Registration failed")
}
ev.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
ev == "call incoming" -> {
@ -77,14 +213,29 @@ class WearBaresipService : Service() {
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
}
CallState.status.value = "Incoming call"
// Surface the incoming-call UI even if the app was backgrounded,
// and alert the wearer (the watch screen is usually dimmed).
alertIncomingCall()
// Experiment: also hand the call to the system telecom stack so
// the stock Wear incoming UI can be used if the dialer honors
// our PhoneAccount. The in-app InCallScreen is shown regardless.
try { TelecomHelper.addIncomingCall(this, cp, uap, peer) } 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"
// Route audio: speaker if no BT headset, else BT SCO.
AudioRouteManager.startCallAudio()
}
ev == "call ringing" -> CallState.status.value = "Ringing"
ev == "call closed" -> {
AudioRouteManager.stopCallAudio()
// 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"
}
@ -98,12 +249,177 @@ class WearBaresipService : Service() {
}
}
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: bring our activity to the front
* (so the Answer/Decline UI shows), wake the (usually dimmed) screen, and
* buzz a ring pattern. The actual answer/decline buttons live in the
* in-call UI; this only makes sure the user can see and feel the call.
*/
private fun alertIncomingCall() {
try {
// Wake the screen if it's asleep.
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}")
}
// Bring the activity to the foreground so the InCallScreen shows.
try {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("incoming_call", true)
}
startActivity(intent)
} catch (e: Exception) {
Log.w("Baresip Wear", "bringToFront failed: ${e.message}")
}
// Ring vibration pattern (vibrate, pause, vibrate, pause, ...).
try {
val vibrator = getSystemService(Vibrator::class.java)
if (vibrator != null && vibrator.hasVibrator()) {
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}")
}
}
companion object {
const val CHANNEL_ID = "baresip-wear"
const val NOTIFICATION_ID = 1
// Application context, captured in onCreate, for toasts from non-UI code.
lateinit var appContext: Context
private set
fun registerDefaultAccountIfNeeded(context: Context) {
if (WearBaresipServiceHelper.defaultUa() != 0L) return
// Pass the FULL account line (<sip:...>;auth_pass=...;outbound=...),
// NOT just the AOR. ua_alloc needs the whole line to build an
// account that can complete the digest-challenge REGISTER; a bare
// "<sip:user@host>" AOR has no credentials/proxy and hangs at
// "Registering" (and defaults to UDP:5060, which the server does
// not answer on).
val accountLine = readAccountLine(context) ?: return
try {
val uap = Api.ua_alloc(accountLine)
if (uap == 0L) {
CallState.registration.value = "UA alloc failed"
return
}
WearBaresipServiceHelper.setDefaultUa(uap)
val rc = Api.ua_register(uap)
CallState.registration.value = "Registering rc=$rc"
} catch (e: Exception) {
CallState.registration.value = "Register failed: ${e.message}"
}
}
// Re-register after the user edits/saves the account on the Accounts
// screen. Tears down any previously-allocated UA first so the new
// AOR / credentials take effect, then allocates and registers the
// saved account. CRITICAL: pass the FULL account line (with
// auth_pass + outbound/transport), never just the AOR -- ua_alloc
// needs the complete line or the REGISTER cannot complete its digest
// challenge and the status hangs at "Registering".
fun registerSavedAccount(context: Context, accountLine: String) {
try {
val existing = WearBaresipServiceHelper.defaultUa()
if (existing != 0L) {
Api.ua_destroy(existing)
WearBaresipServiceHelper.clearDefaultUa()
}
val uap = Api.ua_alloc(accountLine.trim())
if (uap == 0L) {
CallState.registration.value = "UA alloc failed"
return
}
WearBaresipServiceHelper.setDefaultUa(uap)
val rc = Api.ua_register(uap)
CallState.registration.value = "Registering rc=$rc"
} catch (e: Exception) {
CallState.registration.value = "Register failed: ${e.message}"
}
}
// Reads the first non-comment, non-blank line of the accounts file --
// the full baresip account line, including <sip:...>;auth_pass=...;
// outbound=.... This is what ua_alloc needs to build a usable UA.
private fun readAccountLine(context: Context): String? {
return try {
val file = java.io.File(context.filesDir, "accounts")
if (!file.exists()) return null
file.readText().lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
} catch (e: Exception) {
null
}
}
}
private lateinit var nativeThread: Thread
private val networkReceiver = object : BroadcastReceiver() {
private val handler = Handler(Looper.getMainLooper())
private var pending = false
override fun onReceive(context: Context, intent: Intent) {
if (pending) return
pending = true
handler.postDelayed({
pending = false
refreshNativeNetwork()
}, 800)
}
}
init {
System.loadLibrary("wearbaresip")
}
private fun networkFilter(): IntentFilter {
return IntentFilter().apply {
addAction("android.net.conn.CONNECTIVITY_CHANGE")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
addAction("android.net.conn.ACTION_RESTRICT_BACKGROUND_CHANGED")
}
addAction("android.net.wifi.STATE_CHANGE")
}
}
private fun refreshNativeNetwork() {
val addrs = localAddresses()
val dns = collectDnsServers()
try {
refreshNetwork(addrs, dns)
Log.d("Baresip Wear", "refreshNetwork addrs=$addrs dns=$dns")
} catch (e: Exception) {
Log.w("Baresip Wear", "refreshNetwork failed: ${e.message}")
}
}
}

View File

@ -11,24 +11,52 @@ object WearBaresipServiceHelper {
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
}
val target = if (uri.startsWith("sip:")) uri else "sip:$uri"
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) {
if (call.uap != 0L) Api.ua_answer(call.uap, call.callp, Api.VIDMODE_OFF)
else if (defaultUap != 0L) Api.ua_answer(defaultUap, call.callp, Api.VIDMODE_OFF)
@ -40,4 +68,15 @@ object WearBaresipServiceHelper {
CallState.remove(call.callp)
if (CallState.calls.isEmpty()) CallState.status.value = "Idle"
}
// Send a DTMF digit (RFC 2833 / in-band) on an active call. Used by the
// in-call keypad for voicemail menus, IVRs, etc.
fun sendDigit(call: WearCall, digit: Char) {
try {
Api.call_send_digit(call.callp, digit)
android.util.Log.d("Baresip Wear", "sendDigit '$digit' on ${call.callp}")
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "sendDigit failed: ${e.message}")
}
}
}

View File

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

View File

@ -12,7 +12,7 @@ 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.cfg file that
// decrypts the provisioning bundle and writes an 'accounts' file that
// baresip loads on (re)start.
object WearProvisioning {
@ -103,14 +103,20 @@ object WearProvisioning {
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
fun writeAccount(ctx: Context, bundle: Bundle): String {
val filesDir = ctx.filesDir
val aor = if (bundle.connectString.startsWith("sip:")) bundle.connectString else "sip:${bundle.connectString}"
val aor = bundle.connectString.removePrefix("sip:")
val accountLine = buildString {
append("<$aor>")
if (!bundle.outbound1.isNullOrBlank()) append(";outbound=\"${bundle.outbound1}\"")
if (!bundle.outbound2.isNullOrBlank()) append(";outbound2=\"${bundle.outbound2}\"")
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")
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()) {
@ -124,17 +130,19 @@ object WearProvisioning {
// 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("<$aor>")
if (outbound.isNotBlank()) append(";outbound=\"$outbound\"")
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")
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("$aor $password\n", Charsets.UTF_8)
File(filesDir, "auth").writeText("$cleanAor $password\n", Charsets.UTF_8)
}
Log.i("Baresip Wear", "Manual account saved: $aor")
Log.i("Baresip Wear", "Manual account saved: $cleanAor")
}
private fun rsaDecrypt(encrypted: ByteArray): ByteArray {