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:
@ -49,15 +49,41 @@ 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)
|
||||
setContent {
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
private fun alertIncomingCall(peer: String) {
|
||||
// Wake the screen if it's asleep.
|
||||
try {
|
||||
// Wake the screen if it's asleep.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user