From f4b8391fa348516bbcbef29f7a30c70b67731f20 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 26 Oct 2026 16:37:04 -0700 Subject: [PATCH] car: calls show on the car's own screens, and its phone button answers --- .../seb3thehacker/gearslip/GearslipRunner.kt | 19 +++ .../gearslip/ServiceDiscovery.kt | 6 + .../gearslip/call/CarPhoneStatus.kt | 108 ++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarPhoneStatus.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index a784799..dca21f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -96,6 +96,7 @@ class GearslipRunner( @Volatile private var inputKeycodes: List = emptyList() @Volatile private var sensorChannelId: Int = -1 @Volatile private var mediaStatus: CarMediaStatus? = null + @Volatile private var phoneStatus: app.seb3thehacker.gearslip.call.CarPhoneStatus? = null @Volatile private var clusterNav: ClusterNav? = null private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null @@ -190,6 +191,8 @@ class GearslipRunner( LocationCheck.onCarDisconnected() mediaStatus?.stop() mediaStatus = null + phoneStatus?.stop() + phoneStatus = null clusterNav?.stop() clusterNav = null lastSensorNight = null @@ -237,6 +240,8 @@ class GearslipRunner( clusterNav?.onMessage(messageId, body) } else if (frame.channel == mediaStatus?.channelId) { mediaStatus?.onMessage(messageId, body) + } else if (frame.channel == phoneStatus?.channelId) { + phoneStatus?.onMessage(messageId, body) } else if (frame.channel == guidanceLink?.channelId) { guidanceLink?.onMessage(messageId, body) } else if (frame.channel == CarBluetooth.channelId) { @@ -512,6 +517,7 @@ class GearslipRunner( startSensorChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse) startMediaStatusChannel(serviceDiscoveryResponse) + startPhoneStatusChannel(serviceDiscoveryResponse) startClusterNavChannel(serviceDiscoveryResponse) startBluetooth(serviceDiscoveryResponse) startVendorChannel(serviceDiscoveryResponse) @@ -628,6 +634,19 @@ class GearslipRunner( openChannel(channel) } + /** Calls, for the car's own screens: the cluster, the steering wheel display. */ + private fun startPhoneStatusChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findPhoneStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no phone status channel - the car's own screens won't show calls") + return + } + log.i("phone status channel: $channel") + phoneStatus = app.seb3thehacker.gearslip.call.CarPhoneStatus(channel) { id, body -> + send(id, body, encrypted = true, channel = channel) + } + openChannel(channel) + } + /** Bluetooth pairing, for calls: the channel only opens if the driver asks to pair. */ /** * GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 8147a29..112bcad 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -116,6 +116,12 @@ object ServiceDiscovery { */ fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) + /** + * The channel the car takes call status on (PhoneStatusService, Service field 10), for its + * cluster and phone screens. It carries no settings. Null when the car has none. + */ + fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10) + class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int) /** The cluster advertises its minimum update interval in NavigationStatusService field 1. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarPhoneStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarPhoneStatus.kt new file mode 100644 index 0000000..efefa19 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarPhoneStatus.kt @@ -0,0 +1,108 @@ +package app.seb3thehacker.gearslip.call + +import android.os.SystemClock +import android.view.KeyEvent +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.Wire +import app.seb3thehacker.gearslip.car.CarKeys + +/** + * Tells the car about calls, so its own screens show them: the instrument cluster, the steering + * wheel display, the car's phone page. Android Auto does the same over the car's + * PhoneStatusService (Service field 10): + * + * -> PhoneStatusUpdate { repeated PhoneCall calls = 1 } + * PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2; + * number = 3; name = 4 } + * <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 } + * + * Read once a second from [CarCalls]. An update goes when the call changes, and every second + * while one is going, so the car's timer counts along; no calls goes once, as an empty list. + * The car's own call button answers through [CarKeys], like the steering wheel's. + */ +class CarPhoneStatus( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sent: String? = null + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) + GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + MSG_INPUT -> onInput(body) + else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-phone-status").start() + } + + fun stop() { + running = false + } + + private fun tick() { + val call = when (val state = CarCalls.state.value) { + CallUiState.None -> null + is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller) + is CallUiState.Active -> Call( + if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL, + ((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0), + state.caller, + ) + } + // Only a call that's going changes from one second to the next. + val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: "" + if (key == sent) return + val changed = call?.state != lastState + sent = key + lastState = call?.state + val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0) + sendOnChannel(MSG_STATUS, body) + if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}") + } + + private var lastState: Int? = null + + private class Call(val state: Int, val seconds: Long, val caller: Caller?) { + fun encode(): ByteArray { + var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds) + caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) } + caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) } + return body + } + } + + /** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */ + private fun onInput(body: ByteArray) { + val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() } + GearslipLog.i("phone status: <- input action=${action ?: "?"}") + if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false) + } + + companion object { + private const val TICK_MS = 1_000L + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_STATUS = 32769 // 0x8001 + private const val MSG_INPUT = 32770 // 0x8002 + private const val STATE_IN_CALL = 1 + private const val STATE_INCOMING = 4 + private const val STATE_MUTED = 6 + private const val ACTION_CALL = 7 + private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted") + } +}