From a849d98b5e8bdff3fb9c712242d81c78c5d93e96 Mon Sep 17 00:00:00 2001 From: jamie prince Date: Wed, 19 Aug 2026 04:05:40 +0100 Subject: [PATCH] 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 --- gradle/libs.versions.toml | 1 + wear/build.gradle.kts | 3 + wear/src/main/AndroidManifest.xml | 6 + wear/src/main/cpp/baresip.c | 5 +- .../main/java/com/tutpro/baresip/wear/Api.kt | 23 ++ .../java/com/tutpro/baresip/wear/CallState.kt | 37 +++ .../com/tutpro/baresip/wear/MainActivity.kt | 267 +++++++++++++++++- .../tutpro/baresip/wear/WearBaresipService.kt | 50 +++- .../baresip/wear/WearBaresipServiceHelper.kt | 43 +++ .../tutpro/baresip/wear/WearProvisioning.kt | 160 +++++++++++ 10 files changed, 583 insertions(+), 12 deletions(-) create mode 100644 wear/src/main/java/com/tutpro/baresip/wear/Api.kt create mode 100644 wear/src/main/java/com/tutpro/baresip/wear/CallState.kt create mode 100644 wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt create mode 100644 wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80ab3721..b64ff5ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", vers 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-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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 98189e8a..a80d8d57 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -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) } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 8093af0d..5b55d515 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -29,6 +29,12 @@ + + + + + + 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", ""); diff --git a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt new file mode 100644 index 00000000..d648f863 --- /dev/null +++ b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt @@ -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 +} diff --git a/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt b/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt new file mode 100644 index 00000000..4d9fe99b --- /dev/null +++ b/wear/src/main/java/com/tutpro/baresip/wear/CallState.kt @@ -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() + val registration = mutableStateOf("") + val status = mutableStateOf("Idle") + val recentPeers = mutableStateListOf() + + 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" } +} diff --git a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt index 0fc9ffff..8ac83468 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt @@ -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 +} diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt index cf208f43..50ce9015 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt @@ -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: ",[,[,]]". + // 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 { diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt new file mode 100644 index 00000000..120066c2 --- /dev/null +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt @@ -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" + } +} diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt new file mode 100644 index 00000000..9b04227f --- /dev/null +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearProvisioning.kt @@ -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 "/accounts" (no extension) plus "/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) + } +}