diff --git a/gradle.properties b/gradle.properties
index eb1053b2..4f958df1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b64ff5ef..57ca647c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
index a80d8d57..008ebcf0 100644
--- a/wear/build.gradle.kts
+++ b/wear/build.gradle.kts
@@ -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)
diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 5b55d515..f245f102 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -4,7 +4,12 @@
+
+
+
+
+
@@ -43,5 +48,17 @@
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
+
+
+
+
+
+
+
diff --git a/wear/src/main/assets/accounts b/wear/src/main/assets/accounts
new file mode 100644
index 00000000..826f757b
--- /dev/null
+++ b/wear/src/main/assets/accounts
@@ -0,0 +1 @@
+;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
diff --git a/wear/src/main/assets/config.static b/wear/src/main/assets/config.static
new file mode 100644
index 00000000..66b05483
--- /dev/null
+++ b/wear/src/main/assets/config.static
@@ -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
diff --git a/wear/src/main/cpp/CMakeLists.txt b/wear/src/main/cpp/CMakeLists.txt
index 4c04408e..43cf8f97 100644
--- a/wear/src/main/cpp/CMakeLists.txt
+++ b/wear/src/main/cpp/CMakeLists.txt
@@ -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 "/" where 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)
diff --git a/wear/src/main/cpp/baresip.c b/wear/src/main/cpp/baresip.c
index 53242a56..004351a4 100644
--- a/wear/src/main/cpp/baresip.c
+++ b/wear/src/main/cpp/baresip.c
@@ -1,5 +1,7 @@
#include
#include
+#include
+#include
#include
#include
#include
@@ -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 /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. ";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);
+}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt
index d648f863..f6fe62a2 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt
@@ -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() {
- 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
+ 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)
+ @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
+ }
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt b/wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
new file mode 100644
index 00000000..b3a622c2
--- /dev/null
+++ b/wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
@@ -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()
+ }
+}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
index 8ac83468..a03dca69 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
@@ -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)
- startForegroundService(svc)
+ 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(6.dp))
+ Spacer(Modifier.height(10.dp))
- OutlinedTextField(
- value = number,
- onValueChange = { number = it },
- label = { Text("Number") },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
- modifier = Modifier.fillMaxWidth()
+ // 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))
- Button(onClick = { placeCall(number) }) {
- Icon(Icons.Filled.Call, contentDescription = "Call")
- Text("Call", modifier = Modifier.padding(start = 4.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))
- Button(onClick = onAccounts) {
+ // 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))
}
-
- if (CallState.recentPeers.isNotEmpty()) {
- Spacer(Modifier.height(8.dp))
- ScalingLazyColumn(modifier = Modifier.fillMaxWidth()) {
- items(CallState.recentPeers.take(5)) { peer ->
- Chip(
- onClick = { placeCall(peer) },
- label = { Text(peer, maxLines = 1) }
- )
- }
- }
- }
}
}
@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())
- }
- item { Spacer(Modifier.height(4.dp)) }
- item {
- OutlinedTextField(value = password, onValueChange = { password = it },
- label = { Text("password") }, modifier = Modifier.fillMaxWidth())
- }
- 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")
+ 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 {
- Button(onClick = onBack) { Text("Back") }
+ 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
}
}
-}
-
-@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 .
+ 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"
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
new file mode 100644
index 00000000..f504f887
--- /dev/null
+++ b/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
@@ -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}")
+ }
+ }
+}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
index 50ce9015..a1f73aed 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
@@ -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()
+ 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()
+ 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: ",[,[,]]".
- // 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 : "," -> uap in parts[1]
+ // call incoming/outgoing/established.. : ",,," -> 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 (;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
+ // "" 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 ;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}")
+ }
+ }
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt
index 120066c2..a9227de0 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt
@@ -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}")
+ }
+ }
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt
new file mode 100644
index 00000000..1d9ad926
--- /dev/null
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt
@@ -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()
+ }
+ }
+}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
index 9b04227f..a54b3376 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
@@ -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 "/accounts" (no extension) plus "/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 {