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

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

View File

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

View File

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

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

View File

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

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