Compare commits

..

3 Commits

Author SHA1 Message Date
f0a4fd18cd Wear UI: intercept system back so Messages/Conversation/Accounts pop within app
Add BackHandler in WearApp: back from thread: -> messages, messages/accounts
-> dialer. At the root dialer route the handler is disabled so back finishes
the Activity normally. Fixes back-from-Messages dropping to the clock face.
2026-08-21 04:09:41 +01:00
100ee241a2 Wear SIP: plaintext SIP MESSAGE (RFC 3428) send/receive + inbox UI
- Native: add message_send() JNI (Api.message_send) and wire the
  existing message_listen receive callback (message_handler) to forward
  inbound MESSAGEs to WearBaresipService.messageEvent.
- Kotlin: WearBaresipServiceHelper.sendMessage (normalizes peer ->
  sip:peer@mail.txt3.net), MessageStore (JSON file persistence, no extra
  deps), messageEvent stores + posts a CATEGORY_MESSAGE notification.
- UI: MessagesScreen (conversation list) + ConversationScreen (thread +
  send field), reached via a new 'Messages' button on the dialer.
- README roadmap: mark SIP MESSAGE done; reword encryption plan to
  asymmetric E2E over SIP MESSAGE with NFC key exchange (OMEMO is
  XMPP-only and does not apply to SIP).

Verified: builds, app starts, registers over TLS, no crash. Live
two-endpoint exchange still to be tested with a real peer.
2026-08-21 03:48:21 +01:00
0f883c9847 Wear SIP: switch transport to TLS:5062 (verify disabled, mTLS pending)
- accounts: outbound transport=tls on port 5062.
- config.static: tls_verify no + tls_selfsigned yes (server cert is an
  expired Asterisk Private CA; verification disabled for now; mTLS client
  cert support planned but not yet enabled).
- Registration over TLS verified working on Galaxy Watch (Android 16).

Note: TLS transport is independent of the self-managed incoming-call UI
path (verified earlier over UDP), so lock-screen answer/decline + audio
carry over unchanged.
2026-08-21 03:22:04 +01:00
9 changed files with 408 additions and 8 deletions

View File

@ -123,10 +123,25 @@ Note: a clean native rebuild is required after editing `baresip.c`:
Near-term (core completeness): Near-term (core completeness):
- **SIP MESSAGE (IM) support.** baresip already has a `message` module. Add: - **SIP MESSAGE (IM) — DONE in this branch.** Plaintext RFC 3428 MESSAGE is
- a native `MESSAGE` event → Kotlin bridge, implemented end-to-end:
- an inbox/thread UI (or at least a toast + unread badge), - Native: `message_send()` JNI + a `message_listen` receive callback
- outbound `Api.message_send()` from the dialer. (`message_handler` in `baresip.c`) forwarding to `WearBaresipService.messageEvent`.
- Kotlin: `Api.message_send`, `WearBaresipServiceHelper.sendMessage` (normalizes
peer → `sip:peer@mail.txt3.net`), `MessageStore` (JSON file in `filesDir`),
and `MainActivity` `MessagesScreen` / `ConversationScreen` (conversation list +
thread + send field, reached via a "Messages" button on the dialer).
- Incoming messages are persisted and surfaced via a `CATEGORY_MESSAGE` notification.
- **Encryption is NOT yet in this pass** — see the long-term item; the body is
sent in cleartext (protected only by the TLS SIP transport in transit).
- **Encrypted SIP MESSAGE (next phase).** Plan agreed: **asymmetric E2E** using a
per-device X25519/Ed25519 keypair, with public keys exchanged **out-of-band via
NFC tap** (strongest trust — no server in the path), falling back to exchange
over a plaintext MESSAGE if NFC is unavailable (trusts the TLS server). The MESSAGE
body is then encrypted with the peer's public key before `message_send`. Note:
**OMEMO does not apply here** — OMEMO is an XMPP (Jabber) standard and cannot run
over SIP; we implement the same *class* of protection (Signal-style envelope) on
top of SIP MESSAGE instead. OpenSSL (already bundled) provides the crypto.
- **Read device contacts** via the Wearable Data Layer / `ContactsContract` - **Read device contacts** via the Wearable Data Layer / `ContactsContract`
(the watch mirrors phone contacts through the companion app) so the dialer (the watch mirrors phone contacts through the companion app) so the dialer
can autocomplete instead of requiring typed SIP URIs. can autocomplete instead of requiring typed SIP URIs.
@ -149,11 +164,25 @@ Medium-term (polish & robustness):
- **Re-enable system telecom** as an *optional* path behind a setting, now - **Re-enable system telecom** as an *optional* path behind a setting, now
that the native answer/hangup path is stable — but keep the in-app UI as that the native answer/hangup path is stable — but keep the in-app UI as
the default to avoid the earlier 2-second auto-drop. the default to avoid the earlier 2-second auto-drop.
- **SRTP media encryption** — already compiled in (`srtp.so`, `dtls_srtp.so`,
`gzrtp.so` loaded in `config.static`). Verify SRTP actually negotiates on a
TLS:5062 call: capture the SDP and confirm `a=crypto` (SDES) or
`a=fingerprint` (DTLS-SRTP) is offered/answered. Then pair TLS signalling +
DTLS-SRTP for fully encrypted calls (signalling + media). May need the
Asterisk side set `encryption=yes` / `media_encryption=dtls` (or `sdes`).
- **WebSocket (WS/WSS) transport support** — baresip core supports
`transport=ws`/`wss` and the registrar filter already allows `ws,wss`, but
the `websocket` module is **not** compiled into `libbaresip.a` for this
wear build (only `srtp`/`dtls_srtp`/`gzrtp` modules are present; no
`websocket/` module). To enable: add the `websocket` module to the baresip
build, rebuild `libbaresip.a` + `libwearbaresip.so`, load it in
`config.static`, and use `outbound="sip:host:PORT;transport=wss"`. Only
needed if the server fronts SIP behind WebSocket (WebRTC/CPaaS-style), not
typical for an Asterisk PBX.
Long-term: Long-term:
- **Video calls** (baresip supports it; watch camera is the constraint). - **Video calls** (baresip supports it; watch camera is the constraint).
- **Encrypted messaging / OMEMO** if the server supports it.
- **Wear companion app** on the phone for config + contact sync. - **Wear companion app** on the phone for config + contact sync.
--- ---

