mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Release v0.0.03-pre: weather, template cards, media fallbacks, setup steps
- Weather from Open-Meteo with a place name, a forecast screen and a nav bar icon; a fresh location fix is requested when the last one is stale - Map controls are solid, one size, and fade out after four seconds; pinch to zoom works because every finger now reaches the app - Map templates draw their content in a floating card; toggle and radio rows show their state, the Back and Pan actions work, and details text and buttons are larger - Home player gains Lyrics and Minimise buttons; minimised, it lives in the nav bar - Media apps that hide their library show what they are and can still be controlled; apps that refuse Gearslip as a host say why - Debug mode switch, session report in the log, and log sharing as a file attachment - Launcher, phone apps, weather and calendar load at startup - Web app opens DuckDuckGo by default - Home screen lists the setup steps; the launcher marks apps tested and working
This commit is contained in:
1 parent
94f8f23a56
commit
a8b49a3672
42 files changed
+1899
-386
No files matched your search
@@ -8,6 +8,7 @@ object AppSettings {
|
||||
private const val PREFS = "gearslip_settings"
|
||||
private const val KEY_STARTUP_URL = "startup_url"
|
||||
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||
private const val KEY_DEBUG_MODE = "debug_mode"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -34,4 +35,16 @@ object AppSettings {
|
||||
fun setSeenCompatWarning(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
||||
* preview) on the home screen. On by default while the project is pre-release.
|
||||
*/
|
||||
fun debugMode(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
|
||||
|
||||
fun setDebugMode(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
|
||||
GearslipLog.debug = value
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import java.security.cert.X509Certificate
|
||||
* SPIKE_FINDINGS.md).
|
||||
*/
|
||||
object CertProvider {
|
||||
private val log = GearslipLog.tagged("CERT")
|
||||
|
||||
private const val P12_NAME = "phone.p12"
|
||||
private const val PASS_NAME = "phone.pass"
|
||||
@@ -66,7 +67,7 @@ object CertProvider {
|
||||
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) }
|
||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
@@ -80,7 +81,7 @@ object CertProvider {
|
||||
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) }
|
||||
}.onFailure { log.e("could not load $P12_NAME - falling back to self-signed", it) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -103,7 +104,7 @@ object CertProvider {
|
||||
target.parentFile?.mkdirs()
|
||||
staging.copyTo(target, overwrite = true)
|
||||
importedFile(context, PASS_NAME).writeText(password)
|
||||
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
|
||||
return identity
|
||||
} finally {
|
||||
staging.delete()
|
||||
@@ -116,7 +117,7 @@ object CertProvider {
|
||||
fun removeImported(context: Context) {
|
||||
importedFile(context, P12_NAME).delete()
|
||||
importedFile(context, PASS_NAME).delete()
|
||||
GearslipLog.i("removed the imported certificate")
|
||||
log.i("removed the imported certificate")
|
||||
}
|
||||
|
||||
// The imported files live in the app's private internal storage (not the external files
|
||||
|
||||
@@ -12,9 +12,10 @@ import java.util.concurrent.TimeUnit
|
||||
* the TLS self-test: make our own failures loud on the bench.
|
||||
*/
|
||||
object EncoderSelfTest {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
|
||||
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
|
||||
|
||||
var codecConfig: ByteArray? = null
|
||||
var frames = 0
|
||||
@@ -59,37 +60,37 @@ object EncoderSelfTest {
|
||||
val csd = codecConfig
|
||||
when {
|
||||
csd == null -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
|
||||
false
|
||||
}
|
||||
frames == 0 -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no frames produced")
|
||||
log.e("encoder self-test FAILED: no frames produced")
|
||||
false
|
||||
}
|
||||
!sawKeyFrame -> {
|
||||
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
|
||||
false
|
||||
}
|
||||
!sawResyncKeyFrame -> {
|
||||
GearslipLog.e(
|
||||
log.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(
|
||||
log.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 ===")
|
||||
log.hex(" csd", csd, limit = 48)
|
||||
log.i("=== encoder self-test PASSED ===")
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
runCatching { source.stop() }
|
||||
GearslipLog.e("encoder self-test FAILED with an exception", t)
|
||||
log.e("encoder self-test FAILED with an exception", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -96,12 +97,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
/** 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(),
|
||||
) { }
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
|
||||
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
||||
private val calendarPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { }
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -115,8 +116,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
GearslipLog.i("Gearslip ready")
|
||||
CarSettings.init(this)
|
||||
Prefetch.warm(this)
|
||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||
|
||||
setContent {
|
||||
@@ -141,6 +144,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
CarEnvironment.setPhoneTheme(newConfig)
|
||||
}
|
||||
|
||||
// Throttled inside Prefetch, so this only does work if the last run was a while ago.
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
@@ -268,6 +277,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
private fun closeAccessory() {
|
||||
runner?.stop()
|
||||
SessionReport.print()
|
||||
runCatching { descriptor?.close() }
|
||||
descriptor = null
|
||||
runner = null
|
||||
@@ -287,10 +297,11 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, x: Float, y: Float) {
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
if (PhoneMirror.active.value) relayTouch(action, x, y)
|
||||
else screenProjector?.dispatchTouch(action, x, y)
|
||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ object GearslipLog {
|
||||
|
||||
private var file: File? = null
|
||||
|
||||
/**
|
||||
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
|
||||
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
|
||||
* crash report is never empty.
|
||||
*/
|
||||
@Volatile var debug = true
|
||||
|
||||
// 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.
|
||||
@@ -40,10 +47,13 @@ object GearslipLog {
|
||||
val version: StateFlow<Int> = liveVersion
|
||||
|
||||
fun init(context: Context) {
|
||||
debug = AppSettings.debugMode(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}")
|
||||
SessionReport.init(context)
|
||||
i("debug mode: $debug")
|
||||
}
|
||||
|
||||
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||
@@ -61,13 +71,81 @@ object GearslipLog {
|
||||
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
|
||||
}
|
||||
|
||||
fun i(message: String) = write("I", message)
|
||||
/**
|
||||
* The current session's log, with the session report on top, copied to the cache dir for
|
||||
* sharing as an attachment (a whole log can exceed what an Intent may carry as text).
|
||||
*/
|
||||
fun shareableFile(context: Context): android.net.Uri {
|
||||
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
val out = File(dir, "gearslip-log.txt")
|
||||
out.writeText(SessionReport.render() + "\n\n" + fullText())
|
||||
return androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.logshare", out,
|
||||
)
|
||||
}
|
||||
|
||||
fun w(message: String) = write("W", message)
|
||||
fun i(message: String, tag: String = "APP") = write("I", message, tag)
|
||||
|
||||
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)
|
||||
fun w(message: String, tag: String = "APP") = write("W", message, tag)
|
||||
|
||||
/** Logs the message, then the exception with its cause chain and top stack frames. */
|
||||
fun e(message: String, t: Throwable? = null, tag: String = "APP") {
|
||||
write("E", if (t == null) message else "$message: ${rootCause(t)}", tag)
|
||||
if (t != null) {
|
||||
Log.e(TAG, message, t)
|
||||
stackTrace(t).lines().forEach { write("E", " $it", tag) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A logger that stamps every line with [tag], so the log can be scanned by subsystem. */
|
||||
fun tagged(tag: String) = Tagged(tag)
|
||||
|
||||
class Tagged(private val tag: String) {
|
||||
fun i(message: String) = GearslipLog.i(message, tag)
|
||||
fun w(message: String) = GearslipLog.w(message, tag)
|
||||
fun e(message: String, t: Throwable? = null) = GearslipLog.e(message, t, tag)
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256) = GearslipLog.hex(label, data, limit, tag)
|
||||
fun verdict(headline: String, detail: String) = GearslipLog.verdict(headline, detail)
|
||||
fun flush() = GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** "SSLHandshakeException: Received fatal alert: bad_certificate" - the deepest cause wins. */
|
||||
fun rootCause(t: Throwable): String {
|
||||
var cause: Throwable = t
|
||||
while (cause.cause != null && cause.cause !== cause) cause = cause.cause!!
|
||||
val top = "${t.javaClass.simpleName}: ${t.message}"
|
||||
return if (cause === t) top else "$top <- ${cause.javaClass.simpleName}: ${cause.message}"
|
||||
}
|
||||
|
||||
private fun stackTrace(t: Throwable, frames: Int = 12): String {
|
||||
val out = StringBuilder()
|
||||
var cause: Throwable? = t
|
||||
var depth = 0
|
||||
while (cause != null && depth < 4) {
|
||||
if (depth > 0) out.append("caused by ${cause.javaClass.name}: ${cause.message}\n")
|
||||
cause.stackTrace.take(frames).forEach { out.append("at $it\n") }
|
||||
cause = cause.cause?.takeIf { it !== cause }
|
||||
depth++
|
||||
}
|
||||
return out.toString().trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an uncaught exception in the log file before the process dies, then lets the
|
||||
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
|
||||
*/
|
||||
fun installCrashHandler() {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
SessionReport.fail(SessionReport.Category.CRASH, "uncaught in thread ${thread.name}", throwable)
|
||||
e("FATAL uncaught exception in thread ${thread.name}", throwable)
|
||||
SessionReport.print()
|
||||
flush()
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
|
||||
@@ -89,7 +167,8 @@ object GearslipLog {
|
||||
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
|
||||
}
|
||||
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256) {
|
||||
fun hex(label: String, data: ByteArray, limit: Int = 256, tag: String = "APP") {
|
||||
if (!debug) return
|
||||
val shown = minOf(data.size, limit)
|
||||
val sb = StringBuilder()
|
||||
for (idx in 0 until shown) {
|
||||
@@ -97,16 +176,16 @@ object GearslipLog {
|
||||
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")
|
||||
i("$label (${data.size} bytes)\n$sb$suffix", tag)
|
||||
}
|
||||
|
||||
private fun write(level: String, message: String) {
|
||||
private fun write(level: String, message: String, tag: String = "APP") {
|
||||
when (level) {
|
||||
"W" -> Log.w(TAG, message)
|
||||
"E" -> Log.e(TAG, message)
|
||||
else -> Log.i(TAG, message)
|
||||
else -> if (debug) Log.i(TAG, message)
|
||||
}
|
||||
val line = "${stamp.format(Date())} $level $message"
|
||||
val line = "${stamp.format(Date())} $level ${tag.padEnd(5)} $message"
|
||||
synchronized(live) {
|
||||
live.addLast(line)
|
||||
while (live.size > MAX_LIVE_LINES) live.removeFirst()
|
||||
|
||||
@@ -27,6 +27,7 @@ class GearslipRunner(
|
||||
private val projection: Projection? = null,
|
||||
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
|
||||
) {
|
||||
private val log = GearslipLog.tagged("PROTO")
|
||||
|
||||
private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
|
||||
|
||||
@@ -61,16 +62,18 @@ class GearslipRunner(
|
||||
@Volatile private var lastKeyframeSentAt = 0L
|
||||
|
||||
fun run() {
|
||||
GearslipLog.i("--- spike run starting ---")
|
||||
SessionReport.begin()
|
||||
log.i("--- session starting ---")
|
||||
SessionStatus.connecting("Connected to the head unit")
|
||||
GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||
log.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}")
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
tls = PhoneTls(identity.keyStore, identity.password)
|
||||
|
||||
startWatchdog()
|
||||
@@ -80,7 +83,7 @@ class GearslipRunner(
|
||||
while (running) {
|
||||
val read = input.read(chunk)
|
||||
if (read < 0) {
|
||||
GearslipLog.w("head unit closed the connection (EOF) while in state $state")
|
||||
log.w("head unit closed the connection (EOF) while in state $state")
|
||||
reportDisconnect()
|
||||
return
|
||||
}
|
||||
@@ -92,11 +95,12 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("transport failed in state $state", t)
|
||||
log.e("transport failed in state $state", t)
|
||||
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
GearslipLog.flush()
|
||||
log.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +117,7 @@ class GearslipRunner(
|
||||
private fun handleFrame(frame: Frames.Frame) {
|
||||
val payload = if (frame.encrypted) {
|
||||
if (!tls.handshakeComplete) {
|
||||
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
return
|
||||
}
|
||||
tls.decrypt(frame.payload)
|
||||
@@ -123,7 +127,7 @@ class GearslipRunner(
|
||||
|
||||
val message = assembler.offer(frame, payload) ?: return
|
||||
if (message.size < 2) {
|
||||
GearslipLog.w("runt message on channel ${frame.channel}")
|
||||
log.w("runt message on channel ${frame.channel}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,7 +144,7 @@ class GearslipRunner(
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
audioLink?.onMessage(messageId, body)
|
||||
} else {
|
||||
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}")
|
||||
log.i("ignoring message id $messageId on channel ${frame.channel}")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -152,18 +156,19 @@ class GearslipRunner(
|
||||
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")
|
||||
else -> log.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)")
|
||||
log.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")
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(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,
|
||||
@@ -174,19 +179,20 @@ class GearslipRunner(
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
|
||||
log.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")
|
||||
log.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)")
|
||||
log.i("<- EncapsulatedSSL (${body.size} bytes)")
|
||||
val replies = try {
|
||||
tls.pumpHandshake(body)
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("TLS handshake threw", t)
|
||||
GearslipLog.verdict(
|
||||
log.e("TLS handshake threw", t)
|
||||
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
||||
log.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.",
|
||||
@@ -196,31 +202,34 @@ class GearslipRunner(
|
||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
||||
)
|
||||
state = State.DONE
|
||||
SessionReport.print()
|
||||
return
|
||||
}
|
||||
replies.forEach {
|
||||
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
|
||||
GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)")
|
||||
log.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")
|
||||
log.i("awaiting AuthComplete - this carries the certificate verdict")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAuthComplete(body: ByteArray) {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("<- AuthComplete: status=$status")
|
||||
log.i("<- AuthComplete: status=$status")
|
||||
status?.let { SessionReport.auth(it) }
|
||||
|
||||
when (status) {
|
||||
0 -> {
|
||||
GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
|
||||
log.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
|
||||
state = State.WAIT_SDR
|
||||
SessionStatus.connecting("Certificate accepted - discovering services")
|
||||
sendServiceDiscoveryRequest()
|
||||
}
|
||||
-2 -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
|
||||
log.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 " +
|
||||
@@ -233,7 +242,8 @@ class GearslipRunner(
|
||||
state = State.DONE
|
||||
}
|
||||
-3 -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
|
||||
"Authentication rejected. Broader than a pure certificate error, but the " +
|
||||
"practical answer is the same.",
|
||||
@@ -245,7 +255,8 @@ class GearslipRunner(
|
||||
state = State.DONE
|
||||
}
|
||||
else -> {
|
||||
GearslipLog.verdict(
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
|
||||
log.verdict(
|
||||
"INCONCLUSIVE (AuthComplete status=$status)",
|
||||
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
|
||||
"before drawing any conclusion.",
|
||||
@@ -260,13 +271,13 @@ class GearslipRunner(
|
||||
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)")
|
||||
log.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(
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
||||
log.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 " +
|
||||
@@ -274,13 +285,15 @@ class GearslipRunner(
|
||||
)
|
||||
state = State.DONE
|
||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||
GearslipLog.i("head unit: $info")
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
if (profile != null) {
|
||||
GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
|
||||
log.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)")
|
||||
log.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")
|
||||
@@ -303,18 +316,19 @@ class GearslipRunner(
|
||||
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")
|
||||
log.w("no video service in the discovery response - cannot start Phase A")
|
||||
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name)
|
||||
return
|
||||
}
|
||||
videoChannelId = video.serviceId
|
||||
videoConfigs = video.configs
|
||||
|
||||
GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
video.configs.forEachIndexed { index, config ->
|
||||
GearslipLog.i(" config[$index] = $config")
|
||||
log.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")
|
||||
log.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
|
||||
}
|
||||
|
||||
openChannel(video.serviceId)
|
||||
@@ -322,12 +336,12 @@ class GearslipRunner(
|
||||
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
||||
if (input == null) {
|
||||
GearslipLog.w("no input service advertised - projection will be output-only")
|
||||
log.w("no input service advertised - projection will be output-only")
|
||||
} else {
|
||||
inputChannelId = input.serviceId
|
||||
touchWidth = input.touchWidth
|
||||
touchHeight = input.touchHeight
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"input service: channel=${input.serviceId} touchscreen=" +
|
||||
"${input.touchWidth}x${input.touchHeight}",
|
||||
)
|
||||
@@ -339,13 +353,13 @@ class GearslipRunner(
|
||||
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
|
||||
sinks.forEach { sink ->
|
||||
GearslipLog.i(
|
||||
log.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")
|
||||
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||
return
|
||||
}
|
||||
audioLink = AudioLink(
|
||||
@@ -363,7 +377,7 @@ class GearslipRunner(
|
||||
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
|
||||
channel = serviceId, messageType = Frames.MESSAGE_CONTROL,
|
||||
)
|
||||
GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
|
||||
log.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,40 +396,42 @@ class GearslipRunner(
|
||||
private fun onInputMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE ->
|
||||
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
|
||||
log.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)")
|
||||
log.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)
|
||||
val points = pointers.mapIndexedNotNull { index, pointer ->
|
||||
val fields = Wire.fields(pointer)
|
||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
||||
TouchPoint(id, clamp(rawX, size?.first), clamp(rawY, size?.second))
|
||||
}
|
||||
if (points.isEmpty()) return
|
||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
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")
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
projection?.onTouch(action, x, y)
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,9 +439,9 @@ class GearslipRunner(
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("<- ChannelOpenResponse: status=$status")
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
GearslipLog.verdict(
|
||||
log.verdict(
|
||||
"PHASE A FAILED (channel open rejected)",
|
||||
"The head unit refused to open the video channel (status=$status).",
|
||||
)
|
||||
@@ -435,7 +451,7 @@ class GearslipRunner(
|
||||
// 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)")
|
||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
}
|
||||
|
||||
MSG_MEDIA_CONFIG -> {
|
||||
@@ -443,7 +459,7 @@ class GearslipRunner(
|
||||
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(
|
||||
log.i(
|
||||
"<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
|
||||
"configuration_indices=$indices",
|
||||
)
|
||||
@@ -457,21 +473,25 @@ class GearslipRunner(
|
||||
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
|
||||
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
|
||||
} ?: offered.firstOrNull()
|
||||
GearslipLog.i("selected config index $selectedConfigIndex from offered $offered")
|
||||
log.i("selected config index $selectedConfigIndex from offered $offered")
|
||||
SessionReport.video(
|
||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
||||
)
|
||||
// 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)")
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val fields = Wire.fields(body)
|
||||
GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
log.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")
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
}
|
||||
}
|
||||
@@ -483,11 +503,11 @@ class GearslipRunner(
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||
GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
}
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,18 +523,18 @@ class GearslipRunner(
|
||||
val index = selectedConfigIndex ?: 0
|
||||
val config = videoConfigs.getOrNull(index)
|
||||
if (config == null) {
|
||||
GearslipLog.e("no video config at index $index - cannot start the encoder")
|
||||
log.e("no video config at index $index - cannot start the encoder")
|
||||
return
|
||||
}
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
GearslipLog.e("unsupported resolution ${config.resolutionName}")
|
||||
log.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)")
|
||||
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
||||
|
||||
val useProjection = projection != null
|
||||
videoSource = VideoSource(
|
||||
@@ -524,8 +544,8 @@ class GearslipRunner(
|
||||
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)
|
||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
log.hex(" csd", csd, limit = 64)
|
||||
},
|
||||
onFrame = { data, presentationTimeUs, keyFrame ->
|
||||
sendVideoFrame(data, presentationTimeUs, keyFrame)
|
||||
@@ -538,7 +558,7 @@ class GearslipRunner(
|
||||
projection.onSurfaceReady(surface, width, height, config.density)
|
||||
SessionStatus.projecting()
|
||||
}
|
||||
}.onFailure { e -> GearslipLog.e("encoder start failed", e) }
|
||||
}.onFailure { e -> log.e("encoder start failed", e) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,12 +569,12 @@ class GearslipRunner(
|
||||
// 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")
|
||||
log.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)")
|
||||
log.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.
|
||||
@@ -581,7 +601,7 @@ class GearslipRunner(
|
||||
}
|
||||
if (keyFrame) {
|
||||
if (resyncRequested) {
|
||||
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)")
|
||||
log.i("resync keyframe delivered after $resyncAttempts attempt(s)")
|
||||
}
|
||||
resyncRequested = false
|
||||
resyncAttempts = 0
|
||||
@@ -590,7 +610,7 @@ class GearslipRunner(
|
||||
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}")
|
||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
return
|
||||
@@ -598,7 +618,7 @@ class GearslipRunner(
|
||||
inFlight++
|
||||
framesSent++
|
||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||
GearslipLog.i(
|
||||
log.i(
|
||||
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
|
||||
"ptsUs=$presentationTimeUs)",
|
||||
)
|
||||
@@ -615,7 +635,7 @@ class GearslipRunner(
|
||||
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)")
|
||||
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
||||
}
|
||||
|
||||
private fun send(
|
||||
@@ -662,6 +682,13 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun reportDisconnect() {
|
||||
when (state) {
|
||||
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name)
|
||||
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name)
|
||||
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name)
|
||||
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name)
|
||||
State.DONE -> {}
|
||||
}
|
||||
when (state) {
|
||||
State.DONE -> SessionStatus.disconnected()
|
||||
State.WAIT_VERSION -> SessionStatus.failed(
|
||||
@@ -678,28 +705,29 @@ class GearslipRunner(
|
||||
)
|
||||
}
|
||||
when (state) {
|
||||
State.WAIT_VERSION -> GearslipLog.verdict(
|
||||
State.WAIT_VERSION -> log.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(
|
||||
State.TLS_HANDSHAKE -> log.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(
|
||||
State.WAIT_AUTH -> log.verdict(
|
||||
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
|
||||
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
|
||||
)
|
||||
State.WAIT_SDR -> GearslipLog.verdict(
|
||||
State.WAIT_SDR -> log.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")
|
||||
State.DONE -> log.i("connection closed after the run completed")
|
||||
}
|
||||
SessionReport.print()
|
||||
}
|
||||
|
||||
private fun startWatchdog() {
|
||||
@@ -711,7 +739,7 @@ class GearslipRunner(
|
||||
|
||||
if (state != State.DONE) {
|
||||
val idle = System.currentTimeMillis() - lastProgress
|
||||
if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s")
|
||||
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
|
||||
}
|
||||
|
||||
// Stream health. Without this a drive cannot tell "streaming fine" from
|
||||
@@ -720,7 +748,8 @@ class GearslipRunner(
|
||||
val sent = framesSent
|
||||
val fps = (sent - lastFrames) / 5.0
|
||||
lastFrames = sent
|
||||
GearslipLog.i(
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
|
||||
log.i(
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import javax.net.ssl.X509ExtendedTrustManager
|
||||
* the right tool.
|
||||
*/
|
||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
private val engine: SSLEngine
|
||||
private var netIn = ByteArray(0)
|
||||
@@ -156,7 +157,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
continue
|
||||
}
|
||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
break
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break
|
||||
@@ -173,26 +174,29 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
|
||||
private fun describeSession() {
|
||||
val session = engine.session
|
||||
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
|
||||
SessionReport.tls(session.protocol, session.cipherSuite)
|
||||
try {
|
||||
val peers = session.peerCertificates
|
||||
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
|
||||
log.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}")
|
||||
log.i(" [$index] subject=${certificate.subjectX500Principal}")
|
||||
log.i(" issuer =${certificate.issuerX500Principal}")
|
||||
log.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)")
|
||||
log.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() {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
|
||||
|
||||
@@ -8,8 +8,7 @@ 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.
|
||||
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
||||
*/
|
||||
fun resolve(context: Context): String {
|
||||
val saved = AppSettings.startupUrl(context)
|
||||
@@ -26,9 +25,12 @@ object ProjectedContent {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return TEST_PAGE
|
||||
return DEFAULT_URL
|
||||
}
|
||||
|
||||
/** What the Web app opens until a page is chosen in Settings. */
|
||||
const val DEFAULT_URL = "https://duckduckgo.com"
|
||||
|
||||
/** 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"
|
||||
|
||||
@@ -17,9 +17,14 @@ interface Projection {
|
||||
/**
|
||||
* 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).
|
||||
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). [points] is every
|
||||
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
|
||||
* arrives as it would on the phone's own screen.
|
||||
*/
|
||||
fun onTouch(action: Int, x: Float, y: Float)
|
||||
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||
|
||||
fun onProjectionStopped()
|
||||
}
|
||||
|
||||
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||
class TouchPoint(val id: Int, val x: Float, val y: Float)
|
||||
@@ -136,13 +136,41 @@ class ScreenProjector(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
|
||||
fun dispatchTouch(action: Int, x: Float, y: Float) {
|
||||
/**
|
||||
* [action] is already an Android MotionEvent action; [points] are in projected coordinates and
|
||||
* hold every finger down, with [actionIndex] naming the one the action is about. Two fingers
|
||||
* arrive as one gesture, so pinch and two-finger drags work as they do on the phone itself.
|
||||
*/
|
||||
fun dispatchTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
val target = root ?: return
|
||||
if (points.isEmpty()) return
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
|
||||
|
||||
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
|
||||
val properties = Array(points.size) { i ->
|
||||
MotionEvent.PointerProperties().apply {
|
||||
id = points[i].id
|
||||
toolType = MotionEvent.TOOL_TYPE_FINGER
|
||||
}
|
||||
}
|
||||
val coords = Array(points.size) { i ->
|
||||
MotionEvent.PointerCoords().apply {
|
||||
x = points[i].x
|
||||
y = points[i].y
|
||||
pressure = 1f
|
||||
size = 1f
|
||||
}
|
||||
}
|
||||
// A second finger going down or up carries which finger it was in the action itself.
|
||||
val encoded = when (action) {
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP ->
|
||||
action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
|
||||
else -> action
|
||||
}
|
||||
val event = MotionEvent.obtain(
|
||||
downTime, now, encoded, points.size, properties, coords,
|
||||
0, 0, 1f, 1f, 0, 0, android.view.InputDevice.SOURCE_TOUCHSCREEN, 0,
|
||||
)
|
||||
try {
|
||||
target.dispatchTouchEvent(event)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* The facts a bug report needs, gathered as a session runs and printed as one readable block
|
||||
* when it ends. The log holds the full trace; this holds the answer to "what went wrong,
|
||||
* on what car, on what phone" so nobody has to read the trace to find out.
|
||||
*
|
||||
* Nothing here identifies a person: no serial numbers, accounts, locations or accessory
|
||||
* serials. The head unit and vehicle strings are what the head unit itself announces.
|
||||
*/
|
||||
object SessionReport {
|
||||
|
||||
/** Where a session failed, coarse enough to count and group reports by. */
|
||||
enum class Category(val label: String) {
|
||||
NONE("none"),
|
||||
USB("USB transport"),
|
||||
NO_HANDSHAKE("head unit never started talking"),
|
||||
TLS("TLS handshake failed"),
|
||||
CERTIFICATE("certificate rejected by the head unit"),
|
||||
AUTH("authentication failed"),
|
||||
SERVICE_DISCOVERY("service discovery failed"),
|
||||
VIDEO("video stream failed"),
|
||||
CRASH("app crash"),
|
||||
UNKNOWN("unclassified"),
|
||||
}
|
||||
|
||||
private var appVersion = "?"
|
||||
private var appCode = 0L
|
||||
|
||||
@Volatile private var startedAt = 0L
|
||||
@Volatile var category = Category.NONE; private set
|
||||
@Volatile private var failure = ""
|
||||
@Volatile private var protocolVersion = ""
|
||||
@Volatile private var certSource = ""
|
||||
@Volatile private var certIssuer = ""
|
||||
@Volatile private var tlsProtocol = ""
|
||||
@Volatile private var tlsCipher = ""
|
||||
@Volatile private var authStatus: Int? = null
|
||||
@Volatile private var headUnit = ""
|
||||
@Volatile private var profile = ""
|
||||
@Volatile private var video = ""
|
||||
@Volatile private var lastStream = ""
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
fun init(context: Context) {
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
}
|
||||
|
||||
fun begin() {
|
||||
startedAt = System.currentTimeMillis()
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
|
||||
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
|
||||
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun stream(summary: String) { lastStream = summary }
|
||||
|
||||
/** Records the first failure only: later ones are usually fallout from it. */
|
||||
fun fail(category: Category, detail: String, state: String = "") {
|
||||
if (this.category != Category.NONE) return
|
||||
this.category = category
|
||||
failure = detail
|
||||
lastState = state
|
||||
}
|
||||
|
||||
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
|
||||
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
|
||||
|
||||
/** The report as plain text, for the log file and for anything that gets sent. */
|
||||
fun render(): String {
|
||||
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
|
||||
fun row(name: String, value: String) = if (value.isEmpty()) "" else " ${name.padEnd(16)}$value\n"
|
||||
return buildString {
|
||||
append("=".repeat(60)).append('\n')
|
||||
append("SESSION REPORT\n")
|
||||
append(row("result", if (category == Category.NONE) "ok / no failure recorded" else "FAILED - ${category.label}"))
|
||||
append(row("detail", failure))
|
||||
append(row("failed in state", lastState))
|
||||
append(row("duration", "${seconds}s"))
|
||||
append("\n")
|
||||
append(row("gearslip", "$appVersion (code $appCode)"))
|
||||
append(row("phone", "${Build.MANUFACTURER} ${Build.MODEL}"))
|
||||
append(row("android", "${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH})"))
|
||||
append("\n")
|
||||
append(row("head unit", headUnit))
|
||||
append(row("AA protocol", protocolVersion))
|
||||
append(row("profile", profile))
|
||||
append("\n")
|
||||
append(row("phone cert", certSource))
|
||||
append(row("cert issuer", certIssuer))
|
||||
append(row("TLS", listOf(tlsProtocol, tlsCipher).filter { it.isNotEmpty() }.joinToString(" / ")))
|
||||
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
|
||||
append("\n")
|
||||
append(row("video", video))
|
||||
append(row("last stream stat", lastStream))
|
||||
append("=".repeat(60))
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints the report into the log, once per session. */
|
||||
fun print() {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
private fun authName(status: Int) = when (status) {
|
||||
0 -> "success"
|
||||
-2 -> "certificate error"
|
||||
-3 -> "authentication failure"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,13 @@ import javax.net.ssl.X509ExtendedTrustManager
|
||||
* unit, not about this code.
|
||||
*/
|
||||
object TlsSelfTest {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
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}")
|
||||
log.i("=== TLS self-test starting ===")
|
||||
log.i("cert source: ${identity.source}")
|
||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||
return try {
|
||||
val server = PhoneTls(identity.keyStore, identity.password)
|
||||
val client = Driver(clientEngine())
|
||||
@@ -44,7 +45,7 @@ object TlsSelfTest {
|
||||
}
|
||||
|
||||
if (!server.handshakeComplete) {
|
||||
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||
log.e("self-test FAILED: handshake did not complete after $rounds rounds")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -53,14 +54,14 @@ object TlsSelfTest {
|
||||
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)")
|
||||
log.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 ===")
|
||||
log.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)
|
||||
log.e("self-test FAILED with an exception", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class VideoSource(
|
||||
private val onCodecConfig: (ByteArray) -> Unit,
|
||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||
) {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
/**
|
||||
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
|
||||
@@ -67,11 +68,11 @@ class VideoSource(
|
||||
codec = encoder
|
||||
running = true
|
||||
|
||||
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
|
||||
log.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) }
|
||||
.onFailure { if (running) log.e("encoder loop failed", it) }
|
||||
}.apply { name = "gearslip-encoder"; start() }
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ class VideoSource(
|
||||
codec?.setParameters(Bundle().apply {
|
||||
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||
})
|
||||
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") }
|
||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
|
||||
@@ -19,7 +19,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -63,25 +66,37 @@ fun CarDashboardScreen() {
|
||||
|
||||
@Composable
|
||||
private fun WeatherCard(context: Context) {
|
||||
val weather by produceState<Weather?>(Weather.LOADING) {
|
||||
value = withContext(Dispatchers.IO) { fetchWeather(context) }
|
||||
}
|
||||
val state by Weather.state.collectAsState()
|
||||
val navigator = LocalCarNavigator.current
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
val w = weather
|
||||
when {
|
||||
w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
w == null -> Text(
|
||||
"Weather unavailable - no location fix yet.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.clickable { navigator.weather() },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> Text(
|
||||
"${w.tempC.toInt()}°C · ${w.description}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
is WeatherState.Ready -> {
|
||||
val w = s.data
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +104,8 @@ private fun WeatherCard(context: Context) {
|
||||
|
||||
@Composable
|
||||
private fun AgendaCard(context: Context) {
|
||||
val events by produceState<List<AgendaEvent>?>(null) {
|
||||
value = withContext(Dispatchers.IO) { fetchAgenda(context) }
|
||||
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
||||
value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -143,63 +158,14 @@ private fun eventWhen(event: AgendaEvent): String {
|
||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||
}
|
||||
|
||||
private data class Weather(val tempC: Double, val description: String) {
|
||||
companion object { val LOADING = Weather(Double.NaN, "") }
|
||||
}
|
||||
|
||||
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
|
||||
|
||||
private const val WEATHER_TTL_MS = 60 * 60 * 1000L
|
||||
private var cachedWeather: Weather? = null
|
||||
private var cachedWeatherAt = 0L
|
||||
@Volatile private var cachedAgenda: List<AgendaEvent>? = null
|
||||
|
||||
/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */
|
||||
private fun fetchWeather(context: Context): Weather? {
|
||||
val now = System.currentTimeMillis()
|
||||
cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it }
|
||||
return loadWeather(context)?.also { cachedWeather = it; cachedWeatherAt = now }
|
||||
}
|
||||
|
||||
/** Null means no fix or no permission; the card shows a plain empty state either way. */
|
||||
private fun loadWeather(context: Context): Weather? {
|
||||
if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
return null
|
||||
}
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val location = manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
?: return null
|
||||
|
||||
return runCatching {
|
||||
// Open-Meteo: no API key, no account - the simplest thing that gives a real reading.
|
||||
val url = URL(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}" +
|
||||
"&longitude=${location.longitude}¤t_weather=true",
|
||||
)
|
||||
val body = (url.openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 5_000
|
||||
readTimeout = 5_000
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
val current = JSONObject(body).getJSONObject("current_weather")
|
||||
Weather(current.getDouble("temperature"), weatherDescription(current.getInt("weathercode")))
|
||||
}.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, collapsed to what's worth a glance from the driver's seat. */
|
||||
private fun weatherDescription(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
1, 2 -> "Partly cloudy"
|
||||
3 -> "Overcast"
|
||||
45, 48 -> "Fog"
|
||||
in 51..57 -> "Drizzle"
|
||||
in 61..67 -> "Rain"
|
||||
in 71..77 -> "Snow"
|
||||
in 80..82 -> "Rain showers"
|
||||
in 85..86 -> "Snow showers"
|
||||
in 95..99 -> "Thunderstorm"
|
||||
else -> "-"
|
||||
/** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
|
||||
fun warmDashboard(context: Context) {
|
||||
Weather.refresh(context, force = true)
|
||||
cachedAgenda = fetchAgenda(context)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -12,9 +13,11 @@ 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -49,7 +52,7 @@ 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.
|
||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
||||
*/
|
||||
@Composable
|
||||
fun CarHome() {
|
||||
@@ -61,7 +64,10 @@ fun CarHome() {
|
||||
LaunchedEffect(Unit) { CarServices.autostart(frame) }
|
||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack
|
||||
val navigator = LocalCarNavigator.current
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
||||
|
||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
@@ -136,7 +142,7 @@ private fun MiniPlayer(modifier: Modifier) {
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
Surface(
|
||||
onClick = navigator::media,
|
||||
onClick = { navigator.media() },
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
@@ -146,7 +152,7 @@ private fun MiniPlayer(modifier: Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
@@ -190,6 +196,21 @@ private fun MiniPlayer(modifier: Modifier) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val compact = PaddingValues(horizontal = 8.dp)
|
||||
FilledTonalButton(
|
||||
onClick = { navigator.media(lyrics = true) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Lyrics", maxLines = 1, softWrap = false) }
|
||||
FilledTonalButton(
|
||||
onClick = navigator::minimisePlayer,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Minimise", maxLines = 1, softWrap = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
@@ -48,6 +58,8 @@ private class Tile(
|
||||
val label: String,
|
||||
val icon: ImageBitmap? = null,
|
||||
val glyph: ImageVector? = null,
|
||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||
val verified: Boolean = false,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -61,15 +73,16 @@ private class Entry(
|
||||
|
||||
/**
|
||||
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
||||
* icons is slow, so it happens once per car session; [invalidate] is called when one starts, which
|
||||
* is the only time new installs are picked up.
|
||||
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
||||
* which is when new installs are picked up.
|
||||
*/
|
||||
private object LauncherCache {
|
||||
@Volatile var entries: List<Entry>? = null
|
||||
|
||||
fun invalidate() { entries = null }
|
||||
|
||||
fun load(context: Context): List<Entry> {
|
||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
||||
@Synchronized
|
||||
fun load(context: Context, force: Boolean = false): List<Entry> {
|
||||
if (!force) entries?.let { return it }
|
||||
fun iconOf(pkg: String) = runCatching {
|
||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||
}.getOrNull()
|
||||
@@ -80,8 +93,8 @@ private object LauncherCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */
|
||||
fun invalidateLauncherApps() = LauncherCache.invalidate()
|
||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
|
||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||
@Composable
|
||||
@@ -90,7 +103,7 @@ fun CarLauncher() {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
// Scanned once per car session (see LauncherCache), not every time the launcher opens.
|
||||
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
|
||||
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
@@ -99,7 +112,8 @@ fun CarLauncher() {
|
||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||
) + installed.map { entry ->
|
||||
Tile(entry.label, entry.icon) {
|
||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
@@ -115,6 +129,38 @@ fun CarLauncher() {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(tiles) { AppTile(it) }
|
||||
if (tiles.any { it.verified }) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what the check mark on a tile means, once, under the grid. */
|
||||
@Composable
|
||||
private fun VerifiedLegend() {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
VerifiedBadge(16.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Tested and working with Gearslip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A green disc with a white check: the same mark on every tile and in the legend. */
|
||||
@Composable
|
||||
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFF43A047), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,24 +173,29 @@ private fun AppTile(tile: Tile) {
|
||||
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))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
if (tile.verified) {
|
||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ sealed interface CarScreen {
|
||||
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
|
||||
data object Dashboard : CarScreen
|
||||
|
||||
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
|
||||
data object Weather : CarScreen
|
||||
|
||||
/** The current media app, full size: browse, lyrics and controls. */
|
||||
data object Media : CarScreen
|
||||
|
||||
@@ -37,7 +40,26 @@ class CarNavigator(startId: String) {
|
||||
|
||||
fun home() { current = CarScreen.Home }
|
||||
|
||||
fun media() { current = CarScreen.Media }
|
||||
/**
|
||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
||||
* home screen with the player back in place.
|
||||
*/
|
||||
var playerMinimised by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
|
||||
var lyricsRequested = false
|
||||
|
||||
fun minimisePlayer() { playerMinimised = true }
|
||||
|
||||
fun restorePlayer() { playerMinimised = false }
|
||||
|
||||
fun media(lyrics: Boolean = false) {
|
||||
lyricsRequested = lyrics
|
||||
playerMinimised = false
|
||||
current = CarScreen.Media
|
||||
}
|
||||
|
||||
fun apps() { current = CarScreen.Apps }
|
||||
|
||||
@@ -45,6 +67,8 @@ class CarNavigator(startId: String) {
|
||||
|
||||
fun dashboard() { current = CarScreen.Dashboard }
|
||||
|
||||
fun weather() { current = CarScreen.Weather }
|
||||
|
||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
||||
|
||||
fun open(id: String) { current = CarScreen.App(id) }
|
||||
|
||||
@@ -163,7 +163,7 @@ private fun WebPageSetting() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
url.ifEmpty { "Built-in test page - tap to set a URL" },
|
||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -42,6 +42,8 @@ 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.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -81,7 +83,14 @@ fun CarUi() {
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
remember(appContext) { invalidateLauncherApps(); CarServices.init(appContext) }
|
||||
val controlsWake = remember { MapControlsWake() }
|
||||
LaunchedEffect(controlsWake) {
|
||||
while (true) {
|
||||
delay(250)
|
||||
controlsWake.tick()
|
||||
}
|
||||
}
|
||||
remember(appContext) { Prefetch.warm(appContext, force = true); 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() }
|
||||
@@ -90,9 +99,23 @@ fun CarUi() {
|
||||
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||
LocalCarNavigator provides navigator,
|
||||
LocalDarkOutside provides darkOutside,
|
||||
LocalMapControlsWake provides controlsWake,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colors) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Watches every touch without taking any: first in line, consumes nothing.
|
||||
.pointerInput(controlsWake) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial)
|
||||
controlsWake.touch()
|
||||
}
|
||||
}
|
||||
},
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
||||
val pad = with(base) {
|
||||
PaddingValues(
|
||||
@@ -113,6 +136,7 @@ fun CarUi() {
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
@@ -140,26 +164,36 @@ private fun CarNavBar(navigator: CarNavigator) {
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val now by rememberNow()
|
||||
|
||||
// The home and media screens already show what's playing; everywhere else it lives in the
|
||||
// middle of this bar so it stays one tap away.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media
|
||||
// The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
|
||||
val weather by Weather.state.collectAsState()
|
||||
|
||||
// One row, so nothing can sit on top of anything else: the player takes whatever width the
|
||||
// fixed items leave and shortens its title to fit.
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable { navigator.dashboard() }
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -173,19 +207,29 @@ private fun CarNavBar(navigator: CarNavigator) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
|
||||
if (showNowPlaying) {
|
||||
NavNowPlaying(navigator, Modifier.align(Alignment.Center))
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
@@ -212,19 +256,19 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
|
||||
|
||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.hasTrack) return
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
@@ -233,6 +277,7 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -242,7 +287,8 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" }.let { if (it.length > 15) it.take(15) + "…" else it },
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
import android.os.SystemClock
|
||||
|
||||
/**
|
||||
* Whether the map's buttons (settings, pan, zoom, locate) are showing.
|
||||
*
|
||||
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
||||
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
||||
* writes state when the buttons were actually hidden.
|
||||
*/
|
||||
class MapControlsWake {
|
||||
|
||||
var awake by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var lastTouch = SystemClock.uptimeMillis()
|
||||
|
||||
fun touch() {
|
||||
lastTouch = SystemClock.uptimeMillis()
|
||||
if (!awake) awake = true
|
||||
}
|
||||
|
||||
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
||||
fun tick() {
|
||||
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val IDLE_MS = 4_000L
|
||||
}
|
||||
}
|
||||
|
||||
val LocalMapControlsWake = staticCompositionLocalOf { MapControlsWake() }
|
||||
@@ -3,6 +3,8 @@ package app.seb3thehacker.gearslip.car
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import android.provider.Settings
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -73,8 +75,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
val phase by media.phase.collectAsStateWithLifecycle()
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val browse by media.browse.collectAsStateWithLifecycle()
|
||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||
var tab by remember { mutableStateOf(Tab.BROWSE) }
|
||||
val navigator = LocalCarNavigator.current
|
||||
var tab by remember {
|
||||
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
|
||||
}
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
@@ -88,10 +94,25 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
}
|
||||
|
||||
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.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
CarMedia.Rejection.REFUSED -> 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),
|
||||
@@ -108,7 +129,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
}
|
||||
}
|
||||
when (tab) {
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize())
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
@@ -189,10 +210,23 @@ private fun AudioBadge(capture: CarAudio.Capture) {
|
||||
}
|
||||
|
||||
@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) }
|
||||
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
detail?.let {
|
||||
Text(
|
||||
it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
action?.let { (label, run) ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +305,7 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
||||
// --- browsing --------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) {
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
@@ -285,6 +319,10 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
browse.unavailable -> Notice(
|
||||
"$appLabel keeps its library to itself.",
|
||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||
)
|
||||
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)) {
|
||||
|
||||
@@ -48,8 +48,8 @@ import kotlinx.coroutines.withContext
|
||||
@Composable
|
||||
fun PhoneAppsScreen() {
|
||||
val context = LocalContext.current
|
||||
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) {
|
||||
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) }
|
||||
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
||||
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||
* network or the calendar provider.
|
||||
*
|
||||
* Safe to call as often as you like: calls closer together than [MIN_GAP_MS] are dropped.
|
||||
*/
|
||||
object Prefetch {
|
||||
|
||||
private const val MIN_GAP_MS = 60_000L
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-prefetch").apply { isDaemon = true } }
|
||||
@Volatile private var lastRun = 0L
|
||||
|
||||
/** [force] skips the throttle; use it when something changed, such as a permission grant. */
|
||||
fun warm(context: Context, force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastRun < MIN_GAP_MS) return
|
||||
lastRun = now
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
val start = System.currentTimeMillis()
|
||||
step("launcher apps") { warmLauncherApps(app) }
|
||||
step("phone apps") { PhoneApps.warm(app) }
|
||||
step("weather and calendar") { warmDashboard(app) }
|
||||
GearslipLog.i("prefetch done in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
}
|
||||
|
||||
// One failing loader must not stop the others.
|
||||
private fun step(name: String, block: () -> Unit) {
|
||||
runCatching(block).onFailure { GearslipLog.w("prefetch: $name failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,15 @@ 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.heightIn
|
||||
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.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -82,16 +85,17 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t
|
||||
|
||||
@Composable
|
||||
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
(template.navigationInfo as? RoutingInfo)?.let {
|
||||
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
|
||||
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
|
||||
}
|
||||
RightEdgeControls(
|
||||
template.actionStrip,
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
template.destinationTravelEstimate?.let {
|
||||
if (!pan) template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
|
||||
}
|
||||
}
|
||||
@@ -207,25 +211,39 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
||||
// --- 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.
|
||||
* The shape every remaining map-backed template shares: the app's map across the whole surface,
|
||||
* the template's content in a card floating over its left side, and both action strips stacked at
|
||||
* the right edge. The card is as tall as its content, up to the height of the screen, so a
|
||||
* handful of buttons no longer claim a whole column of the map. [fullHeight] is for content that
|
||||
* needs the room, such as a search field with its keyboard.
|
||||
*/
|
||||
@Composable
|
||||
private fun MapPaneLayout(
|
||||
modifier: Modifier,
|
||||
actionStrip: androidx.car.app.model.ActionStrip?,
|
||||
mapActionStrip: androidx.car.app.model.ActionStrip?,
|
||||
pane: @Composable (Modifier) -> Unit,
|
||||
fullHeight: Boolean = false,
|
||||
pane: @Composable () -> Unit,
|
||||
) {
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
pane(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(CONTENT_PANE_FRACTION),
|
||||
)
|
||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(12.dp)
|
||||
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
|
||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalMapEdgeActions provides edgeActions) { pane() }
|
||||
}
|
||||
}
|
||||
RightEdgeControls(
|
||||
actionStrip,
|
||||
if (pan) null else actionStrip,
|
||||
mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
@@ -238,8 +256,9 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
|
||||
modifier,
|
||||
template.actionStrip,
|
||||
template.mapController?.mapActionStrip,
|
||||
) { paneModifier ->
|
||||
ContentTemplate(template.contentTemplate, paneModifier)
|
||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||
) {
|
||||
ContentTemplate(template.contentTemplate, Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +268,8 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
modifier,
|
||||
template.actionStrip,
|
||||
template.mapController?.mapActionStrip,
|
||||
) { paneModifier ->
|
||||
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
) {
|
||||
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, ""),
|
||||
@@ -266,26 +285,26 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||
ListPane(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.isLoading,
|
||||
template.itemList,
|
||||
paneModifier,
|
||||
Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
|
||||
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier ->
|
||||
MapPaneLayout(modifier, template.actionStrip, null) {
|
||||
ListPane(
|
||||
template.title.text(),
|
||||
template.headerAction,
|
||||
template.isLoading,
|
||||
template.itemList,
|
||||
paneModifier,
|
||||
Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -293,15 +312,15 @@ private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifie
|
||||
/** 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()) {
|
||||
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
|
||||
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
Box(Modifier.weight(1f)) {
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp))
|
||||
} else {
|
||||
@@ -338,3 +357,8 @@ 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
|
||||
|
||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||
private fun Template?.needsFullHeight() =
|
||||
this is androidx.car.app.model.SearchTemplate || this is androidx.car.app.model.signin.SignInTemplate ||
|
||||
this is androidx.car.app.model.TabTemplate || this is androidx.car.app.model.LongMessageTemplate
|
||||
@@ -103,7 +103,10 @@ private fun ListContent(template: ListTemplate, modifier: Modifier) {
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.isLoading) Loading()
|
||||
else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList })
|
||||
else ItemListColumn(
|
||||
template.singleList, template.sectionedLists?.map { it.itemList },
|
||||
Modifier.weight(1f, fill = false),
|
||||
)
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -117,7 +120,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.isLoading) Loading() else GridItems(template.singleList)
|
||||
if (template.isLoading) Loading() else GridItems(template.singleList, Modifier.weight(1f, fill = false))
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
@@ -484,7 +487,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
||||
@Composable
|
||||
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
|
||||
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
|
||||
Column(Modifier.fillMaxSize(), content = content)
|
||||
Column(Modifier.fillMaxWidth(), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,21 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import app.seb3thehacker.gearslip.host.selected
|
||||
import app.seb3thehacker.gearslip.host.checkedChanged
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -74,9 +89,11 @@ internal fun ChromeSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
alpha: Float = 1f,
|
||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation,
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = shape,
|
||||
@@ -109,7 +126,12 @@ internal fun HeaderBar(
|
||||
endActions: List<Action> = emptyList(),
|
||||
actionStrip: ActionStrip? = null,
|
||||
) {
|
||||
val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() }
|
||||
// Beside a map the action strip already sits at the right edge, so the header must not repeat it;
|
||||
// and an app that lists the same action in two places should still see it once.
|
||||
val onMap = LocalMapEdgeActions.current
|
||||
val end = (endActions + if (onMap != null) emptyList() else actionStrip?.actions.orEmpty())
|
||||
.filter { it.isDrawable() && (onMap == null || it.identity() !in onMap) }
|
||||
.distinctBy { it.identity() }
|
||||
if (title.isEmpty() && startAction == null && end.isEmpty()) return
|
||||
|
||||
Row(
|
||||
@@ -120,8 +142,9 @@ internal fun HeaderBar(
|
||||
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -130,6 +153,22 @@ internal fun HeaderBar(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set inside the card beside a map to what the map's right edge already shows: the action strip is
|
||||
* drawn there rather than in the header, and the header leaves out anything the edge repeats.
|
||||
* Null anywhere else.
|
||||
*/
|
||||
internal val LocalMapEdgeActions = androidx.compose.runtime.compositionLocalOf<Set<String>?> { null }
|
||||
|
||||
/** What makes two actions the same button: what they do and what they look like. */
|
||||
internal fun Action.identity(): String {
|
||||
val icon = icon?.icon
|
||||
val image = if (icon != null && icon.type == androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) {
|
||||
"${icon.resPackage}:${icon.resId}"
|
||||
} else icon?.toString().orEmpty()
|
||||
return "$type|${title.text()}|$image"
|
||||
}
|
||||
|
||||
/** Reads a header whichever way the template carries it. */
|
||||
internal fun headerTitle(header: Header?, fallback: String): String =
|
||||
header?.title.text().ifEmpty { fallback }
|
||||
@@ -150,8 +189,35 @@ internal fun RightEdgeControls(
|
||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
if (actions.isEmpty() && mapActions.isEmpty()) return
|
||||
// A template that stops offering Pan must not leave the screen stuck in pan mode.
|
||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
val awake = LocalMapControlsWake.current.awake
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val visible = awake || pan
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||
label = "mapControls",
|
||||
)
|
||||
Column(
|
||||
modifier
|
||||
.alpha(alpha)
|
||||
.let { base ->
|
||||
if (visible) base else base.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (actions.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
actions.forEach { ActionButton(it) }
|
||||
@@ -161,43 +227,104 @@ internal fun RightEdgeControls(
|
||||
}
|
||||
}
|
||||
|
||||
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
|
||||
@Composable
|
||||
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier) {
|
||||
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, large: Boolean = false) {
|
||||
val drawable = actions.filter { it.isDrawable() }
|
||||
if (drawable.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
drawable.forEach { ActionButton(it) }
|
||||
Row(if (large) modifier.fillMaxWidth() else modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
drawable.forEach { ActionButton(it, large = large, modifier = if (large) Modifier.weight(1f) else Modifier) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
||||
private fun CarRow.isBlank(): Boolean =
|
||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||
|
||||
/**
|
||||
* One look everywhere: solid, in the theme's own colors, whether the button sits on a card or over
|
||||
* the map. An app that names a color for its button (a green Start) keeps it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ActionButton(action: Action) {
|
||||
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
||||
if (action.type == Action.TYPE_APP_ICON) {
|
||||
AppIconBadge()
|
||||
return
|
||||
}
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val title = action.title.text()
|
||||
val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f))
|
||||
val panOn by CarServices.nav.panMode.collectAsState()
|
||||
val active = action.type == Action.TYPE_PAN && panOn
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
val fallback = scheme.surfaceContainerHighest
|
||||
val background = if (active) scheme.primary else action.backgroundColor.color(dark, fallback)
|
||||
val content = when {
|
||||
active -> scheme.onPrimary
|
||||
custom -> Color.White
|
||||
else -> scheme.onSurface
|
||||
}
|
||||
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")
|
||||
contentColor = content,
|
||||
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
|
||||
modifier = modifier.clickable(enabled = action.isEnabled) {
|
||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
||||
else 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 (large && title.isNotEmpty()) {
|
||||
// The label sits dead centre on the button; the icon hangs off the left edge, so the
|
||||
// pair's uneven visual weight can't pull the words off to one side.
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else if (title.isEmpty()) {
|
||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
||||
androidx.compose.foundation.layout.Box(Modifier.size(ICON_BUTTON_SIZE), contentAlignment = Alignment.Center) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(ICON_BUTTON_GLYPH), standardFor(action))
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||
@Composable
|
||||
private fun AppIconBadge() {
|
||||
val context = LocalContext.current
|
||||
val pkg = CarServices.nav.appPackage
|
||||
val icon = remember(pkg) {
|
||||
pkg?.let {
|
||||
runCatching {
|
||||
context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
} ?: return
|
||||
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)))
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -270,8 +397,13 @@ internal fun ItemListColumn(
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
return
|
||||
}
|
||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||
val onSelected = single?.onSelectedDelegate
|
||||
val chosen = single?.selectedIndex ?: -1
|
||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(rows) { RowItem(it) }
|
||||
itemsIndexed(rows) { index, row ->
|
||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,9 +413,9 @@ 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),
|
||||
columns = GridCells.Adaptive(84.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
@@ -319,26 +451,47 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
|
||||
val rows = pane?.rows.orEmpty()
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
Column(modifier) {
|
||||
rows.forEach { RowItem(it) }
|
||||
rows.forEach { RowItem(it, large = true) }
|
||||
ActionRow(
|
||||
pane?.actions.orEmpty(),
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
large = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowItem(row: CarRow) {
|
||||
val clickable = row.onClickDelegate != null && row.isEnabled
|
||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||
val toggle = row.toggle
|
||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||
.let {
|
||||
if (!clickable) it else it.clickable {
|
||||
when {
|
||||
toggle != null -> {
|
||||
checked = !checked
|
||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||
}
|
||||
selection != null -> selection.onPick()
|
||||
else -> row.onClickDelegate.click("row")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selection != null) {
|
||||
RadioButton(selected = selection.selected, onClick = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -346,24 +499,32 @@ internal fun RowItem(row: CarRow) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title.text(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (large) FontWeight.SemiBold else null,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.texts.orEmpty().take(2).forEach { line ->
|
||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||
Text(
|
||||
line.text(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
if (toggle != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||
|
||||
internal fun Color.luminanceIsDark(): Boolean =
|
||||
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
||||
|
||||
@@ -381,3 +542,6 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
||||
}
|
||||
return seen > 0
|
||||
}
|
||||
|
||||
private val ICON_BUTTON_SIZE = 54.dp
|
||||
private val ICON_BUTTON_GLYPH = 30.dp
|
||||
@@ -0,0 +1,213 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
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 org.json.JSONObject
|
||||
|
||||
class HourForecast(val time: Long, val temp: Int, val code: Int, val isDay: Boolean, val rainChance: Int)
|
||||
|
||||
class DayForecast(val date: Long, val high: Int, val low: Int, val code: Int, val rainChance: Int)
|
||||
|
||||
class WeatherData(
|
||||
val place: String,
|
||||
val temp: Int,
|
||||
val feelsLike: Int,
|
||||
val code: Int,
|
||||
val isDay: Boolean,
|
||||
val humidity: Int,
|
||||
val wind: Int,
|
||||
val tempUnit: String,
|
||||
val windUnit: String,
|
||||
val hours: List<HourForecast>,
|
||||
val days: List<DayForecast>,
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
|
||||
sealed interface WeatherState {
|
||||
data object Loading : WeatherState
|
||||
data class Unavailable(val reason: String) : WeatherState
|
||||
data class Ready(val data: WeatherData) : WeatherState
|
||||
}
|
||||
|
||||
/**
|
||||
* Current weather and a week's forecast for wherever the phone is, from Open-Meteo (free, no
|
||||
* key or account; the data is CC BY 4.0, so the weather screen credits it).
|
||||
*
|
||||
* The location is the phone's last fix if it is recent, otherwise a fresh one is requested, so
|
||||
* a phone that has been idle does not leave the card on "unavailable".
|
||||
*/
|
||||
object Weather {
|
||||
|
||||
private const val TTL_MS = 30 * 60 * 1000L
|
||||
private const val FIX_MAX_AGE_MS = 30 * 60 * 1000L
|
||||
private const val FIX_TIMEOUT_S = 12L
|
||||
|
||||
private val flow = MutableStateFlow<WeatherState>(WeatherState.Loading)
|
||||
val state: StateFlow<WeatherState> = flow
|
||||
|
||||
private val io = Executors.newSingleThreadExecutor { Thread(it, "gearslip-weather").apply { isDaemon = true } }
|
||||
|
||||
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
|
||||
|
||||
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
|
||||
fun refresh(context: Context, force: Boolean = false) {
|
||||
val current = flow.value
|
||||
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
|
||||
val app = context.applicationContext
|
||||
io.execute { load(app) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun load(context: Context) {
|
||||
// Keep showing the old reading while a new one loads; only the first load shows a spinner.
|
||||
val location = locate(context)
|
||||
if (location == null) {
|
||||
if (flow.value !is WeatherState.Ready) {
|
||||
val allowed = hasLocationPermission(context)
|
||||
flow.value = WeatherState.Unavailable(
|
||||
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val data = runCatching { fetch(context, location) }
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
||||
.getOrNull()
|
||||
if (data != null) {
|
||||
flow.value = WeatherState.Ready(data)
|
||||
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
}
|
||||
|
||||
// --- location -------------------------------------------------------------------------
|
||||
|
||||
private fun hasLocationPermission(context: Context) =
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun locate(context: Context): Location? {
|
||||
if (!hasLocationPermission(context)) return null
|
||||
val manager = context.getSystemService(LocationManager::class.java) ?: return null
|
||||
val known = manager.allProviders
|
||||
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
|
||||
.maxByOrNull { it.time }
|
||||
if (known != null && System.currentTimeMillis() - known.time < FIX_MAX_AGE_MS) return known
|
||||
|
||||
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
|
||||
?: return known
|
||||
val latch = CountDownLatch(1)
|
||||
var fresh: Location? = null
|
||||
runCatching {
|
||||
manager.getCurrentLocation(provider, null, io) { fresh = it; latch.countDown() }
|
||||
latch.await(FIX_TIMEOUT_S, TimeUnit.SECONDS)
|
||||
}.onFailure { GearslipLog.w("weather: fresh fix failed: ${it.message}") }
|
||||
// A stale fix still gives the right city, which is all a forecast needs.
|
||||
return fresh ?: known
|
||||
}
|
||||
|
||||
private fun placeName(context: Context, location: Location): String {
|
||||
val key = "%.2f,%.2f".format(Locale.US, location.latitude, location.longitude)
|
||||
placeCache?.let { if (it.first == key) return it.second }
|
||||
val name = runCatching {
|
||||
if (!Geocoder.isPresent()) return@runCatching null
|
||||
@Suppress("DEPRECATION")
|
||||
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
|
||||
?.firstOrNull()?.let { it.locality ?: it.subAdminArea ?: it.adminArea }
|
||||
}.getOrNull() ?: "%.2f, %.2f".format(Locale.US, location.latitude, location.longitude)
|
||||
placeCache = key to name
|
||||
return name
|
||||
}
|
||||
|
||||
// --- network --------------------------------------------------------------------------
|
||||
|
||||
private fun fetch(context: Context, location: Location): WeatherData {
|
||||
val imperial = Locale.getDefault().country in setOf("US", "LR", "MM")
|
||||
val url = URL(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
|
||||
"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
|
||||
"&hourly=temperature_2m,weather_code,precipitation_probability,is_day" +
|
||||
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" +
|
||||
"&timezone=auto&timeformat=unixtime&forecast_days=7" +
|
||||
if (imperial) "&temperature_unit=fahrenheit&wind_speed_unit=mph" else "",
|
||||
)
|
||||
val body = (url.openConnection() as HttpURLConnection).run {
|
||||
connectTimeout = 6_000
|
||||
readTimeout = 8_000
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
val json = JSONObject(body)
|
||||
val cur = json.getJSONObject("current")
|
||||
|
||||
val hourly = json.getJSONObject("hourly")
|
||||
val times = hourly.getJSONArray("time")
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val start = (0 until times.length()).firstOrNull { times.getLong(it) >= nowSec - 3600 } ?: 0
|
||||
val hours = (start until minOf(start + 12, times.length())).map { i ->
|
||||
HourForecast(
|
||||
time = times.getLong(i) * 1000,
|
||||
temp = Math.round(hourly.getJSONArray("temperature_2m").getDouble(i)).toInt(),
|
||||
code = hourly.getJSONArray("weather_code").getInt(i),
|
||||
isDay = hourly.getJSONArray("is_day").getInt(i) == 1,
|
||||
rainChance = hourly.getJSONArray("precipitation_probability").optInt(i, 0),
|
||||
)
|
||||
}
|
||||
|
||||
val daily = json.getJSONObject("daily")
|
||||
val dates = daily.getJSONArray("time")
|
||||
val days = (0 until dates.length()).map { i ->
|
||||
DayForecast(
|
||||
date = dates.getLong(i) * 1000,
|
||||
high = Math.round(daily.getJSONArray("temperature_2m_max").getDouble(i)).toInt(),
|
||||
low = Math.round(daily.getJSONArray("temperature_2m_min").getDouble(i)).toInt(),
|
||||
code = daily.getJSONArray("weather_code").getInt(i),
|
||||
rainChance = daily.getJSONArray("precipitation_probability_max").optInt(i, 0),
|
||||
)
|
||||
}
|
||||
|
||||
return WeatherData(
|
||||
place = placeName(context, location),
|
||||
temp = Math.round(cur.getDouble("temperature_2m")).toInt(),
|
||||
feelsLike = Math.round(cur.getDouble("apparent_temperature")).toInt(),
|
||||
code = cur.getInt("weather_code"),
|
||||
isDay = cur.getInt("is_day") == 1,
|
||||
humidity = cur.getInt("relative_humidity_2m"),
|
||||
wind = Math.round(cur.getDouble("wind_speed_10m")).toInt(),
|
||||
tempUnit = if (imperial) "°F" else "°C",
|
||||
windUnit = if (imperial) "mph" else "km/h",
|
||||
hours = hours,
|
||||
days = days,
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Open-Meteo's WMO weather codes, in words. */
|
||||
fun describe(code: Int): String = when (code) {
|
||||
0 -> "Clear"
|
||||
1 -> "Mostly clear"
|
||||
2 -> "Partly cloudy"
|
||||
3 -> "Overcast"
|
||||
45, 48 -> "Fog"
|
||||
in 51..57 -> "Drizzle"
|
||||
in 61..67 -> "Rain"
|
||||
in 71..77 -> "Snow"
|
||||
in 80..82 -> "Rain showers"
|
||||
in 85..86 -> "Snow showers"
|
||||
in 95..99 -> "Thunderstorm"
|
||||
else -> "-"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
private val SUN = Color(0xFFFFB300)
|
||||
private val MOON = Color(0xFFCFD8DC)
|
||||
private val CLOUD = Color(0xFF90A4AE)
|
||||
private val RAIN = Color(0xFF29B6F6)
|
||||
private val SNOW = Color(0xFF81D4FA)
|
||||
private val BOLT = Color(0xFFFFD54F)
|
||||
|
||||
/** Sun, moon, cloud, rain, snow, storm or fog for a WMO weather code. Drawn, so it needs no icon pack. */
|
||||
@Composable
|
||||
fun WeatherIcon(code: Int, isDay: Boolean, size: Dp = 28.dp, modifier: Modifier = Modifier) {
|
||||
Canvas(modifier.size(size)) {
|
||||
when (code) {
|
||||
0 -> if (isDay) sun(center, this.size.minDimension * 0.5f) else moon(center, this.size.minDimension * 0.5f)
|
||||
1, 2 -> {
|
||||
if (isDay) sun(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||
else moon(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
|
||||
cloud(Offset(this.size.width * 0.06f, this.size.height * 0.34f), Size(this.size.width * 0.9f, this.size.height * 0.6f))
|
||||
}
|
||||
3 -> cloud(Offset(0f, this.size.height * 0.2f), Size(this.size.width, this.size.height * 0.7f))
|
||||
45, 48 -> fog()
|
||||
in 71..77, in 85..86 -> precip(SNOW, dots = true)
|
||||
in 95..99 -> storm()
|
||||
else -> precip(RAIN, dots = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.sun(c: Offset, r: Float) {
|
||||
drawCircle(SUN, r * 0.55f, c)
|
||||
val stroke = r * 0.14f
|
||||
for (i in 0 until 8) {
|
||||
val a = Math.toRadians(i * 45.0)
|
||||
drawLine(
|
||||
SUN, c + Offset(cos(a).toFloat(), sin(a).toFloat()) * (r * 0.75f),
|
||||
c + Offset(cos(a).toFloat(), sin(a).toFloat()) * r, stroke, StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.moon(c: Offset, r: Float) {
|
||||
val disc = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c, r * 0.62f)) }
|
||||
val bite = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c + Offset(r * 0.32f, -r * 0.22f), r * 0.55f)) }
|
||||
drawPath(Path.combine(androidx.compose.ui.graphics.PathOperation.Difference, disc, bite), MOON)
|
||||
}
|
||||
|
||||
private fun DrawScope.cloud(topLeft: Offset, s: Size) {
|
||||
val h = s.height
|
||||
drawCircle(CLOUD, h * 0.42f, topLeft + Offset(s.width * 0.3f, h * 0.55f))
|
||||
drawCircle(CLOUD, h * 0.55f, topLeft + Offset(s.width * 0.55f, h * 0.42f))
|
||||
drawCircle(CLOUD, h * 0.38f, topLeft + Offset(s.width * 0.78f, h * 0.62f))
|
||||
drawRoundRect(CLOUD, topLeft + Offset(s.width * 0.28f, h * 0.55f), Size(s.width * 0.52f, h * 0.45f),
|
||||
androidx.compose.ui.geometry.CornerRadius(h * 0.2f))
|
||||
}
|
||||
|
||||
private fun DrawScope.precip(color: Color, dots: Boolean) {
|
||||
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||
for (i in 0 until 3) {
|
||||
val x = size.width * (0.28f + 0.22f * i)
|
||||
val y = size.height * 0.72f
|
||||
if (dots) drawCircle(color, size.width * 0.045f, Offset(x, y + size.height * 0.08f))
|
||||
else drawLine(color, Offset(x, y), Offset(x - size.width * 0.06f, y + size.height * 0.2f), size.width * 0.07f, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.storm() {
|
||||
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = Path().apply {
|
||||
moveTo(w * 0.55f, h * 0.55f); lineTo(w * 0.36f, h * 0.82f); lineTo(w * 0.5f, h * 0.82f)
|
||||
lineTo(w * 0.42f, h * 1f); lineTo(w * 0.66f, h * 0.7f); lineTo(w * 0.52f, h * 0.7f); close()
|
||||
}
|
||||
drawPath(bolt, BOLT)
|
||||
}
|
||||
|
||||
private fun DrawScope.fog() {
|
||||
val stroke = Stroke(width = size.height * 0.09f, cap = StrokeCap.Round)
|
||||
for (i in 0 until 4) {
|
||||
val y = size.height * (0.25f + 0.17f * i)
|
||||
val inset = if (i % 2 == 0) 0.1f else 0.2f
|
||||
drawLine(CLOUD, Offset(size.width * inset, y), Offset(size.width * (1f - inset), y), stroke.width, stroke.cap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/** The weather in full: now, the next twelve hours and the week. Opened from the nav bar or the dashboard. */
|
||||
@Composable
|
||||
fun WeatherScreen() {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val state by Weather.state.collectAsState()
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> Forecast(s.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Forecast(w: WeatherData) {
|
||||
Text(w.place, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
WeatherIcon(w.code, w.isDay, size = 88.dp)
|
||||
Column {
|
||||
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
|
||||
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(w.hours) { h ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
Column(
|
||||
Modifier.width(84.dp).padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
WeatherIcon(h.code, h.isDay, size = 32.dp)
|
||||
Text("${h.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (h.rainChance > 0) "${h.rainChance}%" else " ",
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
w.days.forEachIndexed { i, d ->
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
|
||||
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
WeatherIcon(d.code, true, size = 28.dp)
|
||||
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
|
||||
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Weather data by Open-Meteo.com (CC BY 4.0)",
|
||||
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -88,6 +88,9 @@ class CarAppConnection(private val context: Context) {
|
||||
private var binding: ServiceConnection? = null
|
||||
private var component: ComponentName? = null
|
||||
|
||||
/** Whose icon the header shows for the standard app-icon action. */
|
||||
val appPackage: String? get() = component?.packageName
|
||||
|
||||
private var frameWidth = 800
|
||||
private var frameHeight = 480
|
||||
private var frameDensity = 160
|
||||
@@ -120,20 +123,59 @@ class CarAppConnection(private val context: Context) {
|
||||
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
|
||||
val bind = {
|
||||
var bindError: Throwable? = null
|
||||
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)
|
||||
bindError = it
|
||||
false
|
||||
}
|
||||
if (!bound) {
|
||||
binding = null
|
||||
fail(Phase.REJECTED, explainBindFailure(app.component, bindError))
|
||||
} else {
|
||||
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
|
||||
}
|
||||
}
|
||||
if (!bound) {
|
||||
binding = null
|
||||
fail(Phase.FAILED, "bindService refused")
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* A service that names a permission can only be bound by an app holding it. Some navigation apps
|
||||
* name one that only Google's own Android Auto host holds, and no other app can be granted.
|
||||
*/
|
||||
private fun explainBindFailure(component: ComponentName, error: Throwable?): String {
|
||||
if (error !is SecurityException) return "the app would not let Gearslip connect"
|
||||
val needed = runCatching { context.packageManager.getServiceInfo(component, 0).permission }.getOrNull()
|
||||
return if (needed != null) {
|
||||
"It only accepts Google's own Android Auto: its service requires the permission $needed, which Gearslip can't hold."
|
||||
} else {
|
||||
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
|
||||
"It only accepts Google's own Android Auto host."
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own account of why it said no, in words rather than an object id. */
|
||||
private fun describeFailure(step: String, value: Any?): String {
|
||||
val failure = value as? androidx.car.app.FailureResponse
|
||||
?: return "$step failed: ${value ?: "no reason given"}"
|
||||
val firstLine = failure.stackTrace.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty()
|
||||
GearslipLog.w("host: $step failed: type=${failure.errorType}\n${failure.stackTrace.lineSequence().take(6).joinToString("\n")}")
|
||||
// The Car App Library's own host check: the app keeps a list of hosts it trusts by signature.
|
||||
if (firstLine.contains("Unrecognized host")) {
|
||||
return "It only trusts hosts on its own allow-list (Google's Android Auto and a few others), and Gearslip isn't on it."
|
||||
}
|
||||
val kind = when (failure.errorType) {
|
||||
androidx.car.app.FailureResponse.SECURITY_EXCEPTION -> "It rejected Gearslip as a host"
|
||||
androidx.car.app.FailureResponse.ILLEGAL_STATE_EXCEPTION -> "It reported a state error"
|
||||
androidx.car.app.FailureResponse.INVALID_PARAMETER_EXCEPTION -> "It reported an invalid parameter"
|
||||
else -> "It failed"
|
||||
}
|
||||
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
@@ -147,6 +189,7 @@ class CarAppConnection(private val context: Context) {
|
||||
binding = null
|
||||
this.carApp = null
|
||||
appManager = null
|
||||
_panMode.value = false
|
||||
surfaceCallback = null
|
||||
component = null
|
||||
_template.value = null
|
||||
@@ -244,6 +287,32 @@ class CarAppConnection(private val context: Context) {
|
||||
}) { manager.getTemplate(it) }
|
||||
}
|
||||
|
||||
private val _panMode = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Pan mode is the host's to run: the Pan action has no click handler, tapping it just switches
|
||||
* the screen to the map and its own controls. The app is told only if its template asks.
|
||||
*/
|
||||
val panMode: StateFlow<Boolean> = _panMode
|
||||
|
||||
fun setPanMode(on: Boolean) {
|
||||
if (_panMode.value == on) return
|
||||
_panMode.value = on
|
||||
GearslipLog.i("host: pan mode ${if (on) "on" else "off"}")
|
||||
(_template.value as? androidx.car.app.navigation.model.NavigationTemplate)
|
||||
?.panModeDelegate.panModeChanged(on)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Back action carries no click handler of its own: the app keeps its own screen stack, so
|
||||
* the host reports the press and the app answers with the template it went back to.
|
||||
*/
|
||||
fun backPressed() {
|
||||
val manager = appManager ?: return
|
||||
GearslipLog.i("host: back pressed")
|
||||
call("onBackPressed", onValue = { requestTemplate() }) { manager.onBackPressed(it) }
|
||||
}
|
||||
|
||||
// --- the surface the app draws its map into -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -498,7 +567,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
override fun onFailure(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
main.post { fail(Phase.FAILED, "$name failed: $value") }
|
||||
main.post { fail(Phase.REJECTED, describeFailure(name, value)) }
|
||||
}
|
||||
}
|
||||
runCatching { send(callback) }
|
||||
|
||||
@@ -140,6 +140,27 @@ fun InputCallbackDelegate?.inputSubmitted(text: String) {
|
||||
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app the driver entered or left pan mode, so it can hide its own map controls. */
|
||||
fun androidx.car.app.navigation.model.PanModeDelegate?.panModeChanged(active: Boolean) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendPanModeChanged(active, doneCallback("pan mode")) }
|
||||
.onFailure { GearslipLog.w("host: could not send pan mode: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app a toggle row was switched; it answers with a template showing the new state. */
|
||||
fun androidx.car.app.model.OnCheckedChangeDelegate?.checkedChanged(checked: Boolean) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendCheckedChange(checked, doneCallback("toggle")) }
|
||||
.onFailure { GearslipLog.w("host: could not send a toggle: ${it.message}") }
|
||||
}
|
||||
|
||||
/** Tells the app which option of a single-choice list was picked. */
|
||||
fun androidx.car.app.model.OnSelectedDelegate?.selected(index: Int) {
|
||||
val delegate = this ?: return
|
||||
runCatching { delegate.sendSelected(index, doneCallback("selection")) }
|
||||
.onFailure { GearslipLog.w("host: could not send a selection: ${it.message}") }
|
||||
}
|
||||
|
||||
/** The driver asked for fresher content, e.g. by pulling a place list. */
|
||||
fun OnContentRefreshDelegate?.refresh() {
|
||||
val delegate = this ?: return
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
/**
|
||||
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks
|
||||
* them so a driver can pick something that works without finding out at the wheel.
|
||||
*
|
||||
* An app belongs here only after a real session: the app starts, draws, takes touches and
|
||||
* answers its buttons. Apps that connect but show no library, or refuse Gearslip as a host, stay
|
||||
* off the list.
|
||||
*/
|
||||
object KnownApps {
|
||||
|
||||
private val working = setOf(
|
||||
"app.organicmaps", // Organic Maps: map, search, routing, settings
|
||||
"net.osmand.plus", // OsmAnd: map, search, routing, settings
|
||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||
)
|
||||
|
||||
fun works(packageName: String): Boolean = packageName in working
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.ComponentName
|
||||
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.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -47,6 +52,15 @@ class NowPlaying(
|
||||
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
||||
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
||||
|
||||
/**
|
||||
* Something is actually playing or paused mid-track. A stopped, errored or empty session, or one
|
||||
* that has not named a track yet, must not keep the player on screen.
|
||||
*/
|
||||
val isActive get() = title.isNotEmpty() && when (state) {
|
||||
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun canDo(action: Long) = actions and action != 0L
|
||||
|
||||
/** The position now, advanced from the last report the way the platform's own UIs do. */
|
||||
@@ -64,6 +78,8 @@ class BrowseState(
|
||||
val entries: List<MediaEntry> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val failed: Boolean = false,
|
||||
/** The app connected but shares no library with us, so there is nothing to browse - only to control. */
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -75,6 +91,15 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||
|
||||
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
||||
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
|
||||
|
||||
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var connecting: MediaApp? = null
|
||||
|
||||
private val _phase = MutableStateFlow(Phase.IDLE)
|
||||
val phase: StateFlow<Phase> = _phase.asStateFlow()
|
||||
|
||||
@@ -92,13 +117,14 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
fun connect(app: MediaApp) {
|
||||
disconnect()
|
||||
connecting = app
|
||||
_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
|
||||
GearslipLog.w("media: ${app.label} refused the connection to its library")
|
||||
attachToSession(app)
|
||||
}
|
||||
override fun onConnectionSuspended() {
|
||||
GearslipLog.w("media: ${app.label} went away")
|
||||
@@ -112,6 +138,44 @@ class CarMedia(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some apps only let Google's own host browse them. Their playback session is still public, so
|
||||
* playback can be driven from it: what is playing, play and pause, skip, seek. Only the library
|
||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||
* grants once on the phone.
|
||||
*/
|
||||
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
|
||||
if (connecting != app) return
|
||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||
val session = try {
|
||||
manager.getActiveSessions(listener).firstOrNull { it.packageName == app.component.packageName }
|
||||
} catch (e: SecurityException) {
|
||||
GearslipLog.w("media: cannot read ${app.label}'s session without notification access")
|
||||
_rejection.value = Rejection.NEEDS_NOTIFICATION_ACCESS
|
||||
_phase.value = Phase.REJECTED
|
||||
return
|
||||
}
|
||||
if (session == null) {
|
||||
// The app only publishes its session once its service has started; give it a moment.
|
||||
if (attempt < SESSION_RETRIES) {
|
||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
||||
} else {
|
||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||
_rejection.value = Rejection.REFUSED
|
||||
_phase.value = Phase.REJECTED
|
||||
}
|
||||
return
|
||||
}
|
||||
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
|
||||
controller = session.also {
|
||||
it.registerCallback(callback)
|
||||
update(it.metadata, it.playbackState)
|
||||
}
|
||||
_browse.value = BrowseState(loading = false, unavailable = true)
|
||||
_phase.value = Phase.READY
|
||||
}
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
val mb = browser ?: return
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
@@ -165,6 +229,8 @@ class CarMedia(private val context: Context) {
|
||||
trail = path.map { it.second },
|
||||
entries = children.map(::entryOf),
|
||||
loading = false,
|
||||
// An empty top level is an app keeping its library to itself, not an empty one.
|
||||
unavailable = path.isEmpty() && children.isEmpty(),
|
||||
)
|
||||
}
|
||||
override fun onError(parentId: String) {
|
||||
@@ -222,6 +288,8 @@ class CarMedia(private val context: Context) {
|
||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||
|
||||
fun disconnect() {
|
||||
connecting = null
|
||||
main.removeCallbacksAndMessages(null)
|
||||
runCatching { controller?.unregisterCallback(callback) }
|
||||
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
|
||||
runCatching { browser?.disconnect() }
|
||||
@@ -231,4 +299,9 @@ class CarMedia(private val context: Context) {
|
||||
_phase.value = Phase.IDLE
|
||||
_now.value = NowPlaying()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_RETRIES = 6
|
||||
const val SESSION_RETRY_MS = 500L
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ object PhoneApps {
|
||||
|
||||
private const val ICON_PX = 128
|
||||
|
||||
@Volatile private var cache: List<PhoneApp>? = null
|
||||
|
||||
/** What the last scan found, or null before the first one. */
|
||||
fun cached(): List<PhoneApp>? = cache
|
||||
|
||||
/** Scans and stores the result; [Prefetch] calls this so the screen opens already filled. */
|
||||
fun warm(context: Context) { cache = installed(context) }
|
||||
|
||||
fun installed(context: Context): List<PhoneApp> {
|
||||
val manager = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ScreenProjector
|
||||
import app.seb3thehacker.gearslip.TouchPoint
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
|
||||
@@ -122,8 +123,14 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
if (viewWidth > 0 && viewHeight > 0) {
|
||||
projector.dispatchTouch(
|
||||
event.actionMasked.toCarAction(),
|
||||
event.x * width / viewWidth,
|
||||
event.y * height / viewHeight,
|
||||
event.actionIndex,
|
||||
List(event.pointerCount) { i ->
|
||||
TouchPoint(
|
||||
event.getPointerId(i),
|
||||
event.getX(i) * width / viewWidth,
|
||||
event.getY(i) * height / viewHeight,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
true
|
||||
@@ -230,10 +237,11 @@ private tailrec fun Context.activity(): Activity? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */
|
||||
/** The actions [ScreenProjector.dispatchTouch] understands reach the car UI, second fingers included. */
|
||||
private fun Int.toCarAction(): Int = when (this) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
|
||||
-> this
|
||||
else -> MotionEvent.ACTION_CANCEL
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ private fun CompatWarning() {
|
||||
Text(
|
||||
"Gearslip may not connect on cars or head units newer than about 2020. " +
|
||||
"Older systems are more likely to work; newer firmware often rejects the " +
|
||||
"connection outright. See SPIKE_FINDINGS.md in the project for details.",
|
||||
"connection outright.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -18,16 +21,21 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
|
||||
@Composable
|
||||
@@ -38,6 +46,8 @@ fun HomeScreen(
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
@@ -65,6 +75,28 @@ fun HomeScreen(
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SetupSteps()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (debug) "Full protocol logging, live logs and car preview."
|
||||
else "Quiet logging. Warnings and errors are still recorded.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = debug, onCheckedChange = {
|
||||
debug = it
|
||||
AppSettings.setDebugMode(context, it)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -80,7 +112,7 @@ fun HomeScreen(
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
if (!live) {
|
||||
if (!live && debug) {
|
||||
FilledTonalButton(
|
||||
onClick = onOpenCarPreview,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -89,8 +121,10 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Live logs")
|
||||
if (debug) {
|
||||
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")
|
||||
@@ -144,3 +178,61 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
||||
private fun Context.appVersionName(): String =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
||||
|
||||
/** One step of the setup list: a sentence that says what to do, and optionally how. */
|
||||
private class Step(val action: String, val detail: String? = null)
|
||||
|
||||
private val SETUP_STEPS = listOf(
|
||||
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||
Step(
|
||||
"On GrapheneOS, exempt Gearslip from exploit protections.",
|
||||
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
|
||||
),
|
||||
Step("Disable or uninstall the Android Auto app."),
|
||||
Step("Load the certificate.", "Open Settings and choose Import certificate."),
|
||||
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
||||
)
|
||||
|
||||
/**
|
||||
* What to do before the first drive, in order. Kept in a narrow card with room at the sides so
|
||||
* the lines stay short enough to read at a glance.
|
||||
*/
|
||||
@Composable
|
||||
private fun SetupSteps() {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.widthIn(max = 560.dp).padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 24.dp, vertical = 22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
"How to connect",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
SETUP_STEPS.forEachIndexed { index, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"${index + 1}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(16.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||
step.detail?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -17,7 +25,6 @@ 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
|
||||
@@ -78,20 +85,34 @@ fun LogsScreen(onBack: () -> Unit) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
||||
.putExtra(Intent.EXTRA_TEXT, text)
|
||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(Intent.createChooser(send, "Share log"))
|
||||
}
|
||||
}) { Text("Share") }
|
||||
TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") }
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Share") }
|
||||
OutlinedButton(
|
||||
onClick = { GearslipLog.clearLive() },
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Clear") }
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!follow && lines.isNotEmpty()) {
|
||||
|
||||
Reference in new issue
Block a user