Wear SIP: incoming-call alert (full-screen intent + screen wake + vibrate)

- High-importance (IMPORTANCE_MAX) call channel; on incoming call post a
  PRIORITY_MAX / CATEGORY_CALL notification with a full-screen intent that
  launches the InCallScreen over the watch face.
- MainActivity: setShowWhenLocked/setTurnScreenOn + FLAG_SHOW_WHEN_LOCKED
  so the in-call UI presents over the dimmed screen / keyguard.
- Request POST_NOTIFICATIONS at runtime (API 33+ silently drops
  notifications without it) alongside RECORD_AUDIO.
- Vibration ring pattern + wake-lock on incoming call; dismiss the
  heads-up when the call closes.

Note: on Samsung Galaxy Watch the watch notification manager blocks
alerting for apps not in its allowed-notifications list; the device
setting (Settings > Notifications > App notifications > Baresip Wear)
must be enabled for the heads-up to fire. Foreground/app-open case
verified working (call answered, audio -> onboard speaker).
This commit is contained in:
2026-08-20 22:25:42 +01:00
parent 15e266cb43
commit bd5b5efa3b
2 changed files with 139 additions and 10 deletions

View File

@ -49,8 +49,25 @@ class MainActivity : ComponentActivity() {
private val recordAudioPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) startBaresipService() else CallState.status.value = "Mic permission denied"
if (granted) {
startBaresipService()
// On API 33+ notifications are silently dropped unless the user
// grants POST_NOTIFICATIONS -- required for the incoming-call
// heads-up (full-screen intent) to wake the watch face.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
postNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
} else {
CallState.status.value = "Mic permission denied"
}
}
private val postNotificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* best-effort; incoming alert still works if already granted */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -58,6 +75,15 @@ class MainActivity : ComponentActivity() {
WearApp(this)
}
// If we were launched by an incoming-call alert (full-screen intent),
// turn the screen on and show over the lock screen, and jump straight
// to the in-call UI.
if (intent?.getBooleanExtra("incoming_call", false) == true) {
android.util.Log.d("Baresip Wear", "onCreate: incoming_call extra seen -> incall")
turnScreenOnForCall()
route.value = "incall"
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED
) {
@ -69,8 +95,46 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
this.intent = intent
if (intent.action == Intent.ACTION_VIEW && intent.data?.scheme == "baresip") {
handleProvisioningIntent(intent)
} else if (intent.getBooleanExtra("incoming_call", false)) {
// Launched from the full-screen call intent while already running.
android.util.Log.d("Baresip Wear", "onNewIntent: incoming_call extra seen -> incall")
turnScreenOnForCall()
route.value = "incall"
}
}
/** Wake the screen and present over the lock screen for an incoming call. */
@Suppress("DEPRECATION")
private fun turnScreenOnForCall() {
try {
// Modern, reliable way to raise an incoming-call UI over the watch
// face / keyguard (API 27+). These are the APIs designed for exactly
// this (alarm / incoming-call) use case.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
val wm = getSystemService(android.view.WindowManager::class.java)
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
val pm = getSystemService(android.os.PowerManager::class.java)
if (pm != null && !pm.isInteractive) {
val wl = pm.newWakeLock(
android.os.PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
android.os.PowerManager.ACQUIRE_CAUSES_WAKEUP,
"BaresipWear:incomingCallUI"
)
wl.acquire(5000)
}
} catch (e: Exception) {
android.util.Log.w("Baresip Wear", "turnScreenOnForCall failed: ${e.message}")
}
}

View File