View File

@ -1,2 +1,2 @@
<sip:01273961147@mail.txt3.net>;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5761;transport=udp";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;sipnat=outbound;natpinhole=yes <sip:01273961147@mail.txt3.net>;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5062;transport=tls";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;sipnat=outbound;natpinhole=yes

View File

@ -40,3 +40,8 @@ opus_cbr no
opus_inbandfec yes opus_inbandfec yes
opus_application voip opus_application voip
dtls_srtp_use_ec prime256v1 dtls_srtp_use_ec prime256v1
# TLS transport (inbound/outbound SIP over TLS). Server cert is an expired
# Asterisk Private CA cert, so verification is disabled for now; mTLS client
# cert support is planned but not yet enabled.
tls_verify no
tls_selfsigned yes

View File

@ -640,6 +640,31 @@ Java_com_tutpro_baresip_wear_Api_account_1aor(JNIEnv *env, jclass clazz, jlong a
return jni_account_aor(env, acc); return jni_account_aor(env, acc);
} }
JNIEXPORT jint JNICALL
Java_com_tutpro_baresip_wear_Api_message_1send(JNIEnv *env, jclass clazz,
jlong uap, jstring jPeer, jstring jMsg)
{
(void)clazz;
struct ua *ua = (struct ua *)(intptr_t)uap;
const char *peer = (*env)->GetStringUTFChars(env, jPeer, 0);
const char *msg = (*env)->GetStringUTFChars(env, jMsg, 0);
int err;
if (!ua || !peer || !msg) {
if (peer) (*env)->ReleaseStringUTFChars(env, jPeer, peer);
if (msg) (*env)->ReleaseStringUTFChars(env, jMsg, msg);
return -1;
}
err = message_send(ua, peer, msg, NULL, NULL);
if (err) {
LOGE("message_send failed: %d\n", err);
}
(*env)->ReleaseStringUTFChars(env, jPeer, peer);
(*env)->ReleaseStringUTFChars(env, jMsg, msg);
return err;
}
JNIEXPORT jlong JNICALL JNIEXPORT jlong JNICALL
Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz, Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz,
jlong uap, jlong xcallp, jint video) jlong uap, jlong xcallp, jint video)

