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:
@ -44,9 +44,12 @@ dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.wear)
|
||||
implementation(libs.androidx.wear.compose)
|
||||
implementation(libs.androidx.wear.compose.foundation)
|
||||
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" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<service
|
||||
|
||||
@ -88,7 +88,7 @@ static void event_handler(enum bevent_ev ev, struct bevent *event, void *arg)
|
||||
case BEVENT_UNREGISTERING:
|
||||
case BEVENT_REGISTER_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;
|
||||
case BEVENT_REGISTER_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);
|
||||
break;
|
||||
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;
|
||||
case BEVENT_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.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.wear.compose.material.Text
|
||||
import androidx.wear.compose.material.TimeText
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.material.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
internal val route = mutableStateOf("dialer")
|
||||
|
||||
private val recordAudioPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
startBaresipService()
|
||||
}
|
||||
if (granted) startBaresipService() else CallState.status.value = "Mic permission denied"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TimeText()
|
||||
Text(text = "Baresip Wear")
|
||||
WearApp(this)
|
||||
}
|
||||
|
||||
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() {
|
||||
val intent = Intent(this, WearBaresipService::class.java)
|
||||
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()
|
||||
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
|
||||
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 baresipStop()
|
||||
|
||||
// Native event callback. Format: "<event>,<arg>[,<uap>[,<callp>]]".
|
||||
// Example: "call incoming,sip:bob@example.com,140512345,987654321"
|
||||
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 {
|
||||
|
||||
@ -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