Compare commits

...

4 Commits

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

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

Note: TLS transport is independent of the self-managed incoming-call UI
path (verified earlier over UDP), so lock-screen answer/decline + audio
carry over unchanged.
2026-08-21 03:22:04 +01:00
a31d6a693c Wear SIP: incoming call UI over lock screen via self-managed telecom
- Launch InCallActivity from WearConnectionService.onCreateIncomingConnection
  (self-managed telecom context) instead of a background FGS startActivity.
  This is the only path Android permits to show UI over a secure lock screen
  / from the background (BAL exemption); a plain FGS startActivity is blocked.
- Register a SELF_MANAGED PhoneAccount (CAPABILITY_SELF_MANAGED only; a
  self-managed account must not also be CALL_PROVIDER) and hand inbound calls
  to TelecomManager.addNewIncomingCall.
- Answer/Decline routed from the system self-managed Connection back to the
  native stack via WearBaresipServiceHelper (deferred ua_answer/ua_hangup).
- Add dedicated InCallActivity (setShowWhenLocked/turnScreenOn) hosting the
  InCallScreen; MainActivity no longer owns the call UI.
- Switch SIP transport to UDP:5761 (matches server); keep sipnat=outbound +
  natpinhole for NAT traversal.
- Remove dead phoneCall FGS upgrade/downgrade (needed DIALER role; the
  self-managed launch path replaces it).

Verified on Galaxy Watch (Android 16, SDK 36): inbound call rings + shows
InCallActivity over the lock screen (pattern lock on) with working two-way
audio; activity dismisses cleanly on hangup.
2026-08-21 03:15:22 +01:00
13 changed files with 666 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,10 +8,12 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -22,6 +24,8 @@ import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -186,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))
} }

View File

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

View File

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

View File

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

View File

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

View File

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