View File

@ -22,5 +22,7 @@ class Api private constructor() {
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int @JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
@JvmStatic external fun account_aor(acc: Long): String @JvmStatic external fun account_aor(acc: Long): String
@JvmStatic external fun net_use_nameserver(servers: String): Int @JvmStatic external fun net_use_nameserver(servers: String): Int
// Send a SIP MESSAGE (RFC 3428) via the given UA. Returns 0 on success.
@JvmStatic external fun message_send(uap: Long, peer: String, msg: String): Int
} }
} }

View File

@ -8,10 +8,12 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
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.layout.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -22,6 +24,8 @@ import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -186,6 +190,17 @@ class MainActivity : ComponentActivity() {
fun WearApp(activity: MainActivity) { fun WearApp(activity: MainActivity) {
// Show the in-call screen whenever there is an active or incoming call. // Show the in-call screen whenever there is an active or incoming call.
val hasCall by remember { derivedStateOf { CallState.calls.isNotEmpty() } } val hasCall by remember { derivedStateOf { CallState.calls.isNotEmpty() } }
// Intercept the system back gesture so navigation stays inside the app
// (Messages/Conversation/Accounts screens pop to their parent instead of
// finishing the Activity and dropping to the clock face). At the root
// "dialer" route the handler is disabled, so back behaves normally.
BackHandler(enabled = activity.route.value != "dialer") {
activity.route.value = when {
activity.route.value.startsWith("thread:") -> "messages"
activity.route.value == "messages" || activity.route.value == "accounts" -> "dialer"
else -> "dialer"
}
}
MaterialTheme { MaterialTheme {
Scaffold(timeText = { TimeText() }) { Scaffold(timeText = { TimeText() }) {
when { when {
@ -195,14 +210,28 @@ fun WearApp(activity: MainActivity) {
onEnd = { hangupCall(it) } onEnd = { hangupCall(it) }
) )
activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" }) activity.route.value == "accounts" -> AccountsScreen(onBack = { activity.route.value = "dialer" })
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" }) activity.route.value == "messages" -> MessagesScreen(
onBack = { activity.route.value = "dialer" },
onOpen = { activity.route.value = "thread:$it" }
)
activity.route.value.startsWith("thread:") -> {
val peer = activity.route.value.removePrefix("thread:")
ConversationScreen(
peer = peer,
onBack = { activity.route.value = "messages" }
)
}
else -> DialerScreen(
onAccounts = { activity.route.value = "accounts" },
onMessages = { activity.route.value = "messages" }
)
} }
} }
} }
} }
@Composable @Composable
fun DialerScreen(onAccounts: () -> Unit) { fun DialerScreen(onAccounts: () -> Unit, onMessages: () -> Unit) {
var number by remember { mutableStateOf("") } var number by remember { mutableStateOf("") }
// Stock-dialer keypad layout: digit + the small sub-letters shown on real // Stock-dialer keypad layout: digit + the small sub-letters shown on real
@ -336,6 +365,132 @@ fun DialerScreen(onAccounts: () -> Unit) {
Icon(Icons.Filled.Settings, contentDescription = "Accounts") Icon(Icons.Filled.Settings, contentDescription = "Accounts")
Text("Accounts", modifier = Modifier.padding(start = 4.dp)) Text("Accounts", modifier = Modifier.padding(start = 4.dp))
} }
Spacer(Modifier.height(6.dp))
// Messages (SIP MESSAGE / RFC 3428).
Button(
onClick = onMessages,
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
)
) {
Icon(Icons.Filled.Email, contentDescription = "Messages")
Text("Messages", modifier = Modifier.padding(start = 4.dp))
}
}
}
@Composable
fun MessagesScreen(onBack: () -> Unit, onOpen: (String) -> Unit) {
val context = LocalContext.current
val conversations = remember { MessageStore.conversations(context) }
MaterialTheme {
Scaffold(timeText = { TimeText() }) {
ScalingLazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text("Messages", style = MaterialTheme.typography.title2,
color = androidx.compose.ui.graphics.Color.White)
Spacer(Modifier.height(8.dp))
}
if (conversations.isEmpty()) {
item {
Text("No messages yet", style = MaterialTheme.typography.body2,
color = androidx.compose.ui.graphics.Color.Gray)
}
}
items(conversations.size) { i ->
val (peer, last) = conversations[i]
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
Button(
onClick = { onOpen(peer) },
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
),
modifier = Modifier.fillMaxWidth()
) {
Column(horizontalAlignment = Alignment.Start) {
Text(label, color = androidx.compose.ui.graphics.Color.White)
Text(
(if (last.direction == "out") "You: " else "") + last.body.take(32),
style = MaterialTheme.typography.body2,
color = androidx.compose.ui.graphics.Color.Gray,
maxLines = 1
)
}
}
Spacer(Modifier.height(4.dp))
}
}
}
}
}
@Composable
fun ConversationScreen(peer: String, onBack: () -> Unit) {
val context = LocalContext.current
var draft by remember { mutableStateOf("") }
val thread = remember { MessageStore.thread(context, peer) }
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
MaterialTheme {
Scaffold(timeText = { TimeText() }) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(label, style = MaterialTheme.typography.title3,
color = androidx.compose.ui.graphics.Color.White)
Spacer(Modifier.height(6.dp))
ScalingLazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
items(thread.size) { i ->
val m = thread[i]
val bg = if (m.direction == "out")
androidx.compose.ui.graphics.Color(0xFF1B3A1B)
else androidx.compose.ui.graphics.Color.DarkGray
Text(
m.body,
modifier = Modifier
.fillMaxWidth()
.background(bg, RoundedCornerShape(6.dp))
.padding(6.dp),
style = MaterialTheme.typography.body2,
color = androidx.compose.ui.graphics.Color.White
)
Spacer(Modifier.height(4.dp))
}
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
label = { Text("Message", color = androidx.compose.ui.graphics.Color.White) },
textStyle = androidx.compose.ui.text.TextStyle(color = androidx.compose.ui.graphics.Color.White),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Button(
onClick = {
val text = draft.trim()
if (text.isNotEmpty()) {
WearBaresipServiceHelper.sendMessage(context, peer, text)
draft = ""
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = androidx.compose.ui.graphics.Color.DarkGray
),
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Filled.Send, contentDescription = "Send")
Text("Send", modifier = Modifier.padding(start = 4.dp))
}
}
}
} }
} }

