Compare commits
4 Commits
899e778e49
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a4fd18cd | |||
| 100ee241a2 | |||
| 0f883c9847 | |||
| a31d6a693c |
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.type.watch" android:required="true" />
|
<uses-feature android:name="android.hardware.type.watch" android:required="true" />
|
||||||
|
|
||||||
@ -45,11 +46,20 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".InCallActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".WearBaresipService"
|
android:name=".WearBaresipService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="microphone"
|
android:foregroundServiceType="microphone|phoneCall"
|
||||||
android:stopWithTask="false" />
|
android:stopWithTask="false" />
|
||||||
|
|
||||||
<!-- Telecom ConnectionService (experiment): lets the stock Wear dialer
|
<!-- Telecom ConnectionService (experiment): lets the stock Wear dialer
|
||||||
@ -64,4 +74,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
<!-- So RoleManager.isRoleAvailable(ROLE_DIALER) / requestRole resolve on
|
||||||
|
API 29+ even when the role-request activity isn't directly visible. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.app.role.action.REQUEST_ROLE" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
<sip:01273961147@mail.txt3.net>;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5060;transport=tcp";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_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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
Normal file
69
wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated incoming/active-call Activity, launched by the foreground service
|
||||||
|
* via a full-screen-intent notification (and a best-effort startActivity).
|
||||||
|
*
|
||||||
|
* Why a separate Activity from the LAUNCHER MainActivity: on modern Wear OS
|
||||||
|
* (Android 12+, this watch runs Android 16) a background service's
|
||||||
|
* startActivity / full-screen intent targeting the LAUNCHER activity is
|
||||||
|
* suppressed when that activity is not already the foreground task. A distinct,
|
||||||
|
* non-LAUNCHER Activity with showWhenLocked/turnScreenOn presents reliably over
|
||||||
|
* the lock screen / watch face for an inbound call.
|
||||||
|
*/
|
||||||
|
class InCallActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
applyCallLockScreenFlags()
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
// Auto-finish when there is no call left (answered+ended, or
|
||||||
|
// remote hangup cleared the state). Polls CallState so we don't
|
||||||
|
// need an explicit close from the service.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (CallState.incoming() != null || CallState.active() != null) {
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
InCallScreen(
|
||||||
|
onAnswer = { WearBaresipServiceHelper.answer(it) },
|
||||||
|
onDecline = { WearBaresipServiceHelper.hangup(it) },
|
||||||
|
onEnd = { call -> if (call != null) WearBaresipServiceHelper.hangup(call) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
applyCallLockScreenFlags()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun applyCallLockScreenFlags() {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
}
|
||||||
|
val params = window.attributes
|
||||||
|
params.flags = params.flags or
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
|
||||||
|
window.attributes = params
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("Baresip Wear", "InCallActivity flags failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,19 +190,48 @@ 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 {
|
||||||
hasCall -> InCallScreen(onEnd = { hangupCall(CallState.active() ?: CallState.incoming()) })
|
hasCall -> InCallScreen(
|
||||||
|
onAnswer = { answerCall(it) },
|
||||||
|
onDecline = { declineCall(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
|
||||||
@ -332,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,7 +656,11 @@ fun AccountsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InCallScreen(onEnd: () -> Unit) {
|
fun InCallScreen(
|
||||||
|
onAnswer: (WearCall) -> Unit,
|
||||||
|
onDecline: (WearCall) -> Unit,
|
||||||
|
onEnd: (WearCall?) -> Unit
|
||||||
|
) {
|
||||||
val call = CallState.incoming() ?: CallState.active()
|
val call = CallState.incoming() ?: CallState.active()
|
||||||
val status = call?.status ?: CallState.status.value
|
val status = call?.status ?: CallState.status.value
|
||||||
val peer = call?.peerUri ?: ""
|
val peer = call?.peerUri ?: ""
|
||||||
@ -533,11 +696,11 @@ fun InCallScreen(onEnd: () -> Unit) {
|
|||||||
|
|
||||||
if (call != null && call.status == "incoming call") {
|
if (call != null && call.status == "incoming call") {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = { answerCall(call) }) {
|
Button(onClick = { onAnswer(call) }) {
|
||||||
Icon(Icons.Filled.Call, contentDescription = "Answer")
|
Icon(Icons.Filled.Call, contentDescription = "Answer")
|
||||||
Text("Answer", modifier = Modifier.padding(start = 4.dp))
|
Text("Answer", modifier = Modifier.padding(start = 4.dp))
|
||||||
}
|
}
|
||||||
Button(onClick = { declineCall(call) }) {
|
Button(onClick = { onDecline(call) }) {
|
||||||
Icon(Icons.Filled.CallEnd, contentDescription = "Decline")
|
Icon(Icons.Filled.CallEnd, contentDescription = "Decline")
|
||||||
Text("End", modifier = Modifier.padding(start = 4.dp))
|
Text("End", modifier = Modifier.padding(start = 4.dp))
|
||||||
}
|
}
|
||||||
@ -551,7 +714,7 @@ fun InCallScreen(onEnd: () -> Unit) {
|
|||||||
contentDescription = "Mute"
|
contentDescription = "Mute"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(onClick = onEnd) {
|
Button(onClick = { onEnd(call) }) {
|
||||||
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
|
||||||
Text("End", modifier = Modifier.padding(start = 4.dp))
|
Text("End", 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 }
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.telecom.PhoneAccount
|
import android.telecom.PhoneAccount
|
||||||
import android.telecom.PhoneAccountHandle
|
import android.telecom.PhoneAccountHandle
|
||||||
@ -11,17 +12,19 @@ import android.telecom.TelecomManager
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers our SIP identity as a telecom PhoneAccount and, on incoming calls,
|
* Registers our SIP identity as a SELF-MANAGED telecom PhoneAccount and, on
|
||||||
* optionally hands the call to the system via TelecomManager.addNewIncomingCall
|
* incoming calls, hands the call to the system via
|
||||||
* so the stock Wear incoming-call UI can be used. Whether the Wear dialer
|
* TelecomManager.addNewIncomingCall. A self-managed account (CAPABILITY_SELF_MANAGED
|
||||||
* actually surfaces a third-party PhoneAccount is device/firmware dependent;
|
* + MANAGE_OWN_CALLS, a normal permission) is the mechanism apps like WhatsApp
|
||||||
* this is wired as an experiment and the in-app InCallScreen remains the
|
* use to show an incoming-call UI over the lock screen WITHOUT becoming the
|
||||||
* primary incoming-call UI.
|
* device's default dialer. It is also exempt from the background-activity-launch
|
||||||
|
* (BAL) restriction, so the system call UI appears even when our app is in the
|
||||||
|
* background / the device is locked.
|
||||||
*/
|
*/
|
||||||
object TelecomHelper {
|
object TelecomHelper {
|
||||||
|
|
||||||
private const val ACCOUNT_ID = "sip-wear"
|
private const val ACCOUNT_ID = "sip-wear"
|
||||||
const val TELECOM_PERMISSION = android.Manifest.permission.READ_PHONE_STATE
|
const val TELECOM_PERMISSION = android.Manifest.permission.MANAGE_OWN_CALLS
|
||||||
private const val TAG = "Baresip Wear Telecom"
|
private const val TAG = "Baresip Wear Telecom"
|
||||||
|
|
||||||
fun accountHandle(context: Context): PhoneAccountHandle {
|
fun accountHandle(context: Context): PhoneAccountHandle {
|
||||||
@ -29,7 +32,7 @@ object TelecomHelper {
|
|||||||
return PhoneAccountHandle(component, ACCOUNT_ID)
|
return PhoneAccountHandle(component, ACCOUNT_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Register (or re-register) the PhoneAccount with the system telecom stack. */
|
/** Register (or re-register) the self-managed PhoneAccount. */
|
||||||
fun registerAccount(context: Context) {
|
fun registerAccount(context: Context) {
|
||||||
try {
|
try {
|
||||||
val tm = context.getSystemService(TelecomManager::class.java) ?: return
|
val tm = context.getSystemService(TelecomManager::class.java) ?: return
|
||||||
@ -42,8 +45,7 @@ object TelecomHelper {
|
|||||||
)
|
)
|
||||||
val account = PhoneAccount.builder(handle, "Baresip Wear (SIP)")
|
val account = PhoneAccount.builder(handle, "Baresip Wear (SIP)")
|
||||||
.setCapabilities(
|
.setCapabilities(
|
||||||
PhoneAccount.CAPABILITY_CALL_PROVIDER or
|
PhoneAccount.CAPABILITY_SELF_MANAGED
|
||||||
PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING
|
|
||||||
)
|
)
|
||||||
.setIcon(
|
.setIcon(
|
||||||
android.graphics.drawable.Icon.createWithResource(
|
android.graphics.drawable.Icon.createWithResource(
|
||||||
@ -52,17 +54,17 @@ object TelecomHelper {
|
|||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
tm.registerPhoneAccount(account)
|
tm.registerPhoneAccount(account)
|
||||||
Log.d(TAG, "registered PhoneAccount $ACCOUNT_ID")
|
Log.d(TAG, "registered self-managed PhoneAccount $ACCOUNT_ID")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "registerAccount failed: ${e.message}")
|
Log.w(TAG, "registerAccount failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands an incoming native call to the system telecom stack. The framework
|
* Hands an incoming native call to the system telecom stack as a
|
||||||
* will bind WearConnectionService and show the incoming-call UI if the Wear
|
* self-managed call. The framework shows its incoming-call UI (over the
|
||||||
* dialer honors our account. Safe to call even if telecom ignores it -- the
|
* lock screen) and forwards answer/decline to WearConnectionService. Our
|
||||||
* in-app InCallScreen is shown independently by the service.
|
* in-app InCallActivity is the fallback if the system UI is suppressed.
|
||||||
*/
|
*/
|
||||||
fun addIncomingCall(context: Context, callp: Long, uap: Long, peer: String) {
|
fun addIncomingCall(context: Context, callp: Long, uap: Long, peer: String) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -126,7 +126,17 @@ class WearBaresipService : Service() {
|
|||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.build()
|
.build()
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
// Start as microphone-only at boot: a phoneCall-type FGS is only valid
|
||||||
|
// once a call is actually active, so declaring phoneCall here (before
|
||||||
|
// any call) throws a SecurityException at startup.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID, notification,
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
// Hold the WiFi radio + CPU so inbound INVITEs can be answered even
|
// Hold the WiFi radio + CPU so inbound INVITEs can be answered even
|
||||||
// when the screen is off.
|
// when the screen is off.
|
||||||
acquireWifiLock()
|
acquireWifiLock()
|
||||||
@ -172,6 +182,16 @@ class WearBaresipService : Service() {
|
|||||||
}
|
}
|
||||||
nativeThread.start()
|
nativeThread.start()
|
||||||
|
|
||||||
|
// Register our SIP identity as a telecom PhoneAccount so inbound calls
|
||||||
|
// can be surfaced via the system incoming-call UI (the only reliable
|
||||||
|
// way to show a call prompt over the lock screen from a background FGS
|
||||||
|
// on Android 12+, which blocks ordinary background Activity launches).
|
||||||
|
try {
|
||||||
|
TelecomHelper.registerAccount(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom register failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
|
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
|
||||||
} else {
|
} else {
|
||||||
@ -309,9 +329,18 @@ class WearBaresipService : Service() {
|
|||||||
// Track it so the Answer button can answer it. We do NOT answer
|
// Track it so the Answer button can answer it. We do NOT answer
|
||||||
// here -- the user answers via the button (deferred to main loop).
|
// here -- the user answers via the button (deferred to main loop).
|
||||||
ev == "call incoming" -> {
|
ev == "call incoming" -> {
|
||||||
if (callp != 0L) {
|
// The real, answerable struct call* is now available.
|
||||||
WearBaresipService.activeCallp = callp
|
if (callp != 0L) WearBaresipService.activeCallp = callp
|
||||||
Log.d("Baresip Wear", "call incoming tracked callp=$callp")
|
Log.d("Baresip Wear", "call incoming tracked callp=$callp")
|
||||||
|
CallState.status.value = "Incoming call"
|
||||||
|
// Hand the call to the system telecom stack so the stock Wear
|
||||||
|
// incoming-call UI is shown over the lock screen (the only
|
||||||
|
// background-launch path Android allows). The in-app UI is a
|
||||||
|
// fallback that is normally blocked by the BAL restriction.
|
||||||
|
try {
|
||||||
|
TelecomHelper.addIncomingCall(this, callp, uap, arg.ifEmpty { "unknown" })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom addIncomingCall failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev == "call outgoing" -> CallState.status.value = "Calling"
|
ev == "call outgoing" -> CallState.status.value = "Calling"
|
||||||
@ -333,6 +362,12 @@ class WearBaresipService : Service() {
|
|||||||
stopRinging()
|
stopRinging()
|
||||||
dismissIncomingCallNotification()
|
dismissIncomingCallNotification()
|
||||||
WearBaresipService.activeCallp = 0L
|
WearBaresipService.activeCallp = 0L
|
||||||
|
// Clear the system telecom UI if it was showing this call.
|
||||||
|
try {
|
||||||
|
WearConnectionService.disconnectActive(android.telecom.DisconnectCause.REMOTE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("Baresip Wear", "telecom disconnect failed: ${e.message}")
|
||||||
|
}
|
||||||
// Release any call wake lock now that the call is over.
|
// Release any call wake lock now that the call is over.
|
||||||
try { wakeLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
|
try { wakeLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
|
||||||
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
|
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
|
||||||
@ -351,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()
|
||||||
@ -362,13 +439,16 @@ class WearBaresipService : Service() {
|
|||||||
/**
|
/**
|
||||||
* Alert the wearer to an incoming call. On Wear OS a bare startActivity
|
* Alert the wearer to an incoming call. On Wear OS a bare startActivity
|
||||||
* from a background service does NOT reliably take over the watch face,
|
* from a background service does NOT reliably take over the watch face,
|
||||||
* so the robust mechanism is a high-priority notification with a
|
* so the robust mechanism is to hand the call to the self-managed telecom
|
||||||
* full-screen intent that launches the InCallScreen over the clock.
|
* stack (WearConnectionService), which launches InCallActivity over the
|
||||||
* We also wake the (usually dimmed) screen and buzz a ring pattern.
|
* lock screen. We also wake the (usually dimmed) screen and buzz a ring
|
||||||
|
* pattern.
|
||||||
*/
|
*/
|
||||||
private fun alertIncomingCall(peer: String) {
|
private fun alertIncomingCall(peer: String) {
|
||||||
// Keep the CPU awake long enough to send the SIP 180/200 and present
|
// The InCallActivity is launched from WearConnectionService (self-managed
|
||||||
// the heads-up. Released when the call ends.
|
// telecom context) when the system binds us for addNewIncomingCall — that
|
||||||
|
// path is permitted over the lock screen / from the background.
|
||||||
|
// Keep the CPU awake briefly so the SIP 180/200 and the heads-up render.
|
||||||
acquireWakeLock(60_000L)
|
acquireWakeLock(60_000L)
|
||||||
// Wake the screen if it's asleep.
|
// Wake the screen if it's asleep.
|
||||||
try {
|
try {
|
||||||
@ -385,25 +465,32 @@ class WearBaresipService : Service() {
|
|||||||
Log.w("Baresip Wear", "wake failed: ${e.message}")
|
Log.w("Baresip Wear", "wake failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort: also try to bring the activity forward (helps when the
|
// NOTE: The incoming-call Activity is launched from
|
||||||
// app is already the foreground task and the full-screen intent is
|
// WearConnectionService.onCreateIncomingConnection (the self-managed
|
||||||
// suppressed by the system).
|
// telecom context), which is the only path Android permits to show UI
|
||||||
|
// over the lock screen / from the background. A plain startActivity
|
||||||
|
// from this foreground service is BAL-blocked on a sleeping device and
|
||||||
|
// must NOT be used here.
|
||||||
try {
|
try {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
@Suppress("DEPRECATION")
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
val pm = getSystemService(android.os.PowerManager::class.java)
|
||||||
putExtra("incoming_call", true)
|
val wl = pm?.newWakeLock(
|
||||||
}
|
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
startActivity(intent)
|
android.os.PowerManager.SCREEN_DIM_WAKE_LOCK,
|
||||||
Log.d("Baresip Wear", "alertIncomingCall: startActivity(incoming_call) dispatched")
|
"BaresipWear:incomingWake"
|
||||||
|
)
|
||||||
|
wl?.acquire(5000)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("Baresip Wear", "bringToFront failed: ${e.message}")
|
Log.w("Baresip Wear", "wake failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// High-priority heads-up with a full-screen intent -> the InCallScreen
|
// High-priority heads-up with a full-screen intent -> the InCallScreen
|
||||||
// (Answer/Decline) is shown over the watch face. This is the reliable
|
// (Answer/Decline) is shown over the watch face. This is the reliable
|
||||||
// alert path on Wear OS.
|
// alert path on Wear OS. A dedicated InCallActivity (not the LAUNCHER
|
||||||
|
// MainActivity) is used so the launch isn't suppressed when the app is
|
||||||
|
// backgrounded.
|
||||||
try {
|
try {
|
||||||
val fsIntent = Intent(this, MainActivity::class.java).apply {
|
val fsIntent = Intent(this, InCallActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
putExtra("incoming_call", true)
|
putExtra("incoming_call", true)
|
||||||
}
|
}
|
||||||
@ -506,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.
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
package com.tutpro.baresip.wear
|
package com.tutpro.baresip.wear
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
import android.telecom.Call.Details
|
import android.telecom.Call.Details
|
||||||
import android.telecom.Connection
|
import android.telecom.Connection
|
||||||
import android.telecom.ConnectionRequest
|
import android.telecom.ConnectionRequest
|
||||||
@ -11,21 +15,20 @@ import android.telecom.PhoneAccountHandle
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telecom ConnectionService scaffold.
|
* Telecom ConnectionService — the self-managed call handler.
|
||||||
*
|
*
|
||||||
* This is an EXPERIMENT to see whether the stock Wear dialer surfaces our SIP
|
* When baresip reports "call incoming" we call TelecomManager.addNewIncomingCall
|
||||||
* account. The native baresip stack remains the source of truth for the call;
|
* (see WearBaresipService / TelecomHelper). The framework binds this service and
|
||||||
* this service only mirrors telecom's incoming-call lifecycle so the system
|
* calls onCreateIncomingConnection(). Because our PhoneAccount is SELF_MANAGED,
|
||||||
* incoming UI can be used if the Wear dialer chooses to honor our PhoneAccount.
|
* the system does NOT draw its own incoming UI (letDialerHandleRinging=false);
|
||||||
|
* instead our app must present the call. The canonical self-managed pattern is
|
||||||
|
* to bring our own full-screen Activity forward from here — the active
|
||||||
|
* self-managed call grants the lock-screen / background-launch permission that a
|
||||||
|
* plain foreground-service startActivity lacks.
|
||||||
*
|
*
|
||||||
* Lifecycle:
|
* The native baresip stack remains the source of truth for the call; this
|
||||||
* - Our service calls TelecomManager.addNewIncomingCall(handle, extras) when
|
* service only mirrors the incoming-call lifecycle so the system can route
|
||||||
* baresip reports "call incoming"; the framework then binds this service and
|
* answer / decline to the native stack via WearBaresipServiceHelper.
|
||||||
* calls onCreateIncomingConnection().
|
|
||||||
* - onCreateIncomingConnection() builds a WearConnection bound to the native
|
|
||||||
* callp (passed via extras) and reports the remote address.
|
|
||||||
* - User actions (answer / disconnect / DTMF) are forwarded to the native
|
|
||||||
* stack through WearBaresipServiceHelper.
|
|
||||||
*/
|
*/
|
||||||
class WearConnectionService : ConnectionService() {
|
class WearConnectionService : ConnectionService() {
|
||||||
|
|
||||||
@ -34,6 +37,54 @@ class WearConnectionService : ConnectionService() {
|
|||||||
const val EXTRA_UAP = "com.tutpro.baresip.wear.extra.UAP"
|
const val EXTRA_UAP = "com.tutpro.baresip.wear.extra.UAP"
|
||||||
const val EXTRA_PEER = "com.tutpro.baresip.wear.extra.PEER"
|
const val EXTRA_PEER = "com.tutpro.baresip.wear.extra.PEER"
|
||||||
private const val TAG = "Baresip Wear Telecom"
|
private const val TAG = "Baresip Wear Telecom"
|
||||||
|
|
||||||
|
// The connection currently shown by the system incoming/active UI.
|
||||||
|
// Cleared when the call ends so a later call can replace it.
|
||||||
|
@Volatile
|
||||||
|
var activeConnection: android.telecom.Connection? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Tell the system UI the call ended (remote hangup / local hangup). */
|
||||||
|
fun disconnectActive(cause: Int = DisconnectCause.REMOTE) {
|
||||||
|
val conn = activeConnection ?: return
|
||||||
|
try {
|
||||||
|
conn.setDisconnected(DisconnectCause(cause))
|
||||||
|
conn.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "disconnectActive failed: ${e.message}")
|
||||||
|
}
|
||||||
|
activeConnection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake the screen and bring our InCallActivity to the front. Called from
|
||||||
|
* the self-managed connection context so the active incoming call
|
||||||
|
* grants the lock-screen / background launch permission.
|
||||||
|
*/
|
||||||
|
private fun showInCallUi(context: Context, callp: Long, uap: Long, peer: String) {
|
||||||
|
try {
|
||||||
|
val pm = context.getSystemService(PowerManager::class.java)
|
||||||
|
val wakeLock = pm?.newWakeLock(
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.FULL_WAKE_LOCK,
|
||||||
|
"BaresipWear:incomingCall"
|
||||||
|
)
|
||||||
|
wakeLock?.acquire(60_000L)
|
||||||
|
val intent = Intent(context, InCallActivity::class.java).apply {
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
)
|
||||||
|
putExtra(EXTRA_CALLP, callp)
|
||||||
|
putExtra(EXTRA_UAP, uap)
|
||||||
|
putExtra(EXTRA_PEER, peer)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
Log.d(TAG, "showInCallUi: InCallActivity started (callp=$callp)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "showInCallUi failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateIncomingConnection(
|
override fun onCreateIncomingConnection(
|
||||||
@ -47,16 +98,18 @@ class WearConnectionService : ConnectionService() {
|
|||||||
Log.d(TAG, "onCreateIncomingConnection callp=$callp uap=$uap peer=$peer")
|
Log.d(TAG, "onCreateIncomingConnection callp=$callp uap=$uap peer=$peer")
|
||||||
|
|
||||||
val conn = WearConnection(callp, uap, peer)
|
val conn = WearConnection(callp, uap, peer)
|
||||||
// Mirror the call for the system incoming UI. We deliberately avoid
|
// Self-managed connection: the system expects OUR app to present the UI
|
||||||
// PROPERTY_SELF_MANAGED (requires MANAGE_OWN_CALLS permission and a
|
// (it will not draw its own). CAPABILITY_MUTE lets the user mute.
|
||||||
// more involved self-managed lifecycle) and video capabilities that
|
|
||||||
// are absent on this Wear SDK. Plain CAPABILITY_MUTE is enough for the
|
|
||||||
// call to be presented.
|
|
||||||
conn.connectionCapabilities = Connection.CAPABILITY_MUTE
|
conn.connectionCapabilities = Connection.CAPABILITY_MUTE
|
||||||
|
conn.connectionProperties = Connection.PROPERTY_SELF_MANAGED
|
||||||
conn.setRinging()
|
conn.setRinging()
|
||||||
// PRESENTATION_ALLOWED == 1
|
// PRESENTATION_ALLOWED == 1
|
||||||
conn.setAddress(Uri.parse(peer), 1)
|
conn.setAddress(Uri.parse(peer), 1)
|
||||||
conn.setCallerDisplayName(peer, 1)
|
conn.setCallerDisplayName(peer, 1)
|
||||||
|
activeConnection = conn
|
||||||
|
// Bring our incoming-call UI forward (over lock screen / from the
|
||||||
|
// background) while the self-managed call is active.
|
||||||
|
showInCallUi(this, callp, uap, peer)
|
||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +126,7 @@ class WearConnectionService : ConnectionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A telecom Connection mirroring one native baresip call. */
|
/** A telecom Connection mirroring one native baresip call. */
|
||||||
private class WearConnection(
|
internal class WearConnection(
|
||||||
private val callp: Long,
|
private val callp: Long,
|
||||||
private val uap: Long,
|
private val uap: Long,
|
||||||
private val peer: String
|
private val peer: String
|
||||||
@ -101,6 +154,7 @@ class WearConnectionService : ConnectionService() {
|
|||||||
if (call != null) WearBaresipServiceHelper.hangup(call)
|
if (call != null) WearBaresipServiceHelper.hangup(call)
|
||||||
setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
|
setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
|
||||||
destroy()
|
destroy()
|
||||||
|
activeConnection = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisconnect() {
|
override fun onDisconnect() {
|
||||||
@ -110,6 +164,7 @@ class WearConnectionService : ConnectionService() {
|
|||||||
if (call != null) WearBaresipServiceHelper.hangup(call)
|
if (call != null) WearBaresipServiceHelper.hangup(call)
|
||||||
setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
|
setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
|
||||||
destroy()
|
destroy()
|
||||||
|
activeConnection = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlayDtmfTone(c: Char) {
|
override fun onPlayDtmfTone(c: Char) {
|
||||||
|
|||||||
Reference in New Issue
Block a user