mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Gearslip: phone-side Android Auto client
This commit is contained in:
commit
24cfbee0c4
98 files changed
+12370
No files matched your search
@@ -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 — projected from the phone</h1>
|
||||
<div id="t">starting…</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
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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") } },
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user