View File

@ -0,0 +1,104 @@
package com.tutpro.baresip.wear
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* A single SIP MESSAGE (RFC 3428) — sent or received.
* Plaintext for now; encryption is a later phase (NFC key exchange +
* asymmetric envelope, see README roadmap).
*/
data class SipMessage(
val id: String = UUID.randomUUID().toString(),
val peer: String,
val direction: String, // "in" | "out"
val body: String,
val timestamp: Long = System.currentTimeMillis()
)
/**
* Tiny file-backed store for SIP MESSAGE history. Uses a JSON file in
* filesDir (no extra dependency). Conversations are keyed by normalized peer.
*/
object MessageStore {
private const val FILE = "messages.json"
private const val TAG = "Baresip Wear Msg"
private var cache: MutableList<SipMessage>? = null
private fun file(context: android.content.Context): File =
File(context.filesDir, FILE)
@Synchronized
fun load(context: android.content.Context): List<SipMessage> {
if (cache != null) return cache!!
val list = mutableListOf<SipMessage>()
try {
val f = file(context)
if (f.exists()) {
val arr = JSONArray(f.readText())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
list.add(
SipMessage(
id = o.optString("id", UUID.randomUUID().toString()),
peer = o.optString("peer", ""),
direction = o.optString("direction", "in"),
body = o.optString("body", ""),
timestamp = o.optLong("timestamp", 0L)
)
)
}
}
} catch (e: Exception) {
Log.w(TAG, "load failed: ${e.message}")
}
cache = list
return list
}
@Synchronized
fun add(context: android.content.Context, msg: SipMessage) {
val list = load(context).toMutableList()
list.add(msg)
// Cap history to keep the file small on a watch.
val trimmed = if (list.size > 500) list.takeLast(500) else list
cache = trimmed.toMutableList()
try {
val arr = JSONArray()
for (m in trimmed) {
arr.put(
JSONObject().apply {
put("id", m.id)
put("peer", m.peer)
put("direction", m.direction)
put("body", m.body)
put("timestamp", m.timestamp)
}
)
}
file(context).writeText(arr.toString())
} catch (e: Exception) {
Log.w(TAG, "save failed: ${e.message}")
}
}
/** Conversations (one per peer), most-recent first. */
fun conversations(context: android.content.Context): List<Pair<String, SipMessage>> {
val byPeer = linkedMapOf<String, SipMessage>()
for (m in load(context)) {
val prev = byPeer[m.peer]
if (prev == null || m.timestamp >= prev.timestamp) byPeer[m.peer] = m
}
return byPeer.entries
.sortedByDescending { it.value.timestamp }
.map { it.key to it.value }
}
fun thread(context: android.content.Context, peer: String): List<SipMessage> =
load(context).filter { it.peer == peer }.sortedBy { it.timestamp }
}

