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 {