diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index ec419de6..f6245731 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
+
@@ -45,11 +46,20 @@
+
+
+
+
+
+
+
diff --git a/wear/src/main/assets/accounts b/wear/src/main/assets/accounts
index e8df280b..ed927e3d 100644
--- a/wear/src/main/assets/accounts
+++ b/wear/src/main/assets/accounts
@@ -1,2 +1,2 @@
-;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
+;auth_user="01273961147";auth_pass="cisco55555";outbound="sip:mail.txt3.net:5761;transport=udp";regint=60;regq=0.5;pubint=0;check_origin=no;mwi=no;sipnat=outbound;natpinhole=yes
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt b/wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
new file mode 100644
index 00000000..74f39466
--- /dev/null
+++ b/wear/src/main/java/com/tutpro/baresip/wear/InCallActivity.kt
@@ -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}")
+ }
+ }
+}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
index 0644aed0..e698f0bf 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt
@@ -189,7 +189,11 @@ fun WearApp(activity: MainActivity) {
MaterialTheme {
Scaffold(timeText = { TimeText() }) {
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" })
else -> DialerScreen(onAccounts = { activity.route.value = "accounts" })
}
@@ -497,7 +501,11 @@ fun AccountsScreen(onBack: () -> Unit) {
}
@Composable
-fun InCallScreen(onEnd: () -> Unit) {
+fun InCallScreen(
+ onAnswer: (WearCall) -> Unit,
+ onDecline: (WearCall) -> Unit,
+ onEnd: (WearCall?) -> Unit
+) {
val call = CallState.incoming() ?: CallState.active()
val status = call?.status ?: CallState.status.value
val peer = call?.peerUri ?: ""
@@ -533,11 +541,11 @@ fun InCallScreen(onEnd: () -> Unit) {
if (call != null && call.status == "incoming call") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- Button(onClick = { answerCall(call) }) {
+ Button(onClick = { onAnswer(call) }) {
Icon(Icons.Filled.Call, contentDescription = "Answer")
Text("Answer", modifier = Modifier.padding(start = 4.dp))
}
- Button(onClick = { declineCall(call) }) {
+ Button(onClick = { onDecline(call) }) {
Icon(Icons.Filled.CallEnd, contentDescription = "Decline")
Text("End", modifier = Modifier.padding(start = 4.dp))
}
@@ -551,7 +559,7 @@ fun InCallScreen(onEnd: () -> Unit) {
contentDescription = "Mute"
)
}
- Button(onClick = onEnd) {
+ Button(onClick = { onEnd(call) }) {
Icon(Icons.Filled.CallEnd, contentDescription = "Hangup")
Text("End", modifier = Modifier.padding(start = 4.dp))
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt b/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
index f504f887..170a645e 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/TelecomHelper.kt
@@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.os.Build
import android.os.Bundle
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
@@ -11,17 +12,19 @@ import android.telecom.TelecomManager
import android.util.Log
/**
- * Registers our SIP identity as a telecom PhoneAccount and, on incoming calls,
- * optionally hands the call to the system via TelecomManager.addNewIncomingCall
- * so the stock Wear incoming-call UI can be used. Whether the Wear dialer
- * actually surfaces a third-party PhoneAccount is device/firmware dependent;
- * this is wired as an experiment and the in-app InCallScreen remains the
- * primary incoming-call UI.
+ * Registers our SIP identity as a SELF-MANAGED telecom PhoneAccount and, on
+ * incoming calls, hands the call to the system via
+ * TelecomManager.addNewIncomingCall. A self-managed account (CAPABILITY_SELF_MANAGED
+ * + MANAGE_OWN_CALLS, a normal permission) is the mechanism apps like WhatsApp
+ * use to show an incoming-call UI over the lock screen WITHOUT becoming the
+ * 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 {
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"
fun accountHandle(context: Context): PhoneAccountHandle {
@@ -29,7 +32,7 @@ object TelecomHelper {
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) {
try {
val tm = context.getSystemService(TelecomManager::class.java) ?: return
@@ -42,8 +45,7 @@ object TelecomHelper {
)
val account = PhoneAccount.builder(handle, "Baresip Wear (SIP)")
.setCapabilities(
- PhoneAccount.CAPABILITY_CALL_PROVIDER or
- PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING
+ PhoneAccount.CAPABILITY_SELF_MANAGED
)
.setIcon(
android.graphics.drawable.Icon.createWithResource(
@@ -52,17 +54,17 @@ object TelecomHelper {
)
.build()
tm.registerPhoneAccount(account)
- Log.d(TAG, "registered PhoneAccount $ACCOUNT_ID")
+ Log.d(TAG, "registered self-managed PhoneAccount $ACCOUNT_ID")
} catch (e: Exception) {
Log.w(TAG, "registerAccount failed: ${e.message}")
}
}
/**
- * Hands an incoming native call to the system telecom stack. The framework
- * will bind WearConnectionService and show the incoming-call UI if the Wear
- * dialer honors our account. Safe to call even if telecom ignores it -- the
- * in-app InCallScreen is shown independently by the service.
+ * Hands an incoming native call to the system telecom stack as a
+ * self-managed call. The framework shows its incoming-call UI (over the
+ * lock screen) and forwards answer/decline to WearConnectionService. Our
+ * in-app InCallActivity is the fallback if the system UI is suppressed.
*/
fun addIncomingCall(context: Context, callp: Long, uap: Long, peer: String) {
try {
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
index 05202506..374b345f 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt
@@ -126,7 +126,17 @@ class WearBaresipService : Service() {
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.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
// when the screen is off.
acquireWifiLock()
@@ -172,6 +182,16 @@ class WearBaresipService : Service() {
}
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) {
registerReceiver(networkReceiver, networkFilter(), Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -309,9 +329,18 @@ class WearBaresipService : Service() {
// Track it so the Answer button can answer it. We do NOT answer
// here -- the user answers via the button (deferred to main loop).
ev == "call incoming" -> {
- if (callp != 0L) {
- WearBaresipService.activeCallp = callp
- Log.d("Baresip Wear", "call incoming tracked callp=$callp")
+ // The real, answerable struct call* is now available.
+ if (callp != 0L) WearBaresipService.activeCallp = 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"
@@ -333,6 +362,12 @@ class WearBaresipService : Service() {
stopRinging()
dismissIncomingCallNotification()
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.
try { wakeLock?.let { if (it.isHeld) it.release() } } catch (_: Exception) {}
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
@@ -362,13 +397,16 @@ class WearBaresipService : Service() {
/**
* 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,
- * so the robust mechanism is a high-priority notification with a
- * full-screen intent that launches the InCallScreen over the clock.
- * We also wake the (usually dimmed) screen and buzz a ring pattern.
+ * so the robust mechanism is to hand the call to the self-managed telecom
+ * stack (WearConnectionService), which launches InCallActivity over the
+ * lock screen. We also wake the (usually dimmed) screen and buzz a ring
+ * pattern.
*/
private fun alertIncomingCall(peer: String) {
- // Keep the CPU awake long enough to send the SIP 180/200 and present
- // the heads-up. Released when the call ends.
+ // The InCallActivity is launched from WearConnectionService (self-managed
+ // 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)
// Wake the screen if it's asleep.
try {
@@ -385,25 +423,32 @@ class WearBaresipService : Service() {
Log.w("Baresip Wear", "wake failed: ${e.message}")
}
- // Best-effort: also try to bring the activity forward (helps when the
- // app is already the foreground task and the full-screen intent is
- // suppressed by the system).
+ // NOTE: The incoming-call Activity is launched from
+ // WearConnectionService.onCreateIncomingConnection (the self-managed
+ // 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 {
- val intent = Intent(this, MainActivity::class.java).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
- putExtra("incoming_call", true)
- }
- startActivity(intent)
- Log.d("Baresip Wear", "alertIncomingCall: startActivity(incoming_call) dispatched")
+ @Suppress("DEPRECATION")
+ val pm = getSystemService(android.os.PowerManager::class.java)
+ val wl = pm?.newWakeLock(
+ android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP or
+ android.os.PowerManager.SCREEN_DIM_WAKE_LOCK,
+ "BaresipWear:incomingWake"
+ )
+ wl?.acquire(5000)
} 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
// (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 {
- 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)
putExtra("incoming_call", true)
}
diff --git a/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt b/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt
index 1d9ad926..09dbf284 100644
--- a/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt
+++ b/wear/src/main/java/com/tutpro/baresip/wear/WearConnectionService.kt
@@ -1,7 +1,11 @@
package com.tutpro.baresip.wear
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.os.PowerManager
import android.telecom.Call.Details
import android.telecom.Connection
import android.telecom.ConnectionRequest
@@ -11,21 +15,20 @@ import android.telecom.PhoneAccountHandle
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
- * account. The native baresip stack remains the source of truth for the call;
- * this service only mirrors telecom's incoming-call lifecycle so the system
- * incoming UI can be used if the Wear dialer chooses to honor our PhoneAccount.
+ * When baresip reports "call incoming" we call TelecomManager.addNewIncomingCall
+ * (see WearBaresipService / TelecomHelper). The framework binds this service and
+ * calls onCreateIncomingConnection(). Because our PhoneAccount is SELF_MANAGED,
+ * 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:
- * - Our service calls TelecomManager.addNewIncomingCall(handle, extras) when
- * baresip reports "call incoming"; the framework then binds this service and
- * 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.
+ * The native baresip stack remains the source of truth for the call; this
+ * service only mirrors the incoming-call lifecycle so the system can route
+ * answer / decline to the native stack via WearBaresipServiceHelper.
*/
class WearConnectionService : ConnectionService() {
@@ -34,6 +37,54 @@ class WearConnectionService : ConnectionService() {
const val EXTRA_UAP = "com.tutpro.baresip.wear.extra.UAP"
const val EXTRA_PEER = "com.tutpro.baresip.wear.extra.PEER"
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(
@@ -47,16 +98,18 @@ class WearConnectionService : ConnectionService() {
Log.d(TAG, "onCreateIncomingConnection callp=$callp uap=$uap peer=$peer")
val conn = WearConnection(callp, uap, peer)
- // Mirror the call for the system incoming UI. We deliberately avoid
- // PROPERTY_SELF_MANAGED (requires MANAGE_OWN_CALLS permission and a
- // 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.
+ // Self-managed connection: the system expects OUR app to present the UI
+ // (it will not draw its own). CAPABILITY_MUTE lets the user mute.
conn.connectionCapabilities = Connection.CAPABILITY_MUTE
+ conn.connectionProperties = Connection.PROPERTY_SELF_MANAGED
conn.setRinging()
// PRESENTATION_ALLOWED == 1
conn.setAddress(Uri.parse(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
}
@@ -73,7 +126,7 @@ class WearConnectionService : ConnectionService() {
}
/** A telecom Connection mirroring one native baresip call. */
- private class WearConnection(
+ internal class WearConnection(
private val callp: Long,
private val uap: Long,
private val peer: String
@@ -101,6 +154,7 @@ class WearConnectionService : ConnectionService() {
if (call != null) WearBaresipServiceHelper.hangup(call)
setDisconnected(DisconnectCause(DisconnectCause.REJECTED))
destroy()
+ activeConnection = null
}
override fun onDisconnect() {
@@ -110,6 +164,7 @@ class WearConnectionService : ConnectionService() {
if (call != null) WearBaresipServiceHelper.hangup(call)
setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
destroy()
+ activeConnection = null
}
override fun onPlayDtmfTone(c: Char) {