View File

@ -386,6 +386,48 @@ class WearBaresipService : Service() {
} }
} }
/**
* Called from the native message_handler (baresip.c) when a SIP MESSAGE
* (RFC 3428) arrives. Signature matches the JNI upcall:
* (long ua, String peer, String contentType, byte[] body).
* Stores the message locally and surfaces a notification.
*/
fun messageEvent(ua: Long, peer: String, ctype: String?, body: ByteArray?) {
val text = try {
body?.toString(Charsets.UTF_8) ?: ""
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "messageEvent decode failed: ${e.message}")
""
}
if (text.isBlank()) return
android.util.Log.d("Baresip Wear", "messageEvent from $peer: ${text.take(40)}")
try {
MessageStore.add(this, SipMessage(peer = peer, direction = "in", body = text))
notifyIncomingMessage(peer, text)
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "messageEvent store failed: ${e.message}")
}
}
/** Post a notification for an incoming SIP MESSAGE. */
private fun notifyIncomingMessage(peer: String, body: String) {
try {
val nm = getSystemService(NotificationManager::class.java) ?: return
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Message from $label")
.setContentText(body)
.setSmallIcon(android.R.drawable.sym_action_call) // reuse a system icon
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.build()
nm.notify(MESSAGE_NOTIFICATION_ID, notification)
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "notifyIncomingMessage failed: ${e.message}")
}
}
private fun showToast(message: String) { private fun showToast(message: String) {
try { try {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
@ -551,6 +593,7 @@ class WearBaresipService : Service() {
// Dedicated high-importance channel for incoming-call heads-up alerts. // Dedicated high-importance channel for incoming-call heads-up alerts.
const val CALL_CHANNEL_ID = "baresip-wear-call" const val CALL_CHANNEL_ID = "baresip-wear-call"
const val CALL_NOTIFICATION_ID = 2 const val CALL_NOTIFICATION_ID = 2
const val MESSAGE_NOTIFICATION_ID = 3
// Tracks the live call's native handle for hangup/decline. Updated on // Tracks the live call's native handle for hangup/decline. Updated on
// incoming/established/closed events. A single call at a time on the watch. // incoming/established/closed events. A single call at a time on the watch.

View File

@ -103,4 +103,41 @@ object WearBaresipServiceHelper {
android.util.Log.w("Baresip Wear", "sendDigit failed: ${e.message}") android.util.Log.w("Baresip Wear", "sendDigit failed: ${e.message}")
} }
} }
// Send a SIP MESSAGE (RFC 3428) to a peer. Normalizes the peer to a
// sip: URI (matching the dial() logic) and stores the outgoing message
// locally on success.
fun sendMessage(context: android.content.Context, peer: String, body: String) {
if (defaultUap == 0L) {
toast("No SIP account - register first")
return
}
val target = normalizePeer(peer)
android.util.Log.d("Baresip Wear", "sendMessage($target) defaultUap=$defaultUap")
try {
val err = Api.message_send(defaultUap, target, body)
if (err == 0) {
MessageStore.add(
context,
SipMessage(peer = target, direction = "out", body = body)
)
} else {
toast("Message failed ($err)")
}
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "sendMessage failed: ${e.message}")
toast("Message failed")
}
}
// Bare peer (no scheme) -> sip:peer@domain; scheme-only -> append @domain;
// already has '@' -> leave as-is. Mirrors dial() normalization.
private fun normalizePeer(peer: String): String {
val domain = "mail.txt3.net" // same default used by dial(); TODO: derive
return when {
peer.contains("@") -> peer
peer.startsWith("sip:") -> "$peer@$domain"
else -> "sip:$peer@$domain"
}
}
} }