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(
|
private val recordAudioPermission = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted ->
|
) { 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
WearApp(this)
|
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)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
== PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
@ -69,8 +95,46 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
this.intent = intent
|
||||||
if (intent.action == Intent.ACTION_VIEW && intent.data?.scheme == "baresip") {
|
if (intent.action == Intent.ACTION_VIEW && intent.data?.scheme == "baresip") {
|
||||||
handleProvisioningIntent(intent)
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@ -34,6 +35,20 @@ class WearBaresipService : Service() {
|
|||||||
)
|
)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
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)
|
AudioRouteManager.init(this)
|
||||||
// Register our SIP identity with the system telecom stack (experiment:
|
// Register our SIP identity with the system telecom stack (experiment:
|
||||||
@ -215,7 +230,7 @@ class WearBaresipService : Service() {
|
|||||||
CallState.status.value = "Incoming call"
|
CallState.status.value = "Incoming call"
|
||||||
// Surface the incoming-call UI even if the app was backgrounded,
|
// Surface the incoming-call UI even if the app was backgrounded,
|
||||||
// and alert the wearer (the watch screen is usually dimmed).
|
// and alert the wearer (the watch screen is usually dimmed).
|
||||||
alertIncomingCall()
|
alertIncomingCall(peer)
|
||||||
// Experiment: also hand the call to the system telecom stack so
|
// Experiment: also hand the call to the system telecom stack so
|
||||||
// the stock Wear incoming UI can be used if the dialer honors
|
// the stock Wear incoming UI can be used if the dialer honors
|
||||||
// our PhoneAccount. The in-app InCallScreen is shown regardless.
|
// 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 ringing" -> CallState.status.value = "Ringing"
|
||||||
ev == "call closed" -> {
|
ev == "call closed" -> {
|
||||||
AudioRouteManager.stopCallAudio()
|
AudioRouteManager.stopCallAudio()
|
||||||
|
dismissIncomingCallNotification()
|
||||||
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
|
// Surface SIP failure reasons (e.g. "488 Not acceptable here")
|
||||||
// as a toast so the user isn't left guessing why the call died.
|
// as a toast so the user isn't left guessing why the call died.
|
||||||
if (arg.isNotBlank()) showToast("Call ended: $arg")
|
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
|
* Alert the wearer to an incoming call. On Wear OS a bare startActivity
|
||||||
* (so the Answer/Decline UI shows), wake the (usually dimmed) screen, and
|
* from a background service does NOT reliably take over the watch face,
|
||||||
* buzz a ring pattern. The actual answer/decline buttons live in the
|
* so the robust mechanism is a high-priority notification with a
|
||||||
* in-call UI; this only makes sure the user can see and feel the call.
|
* 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 {
|
try {
|
||||||
// Wake the screen if it's asleep.
|
|
||||||
val pm = getSystemService(PowerManager::class.java)
|
val pm = getSystemService(PowerManager::class.java)
|
||||||
if (pm != null && !pm.isInteractive) {
|
if (pm != null && !pm.isInteractive) {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@ -279,17 +296,52 @@ class WearBaresipService : Service() {
|
|||||||
Log.w("Baresip Wear", "wake failed: ${e.message}")
|
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 {
|
try {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
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)
|
putExtra("incoming_call", true)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
Log.d("Baresip Wear", "alertIncomingCall: startActivity(incoming_call) dispatched")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("Baresip Wear", "bringToFront failed: ${e.message}")
|
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, ...).
|
// Ring vibration pattern (vibrate, pause, vibrate, pause, ...).
|
||||||
try {
|
try {
|
||||||
val vibrator = getSystemService(Vibrator::class.java)
|
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 {
|
companion object {
|
||||||
const val CHANNEL_ID = "baresip-wear"
|
const val CHANNEL_ID = "baresip-wear"
|
||||||
const val NOTIFICATION_ID = 1
|
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.
|
// Application context, captured in onCreate, for toasts from non-UI code.
|
||||||
lateinit var appContext: Context
|
lateinit var appContext: Context
|
||||||
|
|||||||
Reference in New Issue
Block a user