Wear SIP: audio routing, stock-style dialer, DTMF, incoming-call handling, telecom scaffold
- AudioRouteManager: route to onboard speaker when no BT headset is connected, BT SCO when one is; set MODE_IN_COMMUNICATION + focus. - aaudio: build with AAUDIO_PERFORMANCE_MODE_NONE so Android routing (speakerphone) is honoured instead of being bypassed by low-latency. - DialerScreen: restyled to a stock-dialer layout (large number display, 3x4 keypad with sub-letters, green call FAB, backspace, recent chips). - InCallScreen: added DTMF keypad (sendDigit) for voicemail/IVR. - WearBaresipService: on incoming call, wake screen + vibrate + bring activity to foreground so Answer/Decline is visible. - Fixed dial normalization (bare extension -> full SIP URI with domain) and the uap parse in uaEvent so defaultUap is set and calls connect. - config.static: load g711/opus codecs to resolve 488 Not Acceptable Here. - Telecom scaffold (experiment): PhoneAccount + ConnectionService handed incoming calls via addNewIncomingCall to test stock Wear dialer pickup. - Submodule libbaresip-android rebuilt with g711/opus/aaudio changes. Verified: assembleDebug green, installs and registers on Galaxy Watch, incoming call event fires and is handled without crashing.
This commit is contained in:
@ -8,3 +8,4 @@ android.r8.strictFullModeForKeepRules=false
|
||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||
kotlin.code.style=official
|
||||
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
|
||||
android.suppressUnsupportedCompileSdk=37.0
|
||||
|
||||
@ -5,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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -4,7 +4,12 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
@ -43,5 +48,17 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- Telecom ConnectionService (experiment): lets the stock Wear dialer
|
||||
surface our SIP account if it honors third-party PhoneAccounts. -->
|
||||
<service
|
||||
android:name=".WearConnectionService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.ConnectionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
1
wear/src/main/assets/accounts
Normal file
1
wear/src/main/assets/accounts
Normal file
@ -0,0 +1 @@
|
||||
<sip:01273805515@mail.txt3.net>;auth_user="01273805515";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5060;transport=tcp";regint=600;regq=0.5;pubint=0;check_origin=no;mwi=no
|
||||
42
wear/src/main/assets/config.static
Normal file
42
wear/src/main/assets/config.static
Normal file
@ -0,0 +1,42 @@
|
||||
poll_method epoll
|
||||
call_local_timeout 60
|
||||
call_max_calls 4
|
||||
call_hold_other_calls yes
|
||||
filter_registrar udp,tcp,tls,ws,wss
|
||||
audio_player aaudio,nil
|
||||
audio_source aaudio,nil
|
||||
audio_alert aaudio,nil
|
||||
audio_level no
|
||||
ausrc_format s16
|
||||
auplay_format s16
|
||||
auenc_format s16
|
||||
audec_format s16
|
||||
audio_buffer 20-160
|
||||
audio_silence -35.0
|
||||
audio_telev_pt 101
|
||||
audio_jitter_buffer_type adaptive
|
||||
audio_jitter_buffer_ms 100-200
|
||||
audio_jitter_buffer_size 50
|
||||
rtp_stats no
|
||||
rtp_timeout 60
|
||||
rtp_rxmode thread
|
||||
module aaudio.so
|
||||
module g711.so
|
||||
module opus.so
|
||||
module stun.so
|
||||
module turn.so
|
||||
module ice.so
|
||||
module srtp.so
|
||||
module dtls_srtp.so
|
||||
module gzrtp.so
|
||||
module uuid.so
|
||||
module_app account.so
|
||||
module_app debug_cmd.so
|
||||
module_app mwi.so
|
||||
opus_samplerate 16000
|
||||
opus_stereo no
|
||||
opus_sprop_stereo no
|
||||
opus_cbr no
|
||||
opus_inbandfec yes
|
||||
opus_application voip
|
||||
dtls_srtp_use_ec prime256v1
|
||||
@ -68,6 +68,14 @@ set_target_properties(lib_baresip PROPERTIES IMPORTED_LOCATION
|
||||
|
||||
add_library(wearbaresip SHARED ${CMAKE_SOURCE_DIR}/baresip.c)
|
||||
|
||||
# Export all symbols from libwearbaresip.so. baresip/re are linked in as
|
||||
# static archives with hidden visibility, so by default NONE of their
|
||||
# symbols reach the dynamic symbol table. Loadable application modules
|
||||
# (account.so) dlopen() against this lib and need ua_alloc/conf_parse/
|
||||
# uag_list etc. -- without --export-dynamic those are unresolved and the
|
||||
# module silently fails to load, so no SIP account is ever populated.
|
||||
target_link_options(wearbaresip PRIVATE -Wl,--export-dynamic)
|
||||
|
||||
target_include_directories(wearbaresip PRIVATE
|
||||
${distribution_DIR}/openssl/include
|
||||
${distribution_DIR}/re/include
|
||||
@ -96,3 +104,34 @@ target_link_libraries(
|
||||
lib_sndfile
|
||||
z
|
||||
log)
|
||||
|
||||
# Build the 'account' application module (.so) from the baresip submodule
|
||||
# source. CRITICAL: it must reference the SAME baresip/re instance that is
|
||||
# already loaded inside libwearbaresip.so -- NOT link libbaresip.a/libre.a
|
||||
# statically (that would create a second, independent copy of baresip's
|
||||
# static state -- separate uag_list/mod registry/global ctors -- and the
|
||||
# module silently fails to dlopen, so no account is ever populated). Linking
|
||||
# the MODULE against the SHARED wearbaresip lib makes it resolve every
|
||||
# baresip/re symbol from the one live instance.
|
||||
set(account_SRC
|
||||
${CMAKE_SOURCE_DIR}/../../../../libbaresip-android/baresip/modules/account/account.c)
|
||||
|
||||
add_library(account MODULE ${account_SRC})
|
||||
|
||||
# baresip's module loader opens "<module_path>/<name>" where <name> comes
|
||||
# from the config line "module_app account.so". It expects the file to be
|
||||
# named exactly "account.so" (no "lib" prefix). CMake's default MODULE
|
||||
# output is "libaccount.so", so force the bare name.
|
||||
set_target_properties(account PROPERTIES PREFIX "" OUTPUT_NAME account)
|
||||
|
||||
target_include_directories(account PRIVATE
|
||||
${distribution_DIR}/openssl/include
|
||||
${distribution_DIR}/re/include
|
||||
${distribution_DIR}/baresip/include
|
||||
${CMAKE_SOURCE_DIR}/../../../../libbaresip-android/baresip/include)
|
||||
|
||||
add_definitions(-DHAVE_PTHREAD)
|
||||
|
||||
# Reference the shared baresip lib so there is exactly one baresip instance.
|
||||
target_link_libraries(account wearbaresip log)
|
||||
add_dependencies(account wearbaresip)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#include <string.h>
|
||||
#include <pthread.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <jni.h>
|
||||
#include <aaudio/AAudio.h>
|
||||
#include <stdlib.h>
|
||||
@ -23,7 +25,6 @@ enum
|
||||
};
|
||||
|
||||
static pthread_key_t g_thread_key;
|
||||
|
||||
static void detach_thread(void *env)
|
||||
{
|
||||
(void)env;
|
||||
@ -45,9 +46,23 @@ static JNIEnv *get_jni_env(void)
|
||||
return env;
|
||||
}
|
||||
|
||||
static void signal_handler(int sig)
|
||||
{
|
||||
static bool term = false;
|
||||
|
||||
if (term) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
term = true;
|
||||
LOGI("terminated by signal (%d)\n", sig);
|
||||
ua_stop_all(false);
|
||||
}
|
||||
|
||||
static void ua_exit_handler(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
LOGD("ua exited -- stopping main runloop\n");
|
||||
re_cancel();
|
||||
}
|
||||
|
||||
@ -85,14 +100,19 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "create", "");
|
||||
break;
|
||||
case BEVENT_REGISTERING:
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "registering,%ld", (long)ua);
|
||||
break;
|
||||
case BEVENT_UNREGISTERING:
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "unregistering,%ld", (long)ua);
|
||||
break;
|
||||
case BEVENT_REGISTER_OK:
|
||||
case BEVENT_FALLBACK_OK:
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "%s,%ld", prm, (long)ua);
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "registered,%ld", (long)ua);
|
||||
break;
|
||||
case BEVENT_REGISTER_FAIL:
|
||||
case BEVENT_FALLBACK_FAIL:
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "registering failed,%s", prm);
|
||||
LOGD("register_event: fail prm='%s' ua=%ld\n", prm ? prm : "", (long)ua);
|
||||
len = re_snprintf(event_buf, sizeof event_buf, "registering failed,%ld", (long)ua);
|
||||
break;
|
||||
case BEVENT_SIPSESS_CONN:
|
||||
ua = uag_find_msg(msg);
|
||||
@ -343,9 +363,215 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
static int apply_nameserver(JNIEnv *env, jstring javaServers);
|
||||
|
||||
// NOTE: baresip's account.so module (loaded via conf_modules()) already
|
||||
// auto-allocates and registers the <filesDir>/accounts file at startup, so no
|
||||
// explicit startup register is needed. A bare AOR such as "sip:user@host"
|
||||
// passed to ua_alloc is rejected (sip_addr_decode EINVAL); always pass the
|
||||
// FULL account line (e.g. "<sip:user@host>;auth_pass=...") -- which is exactly
|
||||
// what the account module does via conf_parse.
|
||||
|
||||
static jlong jni_ua_alloc(const char *uri)
|
||||
{
|
||||
struct ua *ua = NULL;
|
||||
int err;
|
||||
|
||||
re_thread_enter();
|
||||
err = ua_alloc(&ua, uri);
|
||||
re_thread_leave();
|
||||
return err == 0 ? (jlong)(intptr_t)ua : 0L;
|
||||
}
|
||||
|
||||
static void jni_ua_destroy(jlong uap)
|
||||
{
|
||||
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||
if (!ua) return;
|
||||
|
||||
re_thread_enter();
|
||||
mem_deref(ua);
|
||||
re_thread_leave();
|
||||
}
|
||||
|
||||
static jint jni_ua_register(jlong uap)
|
||||
{
|
||||
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||
int err;
|
||||
|
||||
re_thread_enter();
|
||||
err = ua_register(ua);
|
||||
re_thread_leave();
|
||||
return err;
|
||||
}
|
||||
|
||||
static void jni_ua_hangup(jlong uap, jlong callp, jint code, const char *reason)
|
||||
{
|
||||
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||
struct call *call = (struct call *)(intptr_t)callp;
|
||||
|
||||
re_thread_enter();
|
||||
ua_hangup(ua, call, code, reason);
|
||||
re_thread_leave();
|
||||
}
|
||||
|
||||
static void jni_ua_answer(jlong uap, jlong callp, jint video)
|
||||
{
|
||||
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||
struct call *call = (struct call *)(intptr_t)callp;
|
||||
|
||||
re_thread_enter();
|
||||
ua_answer(ua, call, (enum vidmode)video);
|
||||
re_thread_leave();
|
||||
}
|
||||
|
||||
static jlong jni_call_connect(jlong callp, const char *peer_uri)
|
||||
{
|
||||
struct call *call = (struct call *)(intptr_t)callp;
|
||||
struct pl pl;
|
||||
int err;
|
||||
|
||||
pl_set_str(&pl, peer_uri);
|
||||
re_thread_enter();
|
||||
err = call_connect(call, &pl);
|
||||
re_thread_leave();
|
||||
return err == 0 ? callp : 0L;
|
||||
}
|
||||
|
||||
static jboolean jni_call_hold(jlong callp, jboolean hold)
|
||||
{
|
||||
struct call *call = (struct call *)(intptr_t)callp;
|
||||
bool ok;
|
||||
|
||||
re_thread_enter();
|
||||
ok = call_hold(call, hold);
|
||||
re_thread_leave();
|
||||
return ok ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
static jint jni_call_send_digit(jlong callp, jchar digit)
|
||||
{
|
||||
struct call *call = (struct call *)(intptr_t)callp;
|
||||
int err;
|
||||
|
||||
re_thread_enter();
|
||||
err = call_send_digit(call, digit);
|
||||
re_thread_leave();
|
||||
return err;
|
||||
}
|
||||
|
||||
static jstring jni_account_aor(JNIEnv *env, jlong acc)
|
||||
{
|
||||
struct account *a = (struct account *)(intptr_t)acc;
|
||||
const char *aor;
|
||||
jstring jstr = NULL;
|
||||
|
||||
re_thread_enter();
|
||||
aor = account_aor(a);
|
||||
if (aor) jstr = (*env)->NewStringUTF(env, aor);
|
||||
re_thread_leave();
|
||||
return jstr;
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1alloc(JNIEnv *env, jclass clazz, jstring jUri)
|
||||
{
|
||||
(void)clazz;
|
||||
const char *uri = (*env)->GetStringUTFChars(env, jUri, NULL);
|
||||
jlong uap = jni_ua_alloc(uri ? uri : "");
|
||||
if (uri) (*env)->ReleaseStringUTFChars(env, jUri, uri);
|
||||
return uap;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1destroy(JNIEnv *env, jclass clazz, jlong uap)
|
||||
{
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
jni_ua_destroy(uap);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1register(JNIEnv *env, jclass clazz, jlong uap)
|
||||
{
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
return jni_ua_register(uap);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1hangup(JNIEnv *env, jclass clazz,
|
||||
jlong uap, jlong callp, jint code, jstring jReason)
|
||||
{
|
||||
const char *reason = NULL;
|
||||
if (jReason) reason = (*env)->GetStringUTFChars(env, jReason, NULL);
|
||||
jni_ua_hangup(uap, callp, code, reason ? reason : "");
|
||||
if (reason) (*env)->ReleaseStringUTFChars(env, jReason, reason);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1answer(JNIEnv *env, jclass clazz,
|
||||
jlong uap, jlong callp, jint video)
|
||||
{
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
jni_ua_answer(uap, callp, video);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_call_1connect(JNIEnv *env, jclass clazz,
|
||||
jlong callp, jstring jPeer)
|
||||
{
|
||||
const char *peer = (*env)->GetStringUTFChars(env, jPeer, NULL);
|
||||
jlong rc = jni_call_connect(callp, peer ? peer : "");
|
||||
if (peer) (*env)->ReleaseStringUTFChars(env, jPeer, peer);
|
||||
return rc;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_call_1hold(JNIEnv *env, jclass clazz,
|
||||
jlong callp, jboolean hold)
|
||||
{
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
return jni_call_hold(callp, hold);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_call_1send_1digit(JNIEnv *env, jclass clazz,
|
||||
jlong callp, jchar digit)
|
||||
{
|
||||
(void)env;
|
||||
(void)clazz;
|
||||
return jni_call_send_digit(callp, digit);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_account_1aor(JNIEnv *env, jclass clazz, jlong acc)
|
||||
{
|
||||
(void)clazz;
|
||||
return jni_account_aor(env, acc);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz,
|
||||
jlong uap, jlong xcallp, jint video)
|
||||
{
|
||||
struct ua *ua = (struct ua *)(intptr_t)uap;
|
||||
struct call *call = NULL;
|
||||
int err;
|
||||
|
||||
re_thread_enter();
|
||||
err = ua_call_alloc(&call, ua, (enum vidmode)video, NULL,
|
||||
(struct call *)(intptr_t)xcallp,
|
||||
call_localuri((struct call *)(intptr_t)xcallp),
|
||||
true);
|
||||
re_thread_leave();
|
||||
return err == 0 ? (jlong)(intptr_t)call : 0L;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
||||
JNIEnv *env, jobject instance, jstring jPath, jstring jAddrs, jint jLogLevel, jstring jSoftware)
|
||||
JNIEnv *env, jobject instance, jstring jPath, jstring jAddrs, jstring jDns, jint jLogLevel, jstring jSoftware, jstring jNativeLibDir)
|
||||
{
|
||||
int err;
|
||||
|
||||
@ -356,9 +582,35 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
||||
const char *path = (*env)->GetStringUTFChars(env, jPath, 0);
|
||||
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
|
||||
const char *software = (*env)->GetStringUTFChars(env, jSoftware, 0);
|
||||
const char *nativelib = jNativeLibDir ? (*env)->GetStringUTFChars(env, jNativeLibDir, 0) : NULL;
|
||||
|
||||
runLoggingThread();
|
||||
|
||||
// baresip does NOT read module_path from the loaded config file; it
|
||||
// defaults to /usr/local (absent on Android), so point the CWD at the
|
||||
// extracted native lib dir so module loading resolves .so modules.
|
||||
if (nativelib) {
|
||||
if (chdir(nativelib) != 0) {
|
||||
LOGE("chdir to nativeLibDir '%s' failed: %s\n",
|
||||
nativelib, strerror(errno));
|
||||
}
|
||||
(*env)->ReleaseStringUTFChars(env, jNativeLibDir, nativelib);
|
||||
}
|
||||
|
||||
// Promote libwearbaresip.so to RTLD_GLOBAL so loadable application
|
||||
// modules (e.g. account.so) can resolve baresip/re symbols. Android's
|
||||
// System.loadLibrary() uses RTLD_LOCAL, hiding these symbols from a
|
||||
// later dlopen() of a module .so.
|
||||
{
|
||||
void *h = dlopen("libwearbaresip.so", RTLD_GLOBAL | RTLD_NOLOAD);
|
||||
if (!h) {
|
||||
LOGE("promote libwearbaresip.so to RTLD_GLOBAL failed: %s\n",
|
||||
dlerror() ? dlerror() : "unknown");
|
||||
} else {
|
||||
dlclose(h);
|
||||
}
|
||||
}
|
||||
|
||||
err = libre_init();
|
||||
if (err) {
|
||||
goto out;
|
||||
@ -389,25 +641,25 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (jDns) {
|
||||
err = apply_nameserver(env, jDns);
|
||||
if (err) {
|
||||
LOGW("apply_nameserver failed: %d\n", err);
|
||||
}
|
||||
}
|
||||
|
||||
dnsc_cache_max(net_dnsc(baresip_network()), 0);
|
||||
|
||||
if (strlen(addrs) > 0) {
|
||||
char *addr_list = (char *)malloc(strlen(addrs) + 1);
|
||||
struct sa temp_sa;
|
||||
char buf[256];
|
||||
net_flush_addresses(baresip_network());
|
||||
strcpy(addr_list, addrs);
|
||||
char *ptr = strtok(addr_list, ";");
|
||||
while (ptr != NULL) {
|
||||
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
|
||||
sa_ntop(&temp_sa, buf, 256);
|
||||
ptr = strtok(NULL, ";");
|
||||
net_add_address_ifname(baresip_network(), &temp_sa, ptr);
|
||||
} else {
|
||||
ptr = strtok(NULL, ";");
|
||||
net_add_address(baresip_network(), &temp_sa);
|
||||
}
|
||||
if (ptr)
|
||||
*(ptr - 1) = ';';
|
||||
ptr = strtok(NULL, ";");
|
||||
}
|
||||
free(addr_list);
|
||||
@ -432,6 +684,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
||||
|
||||
err = conf_modules();
|
||||
if (err) {
|
||||
LOGE("conf_modules failed: (%d)\n", err);
|
||||
goto out;
|
||||
}
|
||||
|
||||
@ -440,7 +693,36 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
||||
goto out;
|
||||
}
|
||||
|
||||
LOGI("running main loop ...\n");
|
||||
err = re_main(signal_handler);
|
||||
|
||||
out:
|
||||
if (err) {
|
||||
LOGE("stopping UAs due to error: (%d)\n", err);
|
||||
ua_stop_all(true);
|
||||
} else {
|
||||
LOGI("main loop exit\n");
|
||||
}
|
||||
|
||||
mq = mem_deref(mq);
|
||||
|
||||
LOGD("closing ...");
|
||||
ua_close();
|
||||
module_app_unload();
|
||||
conf_close();
|
||||
baresip_close();
|
||||
|
||||
bevent_unregister(event_handler);
|
||||
|
||||
LOGD("unloading modules ...");
|
||||
mod_close();
|
||||
|
||||
LOGD("closing re thread\n");
|
||||
re_thread_async_close();
|
||||
|
||||
LOGD("closing libre\n");
|
||||
libre_close();
|
||||
|
||||
(*env)->ReleaseStringUTFChars(env, jPath, path);
|
||||
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
|
||||
(*env)->ReleaseStringUTFChars(env, jSoftware, software);
|
||||
@ -453,6 +735,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
|
||||
(void)instance;
|
||||
|
||||
ua_stop_all(true);
|
||||
re_cancel();
|
||||
baresip_close();
|
||||
libre_close();
|
||||
|
||||
@ -465,3 +748,98 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
|
||||
g_ctx.serviceObj = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh network context for the running stack. Re-applies DNS and local
|
||||
// source-address list so rehandshakes use the current interface.
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_tutpro_baresip_wear_WearBaresipService_refreshNetwork(
|
||||
JNIEnv *env, jobject instance, jstring jAddrs, jstring jDns)
|
||||
{
|
||||
(void)instance;
|
||||
|
||||
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
|
||||
const char *dns = (*env)->GetStringUTFChars(env, jDns, 0);
|
||||
|
||||
if (dns) {
|
||||
int err = apply_nameserver(env, jDns);
|
||||
if (err) {
|
||||
LOGW("refreshNetwork apply_nameserver failed: %d\n", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen(addrs) > 0) {
|
||||
char *addr_list = (char *)malloc(strlen(addrs) + 1);
|
||||
struct sa temp_sa;
|
||||
net_flush_addresses(baresip_network());
|
||||
strcpy(addr_list, addrs);
|
||||
char *ptr = strtok(addr_list, ";");
|
||||
while (ptr != NULL) {
|
||||
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
|
||||
net_add_address(baresip_network(), &temp_sa);
|
||||
}
|
||||
ptr = strtok(NULL, ";");
|
||||
}
|
||||
free(addr_list);
|
||||
}
|
||||
|
||||
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
|
||||
(*env)->ReleaseStringUTFChars(env, jDns, dns);
|
||||
}
|
||||
|
||||
// Apply DNS servers (comma-separated "ip:53") to baresip's resolver. Shared
|
||||
// by the JNI Api.net_use_nameserver and the startup path below.
|
||||
static int apply_nameserver(JNIEnv *env, jstring javaServers)
|
||||
{
|
||||
if (!javaServers) return 0;
|
||||
const char *native_servers = (*env)->GetStringUTFChars(env, javaServers, 0);
|
||||
char servers[256];
|
||||
char *server;
|
||||
struct sa nsv[NET_MAX_NS];
|
||||
uint32_t count = 0;
|
||||
char *comma;
|
||||
int res;
|
||||
int err;
|
||||
|
||||
LOGD("Setting DNS servers '%s'\n", native_servers);
|
||||
if (strlen(native_servers) > 255) {
|
||||
LOGW("net_use_nameserver: too long (%s)\n", native_servers);
|
||||
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
|
||||
return 1;
|
||||
}
|
||||
str_ncpy(servers, native_servers, 256);
|
||||
(*env)->ReleaseStringUTFChars(env, javaServers, native_servers);
|
||||
|
||||
server = &(servers[0]);
|
||||
while ((count < NET_MAX_NS) && ((comma = strchr(server, ',')) != NULL)) {
|
||||
*comma = '\0';
|
||||
err = sa_decode(&(nsv[count]), server, strlen(server));
|
||||
if (err) {
|
||||
LOGW("net_use_nameserver: bad '%s' (%u)\n", server, err);
|
||||
return err;
|
||||
}
|
||||
server = ++comma;
|
||||
count++;
|
||||
}
|
||||
if ((count < NET_MAX_NS) && (strlen(server) > 0)) {
|
||||
err = sa_decode(&(nsv[count]), server, strlen(server));
|
||||
if (err) {
|
||||
LOGW("net_use_nameserver: bad `%s' (%u)\n", server, err);
|
||||
return err;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
res = net_use_nameserver(baresip_network(), nsv, count);
|
||||
return res;
|
||||
}
|
||||
|
||||
// Mirror of phone app's Api.net_use_nameserver. Sets the DNS servers used by
|
||||
// baresip's internal resolver (required on Wear where no dns_server is
|
||||
// configured in config and dns_getaddrinfo defaults to off).
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_tutpro_baresip_wear_Api_net_1use_1nameserver(
|
||||
JNIEnv *env, jobject obj, jstring javaServers)
|
||||
{
|
||||
(void)obj;
|
||||
return apply_nameserver(env, javaServers);
|
||||
}
|
||||
|
||||
@ -2,22 +2,20 @@ package com.tutpro.baresip.wear
|
||||
|
||||
// Minimal native bindings matching the symbols exported by wearbaresip JNI lib.
|
||||
// Only the subset needed by the Wear UI is declared here.
|
||||
object Api {
|
||||
class Api private constructor() {
|
||||
|
||||
companion object {
|
||||
const val VIDMODE_OFF = 0
|
||||
|
||||
external fun ua_alloc(uri: String): Long
|
||||
external fun ua_destroy(uap: Long)
|
||||
external fun ua_register(uap: Long): Int
|
||||
external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
|
||||
external fun ua_answer(uap: Long, callp: Long, video: Int)
|
||||
external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
|
||||
|
||||
external fun call_connect(callp: Long, peerUri: String): Int
|
||||
external fun call_hold(callp: Long, hold: Boolean): Boolean
|
||||
external fun call_send_digit(callp: Long, digit: Char): Int
|
||||
external fun call_destroy(callp: Long)
|
||||
external fun calls_mute(mute: Boolean)
|
||||
|
||||
external fun account_aor(acc: Long): String
|
||||
@JvmStatic external fun ua_alloc(uri: String): Long
|
||||
@JvmStatic external fun ua_destroy(uap: Long)
|
||||
@JvmStatic external fun ua_register(uap: Long): Int
|
||||
@JvmStatic external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
|
||||
@JvmStatic external fun ua_answer(uap: Long, callp: Long, video: Int)
|
||||
@JvmStatic external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
|
||||
@JvmStatic external fun call_connect(callp: Long, peerUri: String): Int
|
||||
@JvmStatic external fun call_hold(callp: Long, hold: Boolean): Boolean
|
||||
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
|
||||
@JvmStatic external fun account_aor(acc: Long): String
|
||||
@JvmStatic external fun net_use_nameserver(servers: String): Int
|
||||
}
|
||||
}
|
||||
|
||||
159
wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
Normal file
159
wear/src/main/java/com/tutpro/baresip/wear/AudioRouteManager.kt
Normal file
@ -0,0 +1,159 @@
|
||||
package com.tutpro.baresip.wear
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Manages call audio routing for the watch.
|
||||
*
|
||||
* Baresip's aaudio module opens AAudio streams with USAGE_VOICE_COMMUNICATION.
|
||||
* On a watch there is no earpiece, so the default in-call route is useless
|
||||
* (often silent). We therefore drive routing explicitly through AudioManager:
|
||||
*
|
||||
* - If a Bluetooth headset (HFP) is CONNECTED -> route over BT SCO.
|
||||
* - Otherwise -> route to the onboard speaker.
|
||||
*
|
||||
* The aaudio module is built with AAUDIO_PERFORMANCE_MODE_NONE so that the
|
||||
* stream actually honours AudioManager routing (LOW_LATENCY would bypass it).
|
||||
*
|
||||
* NOTE on BT detection: we deliberately do NOT use
|
||||
* AudioManager.isBluetoothScoAvailableOffCall() -- that returns true whenever
|
||||
* Bluetooth is merely *enabled* on the watch, not only when a headset is
|
||||
* actually connected, which wrongly routed every call to BT SCO. Instead we
|
||||
* query BluetoothAdapter.getProfileConnectionState(HEADSET); it is guarded so
|
||||
* it never throws (no proxy callback, so no BLUETOOTH_CONNECT crash) and
|
||||
* falls back to the onboard speaker when BT is unavailable or denied.
|
||||
*/
|
||||
object AudioRouteManager {
|
||||
|
||||
private const val TAG = "Baresip Wear Audio"
|
||||
|
||||
private var am: AudioManager? = null
|
||||
private var bluetoothAdapter: BluetoothAdapter? = null
|
||||
private var active = false
|
||||
|
||||
fun init(context: Context) {
|
||||
try {
|
||||
am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "init failed (audio routing disabled): ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when a call becomes active (outgoing established / incoming answered). */
|
||||
fun startCallAudio() {
|
||||
val audioManager = am ?: return
|
||||
active = true
|
||||
|
||||
// Put the device into in-call audio mode so routing policies apply.
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
|
||||
// Grab audio focus so other streams (music, notifications) duck.
|
||||
requestFocus(audioManager)
|
||||
|
||||
applyRoute()
|
||||
Log.d(TAG, "startCallAudio mode=${audioManager.mode} btHeadset=${hasConnectedHeadset()}")
|
||||
}
|
||||
|
||||
/** Called when the call ends; restores normal audio. */
|
||||
fun stopCallAudio() {
|
||||
val audioManager = am ?: return
|
||||
active = false
|
||||
|
||||
if (audioManager.isBluetoothScoOn) {
|
||||
audioManager.stopBluetoothSco()
|
||||
audioManager.isBluetoothScoOn = false
|
||||
}
|
||||
audioManager.isSpeakerphoneOn = false
|
||||
audioManager.mode = AudioManager.MODE_NORMAL
|
||||
abandonFocus(audioManager)
|
||||
Log.d(TAG, "stopCallAudio restored normal mode")
|
||||
}
|
||||
|
||||
private fun applyRoute() {
|
||||
val audioManager = am ?: return
|
||||
if (hasConnectedHeadset()) {
|
||||
// A real HFP headset is connected: route to it.
|
||||
audioManager.isSpeakerphoneOn = false
|
||||
if (!audioManager.isBluetoothScoOn) {
|
||||
try {
|
||||
audioManager.startBluetoothSco()
|
||||
audioManager.isBluetoothScoOn = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "startBluetoothSco failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "route -> Bluetooth SCO")
|
||||
} else {
|
||||
// No connected headset: force the onboard speaker.
|
||||
if (audioManager.isBluetoothScoOn) {
|
||||
audioManager.stopBluetoothSco()
|
||||
audioManager.isBluetoothScoOn = false
|
||||
}
|
||||
audioManager.isSpeakerphoneOn = true
|
||||
Log.d(TAG, "route -> onboard speaker")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only when an HFP headset is actually CONNECTED. Uses
|
||||
* getProfileConnectionState (no proxy callback, so it cannot trigger the
|
||||
* BLUETOOTH_CONNECT SecurityException crash). Returns false on any
|
||||
* failure so we fall back to the onboard speaker. Never throws.
|
||||
*/
|
||||
private fun hasConnectedHeadset(): Boolean {
|
||||
return try {
|
||||
val adapter = bluetoothAdapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
adapter.getProfileConnectionState(BluetoothProfile.HEADSET) ==
|
||||
BluetoothProfile.STATE_CONNECTED
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hasConnectedHeadset failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFocus(audioManager: AudioManager) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = android.media.AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
|
||||
).build()
|
||||
audioManager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
null, AudioManager.STREAM_VOICE_CALL,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "requestAudioFocus failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonFocus(audioManager: AudioManager) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = android.media.AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
|
||||
).build()
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "abandonAudioFocus failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
stopCallAudio()
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,12 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Backspace
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallEnd
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
@ -22,16 +26,21 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.material.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@ -86,9 +95,12 @@ class MainActivity : ComponentActivity() {
|
||||
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
|
||||
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
|
||||
CallState.registration.value = "Provisioned: $aor"
|
||||
// Restart the native stack so it picks up accounts.cfg
|
||||
val svc = Intent(applicationContext, WearBaresipService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(svc)
|
||||
} else {
|
||||
startService(svc)
|
||||
}
|
||||
CallState.status.value = "Restarting SIP"
|
||||
} catch (e: Exception) {
|
||||
CallState.status.value = "Provisioning failed"
|
||||
@ -108,11 +120,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
fun WearApp(activity: MainActivity) {
|
||||
// Show the in-call screen whenever there is an active or incoming call.
|
||||
val hasCall by remember { derivedStateOf { CallState.calls.isNotEmpty() } }
|
||||
MaterialTheme {
|
||||
Scaffold(timeText = { TimeText() }) {
|
||||
when (activity.route.value) {
|
||||
"dialer" -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
||||
"accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
||||
when {
|
||||
hasCall -> InCallScreen(onEnd = { hangupCall(CallState.active() ?: CallState.incoming()) })
|
||||
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
||||
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
||||
}
|
||||
}
|
||||
@ -123,113 +137,321 @@ fun WearApp(activity: MainActivity) {
|
||||
fun DialerScreen(onAccounts: () -> Unit) {
|
||||
var number by remember { mutableStateOf("") }
|
||||
|
||||
// Stock-dialer keypad layout: digit + the small sub-letters shown on real
|
||||
// phones (1 has none, 2=ABC, ...). Tapping a key appends the digit.
|
||||
val keypad = listOf(
|
||||
"1" to "", "2" to "ABC", "3" to "DEF",
|
||||
"4" to "GHI", "5" to "JKL", "6" to "MNO",
|
||||
"7" to "PQRS", "8" to "TUV", "9" to "WXYZ",
|
||||
"*" to "", "0" to "+", "#" to ""
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
.background(androidx.compose.ui.graphics.Color.Black)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Registration status (small, top).
|
||||
Text(
|
||||
text = CallState.registration.value.ifEmpty { "Baresip Wear" },
|
||||
style = MaterialTheme.typography.title3,
|
||||
textAlign = TextAlign.Center
|
||||
style = MaterialTheme.typography.body2,
|
||||
textAlign = TextAlign.Center,
|
||||
color = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Large centered number display, like the stock dialer.
|
||||
Text(
|
||||
text = number.ifEmpty { " " },
|
||||
style = MaterialTheme.typography.title1,
|
||||
textAlign = TextAlign.Center,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// 3-column keypad grid.
|
||||
keypad.chunked(3).forEach { row ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
row.forEach { (digit, sub) ->
|
||||
Button(
|
||||
onClick = { number += digit },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 46.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(digit, style = MaterialTheme.typography.title3)
|
||||
if (sub.isNotEmpty()) {
|
||||
Text(sub, style = MaterialTheme.typography.body2,
|
||||
fontSize = 8.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = number,
|
||||
onValueChange = { number = it },
|
||||
label = { Text("Number") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(onClick = { placeCall(number) }) {
|
||||
// Backspace + Call row, like the stock dialer footer.
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Backspace (only enabled when there is something to delete).
|
||||
Button(
|
||||
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
|
||||
enabled = number.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Backspace, contentDescription = "Delete")
|
||||
}
|
||||
// Green call button.
|
||||
Button(
|
||||
onClick = {
|
||||
android.util.Log.d("Baresip Wear", "Call button clicked, number='$number'")
|
||||
placeCall(number)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = androidx.compose.ui.graphics.Color(0xFF007A3D)
|
||||
),
|
||||
modifier = Modifier.size(56.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = "Call")
|
||||
Text("Call", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
Button(onClick = onAccounts) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
|
||||
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
|
||||
if (CallState.recentPeers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ScalingLazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
items(CallState.recentPeers.take(5)) { peer ->
|
||||
|
||||
// Recent calls as chips; tap to redial.
|
||||
if (CallState.recentPeers.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Recent",
|
||||
color = androidx.compose.ui.graphics.Color.Gray,
|
||||
style = MaterialTheme.typography.body2
|
||||
)
|
||||
CallState.recentPeers.take(4).forEach { peer ->
|
||||
Chip(
|
||||
onClick = { placeCall(peer) },
|
||||
label = { Text(peer, maxLines = 1) }
|
||||
label = { Text(peer, maxLines = 1) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
// Settings / Accounts.
|
||||
Button(
|
||||
onClick = onAccounts,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
|
||||
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountsScreen(onBack: () -> Unit) {
|
||||
// Debug-only: manual account config. To be removed before release.
|
||||
val registration = CallState.registration.value
|
||||
val status = CallState.status.value
|
||||
val recent = CallState.recentPeers.take(5)
|
||||
val context = LocalContext.current
|
||||
|
||||
var aor by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var outbound by remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
var regInt by remember { mutableStateOf("") }
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item { Text("Accounts (debug)", style = MaterialTheme.typography.title3) }
|
||||
item { Spacer(Modifier.height(6.dp)) }
|
||||
item {
|
||||
OutlinedTextField(value = aor, onValueChange = { aor = it },
|
||||
label = { Text("sip:aor") }, modifier = Modifier.fillMaxWidth())
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
val filesDir = context.filesDir
|
||||
val accounts = File(filesDir, "accounts")
|
||||
val auth = File(filesDir, "auth")
|
||||
if (accounts.exists()) {
|
||||
val text = accounts.readText().trim()
|
||||
val first = text.lineSequence().firstOrNull() ?: ""
|
||||
val aorMatch = Regex("^<([^>]+)>").find(first)
|
||||
aor = aorMatch?.groupValues?.getOrNull(1) ?: ""
|
||||
val outMatch = Regex("outbound\\s*=\\s*\"([^\"]*)\"").find(first)
|
||||
outbound = outMatch?.groupValues?.getOrNull(1) ?: ""
|
||||
val regMatch = Regex("regint\\s*=\\s*(\\d+)").find(first)
|
||||
regInt = regMatch?.groupValues?.getOrNull(1) ?: ""
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
item {
|
||||
OutlinedTextField(value = password, onValueChange = { password = it },
|
||||
label = { Text("password") }, modifier = Modifier.fillMaxWidth())
|
||||
if (auth.exists()) {
|
||||
val authLine = auth.readText().trim().lineSequence().firstOrNull() ?: ""
|
||||
val parts = authLine.split(" ", limit = 2)
|
||||
if (parts.size == 2) password = parts[1]
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
item {
|
||||
OutlinedTextField(value = outbound, onValueChange = { outbound = it },
|
||||
label = { Text("outbound (opt)") }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
item {
|
||||
Button(onClick = {
|
||||
val aorStr = if (aor.startsWith("sip:")) aor else "sip:$aor"
|
||||
WearProvisioning.writeManualAccount(context, aorStr, password, outbound)
|
||||
CallState.registration.value = "Saved: $aorStr"
|
||||
onBack()
|
||||
}) {
|
||||
Text("Save")
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
item {
|
||||
Button(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InCallScreen() {
|
||||
val call = CallState.incoming() ?: CallState.active()
|
||||
val status = call?.status ?: CallState.status.value
|
||||
val peer = call?.peerUri ?: ""
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.Black)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "Account", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.title3)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
if (!loaded) {
|
||||
Text(text = "Loading...", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = aor,
|
||||
onValueChange = { aor = it },
|
||||
label = { Text("AOR", color = androidx.compose.ui.graphics.Color.White) },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password", color = androidx.compose.ui.graphics.Color.White) },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = outbound,
|
||||
onValueChange = { outbound = it },
|
||||
label = { Text("Outbound proxy", color = androidx.compose.ui.graphics.Color.White) },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = regInt,
|
||||
onValueChange = { regInt = it },
|
||||
label = { Text("Reg interval seconds", color = androidx.compose.ui.graphics.Color.White) },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
try {
|
||||
val filesDir = context.filesDir
|
||||
// Normalize the AOR: ensure a "sip:" scheme. account_alloc()
|
||||
// rejects a bare "user@host" (returns ENOENT), so we strip any
|
||||
// existing scheme/brackets and re-wrap as <sip:...>.
|
||||
val bare = aor.removePrefix("<").removeSuffix(">")
|
||||
.replaceFirst("(?i)^sip:".toRegex(), "")
|
||||
val sipAor = "sip:$bare"
|
||||
val userPart = bare.substringBefore("@")
|
||||
// Default to the known-working outbound proxy/transport for
|
||||
// this provider: TCP on :5060. The user may override, but an
|
||||
// empty field falls back to TCP on :5060.
|
||||
val out = if (outbound.isNotBlank()) outbound
|
||||
else "sip:mail.txt3.net:5060;transport=tcp"
|
||||
val accountLine = buildString {
|
||||
append("<$sipAor>")
|
||||
append(";auth_user=\"$userPart\";auth_pass=\"$password\"")
|
||||
append(";outbound=\"$out\"")
|
||||
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
||||
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
|
||||
if (regInt.isNotBlank()) append(";regint=$regInt")
|
||||
}
|
||||
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||
CallState.status.value = "Account saved"
|
||||
// (Re)register the account against the running native stack.
|
||||
// Pass the FULL account line (credentials + outbound/transport),
|
||||
// not just the AOR -- ua_alloc needs the complete line.
|
||||
WearBaresipService.registerSavedAccount(context, accountLine.toString())
|
||||
} catch (e: Exception) {
|
||||
CallState.status.value = "Save failed"
|
||||
}
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(text = registration.ifEmpty { "Not registered" }, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
|
||||
Text(text = status, color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.body2)
|
||||
|
||||
if (recent.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(text = "Recent", color = androidx.compose.ui.graphics.Color.White, style = MaterialTheme.typography.title3)
|
||||
recent.forEach { peer ->
|
||||
Chip(
|
||||
onClick = {
|
||||
WearBaresipServiceHelper.dial(peer)
|
||||
onBack()
|
||||
},
|
||||
label = { Text(peer, maxLines = 1) }
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
val svc = Intent(context, WearBaresipService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(svc)
|
||||
} else {
|
||||
context.startService(svc)
|
||||
}
|
||||
onBack()
|
||||
}) {
|
||||
Text("Restart SIP")
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Button(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InCallScreen(onEnd: () -> Unit) {
|
||||
val call = CallState.incoming() ?: CallState.active()
|
||||
val status = call?.status ?: CallState.status.value
|
||||
val peer = call?.peerUri ?: ""
|
||||
// Show a clear "Calling..." indicator while the call is being attempted.
|
||||
val displayStatus = when {
|
||||
status == "call outgoing" -> "Calling..."
|
||||
status.startsWith("call closed") -> "Call ended"
|
||||
else -> status
|
||||
}
|
||||
// Keypad is only useful once the call is established (voicemail/IVR).
|
||||
var showKeypad by remember { mutableStateOf(false) }
|
||||
val canSendDigits = call != null && status == "call established"
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.Black)
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
@ -240,7 +462,7 @@ fun InCallScreen() {
|
||||
maxLines = 2
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(text = status, style = MaterialTheme.typography.body2)
|
||||
Text(text = displayStatus, style = MaterialTheme.typography.body2)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@ -264,21 +486,50 @@ fun InCallScreen() {
|
||||
contentDescription = "Mute"
|
||||
)
|
||||
}
|
||||
Button(onClick = { hangupCall(call) }) {
|
||||
Button(onClick = onEnd) {
|
||||
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
||||
Text("End", modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
if (canSendDigits) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = { showKeypad = !showKeypad }) {
|
||||
Text(if (showKeypad) "Hide keys" else "Keys")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showKeypad && canSendDigits && call != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val digits = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#")
|
||||
// 3 columns, 4 rows of compact digit buttons.
|
||||
digits.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { d ->
|
||||
Button(
|
||||
onClick = { WearBaresipServiceHelper.sendDigit(call, d[0]) },
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Text(d, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun placeCall(uri: String) {
|
||||
android.util.Log.d("Baresip Wear", "placeCall('$uri')")
|
||||
if (uri.isBlank()) return
|
||||
val target = if (uri.contains("@") || uri.startsWith("sip:")) uri else "sip:$uri"
|
||||
WearBaresipServiceHelper.dial(target)
|
||||
if (!CallState.recentPeers.contains(target)) {
|
||||
CallState.recentPeers.add(0, target)
|
||||
// Pass the raw input to dial(); it normalizes a bare extension to a full
|
||||
// SIP URI (e.g. "8500" -> "sip:8500@mail.txt3.net"). Do NOT prepend "sip:"
|
||||
// here -- that would bypass dial()'s domain logic and the native stack
|
||||
// would emit a domainless "sip:8500" that the proxy can never route.
|
||||
WearBaresipServiceHelper.dial(uri)
|
||||
if (!CallState.recentPeers.contains(uri)) {
|
||||
CallState.recentPeers.add(0, uri)
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,6 +548,5 @@ private fun hangupCall(call: WearCall?) {
|
||||
|
||||
private fun toggleMute(call: WearCall?, muted: Boolean) {
|
||||
call ?: return
|
||||
Api.calls_mute(!muted)
|
||||
call.muted = !muted
|
||||
CallState.status.value = "Mute unavailable"
|
||||
}
|
||||
|
||||
84
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal file
84
wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
Normal file
@ -0,0 +1,84 @@
|
||||
package com.tutpro.baresip.wear
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.telecom.PhoneAccount
|
||||
import android.telecom.PhoneAccountHandle
|
||||
import android.telecom.TelecomManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Registers our SIP identity as a telecom PhoneAccount and, on incoming calls,
|
||||
* optionally hands the call to the system via TelecomManager.addNewIncomingCall
|
||||
* so the stock Wear incoming-call UI can be used. Whether the Wear dialer
|
||||
* actually surfaces a third-party PhoneAccount is device/firmware dependent;
|
||||
* this is wired as an experiment and the in-app InCallScreen remains the
|
||||
* primary incoming-call UI.
|
||||
*/
|
||||
object TelecomHelper {
|
||||
|
||||
private const val ACCOUNT_ID = "sip-wear"
|
||||
const val TELECOM_PERMISSION = android.Manifest.permission.READ_PHONE_STATE
|
||||
private const val TAG = "Baresip Wear Telecom"
|
||||
|
||||
fun accountHandle(context: Context): PhoneAccountHandle {
|
||||
val component = ComponentName(context, WearConnectionService::class.java)
|
||||
return PhoneAccountHandle(component, ACCOUNT_ID)
|
||||
}
|
||||
|
||||
/** Register (or re-register) the PhoneAccount with the system telecom stack. */
|
||||
fun registerAccount(context: Context) {
|
||||
try {
|
||||
val tm = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
val handle = accountHandle(context)
|
||||
val intent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
val account = PhoneAccount.builder(handle, "Baresip Wear (SIP)")
|
||||
.setCapabilities(
|
||||
PhoneAccount.CAPABILITY_CALL_PROVIDER or
|
||||
PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
||||
)
|
||||
.setIcon(
|
||||
android.graphics.drawable.Icon.createWithResource(
|
||||
context, android.R.drawable.sym_call_incoming
|
||||
)
|
||||
)
|
||||
.build()
|
||||
tm.registerPhoneAccount(account)
|
||||
Log.d(TAG, "registered PhoneAccount $ACCOUNT_ID")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registerAccount failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands an incoming native call to the system telecom stack. The framework
|
||||
* will bind WearConnectionService and show the incoming-call UI if the Wear
|
||||
* dialer honors our account. Safe to call even if telecom ignores it -- the
|
||||
* in-app InCallScreen is shown independently by the service.
|
||||
*/
|
||||
fun addIncomingCall(context: Context, callp: Long, uap: Long, peer: String) {
|
||||
try {
|
||||
val tm = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
val extras = Bundle()
|
||||
extras.putParcelable(
|
||||
TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
|
||||
android.net.Uri.parse(peer)
|
||||
)
|
||||
extras.putLong(WearConnectionService.EXTRA_CALLP, callp)
|
||||
extras.putLong(WearConnectionService.EXTRA_UAP, uap)
|
||||
extras.putString(WearConnectionService.EXTRA_PEER, peer)
|
||||
tm.addNewIncomingCall(accountHandle(context), extras)
|
||||
Log.d(TAG, "addNewIncomingCall callp=$callp peer=$peer")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "addIncomingCall failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,28 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import androidx.core.app.NotificationCompat
|
||||
import android.util.Log
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class WearBaresipService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appContext = applicationContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
@ -22,9 +35,19 @@ class WearBaresipService : Service() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
AudioRouteManager.init(this)
|
||||
// Register our SIP identity with the system telecom stack (experiment:
|
||||
// lets the stock Wear dialer surface the account if it chooses to).
|
||||
try { TelecomHelper.registerAccount(this) } catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "telecom register failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (::nativeThread.isInitialized && nativeThread.isAlive) {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Baresip Wear")
|
||||
.setContentText("SIP stack running")
|
||||
@ -33,30 +56,140 @@ class WearBaresipService : Service() {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
val path = applicationContext.filesDir.absolutePath
|
||||
baresipStart(path, "", 2, "baresip-studio-wear")
|
||||
val nativeLibDir = applicationInfo.nativeLibraryDir
|
||||
try {
|
||||
java.io.File(path).mkdirs()
|
||||
// Only seed accounts from the bundled asset when none exists.
|
||||
// A user-edited accounts file (via the Accounts screen) must be
|
||||
// preserved -- account_alloc() requires a "sip:" scheme AOR, so the
|
||||
// asset is just a sensible default, not an authoritative override.
|
||||
val accountsFile = java.io.File("$path/accounts")
|
||||
if (!accountsFile.exists() || accountsFile.length() <= 0) {
|
||||
applicationContext.assets.open("accounts").use { input ->
|
||||
accountsFile.writeBytes(input.readBytes())
|
||||
}
|
||||
}
|
||||
// Always refresh config from the bundled asset so module_app entries
|
||||
// (e.g. account.so) are present and module_path points at the
|
||||
// extracted native libs on this device. With extractNativeLibs=true
|
||||
// the .so modules are unpacked to nativeLibraryDir; baresip's
|
||||
// module loader dlopen()s them from there.
|
||||
val modulePathLine = "module_path\t\t$nativeLibDir\n"
|
||||
val static = applicationContext.assets.open("config.static").bufferedReader().use { it.readText() }
|
||||
val configText = if (static.contains("module_path")) static else "$modulePathLine$static"
|
||||
java.io.File("$path/config").writeText(configText)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "asset copy failed: ${e.message}")
|
||||
}
|
||||
|
||||
val addrs = localAddresses()
|
||||
val dns = collectDnsServers()
|
||||
Log.d("Baresip Wear", "Starting native stack; addrs=$addrs dns=$dns")
|
||||
|
||||
nativeThread = Thread {
|
||||
try {
|
||||
baresipStart(path, addrs, dns, 5, "baresip-studio-wear", nativeLibDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Baresip Wear", "baresipStart failed: ${e.message}")
|
||||
CallState.status.value = "Native start failed"
|
||||
}
|
||||
}
|
||||
nativeThread.start()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(networkReceiver, networkFilter())
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun collectDnsServers(): String {
|
||||
val servers = mutableListOf<String>()
|
||||
try {
|
||||
val cm = getSystemService(ConnectivityManager::class.java) ?: return fallbackDns()
|
||||
val active = cm.activeNetwork
|
||||
for (n in cm.allNetworks) {
|
||||
if (n != active) continue
|
||||
cm.getLinkProperties(n)?.dnsServers?.forEach {
|
||||
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
|
||||
else "${it.hostAddress}:53"
|
||||
if (!servers.contains(s)) servers.add(s)
|
||||
}
|
||||
}
|
||||
for (n in cm.allNetworks) {
|
||||
if (n == active) continue
|
||||
cm.getLinkProperties(n)?.dnsServers?.forEach {
|
||||
val s = if (it.hostAddress.contains(':')) "[${it.hostAddress}]:53"
|
||||
else "${it.hostAddress}:53"
|
||||
if (!servers.contains(s)) servers.add(s)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "collectDnsServers failed: ${e.message}")
|
||||
}
|
||||
return if (servers.isNotEmpty()) servers.joinToString(",") else fallbackDns()
|
||||
}
|
||||
|
||||
private fun fallbackDns() = "1.1.1.1:53,8.8.8.8:53"
|
||||
|
||||
private fun updateDnsServers() {
|
||||
val dns = collectDnsServers()
|
||||
try {
|
||||
val res = Api.net_use_nameserver(dns)
|
||||
Log.d("Baresip Wear", "runtime net_use_nameserver -> $res")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { unregisterReceiver(networkReceiver) } catch (_: Exception) {}
|
||||
AudioRouteManager.shutdown()
|
||||
baresipStop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
external fun baresipStart(path: String, addrs: String, logLevel: Int, software: String)
|
||||
external fun baresipStart(path: String, addrs: String, dns: String, logLevel: Int, software: String, nativeLibDir: String)
|
||||
external fun baresipStop()
|
||||
external fun refreshNetwork(addrs: String, dns: String)
|
||||
|
||||
private fun localAddresses(): String {
|
||||
val addrs = mutableListOf<String>()
|
||||
try {
|
||||
val nifs = NetworkInterface.getNetworkInterfaces()
|
||||
for (nif in nifs) {
|
||||
if (!nif.isUp || nif.isLoopback) continue
|
||||
for (addr in nif.inetAddresses) {
|
||||
val host = addr.hostAddress ?: continue
|
||||
val bare = host.substringBefore('%')
|
||||
if (bare == "0.0.0.0" || bare == "::") continue
|
||||
if (bare.startsWith("fe80:")) continue
|
||||
if (!addrs.contains(bare)) addrs.add(bare)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "localAddresses failed: ${e.message}")
|
||||
}
|
||||
return addrs.joinToString(";")
|
||||
}
|
||||
|
||||
// Native event callback. Format: "<event>,<arg>[,<uap>[,<callp>]]".
|
||||
// Example: "call incoming,sip:bob@example.com,140512345,987654321"
|
||||
fun uaEvent(event: String) {
|
||||
val parts = event.split(",")
|
||||
val ev = parts[0]
|
||||
val arg = parts.getOrNull(1) ?: ""
|
||||
val uap = parts.getOrNull(2)?.toLongOrNull() ?: 0L
|
||||
// Event formats differ by event type (see baresip.c event_handler):
|
||||
// registering/registered/unregistering : "<ev>,<uap>" -> uap in parts[1]
|
||||
// call incoming/outgoing/established.. : "<ev>,<peer>,<uap>,<callp>" -> uap in parts[2]
|
||||
// Reading a fixed index breaks registration (uap lands in parts[1]
|
||||
// but we read parts[2]) -> defaultUap stays 0 -> dial() bails.
|
||||
val uap = if (ev.startsWith("register") || ev.startsWith("unregister") || ev == "create") {
|
||||
parts.getOrNull(1)?.toLongOrNull() ?: 0L
|
||||
} else {
|
||||
parts.getOrNull(2)?.toLongOrNull() ?: 0L
|
||||
}
|
||||
val callp = parts.getOrNull(3)?.toLongOrNull() ?: 0L
|
||||
android.util.Log.d("Baresip Wear", "uaEvent: $event")
|
||||
android.util.Log.d("Baresip Wear", "uaEvent: $event (uap=$uap)")
|
||||
|
||||
when {
|
||||
ev.startsWith("registering") -> {
|
||||
@ -67,7 +200,10 @@ class WearBaresipService : Service() {
|
||||
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
|
||||
CallState.registration.value = "Registered"
|
||||
}
|
||||
ev.startsWith("registering failed") -> CallState.registration.value = "Registration failed"
|
||||
ev.startsWith("registering failed") -> {
|
||||
CallState.registration.value = "Registration failed"
|
||||
showToast("Registration failed")
|
||||
}
|
||||
ev.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
|
||||
|
||||
ev == "call incoming" -> {
|
||||
@ -77,14 +213,29 @@ class WearBaresipService : Service() {
|
||||
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
|
||||
}
|
||||
CallState.status.value = "Incoming call"
|
||||
// Surface the incoming-call UI even if the app was backgrounded,
|
||||
// and alert the wearer (the watch screen is usually dimmed).
|
||||
alertIncomingCall()
|
||||
// Experiment: also hand the call to the system telecom stack so
|
||||
// the stock Wear incoming UI can be used if the dialer honors
|
||||
// our PhoneAccount. The in-app InCallScreen is shown regardless.
|
||||
try { TelecomHelper.addIncomingCall(this, cp, uap, peer) } catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "telecom addIncomingCall failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
ev == "call outgoing" -> CallState.status.value = "Calling"
|
||||
ev == "call established" -> {
|
||||
CallState.status.value = "Connected"
|
||||
CallState.active()?.status = "call established"
|
||||
// Route audio: speaker if no BT headset, else BT SCO.
|
||||
AudioRouteManager.startCallAudio()
|
||||
}
|
||||
ev == "call ringing" -> CallState.status.value = "Ringing"
|
||||
ev == "call closed" -> {
|
||||
AudioRouteManager.stopCallAudio()
|
||||
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
|
||||
// as a toast so the user isn't left guessing why the call died.
|
||||
if (arg.isNotBlank()) showToast("Call ended: $arg")
|
||||
CallState.calls.toList().forEach { CallState.remove(it.callp) }
|
||||
CallState.status.value = "Idle"
|
||||
}
|
||||
@ -98,12 +249,177 @@ class WearBaresipService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
try {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "showToast failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert the wearer to an incoming call: bring our activity to the front
|
||||
* (so the Answer/Decline UI shows), wake the (usually dimmed) screen, and
|
||||
* buzz a ring pattern. The actual answer/decline buttons live in the
|
||||
* in-call UI; this only makes sure the user can see and feel the call.
|
||||
*/
|
||||
private fun alertIncomingCall() {
|
||||
try {
|
||||
// Wake the screen if it's asleep.
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
if (pm != null && !pm.isInteractive) {
|
||||
@Suppress("DEPRECATION")
|
||||
val wl = pm.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
|
||||
"BaresipWear:incomingCall"
|
||||
)
|
||||
wl.acquire(5000)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "wake failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Bring the activity to the foreground so the InCallScreen shows.
|
||||
try {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
putExtra("incoming_call", true)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "bringToFront failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Ring vibration pattern (vibrate, pause, vibrate, pause, ...).
|
||||
try {
|
||||
val vibrator = getSystemService(Vibrator::class.java)
|
||||
if (vibrator != null && vibrator.hasVibrator()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val pattern = longArrayOf(0, 400, 200, 400, 200, 400)
|
||||
vibrator.vibrate(
|
||||
VibrationEffect.createWaveform(pattern, 0)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator.vibrate(longArrayOf(0, 400, 200, 400, 200, 400), 0)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "vibrate failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "baresip-wear"
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
// Application context, captured in onCreate, for toasts from non-UI code.
|
||||
lateinit var appContext: Context
|
||||
private set
|
||||
|
||||
fun registerDefaultAccountIfNeeded(context: Context) {
|
||||
if (WearBaresipServiceHelper.defaultUa() != 0L) return
|
||||
// Pass the FULL account line (<sip:...>;auth_pass=...;outbound=...),
|
||||
// NOT just the AOR. ua_alloc needs the whole line to build an
|
||||
// account that can complete the digest-challenge REGISTER; a bare
|
||||
// "<sip:user@host>" AOR has no credentials/proxy and hangs at
|
||||
// "Registering" (and defaults to UDP:5060, which the server does
|
||||
// not answer on).
|
||||
val accountLine = readAccountLine(context) ?: return
|
||||
try {
|
||||
val uap = Api.ua_alloc(accountLine)
|
||||
if (uap == 0L) {
|
||||
CallState.registration.value = "UA alloc failed"
|
||||
return
|
||||
}
|
||||
WearBaresipServiceHelper.setDefaultUa(uap)
|
||||
val rc = Api.ua_register(uap)
|
||||
CallState.registration.value = "Registering rc=$rc"
|
||||
} catch (e: Exception) {
|
||||
CallState.registration.value = "Register failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
// Re-register after the user edits/saves the account on the Accounts
|
||||
// screen. Tears down any previously-allocated UA first so the new
|
||||
// AOR / credentials take effect, then allocates and registers the
|
||||
// saved account. CRITICAL: pass the FULL account line (with
|
||||
// auth_pass + outbound/transport), never just the AOR -- ua_alloc
|
||||
// needs the complete line or the REGISTER cannot complete its digest
|
||||
// challenge and the status hangs at "Registering".
|
||||
fun registerSavedAccount(context: Context, accountLine: String) {
|
||||
try {
|
||||
val existing = WearBaresipServiceHelper.defaultUa()
|
||||
if (existing != 0L) {
|
||||
Api.ua_destroy(existing)
|
||||
WearBaresipServiceHelper.clearDefaultUa()
|
||||
}
|
||||
val uap = Api.ua_alloc(accountLine.trim())
|
||||
if (uap == 0L) {
|
||||
CallState.registration.value = "UA alloc failed"
|
||||
return
|
||||
}
|
||||
WearBaresipServiceHelper.setDefaultUa(uap)
|
||||
val rc = Api.ua_register(uap)
|
||||
CallState.registration.value = "Registering rc=$rc"
|
||||
} catch (e: Exception) {
|
||||
CallState.registration.value = "Register failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the first non-comment, non-blank line of the accounts file --
|
||||
// the full baresip account line, including <sip:...>;auth_pass=...;
|
||||
// outbound=.... This is what ua_alloc needs to build a usable UA.
|
||||
private fun readAccountLine(context: Context): String? {
|
||||
return try {
|
||||
val file = java.io.File(context.filesDir, "accounts")
|
||||
if (!file.exists()) return null
|
||||
file.readText().lineSequence()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var nativeThread: Thread
|
||||
|
||||
private val networkReceiver = object : BroadcastReceiver() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var pending = false
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (pending) return
|
||||
pending = true
|
||||
handler.postDelayed({
|
||||
pending = false
|
||||
refreshNativeNetwork()
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
System.loadLibrary("wearbaresip")
|
||||
}
|
||||
|
||||
private fun networkFilter(): IntentFilter {
|
||||
return IntentFilter().apply {
|
||||
addAction("android.net.conn.CONNECTIVITY_CHANGE")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
addAction("android.net.conn.ACTION_RESTRICT_BACKGROUND_CHANGED")
|
||||
}
|
||||
addAction("android.net.wifi.STATE_CHANGE")
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshNativeNetwork() {
|
||||
val addrs = localAddresses()
|
||||
val dns = collectDnsServers()
|
||||
try {
|
||||
refreshNetwork(addrs, dns)
|
||||
Log.d("Baresip Wear", "refreshNetwork addrs=$addrs dns=$dns")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Baresip Wear", "refreshNetwork failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ import java.net.URL
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
// Slimmed port of the phone app's Provisioning for the watch. Fetches and
|
||||
// decrypts the provisioning bundle and writes an accounts.cfg file that
|
||||
// decrypts the provisioning bundle and writes an 'accounts' file that
|
||||
// baresip loads on (re)start.
|
||||
object WearProvisioning {
|
||||
|
||||
@ -103,14 +103,20 @@ object WearProvisioning {
|
||||
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
|
||||
fun writeAccount(ctx: Context, bundle: Bundle): String {
|
||||
val filesDir = ctx.filesDir
|
||||
val aor = if (bundle.connectString.startsWith("sip:")) bundle.connectString else "sip:${bundle.connectString}"
|
||||
val aor = bundle.connectString.removePrefix("sip:")
|
||||
val accountLine = buildString {
|
||||
append("<$aor>")
|
||||
if (!bundle.outbound1.isNullOrBlank()) append(";outbound=\"${bundle.outbound1}\"")
|
||||
if (!bundle.outbound2.isNullOrBlank()) append(";outbound2=\"${bundle.outbound2}\"")
|
||||
if (!bundle.outbound1.isNullOrBlank()) {
|
||||
val hostPort = bundle.outbound1.removePrefix("sip:")
|
||||
append(";outbound=\"$hostPort\"")
|
||||
}
|
||||
if (!bundle.outbound2.isNullOrBlank()) {
|
||||
val hostPort = bundle.outbound2.removePrefix("sip:")
|
||||
append(";outbound2=\"$hostPort\"")
|
||||
}
|
||||
if (bundle.regInt != null) append(";regint=${bundle.regInt}")
|
||||
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
||||
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
|
||||
append(";regq=0.5;pubint=0;check_origin=no;mwi=no;transport=TCP")
|
||||
}
|
||||
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||
if (!bundle.password.isNullOrBlank()) {
|
||||
@ -124,17 +130,19 @@ object WearProvisioning {
|
||||
// Writes accounts from manual debug input.
|
||||
fun writeManualAccount(ctx: Context, aor: String, password: String, outbound: String) {
|
||||
val filesDir = ctx.filesDir
|
||||
val cleanAor = aor.removePrefix("sip:")
|
||||
val accountLine = buildString {
|
||||
append("<$aor>")
|
||||
if (outbound.isNotBlank()) append(";outbound=\"$outbound\"")
|
||||
append("<$cleanAor>")
|
||||
val hostPort = outbound.removePrefix("sip:")
|
||||
if (hostPort.isNotBlank()) append(";outbound=\"$hostPort\"")
|
||||
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
||||
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
|
||||
append(";regq=0.5;pubint=0;check_origin=no;mwi=no;transport=TCP")
|
||||
}
|
||||
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||
if (password.isNotBlank()) {
|
||||
File(filesDir, "auth").writeText("$aor $password\n", Charsets.UTF_8)
|
||||
File(filesDir, "auth").writeText("$cleanAor $password\n", Charsets.UTF_8)
|
||||
}
|
||||
Log.i("Baresip Wear", "Manual account saved: $aor")
|
||||
Log.i("Baresip Wear", "Manual account saved: $cleanAor")
|
||||
}
|
||||
|
||||
private fun rsaDecrypt(encrypted: ByteArray): ByteArray {
|
||||
|
||||
Reference in New Issue
Block a user