Compare commits
3 Commits
a31d6a693c
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a4fd18cd | |||
| 100ee241a2 | |||
| 0f883c9847 |
@ -123,10 +123,25 @@ Note: a clean native rebuild is required after editing `baresip.c`:
|
||||
|
||||
Near-term (core completeness):
|
||||
|
||||
- **SIP MESSAGE (IM) support.** baresip already has a `message` module. Add:
|
||||
- a native `MESSAGE` event → Kotlin bridge,
|
||||
- an inbox/thread UI (or at least a toast + unread badge),
|
||||
- outbound `Api.message_send()` from the dialer.
|
||||
- **SIP MESSAGE (IM) — DONE in this branch.** Plaintext RFC 3428 MESSAGE is
|
||||
implemented end-to-end:
|
||||
- Native: `message_send()` JNI + a `message_listen` receive callback
|
||||
(`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`
|
||||
(the watch mirrors phone contacts through the companion app) so the dialer
|
||||
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
|
||||
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.
|
||||
- **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:
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -40,3 +40,8 @@ opus_cbr no
|
||||
opus_inbandfec yes
|
||||
opus_application voip
|
||||
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
|
||||
|
||||
@ -640,6 +640,31 @@ Java_com_tutpro_baresip_wear_Api_account_1aor(JNIEnv *env, jclass clazz, jlong a
|
||||
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
|
||||
Java_com_tutpro_baresip_wear_Api_ua_1call_1alloc(JNIEnv *env, jclass clazz,
|
||||
jlong uap, jlong xcallp, jint video)
|
||||
|
||||
@ -22,5 +22,7 @@ class Api private constructor() {
|
||||
@JvmStatic external fun call_send_digit(callp: Long, digit: Char): Int
|
||||
@JvmStatic external fun account_aor(acc: Long): String
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,10 +8,12 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.MicOff
|
||||
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.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -186,6 +190,17 @@ class MainActivity : ComponentActivity() {
|
||||
fun WearApp(activity: MainActivity) {
|
||||
// Show the in-call screen whenever there is an active or incoming call.
|
||||
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 {
|
||||
Scaffold(timeText = { TimeText() }) {
|
||||
when {
|
||||
@ -195,14 +210,28 @@ fun WearApp(activity: MainActivity) {
|
||||
onEnd = { hangupCall(it) }
|
||||
)
|
||||
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
|
||||
fun DialerScreen(onAccounts: () -> Unit) {
|
||||
fun DialerScreen(onAccounts: () -> Unit, onMessages: () -> Unit) {
|
||||
var number by remember { mutableStateOf("") }
|
||||
|
||||
// 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")
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
104
wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt
Normal file
104
wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt
Normal 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 }
|
||||
}
|
||||
@ -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) {
|
||||
try {
|
||||
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.
|
||||
const val CALL_CHANNEL_ID = "baresip-wear-call"
|
||||
const val CALL_NOTIFICATION_ID = 2
|
||||
const val MESSAGE_NOTIFICATION_ID = 3
|
||||
|
||||
// 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.
|
||||
|
||||
@ -103,4 +103,41 @@ object WearBaresipServiceHelper {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user