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