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:
2026-08-19 04:05:40 +01:00
parent 9f94e72b1e
commit a849d98b5e
10 changed files with 583 additions and 12 deletions

View File

@ -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" }

View File

@ -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)
} }

View File

@ -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

View File

@ -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", "");

View 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
}

View 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" }
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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"
}
}

View 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)
}
}