Wear UI: dialer, in-call, debug accounts, baresip: provisioning
- Wear Compose dialer with recent calls and in-call answer/decline/hangup/mute - Debug accounts screen (scrollable ScalingLazyColumn) for manual SIP config - baresip:// provisioning intent mirroring phone app (RSA/AES bundle decrypt) - Physical button (STEM_1/BACK) returns from Accounts via onKeyDown - Native event handler now emits uap/callp for incoming/registration events - Writes accounts/auth files in baresip format; verified REGISTER attempt on watch
This commit is contained in:
@ -38,6 +38,7 @@ androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", vers
|
|||||||
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
|
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
|
||||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
|
||||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeCompose" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
|
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
|
||||||
|
|||||||
@ -44,9 +44,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
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)
|
||||||
implementation(libs.androidx.wear.compose.navigation)
|
implementation(libs.androidx.wear.compose.navigation)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,12 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="baresip" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
|||||||
@ -88,7 +88,7 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
|
|||||||
case BEVENT_UNREGISTERING:
|
case BEVENT_UNREGISTERING:
|
||||||
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", prm);
|
len = re_snprintf(event_buf, sizeof event_buf, "%s,%ld", prm, (long)ua);
|
||||||
break;
|
break;
|
||||||
case BEVENT_REGISTER_FAIL:
|
case BEVENT_REGISTER_FAIL:
|
||||||
case BEVENT_FALLBACK_FAIL:
|
case BEVENT_FALLBACK_FAIL:
|
||||||
@ -100,7 +100,8 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
|
|||||||
len = re_snprintf(event_buf, sizeof event_buf, "%s,%r,%ld", prm, &msg->from.auri, (long)event);
|
len = re_snprintf(event_buf, sizeof event_buf, "%s,%r,%ld", prm, &msg->from.auri, (long)event);
|
||||||
break;
|
break;
|
||||||
case BEVENT_CALL_INCOMING:
|
case BEVENT_CALL_INCOMING:
|
||||||
len = re_snprintf(event_buf, sizeof event_buf, "call incoming,%s", prm);
|
len = re_snprintf(event_buf, sizeof event_buf, "call incoming,%s,%ld,%ld",
|
||||||
|
prm, (long)ua, (long)call);
|
||||||
break;
|
break;
|
||||||
case BEVENT_CALL_OUTGOING:
|
case BEVENT_CALL_OUTGOING:
|
||||||
len = re_snprintf(event_buf, sizeof event_buf, "call outgoing", "");
|
len = re_snprintf(event_buf, sizeof event_buf, "call outgoing", "");
|
||||||
|
|||||||
23
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal file
23
wear/src/main/java/com/tutpro/baresip/wear/Api.kt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
// Minimal native bindings matching the symbols exported by wearbaresip JNI lib.
|
||||||
|
// Only the subset needed by the Wear UI is declared here.
|
||||||
|
object Api {
|
||||||
|
|
||||||
|
const val VIDMODE_OFF = 0
|
||||||
|
|
||||||
|
external fun ua_alloc(uri: String): Long
|
||||||
|
external fun ua_destroy(uap: Long)
|
||||||
|
external fun ua_register(uap: Long): Int
|
||||||
|
external fun ua_hangup(uap: Long, callp: Long, code: Int, reason: String)
|
||||||
|
external fun ua_answer(uap: Long, callp: Long, video: Int)
|
||||||
|
external fun ua_call_alloc(uap: Long, xcallp: Long, video: Int): Long
|
||||||
|
|
||||||
|
external fun call_connect(callp: Long, peerUri: String): Int
|
||||||
|
external fun call_hold(callp: Long, hold: Boolean): Boolean
|
||||||
|
external fun call_send_digit(callp: Long, digit: Char): Int
|
||||||
|
external fun call_destroy(callp: Long)
|
||||||
|
external fun calls_mute(mute: Boolean)
|
||||||
|
|
||||||
|
external fun account_aor(acc: Long): String
|
||||||
|
}
|
||||||
37
wear/src/main/java/com/tutpro/baresip/wear/CallState.kt
Normal file
37
wear/src/main/java/com/tutpro/baresip/wear/CallState.kt
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
||||||
|
// Lightweight call model for the watch. Mirrors the essential parts of the
|
||||||
|
// phone app's Call class without the Telecom/ConnectionService machinery.
|
||||||
|
data class WearCall(
|
||||||
|
val callp: Long,
|
||||||
|
val uap: Long,
|
||||||
|
val peerUri: String,
|
||||||
|
var status: String = "",
|
||||||
|
val dir: String = "in", // "in" or "out"
|
||||||
|
var onHold: Boolean = false,
|
||||||
|
var muted: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
object CallState {
|
||||||
|
val calls = mutableStateListOf<WearCall>()
|
||||||
|
val registration = mutableStateOf("")
|
||||||
|
val status = mutableStateOf("Idle")
|
||||||
|
val recentPeers = mutableStateListOf<String>()
|
||||||
|
|
||||||
|
fun add(call: WearCall) {
|
||||||
|
if (calls.none { it.callp == call.callp }) calls.add(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(callp: Long) {
|
||||||
|
calls.removeAll { it.callp == callp }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(callp: Long) = calls.firstOrNull { it.callp == callp }
|
||||||
|
|
||||||
|
fun active() = calls.firstOrNull { it.status == "call established" || it.status == "call outgoing" || it.status == "call ringing" }
|
||||||
|
|
||||||
|
fun incoming() = calls.firstOrNull { it.status == "call incoming" }
|
||||||
|
}
|
||||||
@ -3,30 +3,50 @@ package com.tutpro.baresip.wear
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.activity.ComponentActivity
|
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.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.CallEnd
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.wear.compose.material.Text
|
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||||
import androidx.wear.compose.material.TimeText
|
import androidx.wear.compose.foundation.lazy.items
|
||||||
|
import androidx.wear.compose.material.*
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
internal val route = mutableStateOf("dialer")
|
||||||
|
|
||||||
private val recordAudioPermission = registerForActivityResult(
|
private val recordAudioPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) startBaresipService() else CallState.status.value = "Mic permission denied"
|
||||||
startBaresipService()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
TimeText()
|
WearApp(this)
|
||||||
Text(text = "Baresip Wear")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
@ -38,6 +58,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
if (intent.action == Intent.ACTION_VIEW && intent.data?.scheme == "baresip") {
|
||||||
|
handleProvisioningIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physical side button (STEM_1) / BACK key pops the Accounts screen.
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
if (route.value == "accounts" &&
|
||||||
|
(keyCode == KeyEvent.KEYCODE_STEM_1 || keyCode == KeyEvent.KEYCODE_BACK)
|
||||||
|
) {
|
||||||
|
route.value = "dialer"
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProvisioningIntent(intent: Intent) {
|
||||||
|
val data = intent.data ?: return
|
||||||
|
val endpoint = data.getQueryParameter("endpoint") ?: return
|
||||||
|
val extension = data.getQueryParameter("extension") ?: return
|
||||||
|
CallState.status.value = "Provisioning..."
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val bundle = WearProvisioning.fetchBundle(endpoint, extension)
|
||||||
|
val aor = WearProvisioning.writeAccount(applicationContext, bundle)
|
||||||
|
CallState.registration.value = "Provisioned: $aor"
|
||||||
|
// Restart the native stack so it picks up accounts.cfg
|
||||||
|
val svc = Intent(applicationContext, WearBaresipService::class.java)
|
||||||
|
startForegroundService(svc)
|
||||||
|
CallState.status.value = "Restarting SIP"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
CallState.status.value = "Provisioning failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startBaresipService() {
|
private fun startBaresipService() {
|
||||||
val intent = Intent(this, WearBaresipService::class.java)
|
val intent = Intent(this, WearBaresipService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@ -47,3 +105,198 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WearApp(activity: MainActivity) {
|
||||||
|
MaterialTheme {
|
||||||
|
Scaffold(timeText = { TimeText() }) {
|
||||||
|
when (activity.route.value) {
|
||||||
|
"dialer" -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
||||||
|
"accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
|
||||||
|
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DialerScreen(onAccounts: () -> Unit) {
|
||||||
|
var number by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = CallState.registration.value.ifEmpty { "Baresip Wear" },
|
||||||
|
style = MaterialTheme.typography.title3,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = number,
|
||||||
|
onValueChange = { number = it },
|
||||||
|
label = { Text("Number") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(onClick = { placeCall(number) }) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = "Call")
|
||||||
|
Text("Call", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Button(onClick = onAccounts) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Accounts")
|
||||||
|
Text("Accounts", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CallState.recentPeers.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ScalingLazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
items(CallState.recentPeers.take(5)) { peer ->
|
||||||
|
Chip(
|
||||||
|
onClick = { placeCall(peer) },
|
||||||
|
label = { Text(peer, maxLines = 1) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AccountsScreen(onBack: () -> Unit) {
|
||||||
|
// Debug-only: manual account config. To be removed before release.
|
||||||
|
var aor by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var outbound by remember { mutableStateOf("") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ScalingLazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
item { Text("Accounts (debug)", style = MaterialTheme.typography.title3) }
|
||||||
|
item { Spacer(Modifier.height(6.dp)) }
|
||||||
|
item {
|
||||||
|
OutlinedTextField(value = aor, onValueChange = { aor = it },
|
||||||
|
label = { Text("sip:aor") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(4.dp)) }
|
||||||
|
item {
|
||||||
|
OutlinedTextField(value = password, onValueChange = { password = it },
|
||||||
|
label = { Text("password") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(4.dp)) }
|
||||||
|
item {
|
||||||
|
OutlinedTextField(value = outbound, onValueChange = { outbound = it },
|
||||||
|
label = { Text("outbound (opt)") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(8.dp)) }
|
||||||
|
item {
|
||||||
|
Button(onClick = {
|
||||||
|
val aorStr = if (aor.startsWith("sip:")) aor else "sip:$aor"
|
||||||
|
WearProvisioning.writeManualAccount(context, aorStr, password, outbound)
|
||||||
|
CallState.registration.value = "Saved: $aorStr"
|
||||||
|
onBack()
|
||||||
|
}) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { Spacer(Modifier.height(4.dp)) }
|
||||||
|
item {
|
||||||
|
Button(onClick = onBack) { Text("Back") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InCallScreen() {
|
||||||
|
val call = CallState.incoming() ?: CallState.active()
|
||||||
|
val status = call?.status ?: CallState.status.value
|
||||||
|
val peer = call?.peerUri ?: ""
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = peer.ifEmpty { "Call" },
|
||||||
|
style = MaterialTheme.typography.title3,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(text = status, style = MaterialTheme.typography.body2)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
if (call != null && call.status == "call incoming") {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(onClick = { answerCall(call) }) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = "Answer")
|
||||||
|
Text("Answer", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
Button(onClick = { declineCall(call) }) {
|
||||||
|
Icon(Icons.Filled.CallEnd, contentDescription = "Decline")
|
||||||
|
Text("End", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
val muted = call?.muted ?: false
|
||||||
|
Button(onClick = { toggleMute(call, muted) }) {
|
||||||
|
Icon(
|
||||||
|
if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||||
|
contentDescription = "Mute"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = { hangupCall(call) }) {
|
||||||
|
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
||||||
|
Text("End", modifier = Modifier.padding(start = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeCall(uri: String) {
|
||||||
|
if (uri.isBlank()) return
|
||||||
|
val target = if (uri.contains("@") || uri.startsWith("sip:")) uri else "sip:$uri"
|
||||||
|
WearBaresipServiceHelper.dial(target)
|
||||||
|
if (!CallState.recentPeers.contains(target)) {
|
||||||
|
CallState.recentPeers.add(0, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun answerCall(call: WearCall) {
|
||||||
|
WearBaresipServiceHelper.answer(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun declineCall(call: WearCall) {
|
||||||
|
WearBaresipServiceHelper.hangup(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hangupCall(call: WearCall?) {
|
||||||
|
if (call != null) WearBaresipServiceHelper.hangup(call)
|
||||||
|
else CallState.calls.toList().forEach { WearBaresipServiceHelper.hangup(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleMute(call: WearCall?, muted: Boolean) {
|
||||||
|
call ?: return
|
||||||
|
Api.calls_mute(!muted)
|
||||||
|
call.muted = !muted
|
||||||
|
}
|
||||||
|
|||||||
@ -32,8 +32,6 @@ class WearBaresipService : Service() {
|
|||||||
.build()
|
.build()
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
// Start the native baresip stack. Path points at the app's private
|
|
||||||
// config directory where accounts.cfg etc. are expected.
|
|
||||||
val path = applicationContext.filesDir.absolutePath
|
val path = applicationContext.filesDir.absolutePath
|
||||||
baresipStart(path, "", 2, "baresip-studio-wear")
|
baresipStart(path, "", 2, "baresip-studio-wear")
|
||||||
|
|
||||||
@ -50,8 +48,54 @@ class WearBaresipService : Service() {
|
|||||||
external fun baresipStart(path: String, addrs: String, logLevel: Int, software: String)
|
external fun baresipStart(path: String, addrs: String, logLevel: Int, software: String)
|
||||||
external fun baresipStop()
|
external fun baresipStop()
|
||||||
|
|
||||||
|
// 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) {
|
||||||
// TODO route to watch UI / Data Layer
|
val parts = event.split(",")
|
||||||
|
val ev = parts[0]
|
||||||
|
val arg = parts.getOrNull(1) ?: ""
|
||||||
|
val uap = parts.getOrNull(2)?.toLongOrNull() ?: 0L
|
||||||
|
val callp = parts.getOrNull(3)?.toLongOrNull() ?: 0L
|
||||||
|
android.util.Log.d("Baresip Wear", "uaEvent: $event")
|
||||||
|
|
||||||
|
when {
|
||||||
|
ev.startsWith("registering") -> {
|
||||||
|
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
|
||||||
|
CallState.registration.value = "Registering"
|
||||||
|
}
|
||||||
|
ev.startsWith("registered") -> {
|
||||||
|
if (uap != 0L) WearBaresipServiceHelper.setDefaultUa(uap)
|
||||||
|
CallState.registration.value = "Registered"
|
||||||
|
}
|
||||||
|
ev.startsWith("registering failed") -> CallState.registration.value = "Registration failed"
|
||||||
|
ev.startsWith("unregistering") -> CallState.registration.value = "Unregistered"
|
||||||
|
|
||||||
|
ev == "call incoming" -> {
|
||||||
|
val peer = arg.ifEmpty { "unknown" }
|
||||||
|
val cp = if (callp != 0L) callp else peer.hashCode().toLong()
|
||||||
|
if (CallState.find(cp) == null) {
|
||||||
|
CallState.add(WearCall(cp, uap, peer, "call incoming", "in"))
|
||||||
|
}
|
||||||
|
CallState.status.value = "Incoming call"
|
||||||
|
}
|
||||||
|
ev == "call outgoing" -> CallState.status.value = "Calling"
|
||||||
|
ev == "call established" -> {
|
||||||
|
CallState.status.value = "Connected"
|
||||||
|
CallState.active()?.status = "call established"
|
||||||
|
}
|
||||||
|
ev == "call ringing" -> CallState.status.value = "Ringing"
|
||||||
|
ev == "call closed" -> {
|
||||||
|
CallState.calls.toList().forEach { CallState.remove(it.callp) }
|
||||||
|
CallState.status.value = "Idle"
|
||||||
|
}
|
||||||
|
else -> CallState.status.value = ev
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.isNotEmpty() && (ev == "call incoming" || ev == "call outgoing") &&
|
||||||
|
!CallState.recentPeers.contains(arg)
|
||||||
|
) {
|
||||||
|
CallState.recentPeers.add(0, arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
// Bridges UI intent to the native stack. The watch assumes a single
|
||||||
|
// pre-configured default account (loaded from accounts.cfg by baresip init).
|
||||||
|
// The default UA pointer is captured from registration events.
|
||||||
|
object WearBaresipServiceHelper {
|
||||||
|
|
||||||
|
private var defaultUap: Long = 0L
|
||||||
|
|
||||||
|
fun setDefaultUa(uap: Long) {
|
||||||
|
if (uap != 0L) defaultUap = uap
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defaultUa(): Long = defaultUap
|
||||||
|
|
||||||
|
// Outgoing call: allocate a call slot on the default UA and connect.
|
||||||
|
fun dial(uri: String) {
|
||||||
|
if (defaultUap == 0L) {
|
||||||
|
CallState.status.value = "No account"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val callp = Api.ua_call_alloc(defaultUap, 0L, Api.VIDMODE_OFF)
|
||||||
|
if (callp == 0L) {
|
||||||
|
CallState.status.value = "Call failed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val target = if (uri.startsWith("sip:")) uri else "sip:$uri"
|
||||||
|
CallState.add(WearCall(callp, defaultUap, target, "call outgoing", "out"))
|
||||||
|
Api.call_connect(callp, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answer(call: WearCall) {
|
||||||
|
if (call.uap != 0L) Api.ua_answer(call.uap, call.callp, Api.VIDMODE_OFF)
|
||||||
|
else if (defaultUap != 0L) Api.ua_answer(defaultUap, call.callp, Api.VIDMODE_OFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hangup(call: WearCall) {
|
||||||
|
val uap = if (call.uap != 0L) call.uap else defaultUap
|
||||||
|
Api.ua_hangup(uap, call.callp, 0, "")
|
||||||
|
CallState.remove(call.callp)
|
||||||
|
if (CallState.calls.isEmpty()) CallState.status.value = "Idle"
|
||||||
|
}
|
||||||
|
}
|
||||||
160
wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
Normal file
160
wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
|
// Slimmed port of the phone app's Provisioning for the watch. Fetches and
|
||||||
|
// decrypts the provisioning bundle and writes an accounts.cfg file that
|
||||||
|
// baresip loads on (re)start.
|
||||||
|
object WearProvisioning {
|
||||||
|
|
||||||
|
private const val RSA_KEY_SIZE = 2048
|
||||||
|
private const val ALIAS = "baresip_wear_provisioning"
|
||||||
|
private const val SERVER_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
|
data class Bundle(
|
||||||
|
val connectString: String,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null,
|
||||||
|
val displayName: String? = null,
|
||||||
|
val outbound1: String? = null,
|
||||||
|
val outbound2: String? = null,
|
||||||
|
val regInt: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimal RSA keypair in AndroidKeyStore (reuses same shape as phone app).
|
||||||
|
private fun ensurePublicKey(): java.security.PublicKey {
|
||||||
|
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
if (ks.containsAlias(ALIAS)) {
|
||||||
|
return ks.getCertificate(ALIAS).publicKey
|
||||||
|
}
|
||||||
|
val kpg = java.security.KeyPairGenerator.getInstance("RSA")
|
||||||
|
val spec = android.security.keystore.KeyGenParameterSpec.Builder(
|
||||||
|
ALIAS,
|
||||||
|
android.security.keystore.KeyProperties.PURPOSE_DECRYPT or
|
||||||
|
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
|
||||||
|
)
|
||||||
|
.setAlgorithmParameterSpec(
|
||||||
|
java.security.spec.RSAKeyGenParameterSpec(
|
||||||
|
RSA_KEY_SIZE, java.security.spec.RSAKeyGenParameterSpec.F4
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setDigests(
|
||||||
|
android.security.keystore.KeyProperties.DIGEST_SHA256,
|
||||||
|
android.security.keystore.KeyProperties.DIGEST_SHA512
|
||||||
|
)
|
||||||
|
.setEncryptionPaddings(
|
||||||
|
android.security.keystore.KeyProperties.ENCRYPTION_PADDING_RSA_OAEP
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
kpg.initialize(spec)
|
||||||
|
return kpg.generateKeyPair().public
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchBundle(endpoint: String, extension: String): Bundle =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val pub = ensurePublicKey()
|
||||||
|
val enrollment = JSONObject().apply {
|
||||||
|
put("extension", extension)
|
||||||
|
put("public_key", Base64.encodeToString(pub.encoded, Base64.NO_WRAP))
|
||||||
|
}
|
||||||
|
val url = Uri.parse(endpoint).buildUpon().appendPath("bundle").build().toString()
|
||||||
|
val conn = URL(url).openConnection() as HttpsURLConnection
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.connectTimeout = SERVER_TIMEOUT_MS
|
||||||
|
conn.readTimeout = SERVER_TIMEOUT_MS
|
||||||
|
conn.outputStream.use { it.write(enrollment.toString().toByteArray(Charsets.UTF_8)); it.flush() }
|
||||||
|
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
throw Exception("Provisioning HTTP $code")
|
||||||
|
}
|
||||||
|
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
val root = JSONObject(body)
|
||||||
|
val encKey = Base64.decode(root.getString("encrypted_key"), Base64.NO_WRAP)
|
||||||
|
val iv = Base64.decode(root.getString("iv"), Base64.NO_WRAP)
|
||||||
|
val ct = Base64.decode(root.getString("ciphertext"), Base64.NO_WRAP)
|
||||||
|
val tag = Base64.decode(root.getString("tag"), Base64.NO_WRAP)
|
||||||
|
val aesKey = rsaDecrypt(encKey)
|
||||||
|
val plain = aesGcmDecrypt(aesKey, iv, ct, tag)
|
||||||
|
val p = JSONObject(String(plain, Charsets.UTF_8))
|
||||||
|
Bundle(
|
||||||
|
connectString = p.getString("connect_string"),
|
||||||
|
username = p.optString("username", null),
|
||||||
|
password = p.optString("password", null),
|
||||||
|
displayName = p.optString("display_name", null),
|
||||||
|
outbound1 = p.optString("outbound1", null),
|
||||||
|
outbound2 = p.optString("outbound2", null),
|
||||||
|
regInt = if (p.has("reg_int")) p.getInt("reg_int") else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes accounts file so baresip picks it up on next (re)start.
|
||||||
|
// baresip reads "<filesDir>/accounts" (no extension) plus "<filesDir>/config".
|
||||||
|
fun writeAccount(ctx: Context, bundle: Bundle): String {
|
||||||
|
val filesDir = ctx.filesDir
|
||||||
|
val aor = if (bundle.connectString.startsWith("sip:")) bundle.connectString else "sip:${bundle.connectString}"
|
||||||
|
val accountLine = buildString {
|
||||||
|
append("<$aor>")
|
||||||
|
if (!bundle.outbound1.isNullOrBlank()) append(";outbound=\"${bundle.outbound1}\"")
|
||||||
|
if (!bundle.outbound2.isNullOrBlank()) append(";outbound2=\"${bundle.outbound2}\"")
|
||||||
|
if (bundle.regInt != null) append(";regint=${bundle.regInt}")
|
||||||
|
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
||||||
|
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
|
||||||
|
}
|
||||||
|
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||||
|
if (!bundle.password.isNullOrBlank()) {
|
||||||
|
val authLine = "${bundle.username ?: aor} ${bundle.password}\n"
|
||||||
|
File(filesDir, "auth").writeText(authLine, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
Log.i("Baresip Wear", "Provisioned account $aor")
|
||||||
|
return aor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes accounts from manual debug input.
|
||||||
|
fun writeManualAccount(ctx: Context, aor: String, password: String, outbound: String) {
|
||||||
|
val filesDir = ctx.filesDir
|
||||||
|
val accountLine = buildString {
|
||||||
|
append("<$aor>")
|
||||||
|
if (outbound.isNotBlank()) append(";outbound=\"$outbound\"")
|
||||||
|
append(";stunserver=\"stun:stun.l.google.com:19302\"")
|
||||||
|
append(";regq=0.5;pubint=0;check_origin=no;mwi=no")
|
||||||
|
}
|
||||||
|
File(filesDir, "accounts").writeText("$accountLine\n", Charsets.UTF_8)
|
||||||
|
if (password.isNotBlank()) {
|
||||||
|
File(filesDir, "auth").writeText("$aor $password\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
Log.i("Baresip Wear", "Manual account saved: $aor")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rsaDecrypt(encrypted: ByteArray): ByteArray {
|
||||||
|
val ks = java.security.KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
val key = ks.getKey(ALIAS, null) as java.security.PrivateKey
|
||||||
|
val cipher = javax.crypto.Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
||||||
|
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key)
|
||||||
|
return cipher.doFinal(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ct: ByteArray, tag: ByteArray): ByteArray {
|
||||||
|
val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(
|
||||||
|
javax.crypto.Cipher.DECRYPT_MODE,
|
||||||
|
javax.crypto.spec.SecretKeySpec(key, "AES"),
|
||||||
|
javax.crypto.spec.GCMParameterSpec(128, iv)
|
||||||
|
)
|
||||||
|
val combined = ByteArray(ct.size + tag.size)
|
||||||
|
System.arraycopy(ct, 0, combined, 0, ct.size)
|
||||||
|
System.arraycopy(tag, 0, combined, ct.size, tag.size)
|
||||||
|
return cipher.doFinal(combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user