diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt index 8ada839..3cdf1c7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt @@ -23,19 +23,19 @@ data class Caller(val number: String, val name: String?) { sealed interface CallUiState { data object None : CallUiState data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState - data class Active(val caller: Caller?) : CallUiState + /** [since] is the uptime the call went off-hook, for the timer. */ + data class Active(val caller: Caller?, val since: Long) : CallUiState } /** - * Tracks the phone's call state for the car screen, and answers or declines through the two + * Tracks the phone's call state for the car screen, and answers or declines through the * doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer, - * and [GearslipCallScreening]'s response to decline one that is still ringing. + * [GearslipCallScreening]'s response to decline one that is still ringing, and + * [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an + * ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends. * - * Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip - * cannot hold, so a call taken here is finished the way it would be without Gearslip at all - - * from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio - * stays on the phone's normal voice path too; nothing here sends it over the USB link, which - * only ever carries media playback. + * Call audio stays on the phone's normal voice path; nothing here sends it over the USB link, + * which only ever carries media playback. */ object CarCalls { @@ -47,9 +47,14 @@ object CarCalls { private var pendingRespond: ((allow: Boolean) -> Unit)? = null private var ringingCaller: Caller? = null + private val _muted = MutableStateFlow(false) + val muted: StateFlow = _muted.asStateFlow() + private var audio: android.media.AudioManager? = null + fun start(context: Context) { if (watcher != null) return if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return + audio = context.getSystemService(android.media.AudioManager::class.java) val manager = context.getSystemService(TelephonyManager::class.java) ?: return val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener { override fun onCallStateChanged(state: Int) = onPhoneState(state) @@ -66,6 +71,7 @@ object CarCalls { watcher = null pendingRespond = null ringingCaller = null + unmute() _state.value = CallUiState.None } @@ -74,11 +80,15 @@ object CarCalls { TelephonyManager.CALL_STATE_IDLE -> { pendingRespond = null ringingCaller = null + // A mute left on would silence the microphone for everything after the call. + unmute() _state.value = CallUiState.None } TelephonyManager.CALL_STATE_OFFHOOK -> { pendingRespond = null - _state.value = CallUiState.Active(ringingCaller) + if (_state.value !is CallUiState.Active) { + _state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis()) + } } TelephonyManager.CALL_STATE_RINGING -> { // The number itself only ever arrives through the screening service; if this @@ -109,6 +119,39 @@ object CarCalls { runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") } } + /** A call Gearslip itself is placing, so the call pill can say who it's to. */ + fun outgoingCall(context: Context, number: String) { + ringingCaller = Caller(number, lookUpName(context, number)) + } + + fun end(context: Context) { + if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) { + GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted") + return + } + val telecom = context.getSystemService(TelecomManager::class.java) ?: return + @Suppress("DEPRECATION") + runCatching { telecom.endCall() } + .onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") } + .onFailure { GearslipLog.w("calls: end failed: ${it.message}") } + } + + fun toggleMute() { + val manager = audio ?: return + val want = !_muted.value + runCatching { manager.isMicrophoneMute = want } + .onFailure { GearslipLog.w("calls: mute failed: ${it.message}") } + // Read back rather than trusted: some phones let the call app overrule it. + _muted.value = manager.isMicrophoneMute + if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change") + } + + private fun unmute() { + if (!_muted.value) return + runCatching { audio?.isMicrophoneMute = false } + _muted.value = false + } + fun answer(context: Context) { if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) { GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt index e8da410..28dbcd9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt @@ -33,21 +33,17 @@ import app.seb3thehacker.gearslip.call.CallUiState import app.seb3thehacker.gearslip.call.CarCalls /** - * Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the - * whole screen, because nothing else matters more right now; an active call is a thin band along - * the top, since from here the driver can only see who it is, not end it - see [CarCalls]. + * Takes the whole car screen while a call is ringing, because nothing else matters more right + * now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing. */ @Composable -fun CallOverlay(modifier: Modifier = Modifier) { +fun CallOverlay() { val state by CarCalls.state.collectAsState() val context = LocalContext.current.applicationContext when (val s = state) { - // Covers the box regardless of where the caller aligned it, so the alignment below is - // only ever felt by the active-call banner, which is the one that isn't full-screen. is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline) - is CallUiState.Active -> ActiveCallBanner(s, modifier) - CallUiState.None -> {} + else -> {} } } @@ -110,25 +106,3 @@ private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Text(label, style = MaterialTheme.typography.labelLarge) } } - -@Composable -private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = modifier.fillMaxWidth(), - ) { - Row( - Modifier.padding(horizontal = 20.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.Center, - ) { - Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.height(0.dp)) - Text( - " On a call" + (s.caller?.let { " with ${it.label}" } ?: ""), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 496cf4a..43d5be8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.material.icons.filled.Call +import app.seb3thehacker.gearslip.call.CarCalls +import app.seb3thehacker.gearslip.call.CallUiState import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.LocalIndication @@ -241,7 +244,9 @@ fun CarUi() { } // A full wait from when hiding becomes possible, not from whenever the last touch was. LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() } - val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake) + // A call keeps the bar up whatever the setting: End and Mute live on it. + val onCall = CarCalls.state.collectAsState().value is CallUiState.Active + val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall) val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp // Only the screen that asked: going Home or anywhere else ends it. val fullscreen = CarFullscreen.active && screen != CarScreen.Home @@ -309,7 +314,7 @@ fun CarUi() { ) // Above the popup: a call is more urgent than any notification. VoiceHelpOverlay(Modifier.align(Alignment.Center)) - CallOverlay(Modifier.align(Alignment.TopCenter)) + CallOverlay() AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset)) if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { // Bottom corner, not top: a screen's own header almost always puts @@ -320,7 +325,7 @@ fun CarUi() { } if (floatingBar || fullscreen) { androidx.compose.animation.AnimatedVisibility( - if (fullscreen) CarFullscreen.revealed else barShown, + if (fullscreen) CarFullscreen.revealed || onCall else barShown, Modifier.align(Alignment.BottomCenter), enter = androidx.compose.animation.slideInVertically { it }, exit = androidx.compose.animation.slideOutVertically { it }, @@ -564,12 +569,16 @@ private fun CarNavBar(navigator: CarNavigator) { // Next turn first, off the map only; the player shares the space when it fits, // and gives way to the turn when it doesn't. + // A call takes the player's place until it ends. val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home } + val call = CarCalls.state.collectAsState().value as? CallUiState.Active BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) { val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - turn?.let { NavTurnPill(it) { navigator.home() } } - if (turn == null || roomForBoth) { + if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } } + if (call != null) { + NavCallPill(call, Modifier.weight(1f, fill = false)) + } else if (turn == null || roomForBoth) { NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false)) } } @@ -836,6 +845,58 @@ private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) { } } +/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */ +@Composable +private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) { + val context = LocalContext.current.applicationContext + val muted by CarCalls.muted.collectAsState() + val elapsed by produceState(0L, call.since) { + while (true) { + value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000 + delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000) + } + } + val scheme = MaterialTheme.colorScheme + Surface( + modifier = modifier.height(44.dp), + shape = RoundedCornerShape(14.dp), + color = scheme.secondaryContainer, + contentColor = scheme.onSecondaryContainer, + ) { + Row( + Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp)) + Text( + call.caller?.label ?: "On a call", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + "%d:%02d".format(elapsed / 60, elapsed % 60), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + ) + Spacer(Modifier.width(2.dp)) + GsIconButton( + if (muted) MediaIcons.MicOff else MediaIcons.Mic, + if (muted) "Unmute" else "Mute", + CarCalls::toggleMute, + tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp, + ) + GsIconButton( + MediaIcons.CallEnd, "End call", { CarCalls.end(context) }, + colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp, + ) + } + } +} + /** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */ private fun nowPlayingLabel(title: String, artist: String): String = listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index c3fffa5..77c65a3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -691,6 +691,10 @@ internal object MediaIcons { val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z") val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z") + val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z") + val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z") + val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z") + private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder( name, 24.dp, 24.dp, 24f, 24f, ).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index 39453f4..ad215fe 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -94,7 +94,10 @@ internal fun placeCall(context: Context, number: String) { } val telecom = context.getSystemService(android.telecom.TelecomManager::class.java) runCatching { telecom!!.placeCall(Uri.fromParts("tel", number, null), null) } - .onSuccess { GearslipLog.i("calls: placing a call") } + .onSuccess { + GearslipLog.i("calls: placing a call") + app.seb3thehacker.gearslip.call.CarCalls.outgoingCall(context, number) + } .onFailure { GearslipLog.w("calls: placing a call failed: ${it.message}") } }