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
|
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2G -Dfile.encoding=UTF-8
|
||||||
|
android.suppressUnsupportedCompileSdk=37.0
|
||||||
|
|||||||
@ -5,7 +5,7 @@ composeBom = "2026.06.01"
|
|||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
exifinterface = "1.4.2"
|
exifinterface = "1.4.2"
|
||||||
fragmentKtx = "1.8.9"
|
fragmentKtx = "1.8.9"
|
||||||
gradleVersion = "9.3.1"
|
gradleVersion = "9.1.0"
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlinStdlibJdk8 = "2.4.0"
|
kotlinStdlibJdk8 = "2.4.0"
|
||||||
android = "2.4.10"
|
android = "2.4.10"
|
||||||
|
|||||||
@ -31,6 +31,12 @@ android {
|
|||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
@ -41,11 +47,11 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation("androidx.activity:activity-compose:1.9.3")
|
||||||
implementation(libs.androidx.wear)
|
implementation(libs.androidx.wear)
|
||||||
implementation(libs.androidx.wear.compose)
|
implementation(libs.androidx.wear.compose)
|
||||||
implementation(libs.androidx.wear.compose.foundation)
|
implementation(libs.androidx.wear.compose.foundation)
|
||||||
|
|||||||
@ -4,7 +4,12 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<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.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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||||
@ -43,5 +48,17 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="microphone"
|
android:foregroundServiceType="microphone"
|
||||||
android:stopWithTask="false" />
|
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>
|
</application>
|
||||||
</manifest>
|
</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)
|
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
|
target_include_directories(wearbaresip PRIVATE
|
||||||
${distribution_DIR}/openssl/include
|
${distribution_DIR}/openssl/include
|
||||||
${distribution_DIR}/re/include
|
${distribution_DIR}/re/include
|
||||||
@ -96,3 +104,34 @@ target_link_libraries(
|
|||||||
lib_sndfile
|
lib_sndfile
|
||||||
z
|
z
|
||||||
log)
|
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 <string.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <errno.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <aaudio/AAudio.h>
|
#include <aaudio/AAudio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@ -23,7 +25,6 @@ enum
|
|||||||
};
|
};
|
||||||
|
|
||||||
static pthread_key_t g_thread_key;
|
static pthread_key_t g_thread_key;
|
||||||
|
|
||||||
static void detach_thread(void *env)
|
static void detach_thread(void *env)
|
||||||
{
|
{
|
||||||
(void)env;
|
(void)env;
|
||||||
@ -45,9 +46,23 @@ static JNIEnv *get_jni_env(void)
|
|||||||
return env;
|
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)
|
static void ua_exit_handler(void *arg)
|
||||||
{
|
{
|
||||||
(void)arg;
|
(void)arg;
|
||||||
|
LOGD("ua exited -- stopping main runloop\n");
|
||||||
re_cancel();
|
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", "");
|
len = re_snprintf(event_buf, sizeof event_buf, "create", "");
|
||||||
break;
|
break;
|
||||||
case BEVENT_REGISTERING:
|
case BEVENT_REGISTERING:
|
||||||
|
len = re_snprintf(event_buf, sizeof event_buf, "registering,%ld", (long)ua);
|
||||||
|
break;
|
||||||
case BEVENT_UNREGISTERING:
|
case BEVENT_UNREGISTERING:
|
||||||
|
len = re_snprintf(event_buf, sizeof event_buf, "unregistering,%ld", (long)ua);
|
||||||
|
break;
|
||||||
case BEVENT_REGISTER_OK:
|
case BEVENT_REGISTER_OK:
|
||||||
case BEVENT_FALLBACK_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;
|
break;
|
||||||
case BEVENT_REGISTER_FAIL:
|
case BEVENT_REGISTER_FAIL:
|
||||||
case BEVENT_FALLBACK_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;
|
break;
|
||||||
case BEVENT_SIPSESS_CONN:
|
case BEVENT_SIPSESS_CONN:
|
||||||
ua = uag_find_msg(msg);
|
ua = uag_find_msg(msg);
|
||||||
@ -343,9 +363,215 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
|
|||||||
return JNI_VERSION_1_6;
|
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
|
JNIEXPORT void JNICALL
|
||||||
Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
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;
|
int err;
|
||||||
|
|
||||||
@ -356,9 +582,35 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
|||||||
const char *path = (*env)->GetStringUTFChars(env, jPath, 0);
|
const char *path = (*env)->GetStringUTFChars(env, jPath, 0);
|
||||||
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
|
const char *addrs = (*env)->GetStringUTFChars(env, jAddrs, 0);
|
||||||
const char *software = (*env)->GetStringUTFChars(env, jSoftware, 0);
|
const char *software = (*env)->GetStringUTFChars(env, jSoftware, 0);
|
||||||
|
const char *nativelib = jNativeLibDir ? (*env)->GetStringUTFChars(env, jNativeLibDir, 0) : NULL;
|
||||||
|
|
||||||
runLoggingThread();
|
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();
|
err = libre_init();
|
||||||
if (err) {
|
if (err) {
|
||||||
goto out;
|
goto out;
|
||||||
@ -389,25 +641,25 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
|||||||
goto out;
|
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);
|
dnsc_cache_max(net_dnsc(baresip_network()), 0);
|
||||||
|
|
||||||
if (strlen(addrs) > 0) {
|
if (strlen(addrs) > 0) {
|
||||||
char *addr_list = (char *)malloc(strlen(addrs) + 1);
|
char *addr_list = (char *)malloc(strlen(addrs) + 1);
|
||||||
struct sa temp_sa;
|
struct sa temp_sa;
|
||||||
char buf[256];
|
|
||||||
net_flush_addresses(baresip_network());
|
net_flush_addresses(baresip_network());
|
||||||
strcpy(addr_list, addrs);
|
strcpy(addr_list, addrs);
|
||||||
char *ptr = strtok(addr_list, ";");
|
char *ptr = strtok(addr_list, ";");
|
||||||
while (ptr != NULL) {
|
while (ptr != NULL) {
|
||||||
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
|
if (0 == sa_set_str(&temp_sa, ptr, 0)) {
|
||||||
sa_ntop(&temp_sa, buf, 256);
|
net_add_address(baresip_network(), &temp_sa);
|
||||||
ptr = strtok(NULL, ";");
|
|
||||||
net_add_address_ifname(baresip_network(), &temp_sa, ptr);
|
|
||||||
} else {
|
|
||||||
ptr = strtok(NULL, ";");
|
|
||||||
}
|
}
|
||||||
if (ptr)
|
|
||||||
*(ptr - 1) = ';';
|
|
||||||
ptr = strtok(NULL, ";");
|
ptr = strtok(NULL, ";");
|
||||||
}
|
}
|
||||||
free(addr_list);
|
free(addr_list);
|
||||||
@ -432,6 +684,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
|||||||
|
|
||||||
err = conf_modules();
|
err = conf_modules();
|
||||||
if (err) {
|
if (err) {
|
||||||
|
LOGE("conf_modules failed: (%d)\n", err);
|
||||||
goto out;
|
goto out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,7 +693,36 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStart(
|
|||||||
goto out;
|
goto out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LOGI("running main loop ...\n");
|
||||||
|
err = re_main(signal_handler);
|
||||||
|
|
||||||
out:
|
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, jPath, path);
|
||||||
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
|
(*env)->ReleaseStringUTFChars(env, jAddrs, addrs);
|
||||||
(*env)->ReleaseStringUTFChars(env, jSoftware, software);
|
(*env)->ReleaseStringUTFChars(env, jSoftware, software);
|
||||||
@ -453,6 +735,7 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
|
|||||||
(void)instance;
|
(void)instance;
|
||||||
|
|
||||||
ua_stop_all(true);
|
ua_stop_all(true);
|
||||||
|
re_cancel();
|
||||||
baresip_close();
|
baresip_close();
|
||||||
libre_close();
|
libre_close();
|
||||||
|
|
||||||
@ -465,3 +748,98 @@ Java_com_tutpro_baresip_wear_WearBaresipService_baresipStop(JNIEnv *env, jobject
|
|||||||
g_ctx.serviceObj = NULL;
|
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.
|
// Minimal native bindings matching the symbols exported by wearbaresip JNI lib.
|
||||||
// Only the subset needed by the Wear UI is declared here.
|
// Only the subset needed by the Wear UI is declared here.
|
||||||
object Api {
|
class Api private constructor() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
const val VIDMODE_OFF = 0
|
const val VIDMODE_OFF = 0
|
||||||
|
@JvmStatic external fun ua_alloc(uri: String): Long
|
||||||
external fun ua_alloc(uri: String): Long
|
@JvmStatic external fun ua_destroy(uap: Long)
|
||||||
external fun ua_destroy(uap: Long)
|
@JvmStatic external fun ua_register(uap: Long): Int
|
||||||
external fun ua_register(uap: Long): Int
|
@JvmStatic external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
|
||||||
external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
|
@JvmStatic external fun ua_answer(uap: Long, callp: Long, video: Int)
|
||||||
external fun ua_answer(uap: Long, callp: Long, video: Int)
|
@JvmStatic external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
|
||||||
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
|
||||||
external fun call_connect(callp: Long, peerUri: String): Int
|
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
|
||||||
external fun call_hold(callp: Long, hold: Boolean): Boolean
|
@JvmStatic external fun account_aor(acc: Long): String
|
||||||
external fun call_send_digit(callp: Long, digit: Char): Int
|
@JvmStatic external fun net_use_nameserver(servers: String): Int
|
||||||
external fun call_destroy(callp: Long)
|
}
|
||||||
external fun calls_mute(mute: Boolean)
|
|
||||||
|
|
||||||
external fun account_aor(acc: Long): String
|
|
||||||
}
|
}
|
||||||
|
|||||||
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.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.Call
|
||||||
import androidx.compose.material.icons.filled.CallEnd
|
import androidx.compose.material.icons.filled.CallEnd
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
@ -22,16 +26,21 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||||
import androidx.wear.compose.foundation.lazy.items
|
import androidx.wear.compose.foundation.lazy.items
|
||||||
import androidx.wear.compose.material.*
|
import androidx.wear.compose.material.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@ -86,9 +95,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
|
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
|
||||||
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
|
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
|
||||||
CallState.registration.value = "Provisioned: $aor"
|
CallState.registration.value = "Provisioned: $aor"
|
||||||
// Restart the native stack so it picks up accounts.cfg
|
|
||||||
val svc = Intent(applicationContext, WearBaresipService::class.java)
|
val svc = Intent(applicationContext, WearBaresipService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(svc)
|
startForegroundService(svc)
|
||||||
|
} else {
|
||||||
|
startService(svc)
|
||||||
|
}
|
||||||
CallState.status.value = "Restarting SIP"
|
CallState.status.value = "Restarting SIP"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
CallState.status.value = "Provisioning failed"
|
CallState.status.value = "Provisioning failed"
|
||||||
@ -108,11 +120,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WearApp(activity: MainActivity) {
|
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 {
|
MaterialTheme {
|
||||||
Scaffold(timeText = { TimeText() }) {
|
Scaffold(timeText = { TimeText() }) {
|
||||||
when (activity.route.value) {
|
when {
|
||||||
"dialer" -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
hasCall -> InCallScreen(onEnd = { hangupCall(CallState.active() ?: CallState.incoming()) })
|
||||||
"accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
||||||
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,113 +137,321 @@ fun WearApp(activity: MainActivity) {
|
|||||||
fun DialerScreen(onAccounts: () -> Unit) {
|
fun DialerScreen(onAccounts: () -> Unit) {
|
||||||
var number by remember { mutableStateOf("") }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(12.dp),
|
.background(androidx.compose.ui.graphics.Color.Black)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.verticalScroll(rememberScrollState())
|
||||||
verticalArrangement = Arrangement.Center
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
// Registration status (small, top).
|
||||||
Text(
|
Text(
|
||||||
text = CallState.registration.value.ifEmpty { "Baresip Wear" },
|
text = CallState.registration.value.ifEmpty { "Baresip Wear" },
|
||||||
style = MaterialTheme.typography.title3,
|
style = MaterialTheme.typography.body2,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Large centered number display, like the stock dialer.
|
||||||
|
Text(
|
||||||
|
text = number.ifEmpty { " " },
|
||||||
|
style = MaterialTheme.typography.title1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = androidx.compose.ui.graphics.Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// 3-column keypad grid.
|
||||||
|
keypad.chunked(3).forEach { row ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
row.forEach { (digit, sub) ->
|
||||||
|
Button(
|
||||||
|
onClick = { number += digit },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.heightIn(min = 46.dp)
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(digit, style = MaterialTheme.typography.title3)
|
||||||
|
if (sub.isNotEmpty()) {
|
||||||
|
Text(sub, style = MaterialTheme.typography.body2,
|
||||||
|
fontSize = 8.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
}
|
||||||
OutlinedTextField(
|
|
||||||
value = number,
|
|
||||||
onValueChange = { number = it },
|
|
||||||
label = { Text("Number") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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")
|
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))
|
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(
|
Chip(
|
||||||
onClick = { placeCall(peer) },
|
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
|
@Composable
|
||||||
fun AccountsScreen(onBack: () -> Unit) {
|
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 aor by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var outbound by remember { mutableStateOf("") }
|
var outbound by remember { mutableStateOf("") }
|
||||||
val context = LocalContext.current
|
var regInt by remember { mutableStateOf("") }
|
||||||
|
var loaded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ScalingLazyColumn(
|
LaunchedEffect(Unit) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
try {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
val filesDir = context.filesDir
|
||||||
) {
|
val accounts = File(filesDir, "accounts")
|
||||||
item { Text("Accounts (debug)", style = MaterialTheme.typography.title3) }
|
val auth = File(filesDir, "auth")
|
||||||
item { Spacer(Modifier.height(6.dp)) }
|
if (accounts.exists()) {
|
||||||
item {
|
val text = accounts.readText().trim()
|
||||||
OutlinedTextField(value = aor, onValueChange = { aor = it },
|
val first = text.lineSequence().firstOrNull() ?: ""
|
||||||
label = { Text("sip:aor") }, modifier = Modifier.fillMaxWidth())
|
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)) }
|
if (auth.exists()) {
|
||||||
item {
|
val authLine = auth.readText().trim().lineSequence().firstOrNull() ?: ""
|
||||||
OutlinedTextField(value = password, onValueChange = { password = it },
|
val parts = authLine.split(" ", limit = 2)
|
||||||
label = { Text("password") }, modifier = Modifier.fillMaxWidth())
|
if (parts.size == 2) password = parts[1]
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(4.dp)) }
|
} catch (_: Exception) {
|
||||||
item {
|
} finally {
|
||||||
OutlinedTextField(value = outbound, onValueChange = { outbound = it },
|
loaded = true
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(androidx.compose.ui.graphics.Color.Black)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(12.dp),
|
.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,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
@ -240,7 +462,7 @@ fun InCallScreen() {
|
|||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(text = status, style = MaterialTheme.typography.body2)
|
Text(text = displayStatus, style = MaterialTheme.typography.body2)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
@ -264,21 +486,50 @@ fun InCallScreen() {
|
|||||||
contentDescription = "Mute"
|
contentDescription = "Mute"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(onClick = { hangupCall(call) }) {
|
Button(onClick = onEnd) {
|
||||||
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
||||||
Text("End", modifier = Modifier.padding(start = 4.dp))
|
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) {
|
private fun placeCall(uri: String) {
|
||||||
|
android.util.Log.d("Baresip Wear", "placeCall('$uri')")
|
||||||
if (uri.isBlank()) return
|
if (uri.isBlank()) return
|
||||||
val target = if (uri.contains("@") || uri.startsWith("sip:")) uri else "sip:$uri"
|
// Pass the raw input to dial(); it normalizes a bare extension to a full
|
||||||
WearBaresipServiceHelper.dial(target)
|
// SIP URI (e.g. "8500" -> "sip:8500@mail.txt3.net"). Do NOT prepend "sip:"
|
||||||
if (!CallState.recentPeers.contains(target)) {
|
// here -- that would bypass dial()'s domain logic and the native stack
|
||||||
CallState.recentPeers.add(0, target)
|
// 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) {
|
private fun toggleMute(call: WearCall?, muted: Boolean) {
|
||||||
call ?: return
|
call ?: return
|
||||||
Api.calls_mute(!muted)
|
CallState.status.value = "Mute unavailable"
|
||||||
call.muted = !muted
|
|
||||||
}
|
}
|
||||||
|
|||||||
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.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
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 androidx.core.app.NotificationCompat
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
class WearBaresipService : Service() {
|
class WearBaresipService : Service() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
appContext = applicationContext
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
@ -22,9 +35,19 @@ class WearBaresipService : Service() {
|
|||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
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 {
|
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)
|
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Baresip Wear")
|
.setContentTitle("Baresip Wear")
|
||||||
.setContentText("SIP stack running")
|
.setContentText("SIP stack running")
|
||||||
@ -33,30 +56,140 @@ class WearBaresipService : Service() {
|
|||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
val path = applicationContext.filesDir.absolutePath
|
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
|
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() {
|
override fun onDestroy() {
|
||||||
|
try { unregisterReceiver(networkReceiver) } catch (_: Exception) {}
|
||||||
|
AudioRouteManager.shutdown()
|
||||||
baresipStop()
|
baresipStop()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
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 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) {
|
fun uaEvent(event: String) {
|
||||||
val parts = event.split(",")
|
val parts = event.split(",")
|
||||||
val ev = parts[0]
|
val ev = parts[0]
|
||||||
val arg = parts.getOrNull(1) ?: ""
|
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
|
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 {
|
when {
|
||||||
ev.startsWith("registering") -> {
|
ev.startsWith("registering") -> {
|
||||||
@ -67,7 +200,10 @@ class WearBaresipService : Service() {
|
|||||||
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
|
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
|
||||||
CallState.registration.value = "Registered"
|
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.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
|
||||||
|
|
||||||
ev == "call incoming" -> {
|
ev == "call incoming" -> {
|
||||||
@ -77,14 +213,29 @@ class WearBaresipService : Service() {
|
|||||||
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
|
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
|
||||||
}
|
}
|
||||||
CallState.status.value = "Incoming call"
|
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 outgoing" -> CallState.status.value = "Calling"
|
||||||
ev == "call established" -> {
|
ev == "call established" -> {
|
||||||
CallState.status.value = "Connected"
|
CallState.status.value = "Connected"
|
||||||
CallState.active()?.status = "call established"
|
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 ringing" -> CallState.status.value = "Ringing"
|
||||||
ev == "call closed" -> {
|
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.calls.toList().forEach { CallState.remove(it.callp) }
|
||||||
CallState.status.value = "Idle"
|
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 {
|
companion object {
|
||||||
const val CHANNEL_ID = "baresip-wear"
|
const val CHANNEL_ID = "baresip-wear"
|
||||||
const val NOTIFICATION_ID = 1
|
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 {
|
init {
|
||||||
System.loadLibrary("wearbaresip")
|
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
|
if (uap != 0L) defaultUap = uap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearDefaultUa() {
|
||||||
|
defaultUap = 0L
|
||||||
|
}
|
||||||
|
|
||||||
fun defaultUa(): Long = defaultUap
|
fun defaultUa(): Long = defaultUap
|
||||||
|
|
||||||
// Outgoing call: allocate a call slot on the default UA and connect.
|
// Outgoing call: allocate a call slot on the default UA and connect.
|
||||||
fun dial(uri: String) {
|
fun dial(uri: String) {
|
||||||
|
android.util.Log.d("Baresip Wear", "dial($uri) defaultUap=$defaultUap")
|
||||||
if (defaultUap == 0L) {
|
if (defaultUap == 0L) {
|
||||||
CallState.status.value = "No account"
|
CallState.status.value = "No account"
|
||||||
|
toast("No SIP account - register first")
|
||||||
|
android.util.Log.w("Baresip Wear", "dial: no default UA")
|
||||||
return
|
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)
|
val callp = Api.ua_call_alloc(defaultUap, 0L, Api.VIDMODE_OFF)
|
||||||
|
android.util.Log.d("Baresip Wear", "ua_call_alloc -> $callp")
|
||||||
if (callp == 0L) {
|
if (callp == 0L) {
|
||||||
CallState.status.value = "Call failed"
|
CallState.status.value = "Call failed"
|
||||||
|
toast("Call failed to start")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val target = if (uri.startsWith("sip:")) uri else "sip:$uri"
|
|
||||||
CallState.add(WearCall(callp, defaultUap, target, "call outgoing", "out"))
|
CallState.add(WearCall(callp, defaultUap, target, "call outgoing", "out"))
|
||||||
Api.call_connect(callp, target)
|
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) {
|
fun answer(call: WearCall) {
|
||||||
if (call.uap != 0L) Api.ua_answer(call.uap, call.callp, Api.VIDMODE_OFF)
|
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)
|
else if (defaultUap != 0L) Api.ua_answer(defaultUap, call.callp, Api.VIDMODE_OFF)
|
||||||
@ -40,4 +68,15 @@ object WearBaresipServiceHelper {
|
|||||||
CallState.remove(call.callp)
|
CallState.remove(call.callp)
|
||||||
if (CallState.calls.isEmpty()) CallState.status.value = "Idle"
|
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
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
// Slimmed port of the phone app's Provisioning for the watch. Fetches and
|
// 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.
|
// baresip loads on (re)start.
|
||||||
object WearProvisioning {
|
object WearProvisioning {
|
||||||
|
|
||||||
@ -103,14 +103,20 @@ object WearProvisioning {
|
|||||||
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
|
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
|
||||||
fun writeAccount(ctx: Context, bundle: Bundle): String {
|
fun writeAccount(ctx: Context, bundle: Bundle): String {
|
||||||
val filesDir = ctx.filesDir
|
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 {
|
val accountLine = buildString {
|
||||||
append("<$aor>")
|
append("<$aor>")
|
||||||
if (!bundle.outbound1.isNullOrBlank()) append(";outbound=\"${bundle.outbound1}\"")
|
if (!bundle.outbound1.isNullOrBlank()) {
|
||||||
if (!bundle.outbound2.isNullOrBlank()) append(";outbound2=\"${bundle.outbound2}\"")
|
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}")
|
if (bundle.regInt != null) append(";regint=${bundle.regInt}")
|
||||||
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
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)
|
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||||
if (!bundle.password.isNullOrBlank()) {
|
if (!bundle.password.isNullOrBlank()) {
|
||||||
@ -124,17 +130,19 @@ object WearProvisioning {
|
|||||||
// Writes accounts from manual debug input.
|
// Writes accounts from manual debug input.
|
||||||
fun writeManualAccount(ctx: Context, aor: String, password: String, outbound: String) {
|
fun writeManualAccount(ctx: Context, aor: String, password: String, outbound: String) {
|
||||||
val filesDir = ctx.filesDir
|
val filesDir = ctx.filesDir
|
||||||
|
val cleanAor = aor.removePrefix("sip:")
|
||||||
val accountLine = buildString {
|
val accountLine = buildString {
|
||||||
append("<$aor>")
|
append("<$cleanAor>")
|
||||||
if (outbound.isNotBlank()) append(";outbound=\"$outbound\"")
|
val hostPort = outbound.removePrefix("sip:")
|
||||||
|
if (hostPort.isNotBlank()) append(";outbound=\"$hostPort\"")
|
||||||
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
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)
|
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||||
if (password.isNotBlank()) {
|
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 {
|
private fun rsaDecrypt(encrypted: ByteArray): ByteArray {
|
||||||
|
|||||||
Reference in New Issue
Block a user