Gearslip: phone-side Android Auto client

This commit is contained in:
seb committed 2026-09-20 02:09:47 -07:00
commit 24cfbee0c4
98 files changed
+12370

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only: renders the car UI to PNGs so it can be checked without a car. -->
<activity android:name="app.seb3thehacker.gearslip.CarPreviewActivity" android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,106 @@
package app.seb3thehacker.gearslip
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.media.ImageReader
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import java.io.File
import java.io.FileOutputStream
/**
* Debug-only harness: runs the real car UI on a virtual display (the same path a head unit
* gets), taps where told, and saves what it rendered as PNGs in the external files dir.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ei dpi 160 --es taps "400,440;90,440"
*
* Writes preview-0.png (before any tap), then one more per tap.
*/
class CarPreviewActivity : ComponentActivity() {
private val main = Handler(Looper.getMainLooper())
private var projector: ScreenProjector? = null
private var reader: ImageReader? = null
@Volatile private var latest: Bitmap? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
CarSettings.init(this)
CarEnvironment.start(this)
val w = intent.getIntExtra("w", 800)
val h = intent.getIntExtra("h", 480)
val dpi = intent.getIntExtra("dpi", 160)
val taps = intent.getStringExtra("taps").orEmpty().split(";").filter { it.isNotBlank() }
.map { it.split(",").let { p -> p[0].toFloat() to p[1].toFloat() } }
val imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 3)
reader = imageReader
imageReader.setOnImageAvailableListener({ r ->
r.acquireLatestImage()?.use { image ->
val plane = image.planes[0]
val padded = plane.rowStride / plane.pixelStride
val bmp = Bitmap.createBitmap(padded, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(plane.buffer)
latest = Bitmap.createBitmap(bmp, 0, 0, w, h)
}
}, main)
val p = ScreenProjector(this).also { projector = it }
p.start(imageReader.surface, w, h, dpi) { CarUi() }
GearslipLog.i("preview: displayId=${p.displayId}")
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
intent.getStringExtra("launch")?.let { pkg ->
main.postDelayed({
packageManager.getLaunchIntentForPackage(pkg)?.let { p.launchApp(it) }
?: GearslipLog.e("preview: no launch intent for $pkg")
}, 1500)
}
var step = 0
fun snap() {
val bmp = latest ?: return GearslipLog.e("preview: no frame yet for step $step")
val out = File(getExternalFilesDir(null), "preview-$step.png")
FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
GearslipLog.i("preview: wrote ${out.name}")
step++
}
// Free-running mode: keep snapshotting on a timer so the display can be driven from
// outside (adb `am start --display` / `input -d`) while the harness records it.
val snaps = intent.getIntExtra("snaps", 0)
if (snaps > 0) {
val every = intent.getIntExtra("every", 3000).toLong()
repeat(snaps) { i -> main.postDelayed({ snap() }, 2000L + every * i) }
return
}
main.postDelayed({
snap()
taps.forEachIndexed { i, (x, y) ->
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80)
}, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
}
}, 2000)
}
override fun onDestroy() {
super.onDestroy()
projector?.stop()
reader?.close()
CarEnvironment.stop()
}
}
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
service of this type is already running, so MirrorService exists purely to satisfy that
and to give the driver a way to stop the capture from the notification shade.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
Declared, not just requested. Templated apps decide whether to talk to a host by asking
whether it holds this permission (HostValidator.isValidHost checks it before falling back
to a signature allow-list containing Android Auto's). It is defined by the car framework
on Android Automotive, which does not exist on a phone, so nothing else defines it here
and Gearslip has to define it to be able to hold it.
-->
<permission
android:name="android.car.permission.TEMPLATE_RENDERER"
android:protectionLevel="normal" />
<uses-permission android:name="android.car.permission.TEMPLATE_RENDERER" />
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Templated apps are invisible to us without this, and the catalog comes back empty. -->
<intent>
<action android:name="androidx.car.app.CarAppService" />
</intent>
<!-- Media apps for the car: found by their browser service. -->
<intent>
<action android:name="android.media.browse.MediaBrowserService" />
</intent>
</queries>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="Gearslip"
android:supportsRtl="true"
android:theme="@style/Theme.Gearslip">
<!--
singleTop: the head unit can re-attach while we are already foregrounded, which
arrives via onNewIntent rather than a fresh instance.
-->
<activity
android:name=".GearslipActivity"
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
android:resource="@xml/accessory_filter" />
</activity>
<service
android:name=".mirror.MirrorService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".host.LocationKeepAlive"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Holds the capture session that carries a media app's audio to the car. -->
<service
android:name=".audio.AudioCaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!--
Touch on the car screen is replayed onto the phone through this service.
dispatchGesture is the only public way for an app to inject input into another app,
and it is unavailable until the user enables Gearslip under Settings > Accessibility.
-->
<service
android:name=".mirror.TouchRelayService"
android:exported="true"
android:label="Gearslip car touch"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/touch_relay" />
</service>
<!--
Reads the phone's notifications so the car can show and answer them. Inert until the
user grants Notification access to Gearslip in the phone's settings.
-->
<service
android:name=".notify.GearslipNotificationListener"
android:exported="true"
android:label="Gearslip notifications"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,28 @@
package app.seb3thehacker.gearslip
import android.content.Context
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
object AppSettings {
private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getString(context: Context, key: String, default: String): String =
prefs(context).getString(key, default) ?: default
fun putString(context: Context, key: String, value: String) {
prefs(context).edit().putString(key, value).apply()
}
/** Blank means "use the built-in test page". */
fun startupUrl(context: Context): String =
prefs(context).getString(KEY_STARTUP_URL, "").orEmpty()
fun setStartupUrl(context: Context, value: String) {
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
}
}
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.net.Uri
import java.io.File
import java.security.KeyStore
import java.security.cert.X509Certificate
/**
* Supplies the certificate the phone presents to the head unit.
*
* FOSS posture: this code ships with NO certificate. Whoever wants to test the trusted-cert
* path supplies their own. Sources, in priority order:
*
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
* copied into the app's private storage.
* 2. **Staged over adb** into the app's external files directory, the way the spike has
* always done it. Still honoured, so existing setups keep working untouched:
*
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
* -name phone -passout pass:aaspike -out phone.p12
* adb push phone.p12 /sdcard/Android/data/app.seb3thehacker.gearslip/files/phone.p12
*
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
* regenerating); a `phone.pass` file next to it overrides it.
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
* SPIKE_FINDINGS.md).
*/
object CertProvider {
private const val P12_NAME = "phone.p12"
private const val PASS_NAME = "phone.pass"
private const val DEFAULT_PASSWORD = "aaspike"
private const val IMPORT_DIR = "identity"
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
class Identity(
val keyStore: KeyStore,
val certificate: X509Certificate,
val password: CharArray,
val source: String,
val kind: Kind = Kind.SELF_SIGNED,
)
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
val generated = SelfSignedCert.generate()
Identity(
generated.keyStore,
generated.certificate,
SelfSignedCert.PASSWORD,
"self-signed (generated) - a real head unit rejects this",
Kind.SELF_SIGNED,
)
}
/**
* The active identity without the cost of generating a self-signed key: null means "nothing
* supplied, a self-signed one would be generated". For the UI, which only wants to describe it.
*/
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
private fun loadFromFiles(context: Context): Identity? {
val imported = importedFile(context, P12_NAME)
if (imported.isFile) {
runCatching {
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
}.onFailure { GearslipLog.e("could not load the imported certificate - trying the next source", it) }
}
val dir = context.getExternalFilesDir(null)
val staged = dir?.let { File(it, P12_NAME) }
if (dir != null && staged != null && staged.isFile) {
runCatching {
val passFile = File(dir, PASS_NAME)
val password = if (passFile.isFile) {
passFile.readText().lineSequence().firstOrNull().orEmpty().trim()
} else {
DEFAULT_PASSWORD
}
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
}.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) }
}
return null
}
/**
* Validates [uri] as a PKCS#12 identity with [password] and, only if it is good, makes it
* the active certificate. A wrong password or a file with no private key throws and
* leaves whatever was active before untouched.
*/
fun importFrom(context: Context, uri: Uri, password: String): Identity {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalArgumentException("could not open the selected file")
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(bytes)
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
val target = importedFile(context, P12_NAME)
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete()
GearslipLog.i("removed the imported certificate")
}
// The imported files live in the app's private internal storage (not the external files
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
private fun importedPassword(context: Context): String =
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
private fun loadP12(file: File, password: String, label: String, kind: Kind): Identity {
val chars = password.toCharArray()
val keyStore = KeyStore.getInstance("PKCS12")
file.inputStream().use { keyStore.load(it, chars) }
val alias = keyStore.aliases().asSequence().firstOrNull { keyStore.isKeyEntry(it) }
?: throw IllegalStateException("the file contains no private-key entry")
val certificate = keyStore.getCertificate(alias) as X509Certificate
return Identity(keyStore, certificate, chars, "$label (alias=$alias)", kind)
}
}
@@ -0,0 +1,96 @@
package app.seb3thehacker.gearslip
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Proves the H.264 encoder actually produces a bitstream on this device, before a drive.
*
* Without this, an unsupported colour format, a rejected Baseline profile, or a null
* getInputImage() would all present in the car as "the head unit shows nothing" - the same
* symptom as a protocol fault, and impossible to tell apart from the log. Same reasoning as
* the TLS self-test: make our own failures loud on the bench.
*/
object EncoderSelfTest {
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
var codecConfig: ByteArray? = null
var frames = 0
var sawKeyFrame = false
var sawResyncKeyFrame = false
var resyncRequestedAtFrame = -1
val done = CountDownLatch(1)
val source = VideoSource(
width = width,
height = height,
frameRate = frameRate,
onCodecConfig = { codecConfig = it },
onFrame = { _, _, keyFrame ->
frames++
if (keyFrame) sawKeyFrame = true
// At frame 5, ask for a resync keyframe - the same call sendVideoFrame makes
// after a drop - and confirm THIS encoder actually honours it. Trusting an
// API we have never exercised on this device is exactly the mistake that
// cost two drives already (the CONTROL message-type bit, the touch scaling).
if (frames == 5) {
resyncRequestedAtFrame = frames
}
if (resyncRequestedAtFrame in 1 until frames && keyFrame) {
sawResyncKeyFrame = true
}
if (frames >= 12) done.countDown()
},
)
return try {
source.start()
// Give it a couple of frames before requesting resync, matching how it is used
// for real: the request always follows at least one already-encoded frame.
Thread {
while (resyncRequestedAtFrame == -1) Thread.sleep(20)
source.requestSyncFrame()
}.apply { isDaemon = true; start() }
done.await(8, TimeUnit.SECONDS)
source.stop()
val csd = codecConfig
when {
csd == null -> {
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
false
}
frames == 0 -> {
GearslipLog.e("encoder self-test FAILED: no frames produced")
false
}
!sawKeyFrame -> {
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
false
}
!sawResyncKeyFrame -> {
GearslipLog.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device",
)
false
}
else -> {
GearslipLog.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working",
)
GearslipLog.hex(" csd", csd, limit = 48)
GearslipLog.i("=== encoder self-test PASSED ===")
true
}
}
} catch (t: Throwable) {
runCatching { source.stop() }
GearslipLog.e("encoder self-test FAILED with an exception", t)
false
}
}
}
@@ -0,0 +1,135 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/**
* Android Auto frame layout, transcribed from aasdk.
*
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
*
* byte 0 channel id
* byte 1 frameType | encryption | messageType
* bytes 2-3 uint16 BE payload length of THIS frame
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
* payload uint16 BE message id, then the body
*/
object Frames {
const val CHANNEL_CONTROL = 0
const val TYPE_MIDDLE = 0
const val TYPE_FIRST = 1
const val TYPE_LAST = 2
const val TYPE_BULK = 3
const val ENCRYPTED = 1 shl 3
/**
* Message type bit. Channel-open messages are CONTROL even when sent on a service
* channel - aasdk sets MessageType::CONTROL only in sendChannelOpenResponse and
* MessageType::SPECIFIC everywhere else (e.g.
* src/Channel/MediaSink/Video/VideoMediaSinkService.cpp:52 vs :66). Getting this wrong
* makes the head unit drop the USB link outright rather than answer.
*/
const val MESSAGE_SPECIFIC = 0
const val MESSAGE_CONTROL = 1 shl 2
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
const val MAX_FRAME_PAYLOAD = 0x4000
class Frame(
val channel: Int,
val frameType: Int,
val encrypted: Boolean,
val messageType: Int,
val payload: ByteArray,
)
/**
* Builds one frame. [totalSize] is the full plaintext message length and is written only
* for FIRST frames, matching aasdk's MessageOutStream::setFrameSize
* (`FrameSize(payloadSize, totalSize)` for FIRST, `FrameSize(payloadSize)` otherwise).
*/
fun build(
channel: Int,
encrypted: Boolean,
payload: ByteArray,
messageType: Int = MESSAGE_SPECIFIC,
frameType: Int = TYPE_BULK,
totalSize: Int? = null,
): ByteArray {
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
val header = byteArrayOf(
channel.toByte(),
flags.toByte(),
((payload.size shr 8) and 0xFF).toByte(),
(payload.size and 0xFF).toByte(),
)
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
val extended = byteArrayOf(
((totalSize shr 24) and 0xFF).toByte(),
((totalSize shr 16) and 0xFF).toByte(),
((totalSize shr 8) and 0xFF).toByte(),
(totalSize and 0xFF).toByte(),
)
return header + extended + payload
}
/**
* Pulls whole frames out of an accumulating buffer.
*
* A read() on the accessory descriptor returns one bulk transfer, which is usually one
* frame - but nothing guarantees it, so this never assumes alignment.
*/
class Parser {
private var pending = ByteArray(0)
fun append(data: ByteArray, length: Int) {
val grown = ByteArray(pending.size + length)
System.arraycopy(pending, 0, grown, 0, pending.size)
System.arraycopy(data, 0, grown, pending.size, length)
pending = grown
}
fun next(): Frame? {
if (pending.size < 4) return null
val channel = pending[0].toInt() and 0xFF
val flags = pending[1].toInt() and 0xFF
val frameType = flags and TYPE_BULK
// Extended size is written only for FIRST - BULK (3) keeps the short header.
val headerLength = if (frameType == TYPE_FIRST) 8 else 4
if (pending.size < headerLength) return null
val frameSize = ((pending[2].toInt() and 0xFF) shl 8) or (pending[3].toInt() and 0xFF)
val total = headerLength + frameSize
if (pending.size < total) return null
val payload = pending.copyOfRange(headerLength, total)
pending = pending.copyOfRange(total, pending.size)
return Frame(
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
)
}
}
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
class Assembler {
private val partial = HashMap<Int, ByteArrayOutputStream>()
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
TYPE_BULK -> payload
TYPE_FIRST -> {
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
null
}
TYPE_MIDDLE -> {
partial[frame.channel]?.write(payload)
null
}
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
else -> null
}
}
}
@@ -0,0 +1,298 @@
package app.seb3thehacker.gearslip
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.view.MotionEvent
import android.view.Surface
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration
import app.seb3thehacker.gearslip.audio.AudioCaptureService
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.mirror.MirrorService
import app.seb3thehacker.gearslip.mirror.PhoneMirror
import app.seb3thehacker.gearslip.mirror.TouchRelayService
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.ui.GearslipApp
import app.seb3thehacker.gearslip.ui.GearslipTheme
import java.io.FileInputStream
import java.io.FileOutputStream
/**
* Hosts the phone UI and, for now, the car session itself.
*
* Launched by the system when the head unit switches the phone into accessory mode. The
* session lives in this activity, so [android:configChanges] in the manifest keeps a
* rotation or theme change from recreating it and dropping the connection.
*/
class GearslipActivity : ComponentActivity(), Projection {
private var descriptor: ParcelFileDescriptor? = null
private var runner: GearslipRunner? = null
private var screenProjector: ScreenProjector? = null
private var worker: Thread? = null
/** The encoder's input Surface, kept so the car's picture can be switched to the mirror. */
private var surface: Surface? = null
private var frameWidth = 0
private var frameHeight = 0
private var frameDensity = 0
/**
* Screen capture always begins with a consent dialog, which only an activity can show -
* so the car UI asks, and the request surfaces here on the phone.
*/
private val captureConsent = registerForActivityResult(StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
MirrorService.start(this, result.resultCode, data)
} else {
GearslipLog.w("screen capture consent declined")
}
}
/** Media audio needs the microphone permission (a platform rule for playback capture) first. */
private val audioPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) askAudioCapture() else {
GearslipLog.w("audio: record permission declined")
CarAudio.denied()
}
}
private val audioConsent = registerForActivityResult(StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
AudioCaptureService.start(this, result.resultCode, data)
} else {
GearslipLog.w("audio: capture consent declined")
CarAudio.denied()
}
}
private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
GearslipLog.w("accessory detached")
closeAccessory()
}
}
}
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
private val locationPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this)
GearslipLog.i("Gearslip ready")
CarSettings.init(this)
CarEnvironment.setPhoneTheme(resources.configuration)
setContent {
GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory)
}
}
PhoneMirror.onStartRequested = { runOnUiThread { requestCapture() } }
PhoneMirror.onTokenReady = { token -> runOnUiThread { showMirror(token) } }
PhoneMirror.onEnded = { runOnUiThread { showCarUi() } }
CarAudio.onConsentRequested = { runOnUiThread { requestAudioCapture() } }
registerReceiver(detachReceiver, IntentFilter(UsbManager.ACTION_USB_ACCESSORY_DETACHED), RECEIVER_NOT_EXPORTED)
handleIntent(intent)
}
// The manifest opts out of recreation on uiMode changes, so a light/dark switch on the phone
// arrives here, and the car UI follows it.
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
CarEnvironment.setPhoneTheme(newConfig)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
override fun onDestroy() {
super.onDestroy()
runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null
PhoneMirror.onTokenReady = null
PhoneMirror.onEnded = null
CarAudio.onConsentRequested = null
CarAudio.release()
PhoneMirror.requestStop()
closeAccessory()
GearslipLog.flush()
}
// --- Media audio --------------------------------------------------------------------
private fun requestAudioCapture() {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
askAudioCapture()
} else {
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
private fun askAudioCapture() {
val manager = getSystemService(MediaProjectionManager::class.java)
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure {
GearslipLog.e("could not ask for audio capture", it)
CarAudio.denied()
}
}
// --- Phone mirroring ----------------------------------------------------------------
private fun requestCapture() {
val manager = getSystemService(MediaProjectionManager::class.java)
runCatching { captureConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure { GearslipLog.e("could not ask for screen capture", it) }
}
/** A Surface has a single producer, so our own display must go before the mirror arrives. */
private fun showMirror(token: MediaProjection) {
val target = surface ?: return
screenProjector?.stop()
screenProjector = null
PhoneMirror.begin(token, target, frameWidth, frameHeight, frameDensity)
if (PhoneMirror.active.value) PhoneMirror.launchPending(this) else showCarUi()
}
private fun showCarUi() {
val target = surface ?: return
if (screenProjector != null) return
val projector = ScreenProjector(this).also { screenProjector = it }
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
}
/**
* Sends a head-unit touch to the phone. Anything landing on the mirror's letterbox padding
* maps to nothing on the phone and is dropped rather than clamped onto a screen edge.
*/
private fun relayTouch(action: Int, x: Float, y: Float) {
val point = PhoneMirror.mapToPhone(this, x, y)
if (point == null) {
// The letterbox bars belong to no window, which makes them a safe way back: a tap
// on the padding leaves the mirror and returns the car to Gearslip's own UI.
if (action == MotionEvent.ACTION_UP) PhoneMirror.requestStop()
return
}
val relay = TouchRelayService.instance ?: return
when (action) {
MotionEvent.ACTION_DOWN -> relay.down(point.x, point.y)
MotionEvent.ACTION_MOVE -> relay.move(point.x, point.y)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> relay.up(point.x, point.y)
}
}
private fun handleIntent(intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
if (accessory == null) {
GearslipLog.e("attach intent carried no accessory")
SessionStatus.failed("Accessory missing", "The system reported a USB attach but did not include the accessory.")
return
}
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
start(accessory)
}
private fun start(accessory: UsbAccessory) {
if (worker?.isAlive == true) {
GearslipLog.w("a run is already in progress")
return
}
describe(accessory)
val manager = getSystemService(UsbManager::class.java)
val pfd = manager.openAccessory(accessory)
if (pfd == null) {
GearslipLog.e("openAccessory returned null - the session was claimed by someone else")
SessionStatus.failed("Accessory unavailable", "Another app claimed the USB accessory before Gearslip could open it.")
return
}
descriptor = pfd
val runner = GearslipRunner(
input = FileInputStream(pfd.fileDescriptor),
output = FileOutputStream(pfd.fileDescriptor),
identityProvider = { CertProvider.load(this) },
projection = this,
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
)
this.runner = runner
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
}
private fun describe(accessory: UsbAccessory) {
GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}")
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
}
private fun closeAccessory() {
runner?.stop()
runCatching { descriptor?.close() }
descriptor = null
runner = null
}
// --- Projection ---------------------------------------------------------------------
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
runOnUiThread {
this.surface = surface
frameWidth = width
frameHeight = height
frameDensity = densityDpi
CarEnvironment.setDisplay(width, height, densityDpi)
CarEnvironment.start(this)
showCarUi()
}
}
override fun onTouch(action: Int, x: Float, y: Float) {
runOnUiThread {
if (PhoneMirror.active.value) relayTouch(action, x, y)
else screenProjector?.dispatchTouch(action, x, y)
}
}
override fun onProjectionStopped() {
runOnUiThread {
PhoneMirror.requestStop()
screenProjector?.stop()
screenProjector = null
surface = null
CarEnvironment.stop()
}
}
}
@@ -0,0 +1,120 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/**
* Logs to logcat and to a file at once.
*
* The file copy is the safety net: entering USB accessory mode tears down adb-over-USB, so
* if wireless adb drops in the car the file is the only surviving record of the run. It
* lives under getExternalFilesDir, which `adb pull` can reach without root.
*/
object GearslipLog {
const val TAG = "Gearslip"
private val stamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val io = Executors.newSingleThreadExecutor()
private const val MAX_LIVE_LINES = 3000
private var file: File? = null
// What the live log screen shows. Bounded, and separate from the file, which always keeps
// everything. The flow carries only a version number so a burst of lines costs the UI one
// snapshot per frame rather than one list copy per line.
private val live = ArrayDeque<String>()
private val liveVersion = MutableStateFlow(0)
val version: StateFlow<Int> = liveVersion
fun init(context: Context) {
val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}")
}
fun snapshot(): List<String> = synchronized(live) { live.toList() }
/** Clears the live view only. The log file on disk is left alone. */
fun clearLive() {
synchronized(live) { live.clear() }
liveVersion.update { it + 1 }
}
/** The whole current session's file, for sharing. Falls back to the live buffer. */
fun fullText(): String {
flush()
val f = file
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
}
fun i(message: String) = write("I", message)
fun w(message: String) = write("W", message)
fun e(message: String, t: Throwable? = null) {
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
if (t != null) Log.e(TAG, message, t)
}
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
fun verdict(headline: String, detail: String) {
val bar = "=".repeat(60)
i(bar)
i("VERDICT: $headline")
i(detail)
i(bar)
// The verdict is the one line the run exists to produce, and the app may be killed
// the moment the accessory detaches. Do not leave it sitting in the write queue.
flush()
}
/** Blocks until everything queued has actually reached the file. */
fun flush(timeoutMs: Long = 2_000) {
val latch = CountDownLatch(1)
runCatching { io.execute { latch.countDown() } }.onFailure { return }
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
}
fun hex(label: String, data: ByteArray, limit: Int = 256) {
val shown = minOf(data.size, limit)
val sb = StringBuilder()
for (idx in 0 until shown) {
sb.append(String.format("%02X", data[idx]))
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
}
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
i("$label (${data.size} bytes)\n$sb$suffix")
}
private fun write(level: String, message: String) {
when (level) {
"W" -> Log.w(TAG, message)
"E" -> Log.e(TAG, message)
else -> Log.i(TAG, message)
}
val line = "${stamp.format(Date())} $level $message"
synchronized(live) {
live.addLast(line)
while (live.size > MAX_LIVE_LINES) live.removeFirst()
}
liveVersion.update { it + 1 }
val target = file ?: return
io.execute {
runCatching { target.appendText(line + "\n") }
}
}
}
@@ -0,0 +1,775 @@
package app.seb3thehacker.gearslip
import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.car.CarEnvironment
import java.io.InputStream
import java.io.OutputStream
/**
* Drives the phone side of the control-channel handshake far enough to answer one question:
* does this head unit accept a self-signed certificate from the phone?
*
* The head unit initiates (aasdk's ControlServiceChannel::sendVersionRequest), so until
* step 5 this is purely reactive:
*
* 1 HU -> phone VersionRequest id 1 plain
* 2 phone -> HU VersionResponse id 2 plain
* 3 both EncapsulatedSSL id 3 plain (repeats until TLS completes)
* 4 HU -> phone AuthComplete id 4 plain <- the answer
* 5 phone -> HU ServiceDiscoveryRequest id 5 ENCRYPTED
* 6 HU -> phone ServiceDiscoveryResponse id 6 ENCRYPTED <- the confirmation
*/
class GearslipRunner(
private val input: InputStream,
private val output: OutputStream,
private val identityProvider: () -> CertProvider.Identity,
private val projection: Projection? = null,
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
) {
private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
private val parser = Frames.Parser()
private val assembler = Frames.Assembler()
private lateinit var tls: PhoneTls
@Volatile private var state = State.WAIT_VERSION
@Volatile private var lastProgress = System.currentTimeMillis()
@Volatile private var running = true
@Volatile private var videoChannelId: Int = -1
private var videoConfigs: List<ServiceDiscovery.VideoConfig> = emptyList()
private var selectedConfigIndex: Int? = null
private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false
private var videoSource: VideoSource? = null
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 4
@Volatile private var framesSent = 0
@Volatile private var dropped = 0
@Volatile private var inputChannelId: Int = -1
@Volatile private var audioLink: AudioLink? = null
private var touchWidth = 0
private var touchHeight = 0
private var touchesSeen = 0
@Volatile private var acksSeen = 0
@Volatile private var lastAckAt = 0L
@Volatile private var splitMessages = 0
@Volatile private var resyncRequested = false
@Volatile private var resyncAttempts = 0
@Volatile private var lastKeyframeSentAt = 0L
fun run() {
GearslipLog.i("--- spike run starting ---")
SessionStatus.connecting("Connected to the head unit")
GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
val identity = identityProvider()
val cert = identity.certificate
GearslipLog.i("phone certificate source: ${identity.source}")
GearslipLog.i(" subject = ${cert.subjectX500Principal}")
GearslipLog.i(" issuer = ${cert.issuerX500Principal}")
GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
tls = PhoneTls(identity.keyStore, identity.password)
startWatchdog()
val chunk = ByteArray(16 * 1024) // AOAP: one read returns one bulk transfer
try {
while (running) {
val read = input.read(chunk)
if (read < 0) {
GearslipLog.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect()
return
}
if (read == 0) continue
parser.append(chunk, read)
while (true) {
val frame = parser.next() ?: break
handleFrame(frame)
}
}
} catch (t: Throwable) {
GearslipLog.e("transport failed in state $state", t)
reportDisconnect()
} finally {
running = false
GearslipLog.flush()
}
}
fun stop() {
running = false
videoSource?.stop()
videoSource = null
audioLink?.close()
audioLink = null
projection?.onProjectionStopped()
SessionStatus.disconnected()
}
private fun handleFrame(frame: Frames.Frame) {
val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) {
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring")
return
}
tls.decrypt(frame.payload)
} else {
frame.payload
}
val message = assembler.offer(frame, payload) ?: return
if (message.size < 2) {
GearslipLog.w("runt message on channel ${frame.channel}")
return
}
val messageId = ((message[0].toInt() and 0xFF) shl 8) or (message[1].toInt() and 0xFF)
val body = message.copyOfRange(2, message.size)
lastProgress = System.currentTimeMillis()
if (frame.channel != Frames.CHANNEL_CONTROL) {
if (frame.channel == videoChannelId) {
onVideoMessage(messageId, body)
} else if (frame.channel == inputChannelId) {
onInputMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) {
audioLink?.onMessage(messageId, body)
} else {
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}")
}
return
}
when (messageId) {
MSG_VERSION_REQUEST -> onVersionRequest(body)
MSG_ENCAPSULATED_SSL -> onHandshake(body)
MSG_AUTH_COMPLETE -> onAuthComplete(body)
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
else -> GearslipLog.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
}
}
private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) {
GearslipLog.w("VersionRequest too short (${body.size} bytes)")
return
}
val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF)
val minor = ((body[2].toInt() and 0xFF) shl 8) or (body[3].toInt() and 0xFF)
GearslipLog.i("<- VersionRequest: head unit speaks $major.$minor")
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
// not to honour whatever that version implies.
val response = byteArrayOf(
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
GearslipLog.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
}
private fun onHandshake(body: ByteArray) {
GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try {
tls.pumpHandshake(body)
} catch (t: Throwable) {
GearslipLog.e("TLS handshake threw", t)
GearslipLog.verdict(
"NOT VIABLE (TLS rejected)",
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
"handshake_failure alert here means it validates the phone's certificate.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit aborted the secure handshake. It validates the phone's certificate.",
)
state = State.DONE
return
}
replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)")
}
if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
state = State.WAIT_AUTH
GearslipLog.i("awaiting AuthComplete - this carries the certificate verdict")
}
}
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- AuthComplete: status=$status")
when (status) {
0 -> {
GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
state = State.WAIT_SDR
SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest()
}
-2 -> {
GearslipLog.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " +
"This is a definitive no - it validates the phone against a trust chain " +
"we cannot issue from.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"The head unit refused the phone's certificate (certificate error).",
)
state = State.DONE
}
-3 -> {
GearslipLog.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " +
"practical answer is the same.",
)
SessionStatus.failed(
"Head unit rejected the certificate",
"Authentication failed. The head unit does not trust the certificate in use.",
)
state = State.DONE
}
else -> {
GearslipLog.verdict(
"INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
"before drawing any conclusion.",
)
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
state = State.DONE
}
}
}
private fun sendServiceDiscoveryRequest() {
val body = Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) // device_name
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)")
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body))
GearslipLog.verdict(
"VIABLE",
"The head unit accepted the presented phone certificate, completed TLS, and is " +
"advertising its channels over the encrypted session. A phone-side client is " +
"possible against THIS unit - portability to newer units is still unproven.",
)
state = State.DONE
val info = ServiceDiscovery.findHeadUnitInfo(body)
GearslipLog.i("head unit: $info")
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
if (profile != null) {
GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
} else {
GearslipLog.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
}
CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE)
SessionStatus.connecting("Starting video")
startVideoChannel(body)
}
// --- Phase A: video channel bring-up (no encoder yet) -------------------------------
//
// Inverting aasdk's head-unit video sink (src/Channel/MediaSink/Video/
// VideoMediaSinkService.cpp): every message that sink *receives* is one we must *send*.
//
// -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
// -> SETUP <- CONFIG (status, max_unacked, configuration_indices)
// -> VIDEO_FOCUS_REQUEST <- VIDEO_FOCUS_NOTIFICATION
//
// Reaching VIDEO_FOCUS_NOTIFICATION means the head unit is waiting for frames, which is
// the Phase A milestone. Phase B then sends CODEC_CONFIG + DATA.
private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) {
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) {
GearslipLog.w("no video service in the discovery response - cannot start Phase A")
return
}
videoChannelId = video.serviceId
videoConfigs = video.configs
GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}")
video.configs.forEachIndexed { index, config ->
GearslipLog.i(" config[$index] = $config")
}
if (video.codecType != CODEC_H264_BP) {
GearslipLog.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
}
openChannel(video.serviceId)
startAudioChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
if (input == null) {
GearslipLog.w("no input service advertised - projection will be output-only")
} else {
inputChannelId = input.serviceId
touchWidth = input.touchWidth
touchHeight = input.touchHeight
GearslipLog.i(
"input service: channel=${input.serviceId} touchscreen=" +
"${input.touchWidth}x${input.touchHeight}",
)
openChannel(input.serviceId)
}
}
/** Media audio rides its own sink; guidance, system and telephony sinks are left closed. */
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
sinks.forEach { sink ->
GearslipLog.i(
"audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " +
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
)
}
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
GearslipLog.w("no media audio sink advertised - media apps will play on the phone only")
return
}
audioLink = AudioLink(
media,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
sendControl = { id, body -> send(id, body, encrypted = true) },
)
openChannel(media.serviceId)
}
private fun openChannel(serviceId: Int) {
val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0)
Protobuf.varintField(2, serviceId.toLong()) // service_id
send(
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
channel = serviceId, messageType = Frames.MESSAGE_CONTROL,
)
GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
}
/**
* Touch events arrive **already in projected video coordinates**, not in the head unit's
* native touchscreen space.
*
* Confirmed on the 2018 Uconnect: its panel is advertised as 1258x708, but with 800x480
* projected the raw x never exceeded 791 and raw y never exceeded ~351 even when tapping
* the far edges. Scaling by touchscreen/video (as an earlier version did) compressed the
* right-hand third of the screen away entirely - taps on a right-edge button landed on
* the middle one. The head unit maps its own panel onto our video before reporting.
*
* So: inject as-is, clamped to the video bounds. The advertised touchscreen size is kept
* only for diagnostics.
*/
private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE ->
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> {
val report = Wire.fields(body)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) {
GearslipLog.i("<- InputReport with no touch payload (key or rotary event)")
return
}
val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return
if (pointers.size > 1 && touchesSeen < 3) {
GearslipLog.i("(multi-touch: ${pointers.size} pointers; using the first)")
}
val first = Wire.fields(pointers[0])
val rawX = Wire.varint(first, 1)?.toInt() ?: return
val rawY = Wire.varint(first, 2)?.toInt() ?: return
val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
val x = clamp(rawX, size?.first)
val y = clamp(rawY, size?.second)
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else ""
GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note")
}
projection?.onTouch(action, x, y)
}
else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
private fun onVideoMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
GearslipLog.verdict(
"PHASE A FAILED (channel open rejected)",
"The head unit refused to open the video channel (status=$status).",
)
SessionStatus.failed("Video channel refused", "The head unit would not open the video channel (status $status).")
return
}
// Setup { required MediaCodecType type = 1 }
send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()),
encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)")
}
MSG_MEDIA_CONFIG -> {
val fields = Wire.fields(body)
val status = Wire.varint(fields, 1)?.toInt()
val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint }
GearslipLog.i(
"<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
"configuration_indices=$indices",
)
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
// Prefer the smallest offered resolution for the first attempt: fewer pixels,
// fewer ways for a first encode to go wrong.
val offered = indices.map { it.toInt() }
val preferred = vehicleProfile?.resolution?.let { want ->
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
}
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
} ?: offered.firstOrNull()
GearslipLog.i("selected config index $selectedConfigIndex from offered $offered")
// VideoFocusRequestNotification { mode = 2; reason = 3 }
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 1L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)")
}
MSG_VIDEO_FOCUS_NOTIFICATION -> {
val fields = Wire.fields(body)
GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
// The head unit repeats this while it waits for frames; only act once.
if (!videoStarted) {
videoStarted = true
GearslipLog.i("PHASE A COMPLETE - video focus granted; starting video source")
startVideoSource()
}
}
MSG_MEDIA_ACK -> {
val fields = Wire.fields(body)
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
inFlight = maxOf(0, inFlight - maxOf(1, acked))
acksSeen++
lastAckAt = System.currentTimeMillis()
if (framesSent <= 3 || framesSent % 60 == 0) {
GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
}
}
else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)")
}
}
// --- Phase B: encode and stream H.264 ----------------------------------------------
//
// -> Start { session_id, configuration_index }
// -> CodecConfig (raw SPS/PPS, NO timestamp - aasdk routes it to onMediaIndication)
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
private fun startVideoSource() {
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
val index = selectedConfigIndex ?: 0
val config = videoConfigs.getOrNull(index)
if (config == null) {
GearslipLog.e("no video config at index $index - cannot start the encoder")
return
}
val (width, height) = config.pixelSize() ?: run {
GearslipLog.e("unsupported resolution ${config.resolutionName}")
return
}
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, index.toLong())
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
val useProjection = projection != null
videoSource = VideoSource(
width = width,
height = height,
frameRate = if (config.frameRate == 1) 60 else 30,
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
onCodecConfig = { csd ->
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
GearslipLog.hex(" csd", csd, limit = 64)
},
onFrame = { data, presentationTimeUs, keyFrame ->
sendVideoFrame(data, presentationTimeUs, keyFrame)
},
).also { source ->
runCatching {
source.start()
val surface = source.inputSurface
if (projection != null && surface != null) {
projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting()
}
}.onFailure { e -> GearslipLog.e("encoder start failed", e) }
}
}
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
// Flow control: the head unit told us max_unacked in its Config reply.
if (inFlight >= maxUnacked) {
// A dropped or never-sent ack would otherwise wedge the stream forever, which
// looks identical to a frozen picture. Assume the window cleared and carry on.
val since = System.currentTimeMillis() - lastAckAt
if (since > ACK_STALL_MS) {
GearslipLog.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
inFlight = 0
} else {
dropped++
if (dropped % 30 == 1) {
GearslipLog.w("dropping frame - $inFlight in flight (max $maxUnacked)")
}
// A dropped frame breaks the decoder's reference chain on the head unit -
// every frame after it is a P-frame assuming a picture that never arrived.
// Without a resync the picture free-runs on corrupted state indefinitely,
// which is the smeared/ghosted look.
//
// Ask on EVERY drop, not just the first in a streak: the resync keyframe we
// asked for is itself just another frame, and can be dropped by this exact
// same check. A one-shot request that gets swallowed by the burst that
// provoked it leaves the stream corrupted for good, with no further request
// ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log,
// dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated
// setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it
// just keeps the "next frame is a keyframe" flag set.
resyncRequested = true
resyncAttempts++
videoSource?.requestSyncFrame()
return
}
}
val timestamp = ByteArray(8)
for (i in 0 until 8) {
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
}
if (keyFrame) {
if (resyncRequested) {
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)")
}
resyncRequested = false
resyncAttempts = 0
lastKeyframeSentAt = System.currentTimeMillis()
}
try {
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
} catch (e: java.io.IOException) {
if (running) GearslipLog.w("video send failed, stopping the encoder: ${e.message}")
running = false
videoSource?.stop()
return
}
inFlight++
framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
GearslipLog.i(
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
"ptsUs=$presentationTimeUs)",
)
}
}
private fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
private fun onPingRequest(body: ByteArray) {
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
GearslipLog.i("<- PingRequest / -> PingResponse (keeping the session alive)")
}
private fun send(
messageId: Int,
body: ByteArray,
encrypted: Boolean,
channel: Int = Frames.CHANNEL_CONTROL,
messageType: Int = Frames.MESSAGE_SPECIFIC,
) {
val payload = byteArrayOf(
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
) + body
synchronized(output) {
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
val framePayload = if (encrypted) tls.encrypt(payload) else payload
output.write(Frames.build(channel, encrypted, framePayload, messageType))
} else {
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
// separately, and carry the total plaintext length in the FIRST frame.
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
var offset = 0
while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
output.write(
Frames.build(
channel, encrypted, framePayload, messageType, frameType,
if (frameType == Frames.TYPE_FIRST) payload.size else null,
),
)
offset += size
}
splitMessages++
}
output.flush()
}
}
private fun reportDisconnect() {
when (state) {
State.DONE -> SessionStatus.disconnected()
State.WAIT_VERSION -> SessionStatus.failed(
"Head unit never started talking",
"The connection opened but the head unit sent nothing. Check the phone is set to use Gearslip for this USB accessory.",
)
State.TLS_HANDSHAKE, State.WAIT_AUTH -> SessionStatus.failed(
"Connection dropped during setup",
"The head unit disconnected while securing the link. This usually means it rejected the certificate.",
)
State.WAIT_SDR -> SessionStatus.failed(
"Connection dropped after authentication",
"The certificate was accepted but the head unit disconnected before finishing setup.",
)
}
when (state) {
State.WAIT_VERSION -> GearslipLog.verdict(
"INCONCLUSIVE (no VersionRequest)",
"The head unit never opened the control channel. This is a connection problem, " +
"not a certificate verdict - check that the accessory was actually claimed.",
)
State.TLS_HANDSHAKE -> GearslipLog.verdict(
"LIKELY NOT VIABLE (dropped during TLS)",
"The unit disconnected mid-handshake without sending an alert we could read. " +
"Rejection is the likeliest reading, but re-run once before trusting it.",
)
State.WAIT_AUTH -> GearslipLog.verdict(
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
)
State.WAIT_SDR -> GearslipLog.verdict(
"INCONCLUSIVE (accepted, then dropped before service discovery)",
"AuthComplete said success, so the certificate was accepted - but our " +
"ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " +
"framing rather than the head unit.",
)
State.DONE -> GearslipLog.i("connection closed after the run completed")
}
}
private fun startWatchdog() {
Thread {
var lastFrames = 0
while (running) {
Thread.sleep(5_000)
if (!running) break
if (state != State.DONE) {
val idle = System.currentTimeMillis() - lastProgress
if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s")
}
// Stream health. Without this a drive cannot tell "streaming fine" from
// "stalled after three frames" - the per-frame logging is deliberately sparse.
if (videoStarted) {
val sent = framesSent
val fps = (sent - lastFrames) / 5.0
lastFrames = sent
GearslipLog.i(
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
)
// Self-healing safety net: force a fresh keyframe periodically regardless
// of whether we think one is owed. Protects against any future case where
// our own drop/resync bookkeeping is wrong or a corruption slips through
// some other path - a real decoder desync should never outlive this.
val idleSinceKeyframe = System.currentTimeMillis() - lastKeyframeSentAt
if (idleSinceKeyframe > PERIODIC_KEYFRAME_MS) {
videoSource?.requestSyncFrame()
}
}
}
}.apply { isDaemon = true }.start()
}
private companion object {
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2
const val MSG_ENCAPSULATED_SSL = 3
const val MSG_AUTH_COMPLETE = 4
const val MSG_SERVICE_DISCOVERY_REQUEST = 5
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
const val MSG_PING_REQUEST = 11
const val MSG_PING_RESPONSE = 12
const val MSG_CHANNEL_OPEN_REQUEST = 7
const val MSG_CHANNEL_OPEN_RESPONSE = 8
// aap_protobuf/service/media/.../MediaMessageId
const val MSG_MEDIA_DATA = 0
const val MSG_MEDIA_CODEC_CONFIG = 1
const val MSG_MEDIA_SETUP = 32768
const val MSG_MEDIA_START = 32769
const val MSG_MEDIA_STOP = 32770
const val MSG_MEDIA_CONFIG = 32771
const val MSG_MEDIA_ACK = 32772
const val MSG_VIDEO_FOCUS_REQUEST = 32775
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
const val STREAM_MEDIA = 3
const val CODEC_H264_BP = 3
const val VIDEO_FOCUS_PROJECTED = 1
const val SESSION_ID = 1
const val MSG_INPUT_REPORT = 32769
const val ACTION_DOWN = 0
const val ACTION_UP = 1
const val ACK_STALL_MS = 2_000L
const val PERIODIC_KEYFRAME_MS = 5_000L
}
}
@@ -0,0 +1,219 @@
package app.seb3thehacker.gearslip
import java.net.Socket
import java.nio.ByteBuffer
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.X509ExtendedTrustManager
/**
* The phone end of the Android Auto TLS session.
*
* Two things here are the opposite of what aasdk does, because aasdk implements the head
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
* **server** and its certificate goes out as the server certificate - sent unconditionally
* on every handshake, which is precisely what we are testing.
*
* TLS here is not layered over the socket: handshake bytes travel inside
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
* the right tool.
*/
class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val engine: SSLEngine
private var netIn = ByteArray(0)
private var appBuffer = ByteBuffer.allocate(32 * 1024)
private var netBuffer = ByteBuffer.allocate(32 * 1024)
var handshakeComplete: Boolean = false
private set
init {
val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore, password) }
.keyManagers
val context = SSLContext.getInstance("TLSv1.2").apply {
init(keyManagers, arrayOf(LoggingTrustManager()), SecureRandom())
}
engine = context.createSSLEngine().apply {
useClientMode = false
// want, not need: we ask the head unit for its certificate so the run also tells
// us what real units present, but we never make the session depend on getting one.
wantClientAuth = true
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
}
engine.beginHandshake()
}
/**
* Feeds received handshake bytes in and returns whatever must be sent back, each element
* becoming one MESSAGE_ENCAPSULATED_SSL message.
*/
fun pumpHandshake(incoming: ByteArray?): List<ByteArray> {
if (incoming != null) netIn += incoming
val outgoing = mutableListOf<ByteArray>()
var source = ByteBuffer.wrap(netIn)
loop@ while (true) {
when (engine.handshakeStatus) {
HandshakeStatus.NEED_TASK -> {
var task = engine.delegatedTask
while (task != null) {
task.run()
task = engine.delegatedTask
}
}
HandshakeStatus.NEED_WRAP -> {
netBuffer.clear()
val result = engine.wrap(EMPTY, netBuffer)
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue@loop
}
netBuffer.flip()
if (netBuffer.hasRemaining()) {
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
}
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
}
HandshakeStatus.NEED_UNWRAP -> {
appBuffer.clear()
val result = engine.unwrap(source, appBuffer)
when (result.status) {
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
else -> Unit
}
}
HandshakeStatus.FINISHED, HandshakeStatus.NOT_HANDSHAKING -> break@loop
else -> break@loop
}
}
netIn = ByteArray(source.remaining()).also { source.get(it) }
if (!handshakeComplete && engine.handshakeStatus == HandshakeStatus.NOT_HANDSHAKING &&
engine.session.cipherSuite != "SSL_NULL_WITH_NULL_NULL"
) {
handshakeComplete = true
describeSession()
}
return outgoing
}
/** Wraps a control-channel payload for an ENCRYPTED frame. */
fun encrypt(plain: ByteArray): ByteArray {
val source = ByteBuffer.wrap(plain)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
netBuffer.clear()
val result = engine.wrap(source, netBuffer)
if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
continue
}
netBuffer.flip()
val chunk = ByteArray(netBuffer.remaining())
netBuffer.get(chunk)
out.write(chunk)
}
return out.toByteArray()
}
/**
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
* self-contained ciphertext.
*/
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
appBuffer.clear()
val result = engine.unwrap(source, appBuffer)
when (result.status) {
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
continue
}
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
break
}
SSLEngineResult.Status.CLOSED -> break
else -> Unit
}
appBuffer.flip()
val chunk = ByteArray(appBuffer.remaining())
appBuffer.get(chunk)
out.write(chunk)
if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break
}
return out.toByteArray()
}
private fun describeSession() {
val session = engine.session
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
try {
val peers = session.peerCertificates
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}")
GearslipLog.i(" issuer =${certificate.issuerX500Principal}")
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
}
}
} catch (_: SSLPeerUnverifiedException) {
GearslipLog.i("head unit presented NO certificate (we asked but did not require one)")
}
}
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
private class LoggingTrustManager : X509ExtendedTrustManager() {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
}
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
checkClientTrusted(chain, authType)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?) =
checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
companion object {
private val EMPTY: ByteBuffer = ByteBuffer.allocate(0)
}
}
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
/** Decides what the car screen shows. */
object ProjectedContent {
/**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then the built-in
* test page.
*/
fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context)
if (saved.isNotEmpty()) {
val url = normalize(saved)
GearslipLog.i("projecting startup URL from settings: $url")
return url
}
val legacy = context.getExternalFilesDir(null)?.let { File(it, "url.txt") }
if (legacy != null && legacy.isFile) {
val url = legacy.readText().trim()
if (url.isNotEmpty()) {
GearslipLog.i("projecting url.txt override: $url")
return url
}
}
return TEST_PAGE
}
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value"
/** Big targets, a live clock, and tap feedback - all verifiable at a glance in a car. */
val TEST_PAGE = """
<html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html{height:100%}
body{margin:0;font-family:sans-serif;background:#101418;color:#fff;
display:flex;flex-direction:column;height:100%}
h1{margin:12px;font-size:28px}
#t{margin:0 12px 8px;font-size:20px;color:#8ab4f8}
.row{display:flex;flex:1;gap:8px;padding:8px}
.b{flex:1;display:flex;align-items:center;justify-content:center;
font-size:26px;border-radius:12px;background:#243043}
.hit{background:#2e7d32}
</style></head><body>
<h1>Gearslip &mdash; projected from the phone</h1>
<div id="t">starting&hellip;</div>
<div class="row">
<div class="b" id="a">TAP A</div><div class="b" id="b">TAP B</div>
<div class="b" id="c">TAP C</div>
</div>
<script>
setInterval(function(){document.getElementById('t').textContent=new Date().toLocaleTimeString();},1000);
['a','b','c'].forEach(function(id){
var e=document.getElementById(id);
e.addEventListener('touchstart',function(){e.classList.add('hit');e.textContent='HIT '+id.toUpperCase();});
e.addEventListener('touchend',function(){setTimeout(function(){e.classList.remove('hit');e.textContent='TAP '+id.toUpperCase();},400);});
});
</script></body></html>
""".trimIndent()
}
@@ -0,0 +1,25 @@
package app.seb3thehacker.gearslip
import android.view.Surface
/**
* Lets [GearslipRunner] drive on-screen content without owning any Android UI itself.
*
* The protocol side decides *when* a surface is needed and *where* touches landed; the
* activity decides *what* to render and how to inject events, because both need a UI thread
* and a Context.
*/
interface Projection {
/** Called once the encoder's input Surface exists and content should start rendering. */
fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int)
/**
* A touch from the head unit, already scaled into projected-video coordinates.
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6).
*/
fun onTouch(action: Int, x: Float, y: Float)
fun onProjectionStopped()
}
@@ -0,0 +1,178 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
/**
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
*
* Only three messages are ever written (AuthResponse is only read):
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
* PingResponse { required int64 timestamp = 1; }
* AuthResponse { required int32 status = 1; }
*/
object Protobuf {
fun varint(value: Long): ByteArray {
val out = ByteArrayOutputStream()
var v = value
while (true) {
val b = (v and 0x7F).toInt()
v = v ushr 7
if (v == 0L) {
out.write(b)
return out.toByteArray()
}
out.write(b or 0x80)
}
}
fun stringField(field: Int, value: String): ByteArray {
val bytes = value.toByteArray(Charsets.UTF_8)
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
}
fun varintField(field: Int, value: Long): ByteArray =
varint((field shl 3).toLong()) + varint(value)
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
fun readInt32Field(data: ByteArray, field: Int): Int? {
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: return null
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, afterValue) = readVarint(data, pos) ?: return null
pos = afterValue
if (number == field) return value.toInt()
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: return null
pos = afterLen + len.toInt()
}
5 -> pos += 4
1 -> pos += 8
else -> return null
}
}
return null
}
/**
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
* (fields 2-4) and head-unit info (7-10) coming out readable is the clearest possible
* proof that the session is live and we are decrypting correctly.
*/
fun describe(data: ByteArray, indent: String = " "): String {
val sb = StringBuilder()
var pos = 0
while (pos < data.size) {
val (tag, afterTag) = readVarint(data, pos) ?: break
pos = afterTag
val number = (tag ushr 3).toInt()
when ((tag and 7L).toInt()) {
0 -> {
val (value, after) = readVarint(data, pos) ?: break
pos = after
sb.append("$indent#$number varint = $value\n")
}
1 -> {
if (pos + 8 > data.size) break
sb.append("$indent#$number fixed64\n"); pos += 8
}
2 -> {
val (len, afterLen) = readVarint(data, pos) ?: break
val end = afterLen + len.toInt()
if (end > data.size) break
val body = data.copyOfRange(afterLen, end)
pos = end
val text = body.toString(Charsets.UTF_8)
if (body.isNotEmpty() && text.all { it == '\n' || it == '\t' || it.code in 32..126 }) {
sb.append("$indent#$number string = \"$text\"\n")
} else {
sb.append("$indent#$number message/bytes (${body.size})\n")
sb.append(describe(body, "$indent "))
}
}
5 -> {
if (pos + 4 > data.size) break
sb.append("$indent#$number fixed32\n"); pos += 4
}
else -> break
}
}
return sb.toString()
}
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L
var shift = 0
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
shift += 7
}
return null
}
}
/** Structured field access, for walking ServiceDiscoveryResponse without protoc. */
object Wire {
class Field(val number: Int, val wireType: Int, val varint: Long, val bytes: ByteArray?)
fun fields(data: ByteArray): List<Field> {
val out = mutableListOf<Field>()
var pos = 0
while (pos < data.size) {
val tag = readVarint(data, pos) ?: break
pos = tag.second
val number = (tag.first ushr 3).toInt()
when ((tag.first and 7L).toInt()) {
0 -> {
val v = readVarint(data, pos) ?: break
pos = v.second
out += Field(number, 0, v.first, null)
}
1 -> { if (pos + 8 > data.size) break; pos += 8 }
2 -> {
val len = readVarint(data, pos) ?: break
val end = len.second + len.first.toInt()
if (end > data.size || end < len.second) break
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
pos = end
}
5 -> { if (pos + 4 > data.size) break; pos += 4 }
else -> break
}
}
return out
}
fun varint(fields: List<Field>, number: Int): Long? =
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
fun bytes(fields: List<Field>, number: Int): ByteArray? =
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
var result = 0L
var shift = 0
var pos = start
while (pos < data.size && shift <= 63) {
val b = data[pos].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
pos++
if (b and 0x80 == 0) return result to pos
shift += 7
}
return null
}
}
@@ -0,0 +1,183 @@
package app.seb3thehacker.gearslip
import android.app.ActivityOptions
import android.app.Presentation
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.SystemClock
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
/**
* Renders the car UI into the encoder's input Surface.
*
* Uses the same mechanism as Android screen mirroring: a VirtualDisplay backed by the
* MediaCodec input Surface, with a Presentation hosting a Compose view on that display.
* Anything drawn there is encoded and projected - no hand-written EGL/GL.
*
* Everything here must run on the main thread: Presentation is a Dialog.
*/
class ScreenProjector(private val context: Context) {
private var virtualDisplay: VirtualDisplay? = null
private var presentation: Presentation? = null
private var owner: ViewTreeOwner? = null
private var root: View? = null
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
private var downTime = 0L
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
stop()
val displayManager = context.getSystemService(DisplayManager::class.java)
// OWN_CONTENT_ONLY, not PUBLIC: we render our own Presentation rather than mirroring
// the phone screen. PUBLIC marks the display as screen sharing, which the platform
// refuses without ADD_MIRROR_DISPLAY/CAPTURE_VIDEO_OUTPUT or a MediaProjection token -
// and going the MediaProjection route would put a consent dialog in front of the
// driver on every connect.
val display = try {
displayManager.createVirtualDisplay(
"gearslip-projection", width, height, densityDpi, surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
)
} catch (t: Throwable) {
GearslipLog.e("createVirtualDisplay failed", t)
null
}
if (display == null) {
GearslipLog.e("createVirtualDisplay returned null - cannot project")
return
}
virtualDisplay = display
val show = Presentation(context, display.display)
val window = checkNotNull(show.window) { "presentation has no window" }
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
// A ComposeView outside an Activity has no lifecycle, saved-state or view-model owner
// to find up its view tree, and refuses to compose without them. Hand it ours.
val treeOwner = ViewTreeOwner()
val decor = window.decorView
decor.setViewTreeLifecycleOwner(treeOwner)
decor.setViewTreeSavedStateRegistryOwner(treeOwner)
decor.setViewTreeViewModelStoreOwner(treeOwner)
decor.setBackgroundColor(Color.BLACK)
show.setContentView(ComposeView(show.context).apply { setContent(content) })
show.show()
presentation = show
owner = treeOwner
root = decor
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
}
/** The projected display's id, for placing other apps' activities on it. */
val displayId: Int? get() = virtualDisplay?.display?.displayId
/**
* Shows or hides our own Compose UI. A Presentation is a TYPE_PRESENTATION window, so it
* draws above any activity on the same display and must be hidden to reveal one.
*/
fun setOverlayVisible(visible: Boolean) {
if (visible) presentation?.show() else presentation?.hide()
}
/**
* Launches another app's activity onto the projected display. The Presentation is hidden
* first, since it would otherwise draw over the app.
*
* Does not work yet for third-party apps, and measuring why is recorded here so the next
* attempt doesn't repeat it. Two separate platform gates, both confirmed on Android 17:
* 1. `setLaunchDisplayId` requires INTERNAL_SYSTEM_WINDOW in the *caller*. We don't hold
* it, so this throws SecurityException. Launching the same activity from a shell-uid
* caller places the task on this display fine.
* 2. Even once placed, the task stays visible=false, because this display is created
* FLAG_PRIVATE - a private display only ever shows its owner's windows.
* Fixing (2) needs a display created with VIRTUAL_DISPLAY_FLAG_TRUSTED, which needs the
* signature permission ADD_TRUSTED_DISPLAY. The route that avoids shell privilege entirely
* is VirtualDeviceManager (a companion-device app-streaming association), which hands out
* a display built for hosting other apps plus its own virtual touchscreen.
*/
fun launchApp(intent: Intent): Boolean {
val display = virtualDisplay ?: return false
return try {
val options = ActivityOptions.makeBasic().setLaunchDisplayId(display.display.displayId)
presentation?.hide()
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle())
GearslipLog.i("launched ${intent.component ?: intent.`package`} on display ${display.display.displayId}")
true
} catch (t: Throwable) {
GearslipLog.e("launchApp failed", t)
presentation?.show()
false
}
}
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
fun dispatchTouch(action: Int, x: Float, y: Float) {
val target = root ?: return
val now = SystemClock.uptimeMillis()
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
try {
target.dispatchTouchEvent(event)
} finally {
event.recycle()
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L
}
fun stop() {
runCatching { presentation?.dismiss() }
runCatching { owner?.destroy() }
runCatching { virtualDisplay?.release() }
presentation = null
owner = null
root = null
virtualDisplay = null
downTime = 0L
}
private class ViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner {
private val registry = LifecycleRegistry(this)
private val savedState = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = registry
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
override val viewModelStore = ViewModelStore()
init {
savedState.performRestore(null)
registry.currentState = Lifecycle.State.RESUMED
}
fun destroy() {
registry.currentState = Lifecycle.State.DESTROYED
viewModelStore.clear()
}
}
}
@@ -0,0 +1,124 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.SecureRandom
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Generates a self-signed RSA-2048 certificate - the thing under test.
*
* Written by hand rather than with BouncyCastle so the spike has no dependencies and no
* second security provider sitting next to Conscrypt. A self-signed X.509 is a fixed
* structure, so this is mechanical; the risk is a silent malformation, which
* [generate] closes off by parsing its own output back through CertificateFactory and
* verifying the signature before returning. A bad certificate therefore fails loudly on the
* bench instead of looking like a head-unit rejection in the car.
*
* RSA-2048 matches aasdk's own key (cert/headunit.key) - see cert/README.md.
*/
object SelfSignedCert {
const val ALIAS = "phone"
val PASSWORD: CharArray = "spike".toCharArray()
// Pre-encoded OIDs, so there is no OID encoder to get wrong.
private val OID_SHA256_RSA = byteArrayOf(
0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x0B
)
private val OID_CN = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03)
private val OID_O = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x0A)
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
fun generate(commonName: String = "Gearslip", organisation: String = "Gearslip"): Result {
val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val now = System.currentTimeMillis()
val notBefore = Date(now - 86_400_000L) // a day of slack for head-unit clock skew
val notAfter = Date(now + 20L * 365 * 86_400_000L) // ~2046, still inside UTCTime's pre-2050 range
val der = buildCertificate(keyPair, commonName, organisation, notBefore, notAfter)
// Self-check: malformed DER throws here, on the bench, not in the car.
val certificate = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
certificate.verify(keyPair.public)
certificate.checkValidity()
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(ALIAS, keyPair.private, PASSWORD, arrayOf(certificate))
}
return Result(keyStore, certificate)
}
private fun buildCertificate(
keyPair: KeyPair,
commonName: String,
organisation: String,
notBefore: Date,
notAfter: Date,
): ByteArray {
val algorithmId = seq(OID_SHA256_RSA, tlv(0x05, ByteArray(0))) // sha256WithRSAEncryption, NULL params
val name = seq(rdn(OID_CN, commonName), rdn(OID_O, organisation))
val serial = BigInteger(64, SecureRandom()).add(BigInteger.ONE)
val tbs = seq(
tlv(0xA0, integer(BigInteger.valueOf(2))), // [0] EXPLICIT version, v3
integer(serial),
algorithmId,
name,
seq(utcTime(notBefore), utcTime(notAfter)),
name,
keyPair.public.encoded, // already a DER SubjectPublicKeyInfo
)
val signature = Signature.getInstance("SHA256withRSA").run {
initSign(keyPair.private)
update(tbs)
sign()
}
return seq(tbs, algorithmId, bitString(signature))
}
// --- minimal DER writer ------------------------------------------------------------
private fun length(n: Int): ByteArray {
if (n < 0x80) return byteArrayOf(n.toByte())
var value = n
val out = ArrayList<Byte>()
while (value > 0) {
out.add(0, (value and 0xFF).toByte())
value = value ushr 8
}
return byteArrayOf((0x80 or out.size).toByte()) + out.toByteArray()
}
private fun tlv(tag: Int, body: ByteArray) = byteArrayOf(tag.toByte()) + length(body.size) + body
private fun seq(vararg parts: ByteArray) = tlv(0x30, parts.reduce { a, b -> a + b })
private fun integer(v: BigInteger) = tlv(0x02, v.toByteArray())
private fun bitString(body: ByteArray) = tlv(0x03, byteArrayOf(0x00) + body)
private fun rdn(oid: ByteArray, value: String) =
tlv(0x31, seq(oid, tlv(0x0C, value.toByteArray(Charsets.UTF_8))))
private fun utcTime(date: Date): ByteArray {
val format = SimpleDateFormat("yyMMddHHmmss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return tlv(0x17, (format.format(date) + "Z").toByteArray(Charsets.US_ASCII))
}
}
@@ -0,0 +1,180 @@
package app.seb3thehacker.gearslip
/**
* Pulls the video service out of a ServiceDiscoveryResponse.
*
* Shape from aasdk (`service/Service.proto`, `service/media/sink/MediaSinkService.proto`,
* `.../message/VideoConfiguration.proto`):
*
* ServiceDiscoveryResponse { repeated Service channels = 1; ... }
* Service { required int32 id = 1; optional MediaSinkService media_sink_service = 3; ... }
* MediaSinkService { optional MediaCodecType available_type = 1;
* repeated VideoConfiguration video_configs = 4; ... }
* VideoConfiguration { codec_resolution = 1; frame_rate = 2; density = 5; ... }
*
* The channel id used in the frame header is this service's `id` - real Android Auto assigns
* channel ids dynamically (aasdk's static ChannelId enum is an approximation; see the TODO
* in include/aasdk/Messenger/ChannelId.hpp).
*/
object ServiceDiscovery {
/**
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
* vehicle profile, but not applied automatically - what they mean varies by unit.
*/
class VideoConfig(
val resolution: Int,
val frameRate: Int,
val density: Int,
val widthMargin: Int = 0,
val heightMargin: Int = 0,
) {
val resolutionName: String get() = RESOLUTIONS[resolution] ?: "unknown($resolution)"
val frameRateName: String get() = if (frameRate == 1) "60fps" else "30fps"
/** Pixel dimensions for this resolution enum, or null if we don't know it. */
fun pixelSize(): Pair<Int, Int>? = PIXEL_SIZES[resolution]
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
override fun toString() =
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
}
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
}
class AudioConfig(val sampleRate: Int, val bits: Int, val channels: Int)
class AudioService(
val serviceId: Int,
val streamType: Int,
val codecType: Int,
val configs: List<AudioConfig>,
) {
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
val streamName: String get() = STREAMS[streamType] ?: "unknown($streamType)"
}
/**
* Every audio sink the head unit offers (media, guidance, system, telephony). Same
* MediaSinkService as video, told apart by carrying `audio_type = 2` and
* `repeated AudioConfiguration audio_configs = 3 { sampling_rate = 1; bits = 2; channels = 3 }`.
*/
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
val audioConfigs = Wire.allBytes(sinkFields, 3)
if (audioConfigs.isEmpty()) continue
add(
AudioService(
serviceId = id,
streamType = Wire.varint(sinkFields, 2)?.toInt() ?: 0,
codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 1,
configs = audioConfigs.map {
val f = Wire.fields(it)
AudioConfig(
sampleRate = Wire.varint(f, 1)?.toInt() ?: 48_000,
bits = Wire.varint(f, 2)?.toInt() ?: 16,
channels = Wire.varint(f, 3)?.toInt() ?: 2,
)
},
),
)
}
}
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
private val RESOLUTIONS = mapOf(
1 to "800x480", 2 to "1280x720", 3 to "1920x1080", 4 to "2560x1440",
5 to "3840x2160", 6 to "720x1280", 7 to "1080x1920", 8 to "1440x2560", 9 to "2160x3840",
)
private val PIXEL_SIZES = mapOf(
1 to (800 to 480), 2 to (1280 to 720), 3 to (1920 to 1080), 4 to (2560 to 1440),
5 to (3840 to 2160), 6 to (720 to 1280), 7 to (1080 to 1920), 8 to (1440 to 2560),
9 to (2160 to 3840),
)
private val CODECS = mapOf(
1 to "AUDIO_PCM", 2 to "AUDIO_AAC_LC", 3 to "VIDEO_H264_BP",
4 to "AUDIO_AAC_LC_ADTS", 5 to "VIDEO_VP9", 6 to "VIDEO_AV1", 7 to "VIDEO_H265",
)
/**
* The head unit's touchscreen, from InputSourceService (Service field 4):
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... }
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
*
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
* scaled to the projected video size before injection.
*/
class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int)
fun findInputService(response: ByteArray): InputService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val input = Wire.bytes(service, 4) ?: continue
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue
val touchFields = Wire.fields(touch)
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue
return InputService(id, width, height)
}
return null
}
fun findVideoService(response: ByteArray): VideoService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel)
val id = Wire.varint(service, 1)?.toInt() ?: continue
val sink = Wire.bytes(service, 3) ?: continue
val sinkFields = Wire.fields(sink)
val videoConfigs = Wire.allBytes(sinkFields, 4)
if (videoConfigs.isEmpty()) continue // audio sink, not video
val codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 0
val configs = videoConfigs.map {
val f = Wire.fields(it)
VideoConfig(
resolution = Wire.varint(f, 1)?.toInt() ?: 0,
frameRate = Wire.varint(f, 2)?.toInt() ?: 0,
density = Wire.varint(f, 5)?.toInt() ?: 0,
widthMargin = Wire.varint(f, 3)?.toInt() ?: 0,
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
)
}
return VideoService(id, codecType, configs)
}
return null
}
/**
* Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse.
* Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger",
* 4="18", 7="Delphi", 8="VP2_R Uconnect 7".
*/
class HeadUnitInfo(
val headUnitName: String,
val carModel: String,
val carYear: String,
val headUnitMake: String,
val headUnitModel: String,
) {
override fun toString() =
"name=\"$headUnitName\" car=\"$carModel\" year=\"$carYear\" " +
"make=\"$headUnitMake\" model=\"$headUnitModel\""
}
fun findHeadUnitInfo(response: ByteArray): HeadUnitInfo {
val fields = Wire.fields(response)
fun text(number: Int) = Wire.bytes(fields, number)?.toString(Charsets.UTF_8).orEmpty()
return HeadUnitInfo(text(2), text(3), text(4), text(7), text(8))
}
}
@@ -0,0 +1,46 @@
package app.seb3thehacker.gearslip
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the phone UI shows about the current car session.
*
* [GearslipRunner] reports progress here as it moves through the handshake, so the UI reads
* a plain state instead of parsing log lines. A failure stays on screen until the next
* attach starts, so the reason is still there when the user picks the phone back up.
*/
object SessionStatus {
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
private val flow = MutableStateFlow(
Snapshot(
Phase.IDLE,
"Not connected",
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.",
),
)
val state: StateFlow<Snapshot> = flow
fun connecting(stage: String) {
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
}
fun projecting() {
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
}
fun failed(headline: String, detail: String) {
flow.value = Snapshot(Phase.FAILED, headline, detail)
}
/** Keeps an earlier failure visible: a rejection is followed by a disconnect every time. */
fun disconnected() {
if (flow.value.phase == Phase.FAILED) return
flow.value = Snapshot(Phase.DISCONNECTED, "Disconnected", "The head unit is no longer connected.")
}
}
@@ -0,0 +1,149 @@
package app.seb3thehacker.gearslip
import java.net.Socket
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLEngineResult
import javax.net.ssl.SSLEngineResult.HandshakeStatus
import javax.net.ssl.X509ExtendedTrustManager
/**
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
* data round trip works.
*
* This exists because of one specific hazard: a bug in the certificate, the key manager or
* the engine wiring produces a dead connection in the car that is indistinguishable from a
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
* unit, not about this code.
*/
object TlsSelfTest {
fun run(identity: CertProvider.Identity): Boolean {
GearslipLog.i("=== TLS self-test starting ===")
GearslipLog.i("cert source: ${identity.source}")
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}")
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try {
val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine())
var outbound = client.pump(null) // ClientHello
var rounds = 0
while (rounds++ < 20) {
if (outbound.isEmpty()) break
val fromServer = mutableListOf<ByteArray>()
outbound.forEach { fromServer += server.pumpHandshake(it) }
if (server.handshakeComplete && fromServer.isEmpty()) break
val fromClient = mutableListOf<ByteArray>()
fromServer.forEach { fromClient += client.pump(it) }
outbound = fromClient
}
if (!server.handshakeComplete) {
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
return false
}
// Data round trip, exercising the same encrypt path the spike uses for
// ServiceDiscoveryRequest.
val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(plaintext)) {
GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
return false
}
GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
true
} catch (t: Throwable) {
GearslipLog.e("self-test FAILED with an exception", t)
false
}
}
private fun clientEngine(): SSLEngine {
val context = SSLContext.getInstance("TLSv1.2")
context.init(null, arrayOf(TrustAll()), SecureRandom())
return context.createSSLEngine().apply {
useClientMode = true
enabledProtocols = arrayOf("TLSv1.2")
beginHandshake()
}
}
/** Deliberately a second, independent implementation - it should not share PhoneTls's bugs. */
private class Driver(private val engine: SSLEngine) {
private var netIn = ByteArray(0)
private var app = ByteBuffer.allocate(32 * 1024)
private var net = ByteBuffer.allocate(32 * 1024)
fun pump(incoming: ByteArray?): List<ByteArray> {
if (incoming != null) netIn += incoming
val out = mutableListOf<ByteArray>()
val source = ByteBuffer.wrap(netIn)
loop@ while (true) {
when (engine.handshakeStatus) {
HandshakeStatus.NEED_TASK -> {
var task = engine.delegatedTask
while (task != null) { task.run(); task = engine.delegatedTask }
}
HandshakeStatus.NEED_WRAP -> {
net.clear()
val r = engine.wrap(ByteBuffer.allocate(0), net)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
net = ByteBuffer.allocate(net.capacity() * 2); continue@loop
}
net.flip()
if (net.hasRemaining()) out += ByteArray(net.remaining()).also { net.get(it) }
if (r.status == SSLEngineResult.Status.CLOSED) break@loop
}
HandshakeStatus.NEED_UNWRAP -> {
app.clear()
val r = engine.unwrap(source, app)
when (r.status) {
SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop
SSLEngineResult.Status.BUFFER_OVERFLOW -> {
app = ByteBuffer.allocate(app.capacity() * 2); continue@loop
}
SSLEngineResult.Status.CLOSED -> break@loop
else -> Unit
}
}
else -> break@loop
}
}
netIn = ByteArray(source.remaining()).also { source.get(it) }
return out
}
fun decrypt(cipher: ByteArray): ByteArray {
val source = ByteBuffer.wrap(cipher)
val out = java.io.ByteArrayOutputStream()
while (source.hasRemaining()) {
app.clear()
val r = engine.unwrap(source, app)
if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) {
app = ByteBuffer.allocate(app.capacity() * 2); continue
}
app.flip()
out.write(ByteArray(app.remaining()).also { app.get(it) })
if (r.bytesConsumed() == 0 && r.bytesProduced() == 0) break
}
return out.toByteArray()
}
}
private class TrustAll : X509ExtendedTrustManager() {
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkClientTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, s: Socket?) = Unit
override fun checkServerTrusted(c: Array<out X509Certificate>?, a: String?, e: SSLEngine?) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
}
@@ -0,0 +1,114 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Pixels of the video frame the car UI keeps clear on each side (the frame is painted, just not used). */
data class Insets(val top: Int = 0, val bottom: Int = 0, val left: Int = 0, val right: Int = 0) {
companion object { val NONE = Insets() }
}
/**
* Per-vehicle display tuning. Head units disagree about how they crop, offset and scale the
* projected frame, so nothing here is applied unless a profile matches the connected unit.
*
* Profiles live in `vehicles.json` in the app's external files dir, so they can be edited and
* pushed with adb without a rebuild:
*
* adb push vehicles.json /sdcard/Android/data/app.seb3thehacker.gearslip/files/vehicles.json
*
* A profile matches when every key in [match] equals the head unit's value (case-insensitive);
* keys it leaves out match anything. The first matching profile wins.
*/
data class VehicleProfile(
val name: String,
val match: Map<String, String>,
/** Preferred video resolution, e.g. "800x480". Null keeps the default choice. */
val resolution: String?,
val insets: Insets,
)
object VehicleProfiles {
private const val FILE = "vehicles.json"
private val DEFAULT_JSON = """
{
"_comment": "Per-vehicle display tuning. match keys: headUnitName, carModel, carYear, headUnitMake, headUnitModel (see the log line 'head unit: ...'). insets are pixels of the video frame to keep clear.",
"vehicles": [
{
"name": "2018 Dodge Challenger (Uconnect 4)",
"match": { "carModel": "Dodge Challenger", "headUnitName": "Uconnect" },
"resolution": "800x480",
"insets": { "top": 30, "bottom": 30, "left": 0, "right": 0 }
}
]
}
""".trimIndent()
/** Reads the file, writing the default one first if there is none so there is something to edit. */
fun load(context: Context): List<VehicleProfile> {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return emptyList()
if (!file.isFile) runCatching { file.writeText(DEFAULT_JSON) }
return runCatching { parse(file.readText()) }
.onFailure { GearslipLog.e("could not read $FILE - ignoring vehicle profiles", it) }
.getOrDefault(emptyList())
}
/** Writes [insets] into the profile called [name] in the file, leaving everything else as it was. */
fun saveInsets(context: Context, name: String, insets: Insets): Boolean {
val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return false
return runCatching {
val root = JSONObject(file.readText())
val vehicles = root.getJSONArray("vehicles")
var found = false
for (i in 0 until vehicles.length()) {
val v = vehicles.getJSONObject(i)
if (v.optString("name") == name) {
v.put(
"insets",
JSONObject()
.put("top", insets.top).put("bottom", insets.bottom)
.put("left", insets.left).put("right", insets.right),
)
found = true
}
}
if (found) file.writeText(root.toString(2))
found
}.onFailure { GearslipLog.e("could not save insets to $FILE", it) }.getOrDefault(false)
}
fun parse(json: String): List<VehicleProfile> {
val vehicles: JSONArray = JSONObject(json).optJSONArray("vehicles") ?: return emptyList()
return (0 until vehicles.length()).map { i ->
val v = vehicles.getJSONObject(i)
val match = v.optJSONObject("match")?.let { m -> m.keys().asSequence().associateWith { m.getString(it) } }
?: emptyMap()
val ins = v.optJSONObject("insets")
VehicleProfile(
name = v.optString("name", "vehicle $i"),
match = match,
resolution = v.optString("resolution").ifEmpty { null },
insets = if (ins == null) Insets.NONE else Insets(
ins.optInt("top"), ins.optInt("bottom"), ins.optInt("left"), ins.optInt("right"),
),
)
}
}
fun find(profiles: List<VehicleProfile>, info: ServiceDiscovery.HeadUnitInfo): VehicleProfile? {
val values = mapOf(
"headUnitName" to info.headUnitName,
"carModel" to info.carModel,
"carYear" to info.carYear,
"headUnitMake" to info.headUnitMake,
"headUnitModel" to info.headUnitModel,
)
return profiles.firstOrNull { p ->
p.match.isNotEmpty() && p.match.all { (k, want) -> values[k].equals(want, ignoreCase = true) }
}
}
}
@@ -0,0 +1,202 @@
package app.seb3thehacker.gearslip
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.os.Bundle
import android.media.Image
import android.view.Surface
/**
* Encodes a generated test card to H.264 Baseline and hands the bitstream to a callback.
*
* Feeds the encoder YUV420 buffers via getInputImage() rather than a Surface: a MediaCodec
* input Surface requires an EGL/OpenGL context to render into (Surface.lockCanvas is not
* supported on it), and for a static test card that is a lot of machinery for no benefit.
* Byte-buffer input keeps the spike dependency-free and fully deterministic. Phase C swaps
* this for a real Surface + GL path when the source becomes a live WebView.
*
* The head unit negotiated MEDIA_CODEC_VIDEO_H264_BP, so the profile is pinned to Baseline.
*/
class VideoSource(
private val width: Int,
private val height: Int,
private val frameRate: Int,
private val mode: Mode = Mode.TEST_CARD,
private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
) {
/**
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
* something else to render into (Phase C drives it from a VirtualDisplay + Presentation,
* which is how Android screen mirroring works and avoids hand-written EGL entirely).
*/
enum class Mode { TEST_CARD, SURFACE }
/** Non-null in SURFACE mode once [start] has run. */
var inputSurface: Surface? = null
private set
private var codec: MediaCodec? = null
private var worker: Thread? = null
@Volatile private var running = false
private var frameIndex = 0L
fun start() {
val format = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
if (mode == Mode.SURFACE) {
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
} else {
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
},
)
setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
}
val encoder = MediaCodec.createEncoderByType(MIME)
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface()
encoder.start()
codec = encoder
running = true
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
worker = Thread {
runCatching { pump(encoder) }
.onFailure { if (running) GearslipLog.e("encoder loop failed", it) }
}.apply { name = "gearslip-encoder"; start() }
}
/**
* Forces the next encoded frame to be a full keyframe (IDR) rather than a P-frame.
*
* Needed whenever a frame gets dropped between us and the head unit: every frame after
* the drop is a P-frame that assumes the decoder still has the reference picture we
* never delivered. Without a resync, the decoder free-runs on a corrupted reference
* forever - visually, exactly the smeared/ghosted look of a partial e-ink refresh,
* because nothing ever forces a clean full-frame redraw.
*/
fun requestSyncFrame() {
runCatching {
codec?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
})
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") }
}
fun stop() {
running = false
worker?.join(1_000)
runCatching { inputSurface?.release() }
inputSurface = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
}
private fun pump(encoder: MediaCodec) {
val info = MediaCodec.BufferInfo()
val frameIntervalUs = 1_000_000L / frameRate
while (running) {
// SURFACE mode has no input buffers to fill - frames arrive from the display.
val inIndex = if (mode == Mode.SURFACE) -1 else encoder.dequeueInputBuffer(TIMEOUT_US)
if (inIndex >= 0) {
val image = encoder.getInputImage(inIndex)
if (image != null) {
drawTestCard(image, frameIndex)
encoder.queueInputBuffer(
inIndex, 0, width * height * 3 / 2, frameIndex * frameIntervalUs, 0,
)
} else {
encoder.queueInputBuffer(inIndex, 0, 0, frameIndex * frameIntervalUs, 0)
}
frameIndex++
}
var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US)
while (outIndex >= 0) {
val buffer = encoder.getOutputBuffer(outIndex)
if (buffer != null && info.size > 0) {
val data = ByteArray(info.size)
buffer.position(info.offset)
buffer.get(data)
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
onCodecConfig(data)
} else {
onFrame(
data,
info.presentationTimeUs,
info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0,
)
}
}
encoder.releaseOutputBuffer(outIndex, false)
outIndex = encoder.dequeueOutputBuffer(info, 0)
}
Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
}
}
/**
* Colour bars with a bar that advances each second, so the car screen shows something
* unmistakably ours and obviously live rather than a frozen or stale frame.
*/
private fun drawTestCard(image: Image, frame: Long) {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val bars = BAR_COLOURS.size
val barHeight = maxOf(1, height / bars)
val marker = ((frame / frameRate) % bars).toInt()
val yBuf = yPlane.buffer
val yStride = yPlane.rowStride
for (row in 0 until height) {
val bar = minOf(bars - 1, row / barHeight)
val (luma, _, _) = BAR_COLOURS[bar]
val value = if (bar == marker) 235 else luma
val base = row * yStride
for (col in 0 until width) {
yBuf.put(base + col, value.toByte())
}
}
val uBuf = uPlane.buffer
val vBuf = vPlane.buffer
for (row in 0 until height / 2) {
val bar = minOf(bars - 1, (row * 2) / barHeight)
val (_, cb, cr) = BAR_COLOURS[bar]
for (col in 0 until width / 2) {
uBuf.put(row * uPlane.rowStride + col * uPlane.pixelStride, cb.toByte())
vBuf.put(row * vPlane.rowStride + col * vPlane.pixelStride, cr.toByte())
}
}
}
private companion object {
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
const val TIMEOUT_US = 10_000L
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
val BAR_COLOURS = listOf(
Triple(180, 128, 128),
Triple(162, 44, 142),
Triple(131, 156, 44),
Triple(112, 72, 58),
Triple(84, 184, 198),
Triple(65, 100, 212),
Triple(35, 212, 114),
)
}
}
@@ -0,0 +1,193 @@
package app.seb3thehacker.gearslip.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.IBinder
import app.seb3thehacker.gearslip.GearslipLog
import kotlin.math.abs
/**
* Hears one app's audio so it can be played on the car.
*
* Playback capture is the one public route to another app's sound, and it hangs off a
* MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent
* result is carried into a foreground service of type mediaProjection before it can be redeemed.
* Capture is scoped to the media app's uid: notification pings and other apps stay on the phone.
*/
class AudioCaptureService : Service() {
@Volatile private var reading = false
private var projection: MediaProjection? = null
private var thread: Thread? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0
val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
if (data == null) {
GearslipLog.e("audio: started without a consent result")
CarAudio.denied()
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
projection = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
} catch (t: Throwable) {
GearslipLog.e("audio: getMediaProjection failed", t)
null
}
val token = projection
if (token == null) {
CarAudio.denied()
stopSelf()
return START_NOT_STICKY
}
token.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
GearslipLog.w("audio: projection stopped")
stopSelf()
}
}, null)
beginCapture(token)
return START_NOT_STICKY
}
private fun beginCapture(token: MediaProjection) {
val rate = CarAudio.sampleRate
val channels = CarAudio.channels
val uid = CarAudio.uid
val record = try {
val config = AudioPlaybackCaptureConfiguration.Builder(token)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.apply { if (uid >= 0) addMatchingUid(uid) }
.build()
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
val format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(rate)
.setChannelMask(mask)
.build()
val minimum = AudioRecord.getMinBufferSize(rate, mask, AudioFormat.ENCODING_PCM_16BIT)
AudioRecord.Builder()
.setAudioPlaybackCaptureConfig(config)
.setAudioFormat(format)
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4))
.build()
} catch (t: Throwable) {
GearslipLog.e("audio: could not set up playback capture", t)
CarAudio.denied()
stopSelf()
return
}
reading = true
thread = Thread({
val buffer = ByteArray(CHUNK_BYTES)
var peak = 0
var heardAt = System.currentTimeMillis()
try {
record.startRecording()
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
CarAudio.captureStarted()
while (reading) {
val n = record.read(buffer, 0, buffer.size)
if (n <= 0) break
for (i in 0 until n - 1 step 2) {
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
val now = System.currentTimeMillis()
val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) {
GearslipLog.i("audio: peak %.3f".format(level))
heardAt = now
peak = 0
}
CarAudio.deliver(buffer, n, level)
}
} catch (t: Throwable) {
GearslipLog.e("audio: capture loop ended", t)
} finally {
runCatching { record.stop() }
record.release()
CarAudio.captureStopped()
}
}, "gearslip-audio").apply { isDaemon = true; start() }
}
override fun onDestroy() {
super.onDestroy()
reading = false
runCatching { thread?.join(500) }
runCatching { projection?.stop() }
projection = null
}
private fun notification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Car audio", NotificationManager.IMPORTANCE_LOW),
)
val stop = PendingIntent.getService(
this, 0,
Intent(this, AudioCaptureService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle("Playing to the car")
.setContentText("Gearslip is sending a media app's audio to the car.")
.setSmallIcon(android.R.drawable.ic_lock_silent_mode_off)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.build()
}
companion object {
private const val CHANNEL = "gearslip_audio"
private const val NOTIFICATION_ID = 43
private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_AUDIO"
private const val EXTRA_RESULT_CODE = "resultCode"
private const val EXTRA_RESULT_DATA = "resultData"
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
private const val CHUNK_BYTES = 4096
private const val LEVEL_LOG_MS = 3_000L
private var appContext: Context? = null
fun start(context: Context, resultCode: Int, data: Intent) {
appContext = context.applicationContext
context.startForegroundService(
Intent(context, AudioCaptureService::class.java)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_RESULT_DATA, data),
)
}
fun stop() {
val context = appContext ?: return
context.startService(Intent(context, AudioCaptureService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,148 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.ServiceDiscovery
import app.seb3thehacker.gearslip.Wire
/**
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
*
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
* -> Setup { PCM } <- Config { status, max_unacked, configuration_indices }
* -> AudioFocusRequest <- AudioFocusResponse (control channel)
* -> Start { session_id, configuration_index }
* -> Data { 8-byte timestamp, PCM }... <- Ack { session_id, ack }
* -> Stop
*
* Inverted from aasdk's AudioMediaSinkService. Bench-verified only as far as capture goes:
* nothing on this side of the wire has met a head unit yet.
*/
class AudioLink(
private val service: ServiceDiscovery.AudioService,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
private val sendControl: (messageId: Int, body: ByteArray) -> Unit,
) : CarAudio.Link {
private var configIndex = 0
@Volatile private var ready = false
@Volatile private var started = false
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 8
@Volatile private var lastAckAt = 0L
private var chunks = 0
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
override val sampleRate get() = config?.sampleRate ?: 48_000
override val channels get() = config?.channels ?: 2
val channelId get() = service.serviceId
fun opened(status: Int) {
if (status != 0) {
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
return
}
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
}
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
MSG_CONFIG -> {
val fields = Wire.fields(body)
val status = Wire.varint(fields, 1)?.toInt()
val window = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices")
if (window != null) maxUnacked = maxOf(1, window)
configIndex = indices.firstOrNull() ?: 0
if (status == STATUS_READY) {
ready = true
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
CarAudio.attach(this)
}
}
MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
}
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
fun onFocus(state: Int) {
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
}
override fun start() {
if (!ready || started) return
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
sendOnChannel(
MSG_START,
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
)
inFlight = 0
lastAckAt = System.currentTimeMillis()
started = true
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
}
override fun stop() {
if (!started) return
started = false
runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
}
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
chunks = 0
}
override fun write(pcm: ByteArray, size: Int) {
if (!started) return
if (inFlight >= maxUnacked) {
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
}
val timestamp = ByteArray(8)
val micros = System.nanoTime() / 1_000
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
try {
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
} catch (e: java.io.IOException) {
started = false
return
}
inFlight++
chunks++
}
fun close() {
stop()
ready = false
CarAudio.detach(this)
}
companion object {
const val MSG_FOCUS_REQUEST = 18
const val MSG_FOCUS_RESPONSE = 19
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_DATA = 0
private const val MSG_SETUP = 32768
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1
private const val FOCUS_RELEASE = 4
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
}
}
@@ -0,0 +1,98 @@
package app.seb3thehacker.gearslip.audio
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
* media app plays, and the car's audio channel, which is where it has to end up.
*
* They come and go independently. The phone side needs on-device consent and can be running on
* the bench with no car attached; the car side exists only while a head unit is connected and
* offers a media audio sink. Whichever pair is present, samples flow.
*/
object CarAudio {
/** What the head unit's media sink accepts, and how to hand it audio. */
interface Link {
val sampleRate: Int
val channels: Int
fun start()
fun stop()
fun write(pcm: ByteArray, size: Int)
}
enum class Capture { OFF, ASKING, ON, DENIED }
private val _capture = MutableStateFlow(Capture.OFF)
val capture: StateFlow<Capture> = _capture.asStateFlow()
/** "-12 dB" style summary of what was last heard, for the media screen. */
private val _level = MutableStateFlow(0f)
val level: StateFlow<Float> = _level.asStateFlow()
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
var onConsentRequested: (() -> Unit)? = null
/** The uid being captured (or waited on). */
@Volatile var uid: Int = -1
private set
@Volatile private var link: Link? = null
/** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2
fun attach(link: Link) {
this.link = link
if (_capture.value == Capture.ON) link.start()
}
fun detach(link: Link) {
if (this.link === link) this.link = null
}
/** A media app was opened: start hearing it. */
fun request(uid: Int) {
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
if (this.uid != uid) AudioCaptureService.stop()
this.uid = uid
_capture.value = Capture.ASKING
val ask = onConsentRequested
if (ask == null) {
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
_capture.value = Capture.DENIED
} else {
ask()
}
}
fun release() {
uid = -1
AudioCaptureService.stop()
_capture.value = Capture.OFF
}
fun denied() {
_capture.value = Capture.DENIED
}
internal fun captureStarted() {
_capture.value = Capture.ON
link?.start()
}
internal fun captureStopped() {
link?.stop()
_level.value = 0f
if (_capture.value == Capture.ON) _capture.value = Capture.OFF
}
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
_level.value = peak
link?.write(pcm, size)
}
}
@@ -0,0 +1,89 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
/**
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
*
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
* with your finger is the touch offset. Ignores vehicle insets on purpose.
*/
@Composable
fun CalibrationApp() {
val navigator = LocalCarNavigator.current
val measurer = rememberTextMeasurer()
var touch by remember { mutableStateOf<Offset?>(null) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
touch = down.position
}
},
) {
Canvas(Modifier.fillMaxSize()) {
val label = TextStyle(color = Color.White, fontSize = 11.sp)
val faint = Color(0x55FFFFFF)
val w = size.width
val h = size.height
var y = 0
while (y <= h) {
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
if (y % 20 == 0) {
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
}
y += 20
}
var x = 0
while (x <= w) {
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
if (x % 50 == 0 && x > 0) {
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
}
x += 20
}
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
touch?.let { t ->
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
}
}
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
Text("Exit calibration")
}
}
}
@@ -0,0 +1,191 @@
package app.seb3thehacker.gearslip.car
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.car.app.model.Template
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.CarAppConnection
/**
* Shows a connected templated app: its own drawing surface, with host chrome over the top.
*
* The surface is a plain SurfaceView. Because the whole car UI is a Presentation on a
* VirtualDisplay wired to the encoder, a SurfaceView here is composited by SurfaceFlinger into
* that display like any other layer - so the app's map reaches the head unit down the existing
* H.264 pipe without Gearslip ever touching a pixel of it.
*/
@Composable
fun CarAppStage(
connection: CarAppConnection,
status: CarAppConnection.Status,
frame: CarEnvironment.Frame,
/** Inside the home screen's map pane: no title bar, the template gets the whole area. */
embedded: Boolean = false,
onDisconnect: () -> Unit,
) {
val template by connection.template.collectAsStateWithLifecycle()
Box(Modifier.fillMaxSize()) {
// The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top.
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
}
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background)
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
}
}
/**
* A SurfaceView lent to the app for as long as it is on screen.
*
* The app writes into this from its own process, so the surface must outlive the handoff;
* [SurfaceHolder.Callback] is what tells us when that stops being true.
*/
@Composable
private fun AppSurface(
connection: CarAppConnection,
frame: CarEnvironment.Frame,
template: Template?,
modifier: Modifier = Modifier,
) {
var size by remember { mutableStateOf(0 to 0) }
// The app lays its map out around whatever the host covers, so a template change moves the
// visible area even though the surface itself hasn't changed.
LaunchedEffect(template, size) {
val (width, height) = size
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height)
connection.reportAreas(visible = visible, stable = visible)
}
}
AndroidView(
modifier = modifier
.pointerInput(connection) {
detectTapGestures { connection.surfaceClick(it.x, it.y) }
}
.pointerInput(connection) {
detectTransformGestures { centroid, pan, zoom, _ ->
// GestureDetector reports scroll as previous-minus-current; a Compose pan
// is the other way round.
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
}
},
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int,
) {
GearslipLog.i("stage: surface ready at ${width}x$height")
size = width to height
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
GearslipLog.i("stage: surface destroyed")
connection.detachSurface()
}
})
}
},
)
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
}
@Composable
private fun ChromeBar(
status: CarAppConnection.Status,
onDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
color = Color.Black.copy(alpha = 0.55f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
status.app ?: "Car app",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val detail = buildList {
status.template?.let { add(it) }
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
}.joinToString(" - ")
Text(detail, style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) }
}
}
}
/** Corner radius of the home screen's map pane and player. */
val EMBEDDED_RADIUS = 8.dp
/**
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
* layer, so the corners are covered from above with the surrounding colour instead.
*/
@Composable
private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
val cut = androidx.compose.ui.graphics.Path().apply {
addRect(bounds)
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()),
),
)
fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd
}
drawPath(cut, color)
}
}
/** Height of [ChromeBar]; the template's own chrome starts below it. */
private val CHROME_BAR = 44.dp
@@ -0,0 +1,40 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.ProjectedContent
/** One tile in the launcher and the screen it opens. */
class CarApp(
val id: String,
val label: String,
val icon: ImageVector,
val content: @Composable () -> Unit,
)
/** Built-in apps. Third-party (Android Auto) apps are a later step and will register here too. */
object CarApps {
val all: List<CarApp> = listOf(
CarApp("web", "Web", Icons.Filled.Search) {
WebApp(content = ProjectedContent.resolve(LocalContext.current))
},
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
PhoneAppsScreen()
},
CarApp("calibrate", "Display test", Icons.Filled.Info) {
CalibrationApp()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarSettingsScreen()
},
)
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
}
@@ -0,0 +1,140 @@
package app.seb3thehacker.gearslip.car
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import java.util.Calendar
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* What the car UI and the apps inside it can know about the world outside the UI.
*
* [phoneDark] is the phone's own theme and picks the car UI's colours. [darkOutside] is a
* separate signal, "is it dark out", for apps that want to adapt (a map, a web page).
*
* Where [darkOutside] comes from is deliberately swappable. Today it is a time-of-day guess.
* The right source is the head unit's own night-mode sensor, which arrives on the AA sensor
* channel once that exists; feed it in through [setSensorNight] and it takes over.
*/
object CarEnvironment {
data class Battery(val percent: Int, val charging: Boolean)
private val phoneDarkFlow = MutableStateFlow(false)
private val darkOutsideFlow = MutableStateFlow(false)
private val batteryFlow = MutableStateFlow(Battery(-1, false))
private val displayFlow = MutableStateFlow("")
/** The projected frame's real geometry, for anything that has to lay out against it. */
data class Frame(val width: Int, val height: Int, val densityDpi: Int)
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow
/** Human-readable projected resolution, for the connection info in car Settings. */
val display: StateFlow<String> = displayFlow
private val vehicleFlow = MutableStateFlow("")
private val insetsFlow = MutableStateFlow(app.seb3thehacker.gearslip.Insets.NONE)
/** Name of the matched vehicle profile, for the connection info in car Settings. */
val vehicle: StateFlow<String> = vehicleFlow
/** How much of the video frame the car UI keeps clear, from the matched vehicle profile. */
val insets: StateFlow<app.seb3thehacker.gearslip.Insets> = insetsFlow
/** Live adjustment from car Settings; not persisted until saved to the vehicle profile. */
fun setInsets(insets: app.seb3thehacker.gearslip.Insets) {
insetsFlow.value = insets
}
fun setVehicle(name: String, insets: app.seb3thehacker.gearslip.Insets) {
vehicleFlow.value = name
insetsFlow.value = insets
}
@Volatile private var sensorNight: Boolean? = null
private var appContext: Context? = null
private var running = false
private val handler = Handler(Looper.getMainLooper())
private val tick = object : Runnable {
override fun run() {
refresh()
handler.postDelayed(this, 60_000)
}
}
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let(::readBattery)
}
}
fun setPhoneTheme(configuration: Configuration) {
phoneDarkFlow.value =
(configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
/** For the AA sensor channel: null goes back to the time-of-day guess. */
fun setSensorNight(night: Boolean?) {
sensorNight = night
refresh()
}
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
frameFlow.value = Frame(width, height, densityDpi)
}
fun start(context: Context) {
if (running) return
running = true
appContext = context.applicationContext
setPhoneTheme(context.resources.configuration)
val sticky = context.applicationContext.registerReceiver(
batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED,
)
sticky?.let(::readBattery)
handler.post(tick)
}
fun stop() {
if (!running) return
running = false
handler.removeCallbacks(tick)
runCatching { appContext?.unregisterReceiver(batteryReceiver) }
}
fun refresh() {
darkOutsideFlow.value = when (CarSettings.nightMode.value) {
NightMode.DAY -> false
NightMode.NIGHT -> true
NightMode.AUTO -> sensorNight ?: guessNightFromClock()
}
}
private fun guessNightFromClock(): Boolean {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
return hour >= 20 || hour < 6
}
private fun readBattery(intent: Intent) {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
batteryFlow.value = Battery(if (level >= 0 && scale > 0) level * 100 / scale else -1, charging)
}
}
@@ -0,0 +1,183 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import android.media.session.PlaybackState
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import kotlinx.coroutines.delay
/**
* The home screen: the map on the left, what is playing on the right.
*
* The map is whatever navigation app was used last, brought back on its own. The player only
* takes its share of the screen while something is loaded; otherwise the map has all of it.
*/
@Composable
fun CarHome() {
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
val navStatus by CarServices.nav.status.collectAsStateWithLifecycle()
val phase by CarServices.media.phase.collectAsStateWithLifecycle()
val now by CarServices.media.now.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { CarServices.autostart(frame) }
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
.weight(if (showPlayer) 1.8f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
MapPane(navStatus, frame)
}
if (showPlayer) {
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
}
}
}
@Composable
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
val navigator = LocalCarNavigator.current
if (status.phase == CarAppConnection.Phase.RUNNING) {
CarAppStage(CarServices.nav, status, frame, embedded = true) { CarServices.stopNav() }
return
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val hasLast = CarSettings.lastNav.value != null
when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
TextButton(onClick = navigator::apps) { Text("Choose another app") }
}
else -> {
Text(
if (hasLast) "Starting the map…" else "No map app chosen yet.",
style = MaterialTheme.typography.titleMedium,
)
if (!hasLast) {
Text(
"Open Car apps and connect a navigation app; it will start here next time.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
}
}
}
}
/** Play/pause and a seek bar, and nothing else: the full controls are in the media screen. */
@Composable
private fun MiniPlayer(modifier: Modifier) {
val media = CarServices.media
val now by media.now.collectAsStateWithLifecycle()
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
var position by remember { mutableStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
delay(500)
}
}
val navigator = LocalCarNavigator.current
Surface(
onClick = navigator::media,
modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(120.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Spacer(Modifier.height(10.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(64.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(38.dp),
)
}
}
}
}
@@ -0,0 +1,155 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
/**
* A keyboard the host draws itself.
*
* Text entry in a car can't use the phone's IME: the car UI lives in a Presentation on a
* virtual display, where a system keyboard has nowhere sensible to appear, and a phone keyboard
* is the wrong shape for a screen a driver glances at anyway. Android Auto draws its own for
* the same reasons, so this does too - large keys, no long-press, no prediction.
*
* It is a pure function of [text]: every key reports the new string and holds no state beyond
* which layer is showing.
*/
@Composable
fun CarKeyboard(
text: String,
onTextChange: (String) -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
submitIcon: Boolean = true,
submitImage: ImageVector? = null,
) {
var symbols by remember { mutableStateOf(false) }
var shifted by remember { mutableStateOf(false) }
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Column(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
rows.forEach { row ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
row.forEach { key ->
val label = if (shifted && !symbols) key.uppercase() else key
Key(label, Modifier.weight(1f)) {
onTextChange(text + label)
shifted = false
}
}
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
if (!symbols) {
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted }
}
Key("space", Modifier.weight(3f)) { onTextChange("$text ") }
Key("⌫", Modifier.weight(1.4f)) {
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
}
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
}
}
}
}
@Composable
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
@Composable
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(8.dp),
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
contentDescription = "Submit",
)
}
}
}
/**
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
*/
private val KEY_HEIGHT = 34.dp
private val LETTER_ROWS = listOf(
"qwertyuiop".map { it.toString() },
"asdfghjkl".map { it.toString() },
"zxcvbnm".map { it.toString() },
)
private val SYMBOL_ROWS = listOf(
"1234567890".map { it.toString() },
listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""),
listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "="),
)
@@ -0,0 +1,127 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.media.MediaCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** One tile on the launcher: either a phone app's own icon or a built-in glyph. */
private class Tile(
val label: String,
val icon: ImageBitmap? = null,
val glyph: ImageVector? = null,
val onClick: () -> Unit,
)
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
fun CarLauncher() {
val context = LocalContext.current
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
val installed by produceState(initialValue = emptyList<Tile>(), context, frame) {
value = withContext(Dispatchers.IO) {
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
val nav = CarAppCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.connectNav(app, frame)
navigator.home()
}
}
val media = MediaCatalog.installed(context).map { app ->
Tile(app.label, iconOf(app.component.packageName)) {
CarServices.openMedia(app)
navigator.media()
}
}
(nav + media).sortedBy { it.label.lowercase() }
}
}
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
) + installed + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(tiles) { AppTile(it) }
}
}
@Composable
private fun AppTile(tile: Tile) {
Surface(
onClick = tile.onClick,
modifier = Modifier.height(120.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
}
Spacer(Modifier.height(8.dp))
Text(
tile.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,56 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
sealed interface CarScreen {
data object Home : CarScreen
data object Settings : CarScreen
data object Apps : CarScreen
/** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen
/** [replyTo] is a notification key to open straight into its reply pane. */
data class Notifications(val replyTo: String? = null) : CarScreen
data class App(val id: String) : CarScreen
}
/**
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
* the on-screen one; an open app can claim it first (a browser going back a page) through
* [backInterceptor].
*/
class CarNavigator(startId: String) {
var current: CarScreen by mutableStateOf(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
)
private set
var backInterceptor: (() -> Boolean)? = null
fun home() { current = CarScreen.Home }
fun media() { current = CarScreen.Media }
fun apps() { current = CarScreen.Apps }
fun settings() { current = CarScreen.Settings }
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
fun open(id: String) { current = CarScreen.App(id) }
fun back() {
if (backInterceptor?.invoke() == true) return
current = CarScreen.Home
}
}
val LocalCarNavigator = staticCompositionLocalOf<CarNavigator> { error("no CarNavigator provided") }
/** "Is it dark outside", for apps that adapt to it. See [CarEnvironment]. */
val LocalDarkOutside = staticCompositionLocalOf { false }
@@ -0,0 +1,113 @@
package app.seb3thehacker.gearslip.car
import android.content.ComponentName
import android.content.Context
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
/**
* The two long-lived things the car keeps running: the map app and the media app.
*
* They belong to the car session rather than to whichever screen is showing, so the home
* screen, the Car apps list and the media screen all look at the same connection. Leaving a
* screen must not drop the route being followed or the song being played.
*/
object CarServices {
private var context: Context? = null
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
private val _mediaApp = MutableStateFlow<MediaApp?>(null)
val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow()
/** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */
private var navStopped = false
private var autoplayPending = false
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
}
// --- map -----------------------------------------------------------------------------
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) {
navStopped = false
CarSettings.setLastNav(app.component.flattenToString())
nav.connect(app, frame.width, frame.height, frame.densityDpi)
}
fun stopNav() {
navStopped = true
nav.disconnect()
}
// --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
CarSettings.setLastMedia(app.component.flattenToString())
val phase = media.phase.value
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return
CarAudio.release()
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
if (CarSettings.pipeAudio.value) CarAudio.request(app.uid)
}
/** Called once the media app is connected: starts playback if that was asked for. */
fun autoplayIfDue() {
if (!autoplayPending) return
autoplayPending = false
if (!media.now.value.playing) media.togglePlay()
}
// --- start and stop ------------------------------------------------------------------
/** Brings back whatever was in use last time. Safe to call repeatedly. */
suspend fun autostart(frame: CarEnvironment.Frame) {
val ctx = context ?: return
if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) {
val last = CarSettings.lastNav.value
val app = last?.let { id ->
withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
}
if (app != null) connectNav(app, frame)
}
if (_mediaApp.value == null) {
val last = CarSettings.lastMedia.value
val app = last?.let { id ->
withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
}
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
}
}
/** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
runCatching { nav.disconnect() }
runCatching { media.disconnect() }
CarAudio.release()
_mediaApp.value = null
navStopped = false
}
}
@@ -0,0 +1,117 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class NightMode { AUTO, DAY, NIGHT }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK }
/**
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
* button is tapped, and persisted through [AppSettings] so they survive the next connection.
*/
object CarSettings {
private const val KEY_SCALE = "car_scale"
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_THEME = "car_app_theme"
/** Id of the app to open on connect; [HOME] opens the launcher. */
const val HOME = "home"
private lateinit var app: Context
private val scaleFlow = MutableStateFlow(1f)
private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(false)
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow
/** Whether the media app starts playing by itself when the car connects. */
val autoplay: StateFlow<Boolean> = autoplayFlow
val scale: StateFlow<Float> = scaleFlow
val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow
/**
* Whether a media app's sound is captured and sent over the car link. Off by default: the
* platform only allows that behind a screen-capture consent dialog, and without it the app
* simply plays through the phone's normal output (e.g. Bluetooth to the car).
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
fun init(context: Context) {
app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
themeFlow.value = runCatching {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
}
fun setScale(value: Float) {
scaleFlow.value = value
AppSettings.putString(app, KEY_SCALE, value.toString())
}
fun setOpenOnConnect(id: String) {
openFlow.value = id
AppSettings.putString(app, KEY_OPEN, id)
}
fun setLastNav(component: String) {
lastNavFlow.value = component
AppSettings.putString(app, KEY_LAST_NAV, component)
}
fun setLastMedia(component: String) {
lastMediaFlow.value = component
AppSettings.putString(app, KEY_LAST_MEDIA, component)
}
fun setAppTheme(theme: AppTheme) {
themeFlow.value = theme
AppSettings.putString(app, KEY_THEME, theme.name)
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
}
fun setPipeAudio(on: Boolean) {
pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
}
fun setNightMode(mode: NightMode) {
nightFlow.value = mode
AppSettings.putString(app, KEY_NIGHT, mode.name)
CarEnvironment.refresh()
}
}
@@ -0,0 +1,219 @@
package app.seb3thehacker.gearslip.car
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Settings that make sense while sitting in the car. Anything that needs a keyboard (the
* startup URL, certificates) lives on the phone, not here.
*/
@Composable
fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val autoplay by CarSettings.autoplay.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState()
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
ChoiceRow(
"Open on connect",
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
open,
) { CarSettings.setOpenOnConnect(it) }
ChoiceRow(
"Size",
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
scale,
) { CarSettings.setScale(it) }
ChoiceRow(
"App theme",
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
theme,
) { CarSettings.setAppTheme(it) }
ChoiceRow(
"Map day/night",
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
night,
) { CarSettings.setNightMode(it) }
ChoiceRow(
"Autoplay on connect",
listOf(false to "Off", true to "On"),
autoplay,
) { CarSettings.setAutoplay(it) }
ChoiceRow(
"Media audio",
listOf(false to "Phone output", true to "Through Gearslip"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"Phone output needs no permission: the app plays as it normally would, e.g. over " +
"Bluetooth to the car. Through Gearslip sends the sound over the car link, and " +
"Android asks for screen-capture consent each time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ScreenFit(vehicle)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
Info("Vehicle", vehicle.ifEmpty { "-" })
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
val navigator = LocalCarNavigator.current
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
options.forEach { (value, label) ->
val on = value == selected
Surface(
onClick = { onSelect(value) },
modifier = Modifier
.weight(1f)
.height(64.dp),
shape = MaterialTheme.shapes.large,
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
}
}
}
}
}
}
@Composable
private fun Info(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp))
Text(value, style = MaterialTheme.typography.bodyLarge)
}
}
/**
* Nudges how much of the frame the UI keeps clear at the top and bottom, live, so a car's
* display can be fitted from the driver's seat. Save writes it to this vehicle's profile.
*/
@Composable
private fun ScreenFit(vehicle: String) {
val insets by CarEnvironment.insets.collectAsState()
val context = LocalContext.current.applicationContext
var saved by remember { mutableStateOf<String?>(null) }
val known = vehicle.isNotEmpty() && vehicle != "Unknown vehicle"
fun change(new: app.seb3thehacker.gearslip.Insets) {
saved = null
CarEnvironment.setInsets(new)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
Text(
"Usable height ${480 - insets.top - insets.bottom}px of 480",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) "Saved to $vehicle" else "Could not save"
},
enabled = known,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
}
}
@Composable
private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1.1f))
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
Text(
"${value}px",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1.3f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
StepButton("+1", Modifier.weight(1f)) { onStep(1) }
StepButton("+5", Modifier.weight(1f)) { onStep(5) }
}
}
@Composable
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(64.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(text, style = MaterialTheme.typography.titleLarge)
}
}
}
@@ -0,0 +1,195 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
/**
* The whole car screen: the current screen on top, the nav bar along the bottom.
*
* Colours follow the phone's theme (Material You). The size setting scales everything by
* changing the density, so text, icons and touch targets grow together.
*/
@Composable
fun CarUi() {
val scale by CarSettings.scale.collectAsState()
val phoneDark by CarEnvironment.phoneDark.collectAsState()
val appTheme by CarSettings.appTheme.collectAsState()
val mediaApp by CarServices.mediaApp.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val appContext = LocalContext.current.applicationContext
val dark = when (appTheme) {
AppTheme.PHONE -> phoneDark
AppTheme.LIGHT -> false
AppTheme.DARK -> true
}
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)
val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
remember(appContext) { CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
CompositionLocalProvider(
LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside,
) {
MaterialTheme(colorScheme = colors) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// Insets are in video-frame pixels, so convert with the un-scaled density.
val pad = with(base) {
PaddingValues(
start = insets.left.toDp(), top = insets.top.toDp(),
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
)
}
// The calibration screen is the raw frame: no insets, no nav bar.
val screen = navigator.current
if (screen == CarScreen.App("calibrate")) {
CarApps.find("calibrate")?.content?.invoke()
} else {
Column(Modifier.fillMaxSize().padding(pad)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
CarScreen.Settings -> CarSettingsScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
// The history screen is already showing them, so it needs no popup.
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
}
CarNavBar(navigator)
}
}
}
}
}
}
/** Back, Home and Apps on the left; clock, phone battery and the darkness signal on the right. */
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier
.fillMaxWidth()
.height(80.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NavItem(Icons.AutoMirrored.Filled.ArrowBack, "Back", selected = false) { navigator.back() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Home, "Home", navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(8.dp))
NavItem(Icons.Filled.Menu, "Apps", navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
)
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
val light = if (darkOutside) "Night" else "Day"
Text(
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(16.dp))
NavItem(
Icons.Filled.Notifications, null, navigator.current is CarScreen.Notifications,
) { navigator.notifications() }
}
}
}
@Composable
private fun NavItem(icon: ImageVector, label: String?, selected: Boolean, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Column(
Modifier
.clip(RoundedCornerShape(24.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
}
if (label != null) Text(label, style = MaterialTheme.typography.labelLarge)
}
}
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
@Composable
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
while (true) {
value = System.currentTimeMillis()
delay(60_000 - value % 60_000)
}
}
@@ -0,0 +1,336 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaApp
import app.seb3thehacker.gearslip.media.MediaArt
import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import android.media.session.PlaybackState
/**
* A media app on the car screen: what it is playing and its controls on the left, the tree it
* offers to browse on the right. The app owns the content; this only draws it.
*/
@Composable
fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val context = LocalContext.current
val media = CarServices.media
val phase by media.phase.collectAsStateWithLifecycle()
val now by media.now.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.BROWSE) }
// The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) }
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") }
Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
AudioBadge(capture)
}
when (phase) {
CarMedia.Phase.REJECTED -> Notice(
"${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.",
)
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
CarMedia.Phase.READY -> Row(
Modifier.fillMaxSize().padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
Column(Modifier.weight(1f).fillMaxHeight()) {
Row {
TextButton(onClick = { tab = Tab.BROWSE }) {
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
}
TextButton(onClick = { tab = Tab.LYRICS }) {
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
}
}
when (tab) {
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
}
}
}
}
}
}
private enum class Tab { BROWSE, LYRICS }
/**
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
* the current line in the accent colour; unsynced ones are plain scrolling text.
*/
@Composable
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
}
val list = rememberLazyListState()
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(300)
}
}
when (val state = lyrics) {
LyricsState.Loading -> Notice("Looking for lyrics…", null)
LyricsState.Missing -> Notice(
if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.",
null,
)
is LyricsState.Found -> {
val lines = state.result.lines
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
LaunchedEffect(current) {
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
}
LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(lines) { index, line ->
Text(
line.text.ifEmpty { " " },
style = MaterialTheme.typography.titleLarge,
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
color = when {
!state.result.synced -> MaterialTheme.colorScheme.onSurface
index == current -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}
private sealed interface LyricsState {
data object Loading : LyricsState
data object Missing : LyricsState
class Found(val result: LyricsResult) : LyricsState
}
@Composable
private fun AudioBadge(capture: CarAudio.Capture) {
val text = when (capture) {
CarAudio.Capture.ON -> "Audio to car"
CarAudio.Capture.ASKING -> "Allow audio capture on the phone"
CarAudio.Capture.DENIED -> "Audio stays on the phone"
CarAudio.Capture.OFF -> return
}
Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun Notice(title: String, detail: String?) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
// --- now playing -----------------------------------------------------------------------------
@Composable
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) {
value = now.art ?: MediaArt.load(context, now.artUri)
}
var position by remember { mutableLongStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
kotlinx.coroutines.delay(500)
}
}
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(170.dp),
) {
art?.let {
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
}
}
Spacer(Modifier.height(8.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
Spacer(Modifier.height(14.dp))
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
}
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
}
}
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
// icons are resources inside the app's own package.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
now.custom.take(3).forEach { action ->
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
}
}
}
// --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
}
Text(
browse.trail.lastOrNull() ?: "Browse",
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
when {
browse.loading -> Notice("Loading…", null)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
}
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(entry.iconBitmap, entry.iconUri) {
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
}
Surface(
onClick = { media.select(entry) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (entry.subtitle.isNotEmpty()) {
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (entry.browsable && entry.playable) {
IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") }
}
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
}
}
/** Material's transport icons, from their published path data (they live in the extended set). */
internal object MediaIcons {
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
private fun icon(name: String, path: String) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f,
).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
}
@@ -0,0 +1,277 @@
package app.seb3thehacker.gearslip.car
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.delay
import java.text.DateFormat
import java.util.Date
/** The notification history, with a reply pane for anything the sending app lets us answer. */
@Composable
fun NotificationsScreen(replyTo: String?) {
val history by CarNotifications.history.collectAsState()
var replying by remember(replyTo) { mutableStateOf(replyTo) }
// Anything arriving while this is open is being looked at, so it never counts as unread.
LaunchedEffect(history) { CarNotifications.markRead() }
val target = history.firstOrNull { it.key == replying && it.reply != null }
if (target != null) {
ReplyPane(target, onDone = { replying = null })
} else {
NotificationList(history, onReply = { replying = it.key })
}
}
@Composable
private fun NotificationList(history: List<CarNotification>, onReply: (CarNotification) -> Unit) {
val listening by CarNotifications.listening.collectAsState()
val context = LocalContext.current.applicationContext
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Notifications",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") }
}
if (!listening) {
AccessBanner {
context.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
if (history.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Nothing yet. New notifications from the phone will show up here.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(history, key = { it.key }) { NotificationRow(it, onReply) }
}
}
}
}
@Composable
private fun AccessBanner(onOpen: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Button(onClick = onOpen) { Text("Open") }
}
}
}
@Composable
private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Column(Modifier.weight(1f)) {
Text(
"${n.appLabel} · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (n.title.isNotBlank()) {
Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (n.text.isNotBlank()) {
Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
when {
n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary)
n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") }
}
}
}
}
@Composable
private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) {
val icon = n.icon
if (icon != null) {
Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp)))
} else {
Icon(Icons.Filled.Notifications, contentDescription = null, modifier = Modifier.size(size))
}
}
/** Same shape as search: a field on top, the host's own keyboard below. */
@Composable
private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
val context = LocalContext.current.applicationContext
var text by remember(n.key) { mutableStateOf("") }
fun send() {
if (text.isBlank()) return
CarNotifications.reply(context, n, text.trim())
onDone()
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.weight(1f),
) {
Text(
if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|",
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
// What is being answered, so the driver isn't replying blind.
Row(
Modifier.weight(1f).fillMaxWidth().padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
CarKeyboard(text, onTextChange = { text = it }, onSubmit = ::send, submitImage = Icons.AutoMirrored.Filled.Send)
}
}
/**
* The popup shown over whatever the car is displaying. It times itself out, and tapping it opens
* the history - or straight into a reply, when the app allows one.
*/
@Composable
fun NotificationPopup(modifier: Modifier = Modifier) {
val popup by CarNotifications.popup.collectAsState()
val navigator = LocalCarNavigator.current
val n = popup ?: return
LaunchedEffect(n.id) {
delay(POPUP_MS)
CarNotifications.dismissPopup(n.id)
}
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 8.dp,
modifier = modifier
.padding(top = 8.dp)
.fillMaxWidth(0.7f)
.clickable { navigator.notifications() },
) {
Row(
Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NotificationIcon(n)
Column(Modifier.weight(1f)) {
Text(n.appLabel, style = MaterialTheme.typography.labelMedium, maxLines = 1)
if (n.title.isNotBlank()) {
Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (n.text.isNotBlank()) {
Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (n.reply != null) {
FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") }
}
IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) {
Icon(Icons.Filled.Close, contentDescription = "Dismiss")
}
}
}
}
private const val POPUP_MS = 8_000L
@@ -0,0 +1,130 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import app.seb3thehacker.gearslip.mirror.PhoneApp
import app.seb3thehacker.gearslip.mirror.PhoneApps
import app.seb3thehacker.gearslip.mirror.PhoneMirror
import app.seb3thehacker.gearslip.mirror.TouchRelayService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Starts a phone app on the car screen.
*
* Picking one hands the whole frame over to the mirror, so this screen is the last thing
* Gearslip draws until mirroring stops - either from the notification on the phone, or by
* tapping the black bars beside the mirrored picture.
*/
@Composable
fun PhoneAppsScreen() {
val context = LocalContext.current
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) {
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) }
}
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
if (!TouchRelayService.enabled) {
TouchRelayWarning()
Spacer(Modifier.height(12.dp))
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(apps) { app ->
PhoneAppTile(app) { PhoneMirror.open(context, app.packageName) }
}
}
}
}
/**
* Mirroring shows the phone whether or not touch works, so this warns rather than blocks -
* the picture is still useful, it just cannot be driven from the car yet.
*/
@Composable
private fun TouchRelayWarning() {
val context = LocalContext.current
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Text(
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
}
}
}
@Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.height(120.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
app.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(52.dp)) }
Spacer(Modifier.height(8.dp))
Text(
app.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,340 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Rect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.TravelEstimate
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import app.seb3thehacker.gearslip.host.text
/**
* Draws the template an app handed us, over the surface it is drawing its map into.
*
* A templated app never draws its own chrome: it describes a screen in the Car App Library's
* model types and every host renders those to suit its own screen. This is Gearslip's
* rendering. It is deliberately laid out for a wide, short car display - controls to the
* right where a hand reaches from the driver's seat, information to the left.
*
* Templates split into two kinds. The six map-backed ones here leave room for the app's own
* drawing and lay chrome around it; everything else fills the screen and is drawn by
* [ContentTemplate].
*/
@Composable
fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
when (template) {
null -> Unit
is NavigationTemplate -> NavigationChrome(template, modifier)
is MapWithContentTemplate -> MapWithContentChrome(template, modifier)
is MapTemplate -> MapChrome(template, modifier)
is PlaceListNavigationTemplate -> PlaceListNavigationChrome(template, modifier)
is RoutePreviewNavigationTemplate -> RoutePreviewChrome(template, modifier)
is PlaceListMapTemplate -> PlaceListMapChrome(template, modifier)
else -> ContentTemplate(template, modifier)
}
}
/**
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
* it. Expressed as the visible rectangle within a [width] x [height] surface.
*
* A template that isn't map-backed covers the surface completely, and reporting an empty
* rectangle is how the app is told to stop bothering.
*/
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) {
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt())
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
null -> Rect(0, 0, width, height)
else -> Rect(0, 0, 0, 0)
}
// --- navigation ------------------------------------------------------------------------------
@Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
Box(modifier.fillMaxWidth()) {
(template.navigationInfo as? RoutingInfo)?.let {
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
}
RightEdgeControls(
template.actionStrip,
template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
}
}
}
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
@Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
}
return
}
val step = info.currentStep ?: return
ChromeSurface(modifier) {
Column(Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Text(
step.cue.text(),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"then",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
next.cue.text(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
/** Time and distance left, along the bottom where it stays out of the map's way. */
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
Surface(
color = Color.Black.copy(alpha = 0.6f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
if (time.isNotEmpty()) {
Text(
time,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = estimate.remainingTimeColor.color(dark, Color.White),
)
}
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.titleMedium,
color = estimate.remainingDistanceColor.color(dark, Color.White),
)
}
if (trip.isNotEmpty()) {
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
}
}
}
}
// --- map with a side pane ----------------------------------------------------------------------
/**
* The shape every remaining map-backed template shares: a pane down the left, the app's map
* showing through on the right, and both action strips stacked at the right edge.
*/
@Composable
private fun MapPaneLayout(
modifier: Modifier,
actionStrip: androidx.car.app.model.ActionStrip?,
mapActionStrip: androidx.car.app.model.ActionStrip?,
pane: @Composable (Modifier) -> Unit,
) {
Box(modifier.fillMaxWidth()) {
pane(
Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.fillMaxWidth(CONTENT_PANE_FRACTION),
)
RightEdgeControls(
actionStrip,
mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
}
}
@Composable
private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ContentTemplate(template.contentTemplate, paneModifier)
}
}
@Composable
private fun MapChrome(template: MapTemplate, modifier: Modifier) {
MapPaneLayout(
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
if (template.pane != null) PaneRows(template.pane)
else ItemListColumn(template.itemList)
}
}
}
}
@Composable
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
ListPane(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
)
}
}
@Composable
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier ->
ListPane(
template.title.text(),
template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
)
}
}
/** Route choices down the side, with the app's "start" action pinned under them. */
@Composable
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxHeight()) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f)) {
if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp))
} else {
ItemListColumn(template.itemList)
}
}
template.navigateAction?.let {
ActionRow(listOf(it), Modifier.padding(12.dp))
}
}
}
}
}
@Composable
private fun ListPane(
title: String,
startAction: androidx.car.app.model.Action?,
loading: Boolean,
itemList: androidx.car.app.model.ItemList?,
modifier: Modifier,
) {
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(title, startAction)
if (loading) Text("Loading…", Modifier.padding(14.dp))
else ItemListColumn(itemList)
}
}
}
/** Fraction of the surface the content pane covers beside a map. */
private const val CONTENT_PANE_FRACTION = 0.42f
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
private const val ESTIMATE_FRACTION = 0.14f
@@ -0,0 +1,498 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.Item
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.LongMessageTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.SearchTemplate
import androidx.car.app.model.Section
import androidx.car.app.model.SectionedItemTemplate
import androidx.car.app.model.TabTemplate
import androidx.car.app.model.Template
import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.PinSignInMethod
import androidx.car.app.model.signin.ProviderSignInMethod
import androidx.car.app.model.signin.QRCodeSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import androidx.car.app.media.model.MediaPlaybackTemplate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.inputChanged
import app.seb3thehacker.gearslip.host.inputSubmitted
import app.seb3thehacker.gearslip.host.submitted
import app.seb3thehacker.gearslip.host.tabSelected
import app.seb3thehacker.gearslip.host.text
import app.seb3thehacker.gearslip.host.textChanged
/**
* Templates that fill a screen with content rather than sitting over a map.
*
* A map-backed template can hold one of these in its side pane, which is why they are drawn by
* the same function either way - the only difference is how much room they get.
*/
@Composable
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
when (template) {
null -> Unit
is ListTemplate -> ListContent(template, modifier)
is GridTemplate -> GridContent(template, modifier)
is PaneTemplate -> PaneContent(template, modifier)
is MessageTemplate -> MessageContent(template, modifier)
is LongMessageTemplate -> LongMessageContent(template, modifier)
is SearchTemplate -> SearchContent(template, modifier)
is SignInTemplate -> SignInContent(template, modifier)
is TabTemplate -> TabContent(template, modifier)
is SectionedItemTemplate -> SectionedContent(template, modifier)
is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier)
else -> UnsupportedChrome(template, modifier)
}
}
/** True when this template is one [ContentTemplate] knows how to draw. */
fun isContentTemplate(template: Template?): Boolean = when (template) {
is ListTemplate, is GridTemplate, is PaneTemplate, is MessageTemplate,
is LongMessageTemplate, is SearchTemplate, is SignInTemplate, is TabTemplate,
is SectionedItemTemplate, is MediaPlaybackTemplate,
-> true
else -> false
}
// --- lists, grids and panes ---------------------------------------------------------------------
@Composable
private fun ListContent(template: ListTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) Loading()
else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList })
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun GridContent(template: GridTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) Loading() else GridItems(template.singleList)
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
}
}
// --- messages -------------------------------------------------------------------------------
@Composable
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
template.icon?.let {
CarGlyph(it, Modifier.size(32.dp))
Spacer(Modifier.width(12.dp))
}
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
}
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
/**
* The long form is the same idea with a scroll: it's what apps use for terms and conditions,
* which is also why it is only ever shown parked.
*/
@Composable
private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
template.title.text(),
template.headerAction,
actionStrip = template.actionStrip,
)
Text(
template.message.text(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
)
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
// --- search ---------------------------------------------------------------------------------
/**
* Search: the host owns the text and the keyboard, and the app owns the results.
*
* Every keystroke goes to the app through [SearchTemplate.getSearchCallbackDelegate], which is
* what lets results update as you type. The text lives here rather than in the template
* because the app never sends it back - it only ever sends a new list of results.
*/
@Composable
private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
// Seeded once. The app refreshes this template on every keystroke, so re-seeding from
// initialSearchText would fight whatever is being typed.
var query by remember { mutableStateOf(template.initialSearchText.orEmpty()) }
val delegate = template.searchCallbackDelegate
ContentSurface(modifier) {
// Back button and field share a row: on a 480px screen the keyboard and the results
// are already competing for every pixel, so the header can't have one to itself.
Row(
Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
template.headerAction?.let { ActionButton(it) }
SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f))
ActionRow(template.actionStrip?.actions.orEmpty())
}
Box(Modifier.weight(1f)) {
if (template.isLoading) Loading()
else ItemListColumn(template.itemList, modifier = Modifier.fillMaxSize())
}
CarKeyboard(
text = query,
onTextChange = {
query = it
delegate.textChanged(it)
},
onSubmit = { delegate.submitted(query) },
)
}
}
@Composable
private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Text(
query.ifEmpty { hint.ifEmpty { "Search" } },
style = MaterialTheme.typography.titleMedium,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
// --- sign-in --------------------------------------------------------------------------------
/**
* Sign-in comes in four shapes and the host has to draw whichever the app picked. Only the
* typed one needs the keyboard; a PIN or QR code is something the driver reads off the screen
* and completes on their phone.
*/
@Composable
private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
template.title.text(),
template.headerAction,
actionStrip = template.actionStrip,
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
val instructions = template.instructions.text()
if (instructions.isNotEmpty()) {
Text(
instructions,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (val method = template.signInMethod) {
is InputSignInMethod -> InputSignIn(method, Modifier.weight(1f))
is PinSignInMethod -> Callout(method.pinCode.toString(), Modifier.weight(1f))
is QRCodeSignInMethod -> Callout(method.uri.toString(), Modifier.weight(1f))
is ProviderSignInMethod -> Box(Modifier.weight(1f), Alignment.Center) {
ActionRow(listOf(method.action))
}
else -> Spacer(Modifier.weight(1f))
}
val additional = template.additionalText.text()
if (additional.isNotEmpty()) {
Text(
additional,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
var value by remember { mutableStateOf(method.defaultValue.text()) }
val delegate = method.inputCallbackDelegate
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
Column(modifier) {
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text())
val error = method.errorMessage.text()
if (error.isNotEmpty()) {
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Spacer(Modifier.weight(1f))
CarKeyboard(
text = value,
onTextChange = {
value = it
delegate.inputChanged(it)
},
onSubmit = { delegate.inputSubmitted(value) },
submitIcon = false,
)
}
}
/** A code the driver reads off the screen. Big, centred, nothing else competing with it. */
@Composable
private fun Callout(value: String, modifier: Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
// --- tabs -----------------------------------------------------------------------------------
@Composable
private fun TabContent(template: TabTemplate, modifier: Modifier) {
val active = template.activeTabContentId
ContentSurface(modifier) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.verticalScrollNone(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
template.headerAction?.let { ActionButton(it) }
template.tabs.forEach { tab ->
val selected = tab.contentId == active
Surface(
color = if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(20.dp),
modifier = Modifier.clickable {
template.tabCallbackDelegate.tabSelected(tab.contentId)
},
) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
tab.icon?.let {
CarGlyph(it, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
}
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
}
if (template.isLoading) Loading()
else ContentTemplate(template.tabContents?.template, Modifier.weight(1f))
}
}
/** Tabs share a row; this keeps the modifier chain readable where no scroll is wanted. */
private fun Modifier.verticalScrollNone(): Modifier = this
// --- sectioned items --------------------------------------------------------------------------
/**
* Sections fetch their items across the binder on demand rather than carrying them, so each one
* is asked for its full range once and the answer is held while the section is on screen.
*/
@Composable
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
if (template.isLoading) {
Loading()
return@ContentSurface
}
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
template.sections.forEach { section ->
PaneTitle(section.title.text())
SectionRows(section)
}
}
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun SectionRows(section: Section<*>) {
var items by remember(section) { mutableStateOf<List<Item>>(emptyList()) }
LaunchedEffect(section) {
val delegate = section.itemsDelegate
val size = runCatching { delegate.size }.getOrDefault(0)
if (size <= 0) return@LaunchedEffect
runCatching {
delegate.requestItemRange(0, size - 1, object : OnDoneCallback {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
@Suppress("UNCHECKED_CAST")
items = (value as? List<Item>).orEmpty()
}
override fun onFailure(response: Bundleable) =
GearslipLog.w("host: a section refused to hand over its items")
})
}.onFailure { GearslipLog.w("host: could not fetch section items: ${it.message}") }
}
if (items.isEmpty()) {
val message = section.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
return
}
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
}
// --- media ----------------------------------------------------------------------------------
/**
* Media playback is drawn from the app's MediaSession rather than from the template, which
* carries only a header. Gearslip has no media channel yet, so this says so plainly instead of
* showing an empty player.
*/
@Composable
private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
// --- shared ---------------------------------------------------------------------------------
@Composable
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxSize(), content = content)
}
}
private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnScope
@Composable
private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
}
}
@@ -0,0 +1,383 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.Pane
import androidx.car.app.model.Row as CarRow
import androidx.car.app.model.Template
import app.seb3thehacker.gearslip.host.click
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.standardType
import app.seb3thehacker.gearslip.host.text
/**
* The pieces every template is built from.
*
* The Car App Library's templates are combinations of a small set of parts - a header, an
* action strip, a list of rows, a grid of tiles - so the host only has to decide once how each
* part looks, and each template is then mostly an arrangement of them.
*/
// --- surfaces and text -------------------------------------------------------------------------
/**
* The app keeps drawing its map across the whole surface - the visible area we report tells it
* where to put its own markers, not where to stop painting. So anything the host lays over the
* map is opaque by default, or the map reads through it.
*/
@Composable
internal fun ChromeSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
alpha: Float = 1f,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
content = content,
)
}
@Composable
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
ChromeSurface {
Text(
"${template.javaClass.simpleName} isn't drawn yet",
Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
/**
* A template's header: a back or app action on the left, a title, and the app's own actions on
* the right. Templates carry this either as a [Header] or as the older title-plus-action pair.
*/
@Composable
internal fun HeaderBar(
title: String,
startAction: Action? = null,
endActions: List<Action> = emptyList(),
actionStrip: ActionStrip? = null,
) {
val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() }
if (title.isEmpty() && startAction == null && end.isEmpty()) return
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
end.forEach { ActionButton(it) }
}
}
/** Reads a header whichever way the template carries it. */
internal fun headerTitle(header: Header?, fallback: String): String =
header?.title.text().ifEmpty { fallback }
// --- actions ------------------------------------------------------------------------------------
/**
* Both strips share the right edge, where a hand reaches from the driver's seat: the app's own
* actions in a row along the top, its map controls stacked under them. Keeping them in one
* column is what stops them colliding on a screen only 480px tall.
*/
@Composable
internal fun RightEdgeControls(
actionStrip: ActionStrip?,
mapActionStrip: ActionStrip?,
modifier: Modifier = Modifier,
) {
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return
Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) }
}
}
mapActions.forEach { ActionButton(it) }
}
}
@Composable
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier) {
val drawable = actions.filter { it.isDrawable() }
if (drawable.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it) }
}
}
@Composable
internal fun ActionButton(action: Action) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f))
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface(
color = background,
contentColor = Color.White,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.clickable(enabled = action.isEnabled) {
action.onClickDelegate.click("action")
},
) {
Row(
Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (title.isNotEmpty()) {
if (hasGlyph) Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1)
}
}
}
}
/** A standard Action carries no icon of its own - the host is expected to supply the glyph. */
private fun standardFor(action: Action): Int? = when (action.type) {
Action.TYPE_BACK -> CarIcon.TYPE_BACK
Action.TYPE_PAN -> CarIcon.TYPE_PAN
Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON
else -> null
}
// --- icons ----------------------------------------------------------------------------------
/**
* Draws an icon the app described, falling back to our own glyph for the standard types the
* host owns. A custom icon that fails to load leaves the space empty rather than a placeholder,
* since a wrong glyph on a map control is worse than none.
*/
@Composable
internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) {
val context = LocalContext.current
val bitmap = icon.image(context)
if (bitmap != null) {
// An icon that names a tint (DEFAULT included) expects the host to colour it for the
// surface it sits on; without this a white glyph vanishes on a light theme.
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val content = LocalContentColor.current
val white = remember(bitmap) { bitmap.isPlainWhite() }
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
// text so it stays readable on a light one.
val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white }
Image(
bitmap, contentDescription = null, modifier = modifier,
colorFilter = tint?.let { ColorFilter.tint(it) },
)
return
}
val vector = when (standard ?: icon.standardType) {
CarIcon.TYPE_BACK -> Icons.Filled.ArrowBack
CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning
// No four-way pan glyph in material-icons-core; this is the closest honest stand-in.
CarIcon.TYPE_PAN -> Icons.Filled.Menu
else -> null
} ?: return
Icon(vector, contentDescription = null, modifier = modifier)
}
// --- lists, grids and panes ---------------------------------------------------------------------
@Composable
internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@Composable
internal fun ItemListColumn(
single: ItemList?,
sectioned: List<ItemList>? = null,
modifier: Modifier = Modifier,
) {
val rows = buildList {
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
return
}
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
items(rows) { RowItem(it) }
}
}
/** A grid of large tappable tiles - what apps use for a home screen beside the map. */
@Composable
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items) { item ->
Column(
Modifier
.clickable { item.onClickDelegate.click("grid item") }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CarGlyph(item.image, Modifier.size(36.dp))
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = item.text.text()
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
val rows = pane?.rows.orEmpty()
Column(modifier) {
rows.forEach { RowItem(it) }
ActionRow(
pane?.actions.orEmpty(),
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
@Composable
internal fun RowItem(row: CarRow) {
val clickable = row.onClickDelegate != null && row.isEnabled
Row(
Modifier
.fillMaxWidth()
.let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(2).forEach { line ->
Text(
line.text(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
}
}
internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
/** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */
private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
val pixels = IntArray(width * height)
readPixels(pixels)
var seen = 0
for (pixel in pixels) {
val alpha = pixel ushr 24
if (alpha < 40) continue
seen++
if ((pixel shr 16 and 0xFF) < 225 || (pixel shr 8 and 0xFF) < 225 || (pixel and 0xFF) < 225) return false
}
return seen > 0
}
@@ -0,0 +1,92 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.sin
/**
* A progress bar whose played part is a wave, and which flattens out while paused.
*
* The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or
* dragging seeks; the position is committed on release rather than on every pixel of a drag,
* because each commit is a request to the media app.
*/
@Composable
fun WavyProgress(
fraction: Float,
playing: Boolean,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
val played = MaterialTheme.colorScheme.primary
val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave")
val phase = if (playing) {
rememberInfiniteTransition(label = "phase").animateFloat(
0f, (2 * PI).toFloat(),
infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart),
label = "phase",
).value
} else 0f
var dragging by remember { mutableStateOf<Float?>(null) }
val shown = (dragging ?: fraction).coerceIn(0f, 1f)
Canvas(
modifier
.fillMaxWidth()
.height(32.dp)
.pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) },
onDragEnd = { dragging?.let(onSeek); dragging = null },
onDragCancel = { dragging = null },
) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) }
},
) {
val stroke = 4.dp.toPx()
val mid = size.height / 2
val end = size.width * shown
val wavelength = 22.dp.toPx()
val amp = 3.5.dp.toPx() * amplitude
// Unplayed part: a straight line, so the wave reads as "this much is done".
drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round)
val wave = Path().apply {
moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat())
var x = 0f
while (x < end) {
x = minOf(end, x + 2f)
lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat())
}
}
drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round))
drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round)
}
}
@@ -0,0 +1,83 @@
package app.seb3thehacker.gearslip.car
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
/**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
* The bottom bar's Back walks the page history first and only leaves the app at the start.
*/
@Composable
fun WebApp(content: String) {
val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current
var web by remember { mutableStateOf<WebView?>(null) }
DisposableEffect(web) {
navigator.backInterceptor = {
val view = web
if (view != null && view.canGoBack()) {
view.goBack()
true
} else {
false
}
}
onDispose { navigator.backInterceptor = null }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient()
if (content.startsWith("http://") || content.startsWith("https://")) {
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
web = this
}
},
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
}
private class LoggingClient : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
}
// onPageFinished fires for error pages too, so without this a blocked load (missing
// INTERNET permission, no network) looks like a successful one.
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) {
GearslipLog.e("car web page FAILED: ${request.url} - ${error?.errorCode} ${error?.description}")
}
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: WebResourceResponse?) {
if (request?.isForMainFrame == true) {
GearslipLog.w("car web page HTTP ${response?.statusCode} for ${request.url}")
}
}
}
@@ -0,0 +1,17 @@
package app.seb3thehacker.gearslip.host
/**
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
* (there are many: one per screen it registers) are noise in every list of apps to open.
*/
object AndroidAuto {
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
fun matches(packageName: String, label: String): Boolean {
val pkg = packageName.lowercase()
if (PACKAGE_MARKERS.any { it in pkg }) return true
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
return "android auto" in name || "androidauto" in name.replace(" ", "")
}
}
@@ -0,0 +1,81 @@
package app.seb3thehacker.gearslip.host
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.car.app.CarAppService
/** A templated app installed on the phone, and the category it asked to be treated as. */
class TemplateApp(
val component: ComponentName,
val label: String,
val categories: List<String>,
) {
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
/** "navigation", "media", ... - what the app told the system it is. */
val kind: String
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
?.removePrefix(CATEGORY_PREFIX)?.lowercase()
?: "app"
private companion object {
const val CATEGORY_PREFIX = "androidx.car.app.category."
}
}
/**
* Finds apps that can be rendered by a host.
*
* A templated app exports a CarAppService and never draws anything itself - it hands the host
* a template and lets the host decide how it looks. That is what makes hosting worth doing:
* the app arrives already designed for a car screen, rather than a phone layout squeezed onto
* one, which is all mirroring could ever manage.
*/
object CarAppCatalog {
fun installed(context: Context): List<TemplateApp> {
val manager = context.packageManager
val intent = Intent(CarAppService.SERVICE_INTERFACE)
return manager.queryIntentServices(intent, 0)
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.map { resolved ->
val info = resolved.serviceInfo
TemplateApp(
component = ComponentName(info.packageName, info.name),
label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName,
categories = categoriesOf(context, info.packageName, info.name),
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
/**
* queryIntentServices does not report the matched filter's categories, so the categories
* are recovered by re-querying with each one and seeing which still match.
*/
private fun categoriesOf(context: Context, packageName: String, className: String): List<String> {
val manager = context.packageManager
return KNOWN_CATEGORIES.filter { category ->
val probe = Intent(CarAppService.SERVICE_INTERFACE)
.setPackage(packageName)
.addCategory(category)
manager.queryIntentServices(probe, 0).any { it.serviceInfo.name == className }
}
}
private val KNOWN_CATEGORIES = listOf(
CarAppService.CATEGORY_NAVIGATION_APP,
CarAppService.CATEGORY_POI_APP,
CarAppService.CATEGORY_PARKING_APP,
CarAppService.CATEGORY_CHARGING_APP,
CarAppService.CATEGORY_MESSAGING_APP,
CarAppService.CATEGORY_CALLING_APP,
CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP,
)
}
@@ -0,0 +1,564 @@
package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.res.Configuration
import android.graphics.Rect
import android.location.Location
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.view.Surface
import androidx.car.app.CarAppService
import androidx.car.app.CarContext
import androidx.car.app.HandshakeInfo
import androidx.car.app.IAppHost
import androidx.car.app.IAppManager
import androidx.car.app.ICarApp
import androidx.car.app.ICarHost
import androidx.car.app.IOnDoneCallback
import androidx.car.app.model.Template
import androidx.car.app.model.TemplateWrapper
import androidx.car.app.ISurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.car.app.AppInfo
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.constraints.IConstraintHost
import androidx.car.app.suggestion.ISuggestionHost
import androidx.car.app.hardware.ICarHardwareHost
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.navigation.INavigationHost
import androidx.car.app.serialization.Bundleable
import androidx.car.app.versioning.CarAppApiLevels
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Speaks the Car App Library's host half, so a templated app can be rendered by Gearslip.
*
* This is the opposite end of the interface an app implements: the app exports a
* CarAppService and describes a screen as a template, and whichever host it is bound to
* decides how that template looks. Android Auto is one such host. So is this.
*
* Whether an app will talk to us at all is decided by its HostValidator, which accepts a host
* that either holds android.car.permission.TEMPLATE_RENDERER or is signed by a key on its
* allow-list. Gearslip declares that permission in its manifest, which is the whole reason
* this can work from an ordinary app - see AndroidManifest.xml.
*
* Every binder callback lands on a binder thread and is hopped to the main thread, because
* the state here feeds Compose.
*/
class CarAppConnection(private val context: Context) {
enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED }
data class Status(
val phase: Phase = Phase.IDLE,
val app: String? = null,
val detail: String? = null,
val apiLevel: Int = 0,
val template: String? = null,
val surfaceRequested: Boolean = false,
val surfaceAttached: Boolean = false,
)
/** The Surface lent to the app, plus the size the app should draw at. */
private data class Lent(val surface: Surface, val width: Int, val height: Int, val dpi: Int)
private val _status = MutableStateFlow(Status())
val status: StateFlow<Status> = _status.asStateFlow()
/** The app's current screen, as it described it. Null until the first one arrives. */
private val _template = MutableStateFlow<Template?>(null)
val template: StateFlow<Template?> = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null
private var lent: Lent? = null
private var binding: ServiceConnection? = null
private var component: ComponentName? = null
private var frameWidth = 800
private var frameHeight = 480
private var frameDensity = 160
// --- connection ---------------------------------------------------------------------
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) {
disconnect()
frameWidth = width
frameHeight = height
frameDensity = densityDpi
component = app.component
update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
main.post { onBound(service) }
}
override fun onServiceDisconnected(name: ComponentName) {
main.post { fail(Phase.FAILED, "the app's service disconnected") }
}
override fun onNullBinding(name: ComponentName) {
// A templated app that refuses a host returns no binder at all.
main.post { fail(Phase.REJECTED, "the app refused to bind - host not accepted") }
}
}
binding = connection
LocationKeepAlive.start(context)
val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
}.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it)
false
}
if (!bound) {
binding = null
fail(Phase.FAILED, "bindService refused")
} else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
}
}
fun disconnect() {
lent?.let { sendSurfaceDestroyed(it) }
lent = null
val carApp = this.carApp
if (carApp != null) {
runCatching { carApp.onAppPause(noop("onAppPause")) }
runCatching { carApp.onAppStop(noop("onAppStop")) }
}
LocationKeepAlive.stop(context)
binding?.let { runCatching { context.unbindService(it) } }
binding = null
this.carApp = null
appManager = null
surfaceCallback = null
component = null
_template.value = null
update { Status() }
}
// --- handshake ---------------------------------------------------------------------
private fun onBound(service: IBinder) {
val app = ICarApp.Stub.asInterface(service)
carApp = app
update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") }
GearslipLog.i("host: bound; requesting app info")
call("getAppInfo", onValue = { value ->
val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}")
onAppInfo(info)
}) { app.getAppInfo(it) }
}
/**
* Picks the newest API level both sides understand. An app that needs more than this host
* offers cannot be rendered correctly, so it is refused here rather than half-drawn.
*/
private fun onAppInfo(info: AppInfo) {
val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel)
GearslipLog.i(
"host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " +
"(library ${info.libraryDisplayVersion}), host $HOST_API_LEVEL -> $negotiated"
)
if (negotiated < info.minCarAppApiLevel) {
fail(Phase.FAILED, "app needs car api ${info.minCarAppApiLevel}, host offers $HOST_API_LEVEL")
return
}
val app = carApp ?: return
update { it.copy(apiLevel = negotiated, detail = "handshake") }
val handshake = runCatching { Bundleable.create(HandshakeInfo(context.packageName, negotiated)) }
.getOrElse { return fail(Phase.FAILED, "could not bundle the handshake: ${it.message}") }
call("onHandshakeCompleted", onValue = { onHandshakeAccepted() }) {
app.onHandshakeCompleted(handshake, it)
}
}
private fun onHandshakeAccepted() {
val app = carApp ?: return
GearslipLog.i("host: handshake accepted; creating the app")
val intent = Intent(Intent.ACTION_MAIN).setComponent(component)
call("onAppCreate", onValue = { onCreated() }) {
app.onAppCreate(carHost, intent, carConfiguration(), it)
}
}
private fun onCreated() {
val app = carApp ?: return
GearslipLog.i("host: created; starting")
call("onAppStart", onValue = {
call("onAppResume", onValue = { onRunning() }) { app.onAppResume(it) }
}) { app.onAppStart(it) }
}
private fun onRunning() {
val app = carApp ?: return
update { it.copy(phase = Phase.RUNNING, detail = "connected") }
GearslipLog.verdict(
"${component?.packageName} accepted Gearslip as a template host",
"Handshake completed at car API level ${_status.value.apiLevel}.",
)
call("getManager(app)", onValue = { value ->
appManager = when (value) {
is IAppManager -> value
is IBinder -> IAppManager.Stub.asInterface(value)
else -> null
}
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
requestTemplate()
}) { app.getManager(CarContext.APP_SERVICE, it) }
}
/** The app describes its current screen; this is what the host draws. */
private fun requestTemplate() {
val manager = appManager ?: return
call("getTemplate", onValue = { value ->
// TemplateWrapper carries the template plus the id the host echoes back on refresh.
val template = (value as? TemplateWrapper)?.template ?: value as? Template
if (template == null) {
GearslipLog.w("host: getTemplate returned ${value?.javaClass?.name}")
return@call
}
GearslipLog.i("host: template = ${template.javaClass.simpleName}")
_template.value = template
update { it.copy(template = template.javaClass.simpleName) }
}) { manager.getTemplate(it) }
}
// --- the surface the app draws its map into -------------------------------------------
/**
* Lends the app a Surface to draw into.
*
* Navigation apps render their own map rather than describing it as a template, so the host
* has to give them somewhere to draw. Gearslip's car UI already lives in a Presentation on a
* VirtualDisplay backed by the encoder, so the Surface handed over here belongs to a
* SurfaceView in that Presentation: the app draws straight into the car's picture and
* SurfaceFlinger composites it under our chrome, with no readback or GL on our side.
*
* Safe to call before the app has asked for a surface - whichever of the two arrives second
* completes the handoff.
*/
fun attachSurface(surface: Surface, width: Int, height: Int, dpi: Int) {
main.post {
val current = lent
if (current != null &&
current.surface == surface &&
current.width == width &&
current.height == height
) {
return@post
}
current?.let { sendSurfaceDestroyed(it) }
lent = Lent(surface, width, height, dpi)
sendSurfaceAvailable()
}
}
/** Takes the Surface back, e.g. when the SurfaceView goes away. */
fun detachSurface() {
main.post {
val current = lent ?: return@post
lent = null
sendSurfaceDestroyed(current)
update { it.copy(surfaceAttached = false) }
}
}
private fun sendSurfaceAvailable() {
val callback = surfaceCallback ?: return
val surface = lent ?: return
if (!surface.surface.isValid) {
GearslipLog.w("host: not lending an invalid surface")
return
}
val container = runCatching {
Bundleable.create(
SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi)
)
}.getOrElse {
GearslipLog.e("host: could not bundle the surface", it)
return
}
val sent = runCatching { callback.onSurfaceAvailable(container, noop("onSurfaceAvailable")) }
if (sent.isFailure) {
GearslipLog.e("host: onSurfaceAvailable threw", sent.exceptionOrNull())
return
}
// The app draws for the whole surface; the visible area is what isn't covered by host
// chrome, and the stable area is the part that never gets covered. We keep our chrome
// to a top bar, so both are the surface minus that bar - reported by the UI via
// [reportAreas] once it has measured. Until then the whole surface is fair game.
val whole = Rect(0, 0, surface.width, surface.height)
reportAreas(whole, whole)
GearslipLog.i(
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
)
update { it.copy(surfaceAttached = true) }
}
private fun sendSurfaceDestroyed(surface: Lent) {
val callback = surfaceCallback ?: return
val container = runCatching {
Bundleable.create(
SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi)
)
}.getOrNull() ?: return
runCatching { callback.onSurfaceDestroyed(container, noop("onSurfaceDestroyed")) }
GearslipLog.i("host: took the surface back")
}
/** Tells the app which part of its surface the driver can actually see. */
fun reportAreas(visible: Rect, stable: Rect) {
val callback = surfaceCallback ?: return
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
}
// --- gestures on the app's surface -----------------------------------------------------
//
// These are the map interactions a host forwards: the app never sees raw touches, only
// these four. Coordinates are in surface pixels. Scroll distances follow GestureDetector's
// convention (previous minus current), which is the opposite sign to a Compose drag.
fun surfaceClick(x: Float, y: Float) = onSurface("onClick") { it.onClick(x, y) }
fun surfaceScroll(distanceX: Float, distanceY: Float) =
onSurface("onScroll") { it.onScroll(distanceX, distanceY) }
fun surfaceFling(velocityX: Float, velocityY: Float) =
onSurface("onFling") { it.onFling(velocityX, velocityY) }
fun surfaceScale(focusX: Float, focusY: Float, scaleFactor: Float) =
onSurface("onScale") { it.onScale(focusX, focusY, scaleFactor) }
private inline fun onSurface(name: String, send: (ISurfaceCallback) -> Unit) {
val callback = surfaceCallback ?: return
runCatching { send(callback) }.onFailure { GearslipLog.w("host: $name failed: ${it.message}") }
}
// --- host interfaces the app calls back into -----------------------------------------
private val appHost = object : IAppHost.Stub() {
override fun invalidate() {
GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() }
}
override fun showToast(text: CharSequence?, duration: Int) {
GearslipLog.i("host: toast \"$text\"")
}
/**
* Navigation apps draw their own map, so the host lends them a Surface. Receiving this
* is the signal that the app is ready to render into the car's picture.
*/
override fun setSurfaceCallback(callback: ISurfaceCallback?) {
GearslipLog.i("host: app asked for a surface (callback=${callback != null})")
main.post {
surfaceCallback = callback
update { it.copy(surfaceRequested = callback != null) }
if (callback != null) sendSurfaceAvailable()
}
}
override fun sendLocation(location: Location?) = Unit
override fun showAlert(alert: Bundleable?) = Unit
override fun dismissAlert(alertId: Int) = Unit
override fun openMicrophone(request: Bundleable?): Bundleable? = null
}
private val navigationHost = object : INavigationHost.Stub() {
override fun navigationStarted() = GearslipLog.i("host: navigation started")
override fun navigationEnded() = GearslipLog.i("host: navigation ended")
override fun updateTrip(trip: Bundleable?) = Unit
}
/**
* Answers car-hardware questions with "this car doesn't have that".
*
* A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but
* it has to be an actual answer. Returning no binder for this service at all is what made
* Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the
* service, and the library's own "unsupported" path only runs once a reply arrives.
*
* The reply's Bundleable is ignored by the library whenever isSupported is false, but the
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
*/
private val hardwareHost = object : ICarHardwareHost.Stub() {
override fun getCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun subscribeCarHardwareResult(
type: Int,
params: Bundleable?,
result: ICarHardwareResult?,
) = unsupported(type, result)
override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit
private fun unsupported(type: Int, result: ICarHardwareResult?) {
val target = result ?: return
runCatching {
target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder())
}.onFailure { GearslipLog.w("host: could not answer hardware result $type") }
}
}
/**
* Tells apps how much content this screen can take.
*
* These are safety limits in a real car - how many rows a driver may be shown at once -
* and the app trims its own lists to them. Gearslip's numbers match what the Car App
* Library documents for a projected head unit; they are about the screen, not about us.
*/
private val constraintHost = object : IConstraintHost.Stub() {
override fun getContentLimit(type: Int): Int = when (type) {
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
else -> 12
}
/** The app may refresh its own template without the driver touching anything. */
override fun isAppDrivenRefreshEnabled(): Boolean = true
}
/**
* Accepts the shortcuts an app offers for a car launcher.
*
* Gearslip has nowhere to show these yet, but accepting and dropping them is right: an app
* that pushes suggestions shouldn't fail because its host doesn't surface them.
*/
private val suggestionHost = object : ISuggestionHost.Stub() {
override fun updateSuggestions(suggestions: Bundleable?) = Unit
}
private val carHost = object : ICarHost.Stub() {
override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
CarContext.HARDWARE_SERVICE -> hardwareHost.asBinder()
CarContext.CONSTRAINT_SERVICE -> constraintHost.asBinder()
CarContext.SUGGESTION_SERVICE -> suggestionHost.asBinder()
else -> {
GearslipLog.i("host: app asked for an unsupported service \"$type\"")
null
}
}
override fun startCarApp(intent: Intent?) {
GearslipLog.i("host: app wants to start $intent")
}
override fun finish() {
GearslipLog.i("host: app finished")
main.post { disconnect() }
}
}
// --- plumbing ------------------------------------------------------------------------
/**
* Every call in this protocol is one-way plus an IOnDoneCallback, so this pairs them back
* up and funnels a remote failure into the visible status rather than losing it.
*/
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
val callback = object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { onValue(value) }
}
override fun onFailure(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { fail(Phase.FAILED, "$name failed: $value") }
}
}
runCatching { send(callback) }
.onFailure { fail(Phase.REJECTED, "$name threw: ${it.javaClass.simpleName} ${it.message}") }
}
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
}
/**
* The app lays itself out for this, so it describes the car screen rather than the phone,
* including UI_MODE_TYPE_CAR - templated apps branch on that.
*/
/** The day/night value last handed to the app, so a change is only pushed when it is one. */
private var sentNight: Boolean? = null
/**
* Tells a running app that the car's light changed. Templated apps take their map style from
* the configuration the host reports, so this is what turns the map to day or night.
*/
fun pushConfiguration() {
val app = carApp ?: return
if (_status.value.phase != Phase.RUNNING) return
if (CarEnvironment.darkOutside.value == sentNight) return
val configuration = carConfiguration()
call("onConfigurationChanged", onValue = { }) { app.onConfigurationChanged(configuration, it) }
}
private fun carConfiguration(): Configuration {
val configuration = Configuration(context.resources.configuration)
val night = CarEnvironment.darkOutside.value
sentNight = night
configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
configuration.densityDpi = frameDensity
configuration.screenWidthDp = frameWidth * 160 / frameDensity
configuration.screenHeightDp = frameHeight * 160 / frameDensity
configuration.smallestScreenWidthDp =
minOf(configuration.screenWidthDp, configuration.screenHeightDp)
configuration.orientation =
if (frameWidth >= frameHeight) Configuration.ORIENTATION_LANDSCAPE
else Configuration.ORIENTATION_PORTRAIT
configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK.inv()) or
Configuration.UI_MODE_TYPE_CAR
return configuration
}
private fun fail(phase: Phase, detail: String) {
GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) }
}
private fun update(block: (Status) -> Status) {
_status.value = block(_status.value)
}
private companion object {
/** The newest template API this host claims to understand. */
const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8
}
}
@@ -0,0 +1,160 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import android.graphics.drawable.Drawable
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.model.InputCallbackDelegate
import androidx.car.app.model.OnClickDelegate
import androidx.car.app.model.OnContentRefreshDelegate
import androidx.car.app.model.SearchCallbackDelegate
import androidx.car.app.model.TabCallbackDelegate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
import java.util.concurrent.TimeUnit
/**
* Turns the Car App Library's model objects into things Compose can draw.
*
* A templated app describes its screen in these types and says nothing about how they should
* look - that is the host's job, and every host looks different. These helpers are the seam
* between the app's description and Gearslip's rendering of it.
*/
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
/**
* Loads an icon the app described.
*
* A [CarIcon] is either a standard type the host is expected to draw itself, or an IconCompat
* pointing into the app's own resources - which is why this needs a Context that can see the
* app, and why a failure here is a missing glyph rather than an error worth stopping for.
*/
fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null
val drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX
return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull()
}
/** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */
val CarIcon?.standardType: Int? get() = this?.takeIf { it.icon == null }?.type
/**
* Resolves a colour the app asked for. The named colours deliberately stay close to what a
* driver expects them to mean rather than matching the app's brand.
*/
fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
null, CarColor.TYPE_DEFAULT -> default
CarColor.TYPE_CUSTOM -> Color(if (dark) colorDark else color)
CarColor.TYPE_RED -> Color(0xFFE53935)
CarColor.TYPE_GREEN -> Color(0xFF43A047)
CarColor.TYPE_BLUE -> Color(0xFF1E88E5)
CarColor.TYPE_YELLOW -> Color(0xFFFDD835)
else -> default
}
/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */
fun Distance?.display(): String {
val distance = this ?: return ""
val unit = when (displayUnit) {
Distance.UNIT_METERS -> "m"
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> "mi"
Distance.UNIT_FEET -> "ft"
Distance.UNIT_YARDS -> "yd"
else -> ""
}
val decimals = when (displayUnit) {
Distance.UNIT_KILOMETERS_P1, Distance.UNIT_MILES_P1 -> 1
Distance.UNIT_KILOMETERS, Distance.UNIT_MILES -> if (displayDistance < 10) 1 else 0
else -> 0
}
return "%.${decimals}f $unit".format(distance.displayDistance)
}
/** "18 min" from the app's remaining-time estimate. */
fun remainingTime(seconds: Long): String {
if (seconds <= 0) return ""
val hours = TimeUnit.SECONDS.toHours(seconds)
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
}
/**
* Tells the app something was tapped.
*
* [OnClickDelegate.isParkedOnly] marks an action the app itself considers unsafe while moving.
* Gearslip has no speed signal yet, so these are allowed through and the decision is recorded
* here as the place to gate them once the sensor channel exists.
*/
fun OnClickDelegate?.click(label: String = "action") {
val delegate = this ?: return
runCatching { delegate.sendClick(doneCallback("a $label tap")) }
.onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") }
}
/**
* Search is a conversation: the host owns the text and the keyboard, and tells the app about
* every keystroke so it can update its results as you type. [submitted] is the explicit "go".
*/
fun SearchCallbackDelegate?.textChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchTextChanged(text, doneCallback("search text")) }
.onFailure { GearslipLog.w("host: could not send search text: ${it.message}") }
}
fun SearchCallbackDelegate?.submitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendSearchSubmitted(text, doneCallback("search submit")) }
.onFailure { GearslipLog.w("host: could not submit a search: ${it.message}") }
}
/** Tells the app which tab the driver picked; the app answers with a new template. */
fun TabCallbackDelegate?.tabSelected(contentId: String) {
val delegate = this ?: return
runCatching { delegate.sendTabSelected(contentId, doneCallback("tab")) }
.onFailure { GearslipLog.w("host: could not select a tab: ${it.message}") }
}
/** Free text typed into a sign-in field, reported the same way search text is. */
fun InputCallbackDelegate?.inputChanged(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputTextChanged(text, doneCallback("input text")) }
.onFailure { GearslipLog.w("host: could not send input text: ${it.message}") }
}
fun InputCallbackDelegate?.inputSubmitted(text: String) {
val delegate = this ?: return
runCatching { delegate.sendInputSubmitted(text, doneCallback("input submit")) }
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
}
/** The driver asked for fresher content, e.g. by pulling a place list. */
fun OnContentRefreshDelegate?.refresh() {
val delegate = this ?: return
runCatching { delegate.sendContentRefreshRequested(doneCallback("refresh")) }
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
}
private fun doneCallback(label: String) = object : OnDoneCallback {
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
override fun onSuccess(response: Bundleable?) = Unit
override fun onFailure(response: Bundleable) = GearslipLog.w("host: the app rejected $label")
}
/** An action with nothing to show is one the host should skip rather than draw as a blank. */
fun Action.isDrawable(): Boolean =
icon != null || title.text().isNotEmpty() || type != Action.TYPE_CUSTOM
private const val ICON_FALLBACK_PX = 48
@@ -0,0 +1,58 @@
package app.seb3thehacker.gearslip.host
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.IBinder
/**
* Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen.
*
* Android only gives an app location in the background if something visible to the user is
* running for it. A nav app bound as a car app has nothing of its own showing, so it goes
* without a fix as soon as the phone screen leaves it. Binding with BIND_INCLUDE_CAPABILITIES
* lends it this service's location capability, which is why this foreground service exists.
* It needs the ordinary location permission, granted on the phone.
*/
class LocationKeepAlive : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Navigation location", NotificationManager.IMPORTANCE_LOW),
)
val notification = Notification.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle("Gearslip is sharing location with the car map")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
return START_NOT_STICKY
}
companion object {
private const val CHANNEL = "gearslip_location"
private const val NOTIFICATION_ID = 44
/** Public flag value; the constant itself is hidden from the SDK. */
const val BIND_INCLUDE_CAPABILITIES = 0x1000
fun start(context: Context) {
val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
}
fun stop(context: Context) {
context.stopService(Intent(context, LocationKeepAlive::class.java))
}
}
}
@@ -0,0 +1,234 @@
package app.seb3thehacker.gearslip.media
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** One row of an app's browse tree. */
class MediaEntry(
val id: String,
val title: String,
val subtitle: String,
val iconUri: Uri?,
val iconBitmap: Bitmap?,
val browsable: Boolean,
val playable: Boolean,
)
class CustomAction(val id: String, val name: String, val extras: Bundle?)
/** What is playing, as the car's now-playing panel needs it. */
class NowPlaying(
val title: String = "",
val artist: String = "",
val album: String = "",
val art: Bitmap? = null,
val artUri: Uri? = null,
val state: Int = PlaybackState.STATE_NONE,
val positionMs: Long = 0,
val durationMs: Long = 0,
val speed: Float = 1f,
val updatedAt: Long = 0,
val actions: Long = 0,
val custom: List<CustomAction> = emptyList(),
val error: String? = null,
) {
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
fun canDo(action: Long) = actions and action != 0L
/** The position now, advanced from the last report the way the platform's own UIs do. */
fun currentPosition(): Long {
if (!playing || state != PlaybackState.STATE_PLAYING) return positionMs
val elapsed = SystemClock.elapsedRealtime() - updatedAt
val moved = positionMs + (elapsed * speed).toLong()
return if (durationMs > 0) moved.coerceAtMost(durationMs) else moved
}
}
class BrowseState(
/** Titles of the folders entered so far; empty at the app's root. */
val trail: List<String> = emptyList(),
val entries: List<MediaEntry> = emptyList(),
val loading: Boolean = true,
val failed: Boolean = false,
)
/**
* Talks to one media app the way Android Auto does: connect to its MediaBrowserService, browse
* its tree, and drive playback through the session it publishes. The app decides what its
* tree contains and what the buttons do; this only presents it.
*/
class CarMedia(private val context: Context) {
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _browse = MutableStateFlow(BrowseState())
val browse: StateFlow<BrowseState> = _browse.asStateFlow()
private val _now = MutableStateFlow(NowPlaying())
val now: StateFlow<NowPlaying> = _now.asStateFlow()
private var browser: MediaBrowser? = null
private var controller: MediaController? = null
private var subscribed: String? = null
private var rootId: String? = null
private val path = ArrayDeque<Pair<String, String>>() // id to title
fun connect(app: MediaApp) {
disconnect()
_phase.value = Phase.CONNECTING
_browse.value = BrowseState()
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() {
GearslipLog.w("media: ${app.label} refused the connection")
_phase.value = Phase.REJECTED
}
override fun onConnectionSuspended() {
GearslipLog.w("media: ${app.label} went away")
_phase.value = Phase.REJECTED
}
}, null)
browser = mb
runCatching { mb.connect() }.onFailure {
GearslipLog.e("media: connect threw", it)
_phase.value = Phase.REJECTED
}
}
private fun onConnected(app: MediaApp) {
val mb = browser ?: return
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
controller = MediaController(context, mb.sessionToken).also {
it.registerCallback(callback)
update(it.metadata, it.playbackState)
}
rootId = mb.root
path.clear()
_phase.value = Phase.READY
open(mb.root)
}
private val callback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
}
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
fun text(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }.orEmpty()
fun art(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) }
fun uri(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }?.let(Uri::parse)
_now.value = NowPlaying(
title = text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_TITLE),
artist = text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, MediaMetadata.METADATA_KEY_ARTIST),
album = text(MediaMetadata.METADATA_KEY_ALBUM),
art = art(MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_DISPLAY_ICON),
artUri = uri(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, MediaMetadata.METADATA_KEY_ART_URI, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
state = state?.state ?: PlaybackState.STATE_NONE,
positionMs = state?.position ?: 0,
durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0,
speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
error = state?.errorMessage?.toString(),
)
}
// --- browsing ------------------------------------------------------------------------
private fun open(parentId: String) {
val mb = browser ?: return
subscribed?.let { runCatching { mb.unsubscribe(it) } }
subscribed = parentId
_browse.update { BrowseState(trail = path.map { it.second }, loading = true) }
mb.subscribe(parentId, object : MediaBrowser.SubscriptionCallback() {
override fun onChildrenLoaded(parentId: String, children: MutableList<MediaBrowser.MediaItem>) {
if (parentId != subscribed) return
_browse.value = BrowseState(
trail = path.map { it.second },
entries = children.map(::entryOf),
loading = false,
)
}
override fun onError(parentId: String) {
if (parentId != subscribed) return
GearslipLog.w("media: could not load $parentId")
_browse.value = BrowseState(trail = path.map { it.second }, loading = false, failed = true)
}
})
}
private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry {
val d = item.description
return MediaEntry(
id = item.mediaId.orEmpty(),
title = d.title?.toString().orEmpty(),
subtitle = d.subtitle?.toString().orEmpty(),
iconUri = d.iconUri,
iconBitmap = d.iconBitmap,
browsable = item.isBrowsable,
playable = item.isPlayable,
)
}
/** Folders open; tracks play. Some entries are both (an album you can browse or play whole). */
fun select(entry: MediaEntry) {
if (entry.browsable) {
path.addLast(entry.id to entry.title)
open(entry.id)
} else if (entry.playable) {
controller?.transportControls?.playFromMediaId(entry.id, null)
}
}
fun playAll(entry: MediaEntry) {
controller?.transportControls?.playFromMediaId(entry.id, null)
}
val canGoUp get() = path.isNotEmpty()
fun up() {
if (path.isEmpty()) return
path.removeLast()
open(path.lastOrNull()?.first ?: rootId ?: return)
}
// --- transport -----------------------------------------------------------------------
fun togglePlay() {
val controls = controller?.transportControls ?: return
if (_now.value.playing) controls.pause() else controls.play()
}
fun next() = controller?.transportControls?.skipToNext()
fun previous() = controller?.transportControls?.skipToPrevious()
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun disconnect() {
runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() }
controller = null
browser = null
subscribed = null
_phase.value = Phase.IDLE
_now.value = NowPlaying()
}
}
@@ -0,0 +1,95 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class LyricLine(val timeMs: Long, val text: String)
/** [synced] means every line carries a time, so the current one can be followed along. */
class LyricsResult(val lines: List<LyricLine>, val synced: Boolean)
/**
* Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key.
*
* Media apps almost never publish lyrics in their session metadata, so this is the only general
* source. It sends the track's title, artist, album and length to lrclib.net, which is why the
* lookup happens only when the driver opens the lyrics view and never in the background.
*/
object Lyrics {
private val cache = HashMap<String, LyricsResult?>()
suspend fun find(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null
val key = "$title|$artist|$album|$durationMs"
synchronized(cache) { if (cache.containsKey(key)) return cache[key] }
val result = withContext(Dispatchers.IO) {
runCatching {
exact(title, artist, album, durationMs) ?: search(title, artist)
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
}
synchronized(cache) { cache[key] = result }
return result
}
private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (artist.isBlank()) return null
val query = buildString {
append("track_name=").append(enc(title)).append("&artist_name=").append(enc(artist))
if (album.isNotBlank()) append("&album_name=").append(enc(album))
if (durationMs > 0) append("&duration=").append(durationMs / 1000)
}
return get("https://lrclib.net/api/get?$query")?.let { parse(JSONObject(it)) }
}
private fun search(title: String, artist: String): LyricsResult? {
val body = get("https://lrclib.net/api/search?q=" + enc("$artist $title".trim())) ?: return null
val hits = JSONArray(body)
for (i in 0 until hits.length()) parse(hits.getJSONObject(i))?.let { return it }
return null
}
private fun parse(json: JSONObject): LyricsResult? {
val synced = json.optString("syncedLyrics").takeIf { it.isNotBlank() && it != "null" }
if (synced != null) {
val lines = parseLrc(synced)
if (lines.isNotEmpty()) return LyricsResult(lines, synced = true)
}
val plain = json.optString("plainLyrics").takeIf { it.isNotBlank() && it != "null" } ?: return null
return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false)
}
/** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */
private fun parseLrc(text: String): List<LyricLine> {
val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""")
return text.lines().flatMap { raw ->
val stamps = stamp.findAll(raw).toList()
val words = raw.replace(stamp, "").trim()
stamps.map {
val ms = ((it.groupValues[1].toLong() * 60 + it.groupValues[2].toDouble()) * 1000).toLong()
LyricLine(ms, words)
}
}.sortedBy { it.timeMs }
}
private fun get(url: String): String? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 5_000
readTimeout = 8_000
setRequestProperty("User-Agent", "Gearslip (car media host)")
}
return try {
if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText()
} finally {
connection.disconnect()
}
}
private fun enc(value: String) = URLEncoder.encode(value, "UTF-8")
}
@@ -0,0 +1,34 @@
package app.seb3thehacker.gearslip.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.LruCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
/** Cover art arrives as a content:// or https:// URI; this fetches and remembers it. */
object MediaArt {
private val cache = LruCache<String, Bitmap>(48)
suspend fun load(context: Context, uri: Uri?): Bitmap? {
if (uri == null) return null
val key = uri.toString()
cache.get(key)?.let { return it }
return withContext(Dispatchers.IO) {
runCatching {
val stream = when (uri.scheme) {
"http", "https" -> URL(key).openConnection().apply {
connectTimeout = 5_000
readTimeout = 8_000
}.getInputStream()
else -> context.contentResolver.openInputStream(uri)
} ?: return@runCatching null
stream.use { BitmapFactory.decodeStream(it) }
}.getOrNull()?.also { cache.put(key, it) }
}
}
}
@@ -0,0 +1,57 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.service.media.MediaBrowserService
/** A media app that has told the system it belongs in a car. */
class MediaApp(
val component: ComponentName,
val label: String,
/** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */
val uid: Int,
)
/**
* Finds media apps that opted in to Android Auto.
*
* An app is a car media app when it exports a MediaBrowserService *and* declares the
* `com.google.android.gms.car.application` meta-data pointing at a descriptor with
* `<uses name="media"/>`. The second half is what separates Deezer from a phone-only player
* that merely exposes a browser service to Bluetooth and Wear.
*/
object MediaCatalog {
private const val CAR_META = "com.google.android.gms.car.application"
fun installed(context: Context): List<MediaApp> {
val manager = context.packageManager
return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA)
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filter { declaresCar(manager, it.serviceInfo) }
.map {
val info = it.serviceInfo
MediaApp(
component = ComponentName(info.packageName, info.name),
label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName,
uid = info.applicationInfo?.uid ?: -1,
)
}
.distinctBy { it.component.packageName }
.sortedBy { it.label.lowercase() }
.toList()
}
private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
if (service.metaData?.containsKey(CAR_META) == true) return true
return runCatching {
manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA)
.metaData?.containsKey(CAR_META) == true
}.getOrDefault(false)
}
}
@@ -0,0 +1,117 @@
package app.seb3thehacker.gearslip.mirror
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
/**
* Holds the screen-capture session for [PhoneMirror].
*
* This exists because of an ordering rule: since Android 10 a MediaProjection may only be
* obtained while a foreground service declaring `mediaProjection` is already running, so the
* consent result has to be carried into a service before it can be redeemed for a token.
* Its notification doubles as the driver's way out - stopping capture from the shade works
* even when the car screen is showing a mirrored app rather than anything of ours.
*/
class MirrorService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0
val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
if (data == null) {
GearslipLog.e("mirror service started without a consent result")
stopSelf()
return START_NOT_STICKY
}
startForeground(
NOTIFICATION_ID, notification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
val media: MediaProjection? = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
} catch (t: Throwable) {
GearslipLog.e("getMediaProjection failed", t)
null
}
if (media == null) {
stopSelf()
return START_NOT_STICKY
}
// The session owns the encoder Surface, so it performs the swap once a token exists.
Handler(Looper.getMainLooper()).post { PhoneMirror.onTokenReady?.invoke(media) }
return START_NOT_STICKY
}
override fun onDestroy() {
super.onDestroy()
Handler(Looper.getMainLooper()).post {
PhoneMirror.end()
PhoneMirror.onEnded?.invoke()
}
}
private fun notification(): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Phone mirroring", NotificationManager.IMPORTANCE_LOW)
)
val stop = PendingIntent.getService(
this, 0,
Intent(this, MirrorService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL)
.setContentTitle("Mirroring to the car")
.setContentText("The car screen is showing this phone.")
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.build()
}
companion object {
private const val CHANNEL = "gearslip_mirror"
private const val NOTIFICATION_ID = 42
private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_MIRROR"
private const val EXTRA_RESULT_CODE = "resultCode"
private const val EXTRA_RESULT_DATA = "resultData"
private var appContext: Context? = null
/** Redeems a screen-capture consent result for a running capture session. */
fun start(context: Context, resultCode: Int, data: Intent) {
appContext = context.applicationContext
context.startForegroundService(
Intent(context, MirrorService::class.java)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_RESULT_DATA, data)
)
}
fun stop() {
val context = appContext ?: return
context.startService(Intent(context, MirrorService::class.java).setAction(ACTION_STOP))
}
}
}
@@ -0,0 +1,85 @@
package app.seb3thehacker.gearslip.mirror
import app.seb3thehacker.gearslip.host.AndroidAuto
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.provider.Settings
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import app.seb3thehacker.gearslip.GearslipLog
/** An app installed on the phone, offered on the car screen as something to mirror. */
class PhoneApp(val packageName: String, val label: String, val icon: ImageBitmap?)
/**
* The launcher's view of the phone, shown in the car so an app can be started without picking
* the phone up. Needs the `<queries>` entry in the manifest - package visibility hides other
* apps from us otherwise, and the list comes back empty rather than failing.
*/
object PhoneApps {
private const val ICON_PX = 128
fun installed(context: Context): List<PhoneApp> {
val manager = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return manager.queryIntentActivities(intent, 0)
.asSequence()
.filter { it.activityInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.activityInfo.packageName, it.loadLabel(manager).toString()) }
.distinctBy { it.activityInfo.packageName }
.map {
PhoneApp(
packageName = it.activityInfo.packageName,
label = it.loadLabel(manager).toString(),
icon = runCatching { it.loadIcon(manager).toImageBitmap() }.getOrNull(),
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
/**
* Starts an app on the phone's own screen, which is what the mirror is pointed at.
*
* Deliberately uses the application context: a composable in the car UI sees a context
* bound to the projected display, and launching from that would try to place the activity
* there - the exact thing the platform refuses.
*/
fun launch(context: Context, packageName: String) {
val app = context.applicationContext
val intent = app.packageManager.getLaunchIntentForPackage(packageName)
if (intent == null) {
GearslipLog.e("no launch intent for $packageName")
return
}
runCatching { app.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
.onSuccess { GearslipLog.i("launched $packageName on the phone") }
.onFailure { GearslipLog.e("launching $packageName failed", it) }
}
/** Opens the phone's accessibility settings, where the touch relay is switched on. */
fun openAccessibilitySettings(context: Context) {
val app = context.applicationContext
runCatching {
app.startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}.onFailure { GearslipLog.e("could not open accessibility settings", it) }
}
private fun Drawable.toImageBitmap(): ImageBitmap {
if (this is BitmapDrawable && bitmap != null) {
return bitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap()
}
val bitmap = Bitmap.createBitmap(ICON_PX, ICON_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
return bitmap.asImageBitmap()
}
}
@@ -0,0 +1,146 @@
package app.seb3thehacker.gearslip.mirror
import android.content.Context
import android.graphics.PointF
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.projection.MediaProjection
import android.view.Surface
import android.view.WindowManager
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Mirrors the phone's screen into the head unit's video stream.
*
* The platform will not let a normal app render another app onto a display it owns (an
* activity may only be placed on a virtual display whose owner shares its uid, and doing it
* for someone else's app needs INTERNAL_SYSTEM_WINDOW - see ScreenProjector.launchApp).
* Mirroring sidesteps that: the app runs where it always does, on the phone's own display,
* and MediaProjection copies that display into the encoder Surface we are already feeding to
* the car. Touch makes the return trip through [TouchRelayService].
*
* The MediaProjection token is produced by [MirrorService], because Android only hands one
* over while a foreground service of type mediaProjection is running.
*/
object PhoneMirror {
private val _active = MutableStateFlow(false)
/** True while the car is showing the phone rather than Gearslip's own car UI. */
val active: StateFlow<Boolean> = _active.asStateFlow()
private var display: VirtualDisplay? = null
private var media: MediaProjection? = null
private var frameWidth = 0
private var frameHeight = 0
/**
* Wired up by the session, which owns the encoder Surface and so is the only thing that
* can swap what draws into it. All three are invoked on the main thread.
*/
internal var onStartRequested: (() -> Unit)? = null
internal var onTokenReady: ((MediaProjection) -> Unit)? = null
internal var onEnded: (() -> Unit)? = null
/** Asks the phone to put up the screen-capture consent dialog. Safe to call from the car. */
fun requestStart() {
onStartRequested?.invoke()
}
fun requestStop() = MirrorService.stop()
private var pendingLaunch: String? = null
/**
* Shows [packageName] on the car screen.
*
* When mirroring has not started yet the app is remembered rather than launched, because
* consent has to be granted while Gearslip is still the foreground app - starting the
* other app first would push us into the background, where no dialog can be shown.
*/
fun open(context: Context, packageName: String) {
if (_active.value) {
PhoneApps.launch(context, packageName)
} else {
pendingLaunch = packageName
requestStart()
}
}
/** Called by the session once the mirror is live. */
fun launchPending(context: Context) {
val packageName = pendingLaunch ?: return
pendingLaunch = null
PhoneApps.launch(context, packageName)
}
/**
* Points [surface] at the phone's screen. The caller must have released whatever else was
* drawing into that Surface first - a Surface has one producer.
*/
fun begin(token: MediaProjection, surface: Surface, width: Int, height: Int, densityDpi: Int) {
end()
media = token
frameWidth = width
frameHeight = height
token.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
GearslipLog.w("media projection stopped (revoked or replaced)")
MirrorService.stop()
}
}, null)
display = try {
token.createVirtualDisplay(
"gearslip-mirror", width, height, densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null,
)
} catch (t: Throwable) {
GearslipLog.e("mirror display failed", t)
null
}
_active.value = display != null
if (display != null) GearslipLog.i("mirroring the phone into ${width}x$height")
}
fun end() {
runCatching { display?.release() }
runCatching { media?.stop() }
display = null
media = null
_active.value = false
}
/**
* Maps a head-unit touch in projected coordinates onto the phone's screen.
*
* Mirroring letterboxes: the phone's display is scaled to fit the car frame with its
* aspect kept, so the live content occupies a centred sub-rectangle and everything outside
* it is padding that no window can receive. Metrics are read per call rather than cached
* because rotating the phone changes the shape of the mirror underneath us.
*
* Returns null for a touch that landed on the padding.
*/
fun mapToPhone(context: Context, x: Float, y: Float): PointF? {
if (frameWidth == 0 || frameHeight == 0) return null
val bounds = context.getSystemService(WindowManager::class.java)
?.currentWindowMetrics?.bounds ?: return null
val phoneW = bounds.width().toFloat()
val phoneH = bounds.height().toFloat()
if (phoneW <= 0f || phoneH <= 0f) return null
val scale = minOf(frameWidth / phoneW, frameHeight / phoneH)
val offsetX = (frameWidth - phoneW * scale) / 2f
val offsetY = (frameHeight - phoneH * scale) / 2f
val px = (x - offsetX) / scale
val py = (y - offsetY) / scale
if (px < 0f || py < 0f || px > phoneW || py > phoneH) return null
return PointF(px, py)
}
}
@@ -0,0 +1,139 @@
package app.seb3thehacker.gearslip.mirror
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.PointF
import android.view.accessibility.AccessibilityEvent
import app.seb3thehacker.gearslip.GearslipLog
/**
* Replays head-unit touches onto the phone while [PhoneMirror] is running.
*
* dispatchGesture is the only public API that lets one app inject input into another, which
* is why mirroring needs an accessibility service at all. Gearslip subscribes to no event
* types and never inspects a window, so nothing about the phone's screen is read here - the
* service is a one-way output.
*
* A head unit sends a stream of DOWN/MOVE/UP, but a gesture is dispatched as a whole path, so
* the two are bridged with continued strokes: each dispatch carries the movement since the
* last one, and the next is only queued once the previous completes. Moves that arrive while
* a segment is in flight collapse into the most recent point, which keeps the replayed path
* tracking the finger instead of falling behind it.
*/
class TouchRelayService : AccessibilityService() {
private var stroke: GestureDescription.StrokeDescription? = null
private var last: PointF? = null
/** A segment is in flight; further movement waits so strokes stay properly ordered. */
private var dispatching = false
/** Most recent unsent position, and whether the finger has already lifted. */
private var pending: PointF? = null
private var lifted = false
override fun onServiceConnected() {
instance = this
GearslipLog.i("touch relay connected")
}
override fun onDestroy() {
super.onDestroy()
instance = null
reset()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit
override fun onInterrupt() = reset()
fun down(x: Float, y: Float) {
reset()
val path = Path().apply { moveTo(x, y); lineTo(x, y) }
val start = GestureDescription.StrokeDescription(path, 0, SEGMENT_MS, true)
stroke = start
last = PointF(x, y)
send(start)
}
fun move(x: Float, y: Float) {
if (stroke == null) return
pending = PointF(x, y)
pump()
}
fun up(x: Float, y: Float) {
if (stroke == null) return
pending = PointF(x, y)
lifted = true
pump()
}
private fun pump() {
if (dispatching) return
val previous = stroke ?: return
val from = last ?: return
val to = pending
// Nothing new to send yet: hold the stroke open until movement or a lift arrives.
if (to == null && !lifted) return
val target = to ?: from
val path = Path().apply { moveTo(from.x, from.y); lineTo(target.x, target.y) }
val next = try {
previous.continueStroke(path, 0, SEGMENT_MS, !lifted)
} catch (t: Throwable) {
GearslipLog.e("continueStroke rejected", t)
reset()
return
}
stroke = next
last = target
pending = null
send(next)
}
private fun send(description: GestureDescription.StrokeDescription) {
val gesture = GestureDescription.Builder().addStroke(description).build()
dispatching = true
val ok = dispatchGesture(gesture, object : GestureResultCallback() {
override fun onCompleted(g: GestureDescription?) {
dispatching = false
if (lifted && pending == null) reset() else pump()
}
override fun onCancelled(g: GestureDescription?) {
dispatching = false
reset()
}
}, null)
if (!ok) {
GearslipLog.w("dispatchGesture refused - is another gesture in flight?")
dispatching = false
reset()
}
}
private fun reset() {
stroke = null
last = null
pending = null
lifted = false
dispatching = false
}
companion object {
/**
* How long each replayed segment is told to take. Short enough that the pointer keeps
* up with the finger, long enough that the framework does not treat a drag as a fling.
*/
private const val SEGMENT_MS = 32L
@Volatile
var instance: TouchRelayService? = null
private set
/** False until the user enables Gearslip under Settings > Accessibility. */
val enabled: Boolean get() = instance != null
}
}
@@ -0,0 +1,47 @@
package app.seb3thehacker.gearslip.notify
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.SystemClock
/**
* Whether the car is moving, judged from the phone's GPS speed.
*
* Used only to keep notification popups off the screen while driving. With no permission or no
* recent fix it answers "not moving", so a missing signal never hides anything for good.
*/
object CarMotion {
private const val MOVING_MS = 2.0f // about 7 km/h
private const val STALE_MS = 15_000L
private var listener: LocationListener? = null
private var speed = 0f
private var fixAt = 0L
val moving: Boolean
get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS
fun start(context: Context) {
if (listener != null) return
if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return
val manager = context.getSystemService(LocationManager::class.java) ?: return
val l = object : LocationListener {
override fun onLocationChanged(location: Location) {
speed = if (location.hasSpeed()) location.speed else 0f
fixAt = SystemClock.elapsedRealtime()
}
}
listener = l
runCatching { manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 2_000L, 0f, l) }
}
fun stop(context: Context) {
listener?.let { l -> context.getSystemService(LocationManager::class.java)?.removeUpdates(l) }
listener = null
}
}
@@ -0,0 +1,124 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** How a notification can be answered: the action the app attached, and the field it fills. */
class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput)
/**
* One notification as the car shows it. Only what the car UI needs is kept - the phone's
* Notification object stays with the listener, apart from the reply action.
*/
data class CarNotification(
/** Stable per conversation: an app re-posting an update reuses the key. */
val key: String,
/** Unique per post, so an update to an already-shown notification still counts as new. */
val id: Long,
val appLabel: String,
val title: String,
val text: String,
val postedAt: Long,
val icon: Bitmap?,
/** Null when the app offered no reply, or once the notification has left the phone. */
val reply: ReplyTarget?,
val replied: Boolean = false,
)
/**
* The car's notification centre: a short history, the one popup on screen, and an unread count.
*
* Held in memory only. Message contents are never written to disk, so a process restart clears
* the history - which is the safer failure for something shown on a car screen.
*/
object CarNotifications {
private const val MAX_HISTORY = 50
/** A reply's own echo (apps re-post the thread with "You: ...") shouldn't pop up again. */
private const val ECHO_WINDOW_MS = 10_000L
private val _history = MutableStateFlow<List<CarNotification>>(emptyList())
val history: StateFlow<List<CarNotification>> = _history.asStateFlow()
private val _popup = MutableStateFlow<CarNotification?>(null)
val popup: StateFlow<CarNotification?> = _popup.asStateFlow()
private val _unread = MutableStateFlow(0)
val unread: StateFlow<Int> = _unread.asStateFlow()
/** True while the system has bound the listener - i.e. the user has granted access. */
private val _listening = MutableStateFlow(false)
val listening: StateFlow<Boolean> = _listening.asStateFlow()
private var nextId = 0L
private var lastReplyKey: String? = null
private var lastReplyAt = 0L
fun setListening(on: Boolean) { _listening.value = on }
fun nextId(): Long = nextId++
/** [quiet] adds to the history without a popup or unread badge (used to seed on connect). */
fun post(notification: CarNotification, quiet: Boolean) {
_history.update { list ->
(listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY)
}
val echo = notification.key == lastReplyKey &&
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
if (quiet || echo) return
// History still keeps it; only the popup is held back while driving.
if (CarMotion.moving) return
_popup.value = notification
}
/** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */
fun gone(key: String) {
_history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } }
}
fun dismissPopup(id: Long) {
_popup.update { if (it?.id == id) null else it }
}
/** Called while the notification screen is showing: nothing is unread there. */
fun markRead() {
_unread.value = 0
_popup.value = null
}
fun clear() {
_history.value = emptyList()
_popup.value = null
_unread.value = 0
}
/** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */
fun reply(context: Context, notification: CarNotification, text: String): Boolean {
val target = notification.reply ?: return false
val fill = Intent()
val results = Bundle().apply { putCharSequence(target.input.resultKey, text) }
android.app.RemoteInput.addResultsToIntent(arrayOf(target.input), fill, results)
return try {
target.action.actionIntent.send(context, 0, fill)
lastReplyKey = notification.key
lastReplyAt = System.currentTimeMillis()
_history.update { list ->
list.map { if (it.key == notification.key) it.copy(replied = true) else it }
}
true
} catch (e: PendingIntent.CanceledException) {
GearslipLog.w("notify: the reply to ${notification.appLabel} was withdrawn by the app")
false
}
}
}
@@ -0,0 +1,89 @@
package app.seb3thehacker.gearslip.notify
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.graphics.drawable.toBitmap
/**
* Feeds the phone's notifications to the car.
*
* Reading other apps' notifications is gated by the user granting "Notification access" to
* Gearslip in the phone's settings - the same on-device consent as any notification mirror,
* and the only one needed. Until then the car UI says so and offers to open that page.
*/
class GearslipNotificationListener : NotificationListenerService() {
override fun onListenerConnected() {
CarNotifications.setListening(true)
// Seed the history with what is already showing, oldest first so the newest ends up on top.
runCatching { activeNotifications.orEmpty().sortedBy { it.postTime } }
.getOrDefault(emptyList())
.forEach { ingest(it, quiet = true) }
}
override fun onListenerDisconnected() = CarNotifications.setListening(false)
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
if (sbn.packageName == packageName) return
val n = sbn.notification
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
if (n.category in QUIET_CATEGORIES) return
val extras = n.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
if (title.isBlank() && text.isBlank()) return
CarNotifications.post(
CarNotification(
key = sbn.key,
id = CarNotifications.nextId(),
appLabel = appLabel(sbn.packageName),
title = title,
text = text,
postedAt = sbn.postTime,
icon = icon(sbn),
reply = replyOf(n),
),
quiet,
)
}
private fun appLabel(packageName: String): String = runCatching {
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
}.getOrDefault(packageName.substringAfterLast('.'))
private fun icon(sbn: StatusBarNotification) = runCatching {
val drawable = sbn.notification.getLargeIcon()?.loadDrawable(this)
?: packageManager.getApplicationIcon(sbn.packageName)
drawable.toBitmap(ICON_PX, ICON_PX)
}.getOrNull()
/** Messaging apps often attach the reply only for wearables, so both places are searched. */
private fun replyOf(n: Notification): ReplyTarget? {
val actions = n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions
for (action in actions) {
if (action.actionIntent == null) continue
val input = action.remoteInputs?.firstOrNull { it.allowFreeFormInput } ?: continue
return ReplyTarget(action, input)
}
return null
}
private companion object {
const val ICON_PX = 96
val QUIET_CATEGORIES = setOf(
Notification.CATEGORY_TRANSPORT,
Notification.CATEGORY_PROGRESS,
Notification.CATEGORY_SERVICE,
Notification.CATEGORY_SYSTEM,
)
}
}
@@ -0,0 +1,255 @@
package app.seb3thehacker.gearslip.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.HardwareBuffer
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ScreenProjector
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarUi
/**
* Runs the real car UI on the bench and shows it on the phone.
*
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
* the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
*
* Debug affordance only. Nothing in the car session path goes through this screen.
*/
@Composable
fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
var viewSize by remember { mutableStateOf(0 to 0) }
var fullscreen by remember { mutableStateOf(false) }
val width = frame.width
val height = frame.height
val density = frame.densityDpi
val projector = remember { ScreenProjector(context) }
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader;
// CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame
// gets copied into a Bitmap. Without both, frames arrive but every copy fails.
val reader = ImageReader.newInstance(
width, height, PixelFormat.RGBA_8888, 3,
HardwareBuffer.USAGE_COMPOSER_OVERLAY or
HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
HardwareBuffer.USAGE_CPU_READ_OFTEN,
)
val handler = Handler(Looper.getMainLooper())
reader.setOnImageAvailableListener({ source ->
val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener
try {
bitmap = image.toBitmap(width, height)
} catch (t: Throwable) {
GearslipLog.e("preview: could not copy a frame", t)
} finally {
image.close()
}
}, handler)
projector.start(reader.surface, width, height, density) { CarUi() }
onDispose {
projector.stop()
reader.close()
bitmap = null
}
}
// The frame is drawn the same way in both layouts; only what surrounds it differs.
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
Box(
modifier
.aspectRatio(width.toFloat() / height)
.background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.x * width / viewWidth,
event.y * height / viewHeight,
)
}
true
},
contentAlignment = Alignment.Center,
) {
bitmap?.let {
Image(
it.asImageBitmap(),
contentDescription = "Car screen",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
} ?: Text("Waiting for a frame…", color = Color.White)
}
}
if (fullscreen) {
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
}
return
}
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
Text(
"Car preview",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 12.dp),
)
Text(
"${width}x$height @ ${density}dpi - touches go to the car UI",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
Row {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
}
}
}
}
/**
* Fills the phone screen with [content], centred and letterboxed.
*
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
* one. While this is showing, the activity is turned to match and the system bars go away - both
* are undone on the way out, so the rest of the phone UI is unaffected.
*/
@Composable
private fun Fullscreen(
landscape: Boolean,
onExit: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val activity = LocalContext.current.activity()
DisposableEffect(activity, landscape) {
if (activity == null) return@DisposableEffect onDispose { }
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
val previousOrientation = activity.requestedOrientation
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
insets.show(WindowInsetsCompat.Type.systemBars())
activity.requestedOrientation = previousOrientation
}
}
BackHandler(onBack = onExit)
Box(
Modifier.fillMaxSize().background(Color.Black),
contentAlignment = Alignment.Center,
) {
content()
// The frame swallows every touch inside it, so leaving needs either the back gesture or
// a button of its own. This one sits in the corner the car UI leaves emptiest.
FilledTonalIconButton(
onClick = onExit,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
}
}
}
/** Compose hands out a themed wrapper, so the activity is a few unwraps down. */
private tailrec fun Context.activity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.activity()
else -> null
}
/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */
private fun Int.toCarAction(): Int = when (this) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
-> this
else -> MotionEvent.ACTION_CANCEL
}
/**
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
* stride, so the copy is made at the padded width and then cropped back.
*/
private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
val plane = planes[0]
val pixelStride = plane.pixelStride
val rowPadding = plane.rowStride - pixelStride * width
val padded = Bitmap.createBitmap(
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(plane.buffer)
return if (padded.width == width) padded
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
}
@@ -0,0 +1,44 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import app.seb3thehacker.gearslip.CertProvider
import java.text.DateFormat
import java.util.Date
/** What the UI says about the certificate a connection would present. */
data class CertSummary(
val kind: CertProvider.Kind,
val headline: String,
val subject: String?,
val issuer: String?,
val validUntil: String?,
) {
companion object {
/** Blocking (parses a key store): call from a background dispatcher. */
fun read(context: Context): CertSummary {
val identity = CertProvider.loadSupplied(context)
?: return CertSummary(
CertProvider.Kind.SELF_SIGNED,
"None loaded - a self-signed one is generated, which real head units reject",
null, null, null,
)
val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name)
val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb"
return CertSummary(
identity.kind,
"$prefix: $name",
cert.subjectX500Principal.name,
cert.issuerX500Principal.name,
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)),
)
}
private fun friendlyName(distinguishedName: String): String {
for (key in listOf("CN", "O")) {
Regex("(?:^|,)$key=([^,]+)").find(distinguishedName)?.let { return it.groupValues[1] }
}
return distinguishedName
}
}
}
@@ -0,0 +1,29 @@
package app.seb3thehacker.gearslip.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
fun GearslipApp(onDisconnect: () -> Unit) {
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
when (screen) {
Screen.HOME -> HomeScreen(
onOpenLogs = { screen = Screen.LOGS },
onOpenSettings = { screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onDisconnect = onDisconnect,
)
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
}
}
@@ -0,0 +1,16 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */
@Composable
fun GearslipTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
MaterialTheme(colorScheme = scheme, content = content)
}
@@ -0,0 +1,172 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeScreen(
onOpenLogs: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onDisconnect: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
) {
Column(
Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
SetupSummary()
Spacer(Modifier.height(16.dp))
}
// Actions sit at the bottom, where a thumb reaches them.
Column(
Modifier.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val live = status.phase == SessionStatus.Phase.CONNECTING ||
status.phase == SessionStatus.Phase.PROJECTING
if (live) {
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Disconnect")
}
}
if (!live) {
FilledTonalButton(
onClick = onOpenCarPreview,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Car preview")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Live logs")
}
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Settings")
}
}
}
}
}
}
@Composable
private fun StatusCard(status: SessionStatus.Snapshot) {
val scheme = MaterialTheme.colorScheme
val (container, content) = when (status.phase) {
SessionStatus.Phase.PROJECTING -> scheme.primaryContainer to scheme.onPrimaryContainer
SessionStatus.Phase.CONNECTING -> scheme.secondaryContainer to scheme.onSecondaryContainer
SessionStatus.Phase.FAILED -> scheme.errorContainer to scheme.onErrorContainer
SessionStatus.Phase.IDLE, SessionStatus.Phase.DISCONNECTED ->
scheme.surfaceContainerHigh to scheme.onSurface
}
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Row(
Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
if (status.phase == SessionStatus.Phase.CONNECTING) {
CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp, color = content)
} else {
Surface(
modifier = Modifier.size(14.dp),
shape = MaterialTheme.shapes.extraLarge,
color = if (status.phase == SessionStatus.Phase.IDLE ||
status.phase == SessionStatus.Phase.DISCONNECTED
) content.copy(alpha = 0.4f) else content,
) {}
}
Column {
Text(status.headline, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text(status.detail, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
/** A read-only recap of what a connection will use, so setup problems are visible before the car. */
@Composable
private fun SetupSummary() {
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
val startup = AppSettings.startupUrl(context)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SummaryRow("Certificate", cert?.headline ?: "Checking…")
SummaryRow("Car screen shows", startup.ifEmpty { "Built-in test page" })
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,141 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val version by GearslipLog.version.collectAsStateWithLifecycle()
val lines = remember(version) { GearslipLog.snapshot() }
val listState = rememberLazyListState()
val atEnd by remember {
derivedStateOf {
val info = listState.layoutInfo
val last = info.visibleItemsInfo.lastOrNull()
last == null || last.index >= info.totalItemsCount - 1
}
}
// Live view: stay pinned to the newest line, but let go the moment the user scrolls up to
// read something, and pick back up when they return to the bottom.
var follow by remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress to atEnd }.collect { (scrolling, end) ->
if (scrolling) follow = end
}
}
LaunchedEffect(lines.size, version) {
if (follow && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("Live logs") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
scope.launch {
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() }
val send = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
.putExtra(Intent.EXTRA_TEXT, text)
context.startActivity(Intent.createChooser(send, "Share log"))
}
}) { Text("Share") }
TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") }
},
)
},
floatingActionButton = {
if (!follow && lines.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = {
follow = true
scope.launch { listState.scrollToItem(lines.size - 1) }
},
) { Text("Jump to latest") }
}
},
) { padding ->
if (lines.isEmpty()) {
Text(
"Nothing logged yet. Lines appear here live while a session runs.",
modifier = Modifier.padding(padding).padding(24.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
SelectionContainer {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
) {
itemsIndexed(lines) { _, line ->
Text(
line,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = colorFor(line),
)
}
}
}
}
}
}
/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */
@Composable
private fun colorFor(line: String): Color = when (line.getOrNull(13)) {
'E' -> MaterialTheme.colorScheme.error
'W' -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
@@ -0,0 +1,224 @@
package app.seb3thehacker.gearslip.ui
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Spacer(Modifier.height(0.dp))
StartupSection()
CertificateSection()
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
@Composable
private fun StartupSection() {
val context = LocalContext.current
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionTitle("Startup URL")
OutlinedTextField(
value = url,
onValueChange = {
url = it
AppSettings.setStartupUrl(context, it)
},
modifier = Modifier.fillMaxWidth(),
label = { Text("URL") },
placeholder = { Text("https://example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
supportingText = {
Text("Shown on the car screen. Leave empty for the built-in test page. Applies on the next connection.")
},
)
}
}
@Composable
private fun CertificateSection() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var refresh by remember { mutableIntStateOf(0) }
val summary by produceState<CertSummary?>(null, refresh) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var notice by remember { mutableStateOf<String?>(null) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SectionTitle("Certificate")
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
val s = summary
if (s == null) {
Text("Checking…", style = MaterialTheme.typography.bodyMedium)
} else {
Text(s.headline, style = MaterialTheme.typography.bodyLarge)
s.subject?.let { Detail("Subject", it) }
s.issuer?.let { Detail("Issuer", it) }
s.validUntil?.let { Detail("Valid until", it) }
}
}
}
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Import certificate (.p12)")
}
if (summary?.kind == CertProvider.Kind.IMPORTED) {
OutlinedButton(
onClick = {
CertProvider.removeImported(context)
notice = "Removed the imported certificate."
refresh++
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Remove imported certificate") }
}
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text(
"An imported certificate takes priority over one staged over adb. Takes effect on the next connection.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
pendingUri?.let { uri ->
PasswordDialog(
onDismiss = { pendingUri = null },
onConfirm = { password, reportError ->
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.importFrom(context, uri, password) }
}
result.onSuccess {
pendingUri = null
notice = "Certificate imported."
refresh++
}.onFailure {
reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.")
}
}
},
)
}
}
@Composable
private fun Detail(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) {
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Certificate password") },
text = {
OutlinedTextField(
value = password,
onValueChange = { password = it; error = null },
singleLine = true,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error != null,
supportingText = { Text(error ?: "Leave empty if the file has none.") },
)
},
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#0c0c0d"/>
<path
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
android:fillColor="#131415"/>
</vector>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="66.80"
android:startY="48.70"
android:endX="81.61"
android:endY="47.71">
<item android:offset="0" android:color="#00E5484D"/>
<item android:offset="1" android:color="#E5484D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#C9CDD2"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#E5484D"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#0c0c0d"/>
</vector>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z"
android:fillColor="#FF000000"
android:fillAlpha="0.35"/>
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
android:fillColor="#FF000000"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#FF000000"/>
<path
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillColor="#00000000"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="touch_relay_description">Lets Gearslip replay touches from the car screen onto the phone while mirroring. Gearslip does not read screen content.</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Material You: DynamicColors.DayNight derives the whole scheme from the device
wallpaper on Android 12+, and falls back to Material3's static baseline scheme on
older versions or when DynamicColors.applyToActivityIfAvailable() is skipped.
-->
<style name="Theme.Gearslip" parent="Theme.Material3.DynamicColors.DayNight">
<item name="android:windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Must match the strings the head unit sends during the AOAP switch. aasdk pins
src/USB/AccessoryModeQueryFactory.cpp:43-51 as manufacturer "Android", model "Android Auto" -
confirmed against the real 2018 Uconnect. Google's own desktop-head-unit reference tool
reports a different model string ("Android Open Automotive Protocol", confirmed via logcat
on 2026-09-17), so both are listed here. `version` is deliberately omitted: head units differ
on what they send, and a mismatch fails the filter silently.
-->
<resources>
<usb-accessory manufacturer="Android" model="Android Auto" />
<usb-accessory manufacturer="Android" model="Android Open Automotive Protocol" />
</resources>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gearslip never reads screen content: it only needs canPerformGestures, which is what lets
dispatchGesture replay a head-unit touch onto whatever app is mirrored. No event types are
requested, so the service is notified of nothing.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityFeedbackType="feedbackGeneric"
android:canPerformGestures="true"
android:description="@string/touch_relay_description"
android:notificationTimeout="0" />
@@ -0,0 +1,306 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Runs the two riskiest pieces - the hand-written X.509 encoder and the SSLEngine wiring -
* on the local JVM, so a defect surfaces here rather than as a mystery failure in a car.
*
* The JVM uses SunJSSE where the phone uses Conscrypt, so this validates the DER and the
* engine logic, not the exact provider behaviour. The on-device "Self-test" button remains
* the authoritative bench check.
*/
class CryptoTest {
@Test
fun `generates a parseable self-signed certificate`() {
val identity = SelfSignedCert.generate(commonName = "TestCN", organisation = "TestO")
val cert = identity.certificate
// X500Principal.getName() is RFC 2253, which lists RDNs in reverse order.
assertEquals("O=TestO,CN=TestCN", cert.subjectX500Principal.name)
assertEquals(cert.subjectX500Principal, cert.issuerX500Principal)
assertEquals(3, cert.version)
assertEquals("SHA256withRSA", cert.sigAlgName)
assertTrue("serial must be positive", cert.serialNumber.signum() > 0)
cert.checkValidity()
cert.verify(cert.publicKey)
val key = identity.keyStore.getKey(SelfSignedCert.ALIAS, SelfSignedCert.PASSWORD)
assertEquals("RSA", key.algorithm)
}
@Test
fun `completes a TLS handshake as the server and round-trips data`() {
val ss = SelfSignedCert.generate()
val identity = CertProvider.Identity(
ss.keyStore, ss.certificate, SelfSignedCert.PASSWORD, "test",
)
assertTrue(TlsSelfTest.run(identity))
}
@Test
fun `frames round-trip through the parser`() {
val payload = ByteArray(300) { it.toByte() }
val parser = Frames.Parser()
val frame = Frames.build(Frames.CHANNEL_CONTROL, encrypted = false, payload = payload)
// Split mid-frame: a bulk transfer boundary must not break parsing.
parser.append(frame, 7)
assertEquals(null, parser.next())
parser.append(frame.copyOfRange(7, frame.size), frame.size - 7)
val parsed = parser.next()!!
assertEquals(Frames.CHANNEL_CONTROL, parsed.channel)
assertEquals(Frames.TYPE_BULK, parsed.frameType)
assertEquals(false, parsed.encrypted)
assertTrue(payload.contentEquals(parsed.payload))
}
@Test
fun `reads negative AuthResponse status values`() {
// STATUS_CERTIFICATE_ERROR = -2 encodes as a 10-byte varint.
val encoded = Protobuf.varintField(1, -2L)
assertEquals(11, encoded.size)
assertEquals(-2, Protobuf.readInt32Field(encoded, 1))
assertEquals(0, Protobuf.readInt32Field(Protobuf.varintField(1, 0L), 1))
assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3L), 1))
}
// --- Phase A: service discovery parsing ---------------------------------------------
private fun lenField(number: Int, body: ByteArray): ByteArray =
Protobuf.varint(((number shl 3) or 2).toLong()) +
Protobuf.varint(body.size.toLong()) + body
/**
* Rebuilds the exact shape the 2018 Uconnect sent (docs/spike/car-run-2026-09-16-run2.log)
* and checks we read it back correctly: video on channel 1, H264_BP, 1280x720 and 800x480
* both at 30fps.
*/
@Test
fun `extracts the video service as the Uconnect described it`() {
val config720 = Protobuf.varintField(1, 2) + Protobuf.varintField(2, 2) +
Protobuf.varintField(3, 0) + Protobuf.varintField(4, 12) +
Protobuf.varintField(5, 240) + Protobuf.varintField(6, 0) +
Protobuf.varintField(8, 10000)
val config480 = Protobuf.varintField(1, 1) + Protobuf.varintField(2, 2) +
Protobuf.varintField(3, 0) + Protobuf.varintField(4, 30) +
Protobuf.varintField(5, 160) + Protobuf.varintField(6, 0) +
Protobuf.varintField(8, 10000)
val mediaSink = Protobuf.varintField(1, 3) + // available_type = H264_BP
lenField(4, config720) + lenField(4, config480) // video_configs
val videoService = Protobuf.varintField(1, 1) + lenField(3, mediaSink)
// An audio sink (no video_configs) must not be mistaken for the video service.
val audioSink = Protobuf.varintField(1, 1) + lenField(3, Protobuf.varintField(1, 48000))
val audioService = Protobuf.varintField(1, 5) + lenField(3, audioSink)
val response = lenField(1, audioService) + lenField(1, videoService)
val video = ServiceDiscovery.findVideoService(response)!!
assertEquals(1, video.serviceId)
assertEquals("VIDEO_H264_BP", video.codecName)
assertEquals(2, video.configs.size)
assertEquals("1280x720", video.configs[0].resolutionName)
assertEquals("30fps", video.configs[0].frameRateName)
assertEquals(240, video.configs[0].density)
assertEquals("800x480", video.configs[1].resolutionName)
assertEquals("30fps", video.configs[1].frameRateName)
}
@Test
fun `media message ids above 32767 survive the 2-byte encoding`() {
// SETUP = 32768 sets the high bit; a signed-byte slip here would corrupt every
// video-channel message.
val id = 32768
val hi = ((id shr 8) and 0xFF).toByte()
val lo = (id and 0xFF).toByte()
val decoded = ((hi.toInt() and 0xFF) shl 8) or (lo.toInt() and 0xFF)
assertEquals(32768, decoded)
}
/**
* Regression: channel-open messages must carry MessageType::CONTROL even on a service
* channel. Sending them as SPECIFIC made the 2018 Uconnect drop the USB link ~160ms
* later with no protocol-level error at all - invisible in code, fatal on the wire.
*/
@Test
fun `channel-open frames carry the CONTROL message type bit`() {
val control = Frames.build(1, encrypted = false, payload = byteArrayOf(0, 7),
messageType = Frames.MESSAGE_CONTROL)
assertEquals(Frames.MESSAGE_CONTROL, control[1].toInt() and Frames.MESSAGE_CONTROL)
val specific = Frames.build(1, encrypted = false, payload = byteArrayOf(0x80.toByte(), 0))
assertEquals(0, specific[1].toInt() and Frames.MESSAGE_CONTROL)
val parser = Frames.Parser()
parser.append(control, control.size)
assertEquals(Frames.MESSAGE_CONTROL, parser.next()!!.messageType)
parser.append(specific, specific.size)
assertEquals(0, parser.next()!!.messageType)
}
@Test
fun `maps resolution enums to pixel sizes and prefers the smallest`() {
val small = ServiceDiscovery.VideoConfig(resolution = 1, frameRate = 2, density = 160)
val large = ServiceDiscovery.VideoConfig(resolution = 2, frameRate = 2, density = 240)
assertEquals(800 to 480, small.pixelSize())
assertEquals(1280 to 720, large.pixelSize())
assertTrue(small.pixelCount() < large.pixelCount())
}
/** The Data payload is an 8-byte big-endian timestamp followed by the access unit. */
@Test
fun `video frame timestamp is 8 bytes big-endian`() {
val pts = 0x0102030405060708L
val stamp = ByteArray(8)
for (i in 0 until 8) stamp[i] = ((pts shr ((7 - i) * 8)) and 0xFF).toByte()
assertEquals(0x01.toByte(), stamp[0])
assertEquals(0x08.toByte(), stamp[7])
var rebuilt = 0L
for (b in stamp) rebuilt = (rebuilt shl 8) or (b.toLong() and 0xFF)
assertEquals(pts, rebuilt)
}
/** Matches the Uconnect's input service: id 2, touchscreen 1258x708. */
@Test
fun `extracts the input service and touchscreen size`() {
val touchScreen = Protobuf.varintField(1, 1258) + Protobuf.varintField(2, 708) +
Protobuf.varintField(3, 1)
val inputSource = lenField(1, Protobuf.varintField(1, 42)) + // keycodes, ignored
lenField(2, touchScreen)
val inputService = Protobuf.varintField(1, 2) + lenField(4, inputSource)
// A video service must not be mistaken for the input service.
val videoService = Protobuf.varintField(1, 1) +
lenField(3, Protobuf.varintField(1, 3) + lenField(4, Protobuf.varintField(1, 1)))
val response = lenField(1, videoService) + lenField(1, inputService)
val input = ServiceDiscovery.findInputService(response)!!
assertEquals(2, input.serviceId)
assertEquals(1258, input.touchWidth)
assertEquals(708, input.touchHeight)
}
/**
* Regression: touch arrives already in projected video coordinates, NOT in the head
* unit's advertised touchscreen space. An earlier version scaled by video/touchscreen,
* which compressed the right-hand third of the screen away - a tap on a right-edge
* button landed on the middle one (confirmed on the Uconnect: 800x480 projected, panel
* advertised 1258x708, raw x never exceeded 791).
*
* Also pins the action mapping: Android Auto's PointerAction values equal Android's
* MotionEvent actions, so no translation table is needed.
*/
@Test
fun `touch coordinates are used as-is and clamped to the video bounds`() {
fun clamp(value: Int, limit: Int?): Float = when {
limit == null -> value.toFloat()
value < 0 -> 0f
value >= limit -> (limit - 1).toFloat()
else -> value.toFloat()
}
// A right-edge tap must stay at the right edge, not be scaled inward.
assertEquals(791f, clamp(791, 800))
assertEquals(216f, clamp(216, 800))
assertEquals(0f, clamp(-5, 800))
assertEquals(799f, clamp(1258, 800))
assertEquals(479f, clamp(708, 480))
assertEquals(351f, clamp(351, 480))
assertEquals(0, android.view.MotionEvent.ACTION_DOWN)
assertEquals(1, android.view.MotionEvent.ACTION_UP)
assertEquals(2, android.view.MotionEvent.ACTION_MOVE)
}
/**
* Regression: a video frame over 16 KB used to throw out of Frames.build and kill the
* encoder loop, freezing the head unit picture. Anything busier than a test card exceeds
* that routinely, so large messages must split into FIRST/MIDDLE/LAST and reassemble.
*/
@Test
fun `oversized messages split and reassemble`() {
val payload = ByteArray(Frames.MAX_FRAME_PAYLOAD * 2 + 500) { (it % 251).toByte() }
val frames = mutableListOf<ByteArray>()
var offset = 0
while (offset < payload.size) {
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
val chunk = payload.copyOfRange(offset, offset + size)
val frameType = when {
offset == 0 -> Frames.TYPE_FIRST
offset + size >= payload.size -> Frames.TYPE_LAST
else -> Frames.TYPE_MIDDLE
}
frames += Frames.build(
7, encrypted = false, payload = chunk, messageType = Frames.MESSAGE_SPECIFIC,
frameType = frameType,
totalSize = if (frameType == Frames.TYPE_FIRST) payload.size else null,
)
offset += size
}
assertEquals(3, frames.size)
// The FIRST frame carries the 4-byte total length; the others use the short header.
assertEquals(4 + 4 + Frames.MAX_FRAME_PAYLOAD, frames[0].size)
assertEquals(4 + Frames.MAX_FRAME_PAYLOAD, frames[1].size)
val parser = Frames.Parser()
val assembler = Frames.Assembler()
var rebuilt: ByteArray? = null
for (frame in frames) {
parser.append(frame, frame.size)
val parsed = parser.next()!!
assembler.offer(parsed, parsed.payload)?.let { rebuilt = it }
}
assertTrue(payload.contentEquals(rebuilt!!))
}
/**
* Regression: a single resync request was not enough. The requested keyframe is itself
* just another frame, so it can be dropped by the same flow-control check that provoked
* the request, and the old logic (request once, wait for a keyframe to clear the flag)
* never asked again - leaving the picture permanently corrupted for the rest of the
* session. Confirmed on the road: docs/spike/phase-c-stuck-resync-2026-09-17.log shows
* dropped=27 in one burst, then 0.0 fps for over 100 seconds with no recovery.
*
* Model of the fixed decision: ask again on every drop, not just the first, and only
* stop asking once a keyframe actually goes out (not merely once requested).
*/
@Test
fun `resync keeps retrying if the requested keyframe is itself dropped`() {
var requests = 0
var resyncRequested = false
fun onDrop() {
resyncRequested = true
requests++ // old, buggy behaviour would gate this behind `if (!resyncRequested)`
}
fun onSent(keyFrame: Boolean) {
if (keyFrame) resyncRequested = false
}
// Burst: drop, drop (this "keyframe attempt" also dropped), drop again, then finally
// a keyframe gets through once the window clears.
onDrop()
assertEquals(1, requests)
onDrop() // the resync keyframe we asked for got dropped too
assertEquals(2, requests) // must ask again - this is what the old code failed to do
onDrop()
assertEquals(3, requests)
onSent(keyFrame = true) // window finally clears, a keyframe gets out
assertTrue(!resyncRequested)
// A later drop must be able to trigger a fresh request cycle.
onDrop()
assertEquals(4, requests)
}
}
@@ -0,0 +1,41 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VehicleProfileTest {
private val challenger = ServiceDiscovery.HeadUnitInfo(
headUnitName = "Uconnect", carModel = "Dodge Challenger", carYear = "18",
headUnitMake = "Delphi", headUnitModel = "VP2_R Uconnect 7",
)
private fun profile(name: String, vararg match: Pair<String, String>) =
VehicleProfile(name, match.toMap(), resolution = null, insets = Insets.NONE)
@Test
fun matchesWhenEveryKeyEqualsIgnoringCase() {
val p = profile("challenger", "carModel" to "dodge challenger", "headUnitName" to "UCONNECT")
assertEquals(p, VehicleProfiles.find(listOf(p), challenger))
}
@Test
fun aDifferentCarDoesNotMatch() {
val p = profile("challenger", "carModel" to "Dodge Challenger")
val honda = ServiceDiscovery.HeadUnitInfo("Honda", "Accord", "25", "", "")
assertNull(VehicleProfiles.find(listOf(p), honda))
}
@Test
fun aProfileWithNoMatchKeysNeverMatchesEverything() {
assertNull(VehicleProfiles.find(listOf(profile("wildcard")), challenger))
}
@Test
fun firstMatchingProfileWins() {
val specific = profile("specific", "carYear" to "18")
val loose = profile("loose", "headUnitName" to "Uconnect")
assertEquals(specific, VehicleProfiles.find(listOf(specific, loose), challenger))
}
}