diff --git a/wear/README.md b/wear/README.md index b0a6fbe8..090fab2e 100644 --- a/wear/README.md +++ b/wear/README.md @@ -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. --- diff --git a/wear/src/main/cpp/baresip.c b/wear/src/main/cpp/baresip.c index f7f319f4..893ece9d 100644 --- a/wear/src/main/cpp/baresip.c +++ b/wear/src/main/cpp/baresip.c @@ -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) diff --git a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt index 8c299bca..30f0c1c9 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/Api.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/Api.kt @@ -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 } } 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 e698f0bf..e94064a0 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt @@ -12,6 +12,7 @@ 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 +23,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 @@ -195,14 +198,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 +353,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)) + } + } + } } } diff --git a/wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt b/wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt new file mode 100644 index 00000000..bd907688 --- /dev/null +++ b/wear/src/main/java/com/tutpro/baresip/wear/MessageStore.kt @@ -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? = null + + private fun file(context: android.content.Context): File = + File(context.filesDir, FILE) + + @Synchronized + fun load(context: android.content.Context): List { + if (cache != null) return cache!! + val list = mutableListOf() + 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> { + val byPeer = linkedMapOf() + 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 = + load(context).filter { it.peer == peer }.sortedBy { it.timestamp } +} 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 374b345f..8abcb095 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt @@ -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. diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt index 20ace56b..c4ca9f50 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipServiceHelper.kt @@ -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" + } + } }