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 a03dca69..f4baa9b5 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/MainActivity.kt @@ -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}") } } 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 a1f73aed..2085a551 100644 --- a/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt +++ b/wear/src/main/java/com/tutpro/baresip/wear/WearBaresipService.kt @@ -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