@ -8,6 +8,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.app.PendingIntent
import android.media.AudioManager
import android.net.ConnectivityManager
import android.os.Build
@ -34,6 +35,20 @@ class WearBaresipService : Service() {
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
// High-importance channel so the incoming-call heads-up (with a
// full-screen intent) pops over the watch face / dimmed screen.
// Use IMPORTANCE_MAX: on some Wear builds HIGH is still below the
// threshold that lets a full-screen intent take over the clock.
val callChannel = NotificationChannel(
CALL_CHANNEL_ID,
"Incoming call",
NotificationManager.IMPORTANCE_MAX
).apply {
setSound(null, null)
enableLights(false)
enableVibration(false)
}
manager.createNotificationChannel(callChannel)
}
AudioRouteManager.init(this)
// Register our SIP identity with the system telecom stack (experiment:
@ -215,7 +230,7 @@ class WearBaresipService : Service() {
CallState.status.value = "Incoming call"
// Surface the incoming-call UI even if the app was backgrounded,
// and alert the wearer (the watch screen is usually dimmed).
alertIncomingCall()
alertIncomingCall(peer)
// Experiment: also hand the call to the system telecom stack so
// the stock Wear incoming UI can be used if the dialer honors
// our PhoneAccount. The in-app InCallScreen is shown regardless.
@ -233,6 +248,7 @@ class WearBaresipService : Service() {
ev == "call ringing" -> CallState.status.value = "Ringing"
ev == "call closed" -> {
AudioRouteManager.stopCallAudio()
dismissIncomingCallNotification()
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
// as a toast so the user isn't left guessing why the call died.
if (arg.isNotBlank()) showToast("Call ended: $arg")
@ -258,14 +274,15 @@ class WearBaresipService : Service() {
}
/**
* Alert the wearer to an incoming call: bring our activity to the front
* (so the Answer/Decline UI shows), wake the (usually dimmed) screen, and
* buzz a ring pattern. The actual answer/decline buttons live in the
* in-call UI; this only makes sure the user can see and feel the call.
* 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.
*/
private fun alertIncomingCall() {
try {
private fun alertIncomingCall(peer: String) {
// Wake the screen if it's asleep.
try {
val pm = getSystemService(PowerManager::class.java)
if (pm != null && !pm.isInteractive) {
@Suppress("DEPRECATION")
@ -279,17 +296,52 @@ class WearBaresipService : Service() {
Log.w("Baresip Wear", "wake failed: ${e.message}")
}
// Bring the activity to the foreground so the InCallScreen shows.
// 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).
try {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
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")
} catch (e: Exception) {
Log.w("Baresip Wear", "bringToFront 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.
try {
val fsIntent = Intent(this, MainActivity::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)
}
val fsPending = PendingIntent.getActivity(
this,
2001,
fsIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val label = peer.replaceAfter("@", "").removeSuffix("@").ifEmpty { peer }
val notification = NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setContentTitle("Incoming call")
.setContentText(label)
.setSmallIcon(android.R.drawable.sym_call_incoming)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setFullScreenIntent(fsPending, true)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000L)
.build()
val nm = getSystemService(NotificationManager::class.java)
nm.notify(CALL_NOTIFICATION_ID, notification)
} catch (e: Exception) {
Log.w("Baresip Wear", "incoming notification failed: ${e.message}")
}
// Ring vibration pattern (vibrate, pause, vibrate, pause, ...).
try {
val vibrator = getSystemService(Vibrator::class.java)
@ -309,9 +361,22 @@ class WearBaresipService : Service() {
}
}
/** Remove the incoming-call heads-up once the call is answered/declined. */
private fun dismissIncomingCallNotification() {
try {
val nm = getSystemService(NotificationManager::class.java)
nm.cancel(CALL_NOTIFICATION_ID)
} catch (e: Exception) {
Log.w("Baresip Wear", "cancel call notification failed: ${e.message}")
}
}
companion object {
const val CHANNEL_ID = "baresip-wear"
const val NOTIFICATION_ID = 1
// Dedicated high-importance channel for incoming-call heads-up alerts.
const val CALL_CHANNEL_ID = "baresip-wear-call"
const val CALL_NOTIFICATION_ID = 2
// Application context, captured in onCreate, for toasts from non-UI code.
lateinit var appContext: Context