diff --git a/.gitignore b/.gitignore
index 1705c65..00a51a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,4 +30,11 @@ CLAUDE.md
# Other
archive/
.wrangler/
-stats-worker/
\ No newline at end of file
+stats-worker/
+
+# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
+*.p12
+*.pfx
+*.pem
+*.crt
+*.key
diff --git a/CLA.md b/CLA.md
new file mode 100644
index 0000000..9c7c7c8
--- /dev/null
+++ b/CLA.md
@@ -0,0 +1,102 @@
+# Gearslip Contributor License Agreement
+
+Version 1.0, October 2026
+
+Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company
+behind Gearslip, use your contribution. You keep the copyright in your work. You sign once,
+and the agreement covers every contribution you make to Gearslip, past and future.
+
+## Why we ask
+
+Gearslip is free software under the GNU Affero General Public License, version 3 (the
+"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the
+AGPL's terms, and that income helps pay for the project. Offering your code on those terms
+needs your permission, and this agreement gives it.
+
+## 1. Definitions
+
+- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company
+ (Secretary of State entity number B20260081700), under that name or any other, and its
+ successors and assigns.
+- **"You"** means the person who signs this agreement.
+- **"Contribution"** means any work of authorship, such as code, documentation, or images,
+ that you submit to Gearslip, whether before or after you sign. A submission includes a
+ pull request, patch, or issue attachment sent to a repository or channel we manage. It
+ excludes anything you mark in writing as "Not a Contribution".
+
+## 2. Copyright license
+
+You grant us, and anyone who receives software from us, a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative
+works of, publicly display, publicly perform, sublicense, and distribute your Contributions
+and derivative works of them.
+
+This license lets us offer your Contributions under licenses other than the AGPL, including
+commercial licenses.
+
+## 3. Patent license
+
+You grant us, and anyone who receives software from us, a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use,
+offer to sell, sell, import, and otherwise transfer your Contributions. This license covers
+only the patent claims you can license that your Contribution, alone or combined with
+Gearslip, would infringe.
+
+If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent
+licenses granted to that party for Gearslip end on the date the suit is filed.
+
+## 4. Our promise to you
+
+As long as we distribute a Contribution, we will also offer it under the AGPL or another
+license approved by the Open Source Initiative. Your work will always stay available as
+free software.
+
+## 5. Your promises
+
+You promise that:
+
+1. **The work is yours.** Each Contribution is your original creation, and you have the
+ right to grant these licenses.
+2. **Your employer agrees.** If your employer has rights in what you create, you have its
+ permission to contribute on its behalf, or it has waived its rights in your
+ Contributions.
+3. **You name other people's work.** If any part of a Contribution is not your own, you say
+ so when you submit it, and you name its source and its license.
+4. **You'll tell us of changes.** If any of these promises stops being true, you will tell
+ us.
+
+## 6. No warranty
+
+You provide your Contributions "as is", without warranties or conditions of any kind. You do
+not have to provide support for them.
+
+## 7. No obligation
+
+We are not required to use or include any Contribution.
+
+## 8. General
+
+- California law governs this agreement.
+- This agreement does not change the rights you have under the AGPL in Gearslip as
+ released.
+- We may publish a new version of this agreement. A new version applies only to
+ Contributions you make after you sign it; this version keeps covering the ones you made
+ under it.
+
+## Signing
+
+Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
+and you sign there with one click. Your GitHub account then serves as your signature.
+
+To sign by hand instead, fill in the following and send it to us on Matrix. The address is
+on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
+
+Name: ______________________________
+
+GitHub username: ______________________________
+
+Email: ______________________________
+
+Signature: ______________________________
+
+Date: ______________________________
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dde7066..0484ad1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -46,6 +46,10 @@ voice, state things positively, cut needless words, and use the serial comma.
## License
-Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a
-commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you
-agree that your contribution may be offered under that license as well.
+Gearslip is licensed under AGPL-3.0, and your contributions are too. Twink Software LLC, which
+owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md).
+
+Before we can merge your first pull request, you sign the
+[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays
+available as free software; the agreement lets us offer it under the commercial license too.
+A bot comments on your pull request with a link, and signing takes one click.
diff --git a/README.md b/README.md
index 65d23c3..b496660 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,11 @@ An Android Auto alternative built for everyone.
[](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip)
+## Which cars work
+
+Most cars work. Starting around 2020, some cars added newer security standards that reject
+Gearslip's certificate, and Gearslip can't run in those cars.
+
## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
@@ -63,8 +68,6 @@ mode.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player.
-- **Gearslip works best with cars from before 2020.** Some newer head units reject its
- certificate.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
diff --git a/docs/icon/archive/gearslip-icon-rounded.png b/docs/icon/archive/gearslip-icon-rounded.png
deleted file mode 100644
index ad868c9..0000000
Binary files a/docs/icon/archive/gearslip-icon-rounded.png and /dev/null differ
diff --git a/docs/icon/archive/gearslip-icon-rounded.svg b/docs/icon/archive/gearslip-icon-rounded.svg
deleted file mode 100644
index 95c7437..0000000
--- a/docs/icon/archive/gearslip-icon-rounded.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/icon/gearslip-icon.png b/docs/icon/gearslip-icon.png
index 3a632e6..69c182d 100644
Binary files a/docs/icon/gearslip-icon.png and b/docs/icon/gearslip-icon.png differ
diff --git a/docs/icon/gearslip-icon.svg b/docs/icon/gearslip-icon.svg
index 8b17565..c2231f9 100644
--- a/docs/icon/gearslip-icon.svg
+++ b/docs/icon/gearslip-icon.svg
@@ -5,7 +5,6 @@
-
diff --git a/docs/icon/on-device-verification.png b/docs/icon/on-device-verification.png
deleted file mode 100644
index fcc2b87..0000000
Binary files a/docs/icon/on-device-verification.png and /dev/null differ
diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts
index 0f8635e..6eed1ad 100644
--- a/gearslip/build.gradle.kts
+++ b/gearslip/build.gradle.kts
@@ -33,7 +33,8 @@ android {
minSdk = 31
targetSdk = 37
versionCode = 8
- versionName = "0.2.00-alpha"
+ // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
+ versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.2.00-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml
index 7ed34c4..5c21aa3 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
-
+
+
@@ -173,6 +176,11 @@
android:name=".notify.TestMessageReceiver"
android:exported="false" />
+
+
+
= emptyList()
@Volatile private var sensorChannelId: Int = -1
+ @Volatile private var mediaStatus: CarMediaStatus? = null
+ @Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List = emptyList()
@Volatile private var audioLink: AudioLink? = null
private var audioMessagesSeen = 0
@@ -107,6 +113,7 @@ class GearslipRunner(
try {
while (running) {
val read = input.read(chunk)
+ if (stopped) return
if (read < 0) {
log.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect()
@@ -120,16 +127,19 @@ class GearslipRunner(
}
}
} catch (t: Throwable) {
+ if (stopped) return
log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
reportDisconnect()
} finally {
running = false
+ writer.shutdownNow()
log.flush()
}
}
fun stop() {
+ stopped = true
running = false
writer.shutdownNow()
CarFocus.request = null
@@ -141,6 +151,10 @@ class GearslipRunner(
audioLink = null
CarSensors.clear()
CarMic.current = null
+ mediaStatus?.stop()
+ mediaStatus = null
+ clusterNav?.stop()
+ clusterNav = null
lastSensorNight = null
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
projection?.onProjectionStopped()
@@ -148,6 +162,8 @@ class GearslipRunner(
}
private fun handleFrame(frame: Frames.Frame) {
+ // A rejected handshake cannot consume later messages as if authentication succeeded.
+ if (authenticationFailed || stopped) return
val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) {
log.w("encrypted frame arrived before the handshake finished - ignoring")
@@ -176,6 +192,10 @@ class GearslipRunner(
onInputMessage(messageId, body)
} else if (frame.channel == sensorChannelId) {
onSensorMessage(messageId, body)
+ } else if (frame.channel == clusterNav?.channelId) {
+ clusterNav?.onMessage(messageId, body)
+ } else if (frame.channel == mediaStatus?.channelId) {
+ mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) {
@@ -225,7 +245,7 @@ class GearslipRunner(
}
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body)
- SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name)
+ SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
log.i("-> ByeByeResponse")
}
@@ -262,10 +282,14 @@ class GearslipRunner(
}
private fun onHandshake(body: ByteArray) {
+ if (state != State.TLS_HANDSHAKE && state != State.WAIT_AUTH) return
log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try {
- tls.pumpHandshake(body)
+ val preparedTls = tls
+ if (stopped) return
+ preparedTls.pumpHandshake(body)
} catch (t: Throwable) {
+ if (stopped) return
log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.verdict(
@@ -278,9 +302,11 @@ class GearslipRunner(
"The head unit aborted the secure handshake. It validates the phone's certificate.",
)
state = State.DONE
+ authenticationFailed = true
SessionReport.print()
return
}
+ if (stopped) return
replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
log.i("-> EncapsulatedSSL (${it.size} bytes)")
@@ -293,6 +319,7 @@ class GearslipRunner(
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
+ if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) }
@@ -304,7 +331,7 @@ class GearslipRunner(
sendServiceDiscoveryRequest()
}
-2 -> {
- SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
+ SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " +
@@ -316,9 +343,10 @@ class GearslipRunner(
"The head unit refused the phone's certificate (certificate error).",
)
state = State.DONE
+ authenticationFailed = true
}
-3 -> {
- SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
+ SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3")
log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " +
@@ -329,9 +357,10 @@ class GearslipRunner(
"Authentication failed. The head unit does not trust the certificate in use.",
)
state = State.DONE
+ authenticationFailed = true
}
else -> {
- SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
+ SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status")
log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
@@ -339,6 +368,7 @@ class GearslipRunner(
)
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
state = State.DONE
+ authenticationFailed = true
}
}
}
@@ -399,7 +429,7 @@ class GearslipRunner(
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) {
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)
+ SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service")
return
}
videoChannelId = video.serviceId
@@ -417,6 +447,8 @@ class GearslipRunner(
startAudioChannel(serviceDiscoveryResponse)
startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse)
+ startMediaStatusChannel(serviceDiscoveryResponse)
+ startClusterNavChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
if (input == null) {
@@ -478,6 +510,28 @@ class GearslipRunner(
openChannel(mic.serviceId)
}
+ /** What's playing, for the car's own screens: the cluster, the media source page. */
+ private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) {
+ val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run {
+ log.i("no media status channel - the car's own screens won't show what's playing")
+ return
+ }
+ log.i("media status channel: $channel")
+ mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
+ openChannel(channel)
+ }
+
+ /** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
+ private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
+ val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
+ log.i("no navigation status channel - the cluster won't show turns")
+ return
+ }
+ log.i("navigation status channel: $channel")
+ clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
+ openChannel(channel)
+ }
+
/**
* Every sensor type the head unit advertised, subscribed to at once - there is no screen
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
@@ -1065,10 +1119,10 @@ 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.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed")
+ State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed")
+ State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed")
+ State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed")
State.DONE -> {}
}
when (state) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt
index e253147..c651284 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt
@@ -31,6 +31,9 @@ object Protobuf {
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
}
+ fun bytesField(field: Int, bytes: ByteArray): ByteArray =
+ varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
+
fun varintField(field: Int, value: Long): ByteArray =
varint((field shl 3).toLong()) + varint(value)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt
index 7a29fc6..c9ffee5 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt
@@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) {
*/
fun dispatchKey(keyCode: Int, down: Boolean) {
val target = root ?: return
+ // Keys handed straight to the view skip the step where Android leaves touch mode, and in
+ // touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
+ if (target.isInTouchMode) {
+ target.requestFocusFromTouch()
+ GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
+ }
// Nothing on the display takes keys until something in it holds focus.
if (target.findFocus() == null) target.requestFocus()
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt
index 3cf57e6..2aad847 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt
@@ -110,6 +110,21 @@ object ServiceDiscovery {
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
}
+ /**
+ * The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9),
+ * which feeds its cluster and media screens. It carries no settings. Null when the car has none.
+ */
+ fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
+
+ /** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
+ fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
+
+ private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
+ Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
+ val service = Wire.fields(channel)
+ if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
+ }
+
/**
* The car's microphone, from MediaSourceService (Service field 5):
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt
index fe2a01a..65737e4 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt
@@ -29,12 +29,30 @@ object SessionReport {
UNKNOWN("unclassified"),
}
+ /**
+ * How far a session got, in the order a connection is built. The usage note's "Where
+ * connections fail" share sends the furthest one reached, so a failure says where it broke.
+ */
+ enum class Stage(val key: String) {
+ USB("usb"), // the link opened; the car may have said nothing
+ VERSIONS("versions"), // the car said which protocol it speaks
+ TLS("tls"), // the secure link is up
+ ACCEPTED("accepted"), // the car accepted the certificate
+ CHANNELS("channels"), // the car listed its channels and said who it is
+ VIDEO("video"), // the car's screen is set up for Gearslip
+ STREAMING("streaming"), // video frames are reaching the car
+ }
+
private var appVersion = "?"
private var appCode = 0L
@Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set
@Volatile private var failure = ""
+ @Volatile var stage = Stage.USB; private set
+ /** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */
+ @Volatile private var failCode = ""
+ @Volatile private var failedAfter = 0L
@Volatile private var protocolVersion = ""
@Volatile private var certSource = ""
@Volatile private var certIssuer = ""
@@ -62,6 +80,7 @@ object SessionReport {
startedAt = System.currentTimeMillis()
category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
+ stage = Stage.USB; failCode = ""; failedAfter = 0
tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null
carScreen = ""; carDpi = 0
@@ -69,34 +88,87 @@ object SessionReport {
printed = false
}
- fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
+ /** Moves the session's furthest stage on; never back. */
+ private fun reached(next: Stage) { if (next > stage) stage = next }
+
+ fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) }
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 tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
+ fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
fun headUnit(info: String) { headUnit = info }
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
+ set(value) { field = value; if (value != null) reached(Stage.CHANNELS) }
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
@Volatile var carScreen = ""; private set
@Volatile var carDpi = 0; private set
- fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi }
+ fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) }
fun profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary }
- fun stream(summary: String) { lastStream = summary }
+ fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) }
- /** Records the first failure only: later ones are usually fallout from it. */
- fun fail(category: Category, detail: String, state: String = "") {
+ /**
+ * Records the first failure only: later ones are usually fallout from it. [code] is the short
+ * thing the car or the phone said, like "auth -3" or "bye 1", for the usage note.
+ */
+ fun fail(category: Category, detail: String, state: String = "", code: String = "") {
if (this.category != Category.NONE) return
this.category = category
failure = detail
lastState = state
+ failCode = code
+ failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
}
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
- fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
+ fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t))
+
+ /**
+ * A short code from an exception, never its whole message: the TLS alert's name, the errno
+ * of a USB failure, or else the exception's class.
+ */
+ private fun codeOf(t: Throwable): String {
+ var root = t
+ while (root.cause != null && root.cause !== root) root = root.cause!!
+ val text = "${t.message} ${root.message}"
+ Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let {
+ return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}"
+ }
+ Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] }
+ return root.javaClass.simpleName.take(40)
+ }
+
+ /**
+ * One session for the usage note's "Where connections fail" share: how far it got, how and
+ * when it ended, and with which kind of certificate. Counts and codes only, nothing personal.
+ */
+ fun sessionNote(): org.json.JSONObject {
+ val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
+ val source = certSource.lowercase()
+ return org.json.JSONObject()
+ .put("stage", stage.key)
+ .put("ended", category.name.lowercase())
+ .put("code", failCode)
+ .put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter))
+ .put("seconds", rounded(seconds))
+ .put("cert", when {
+ "imported" in source -> "imported"
+ "downloaded" in source -> "downloaded"
+ "self" in source -> "self-signed"
+ source.isEmpty() -> ""
+ else -> "other"
+ })
+ }
+
+ /** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */
+ private fun rounded(s: Long): Long = when {
+ s < 60 -> (s + 2) / 5 * 5
+ s < 3600 -> (s + 30) / 60 * 60
+ else -> (s + 150) / 300 * 300
+ }
/** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String {
@@ -145,7 +217,13 @@ object SessionReport {
category == Category.NONE -> "ended before video"
else -> category.label
}
- runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
+ runCatching {
+ UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote())
+ }
+ if (outcome == "connected") runCatching {
+ app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
+ }
+ runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
}
GearslipLog.flush()
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt
index 8a7aa81..02b74f4 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt
@@ -45,7 +45,8 @@ class VideoSource(
private var frameIndex = 0L
fun start() {
- val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE)
+ val level = levelFor(width, height, frameRate)
+ val bitRate = defaultBitRate(width, height, frameRate, level)
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
@@ -74,7 +75,7 @@ class VideoSource(
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
}
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
- setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
+ setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
if (tuned) {
// Hints only: a car screen wants each frame now, not the best frame a moment later.
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
@@ -98,7 +99,7 @@ class VideoSource(
codec = encoder
running = true
- log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode")
+ log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline level ${level.label}, mode=$mode")
worker = Thread {
runCatching { pump(encoder) }
@@ -223,10 +224,40 @@ class VideoSource(
const val TIMEOUT_US = 10_000L
const val SURFACE_TIMEOUT_US = 100_000L
- /** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */
- const val BITS_PER_PIXEL = 0.3
- const val MIN_BIT_RATE = 4_000_000
- const val MAX_BIT_RATE = 12_000_000
+ /**
+ * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
+ * video in the browser stays sharp. Bigger screens get more, but less than in proportion,
+ * since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x,
+ * because frames that close together are mostly alike.
+ */
+ const val BASE_BIT_RATE = 8_000_000.0
+ const val BASE_PIXELS = 800.0 * 480
+ /** No car's decoder is asked for more than this, whatever its level allows. */
+ const val MAX_BIT_RATE = 25_000_000
+
+ /**
+ * The H.264 levels a car screen can need, smallest first: macroblocks a second each
+ * decodes, and the Baseline bitrate it must handle. The encoder declares the smallest
+ * that fits, and the bitrate stays within it, so a car decoder isn't asked for more than
+ * the level it was told about.
+ */
+ enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) {
+ L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000),
+ L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000),
+ L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000),
+ L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000),
+ }
+
+ fun levelFor(width: Int, height: Int, frameRate: Int): Level {
+ val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate
+ return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42
+ }
+
+ fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int {
+ val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75)
+ val rate = if (frameRate > 30) 1.5 else 1.0
+ return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE))
+ }
const val I_FRAME_INTERVAL_S = 5
private const val REPEAT_AFTER_US = 100_000L
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt
index 46fdeb9..d904be6 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt
@@ -60,14 +60,14 @@ class AudioLink(
}
/**
- * The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
- * body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
- * reaches READY and no audio is ever sent.
+ * The 2018 Uconnect and a Dacia ULC 4.5 both answer our Setup with a two-byte message we
+ * cannot identify (id 255, no body) rather than the AVChannelSetupResponse the video channel
+ * gets, so the sink never reaches READY. [MSG_SETUP_ANSWER] takes that as the go-ahead.
*
- * Service discovery already told us everything that message would have: the sink is PCM at
- * 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
- * message that is not coming, assume the advertised configuration and carry on. If that guess
- * is wrong the head unit simply will not ack, which the write path already reports.
+ * Service discovery already told us everything a Config would have: the sink's rate, channels
+ * and its one configuration. So for a car that answers nothing at all, wait a moment and then
+ * assume the advertised configuration. If that guess is wrong the head unit simply will not
+ * ack, which the write path already reports.
*/
private fun armConfigFallback() {
val timer = Thread({
@@ -76,19 +76,25 @@ class AudioLink(
} catch (e: InterruptedException) {
return@Thread
}
- if (ready) return@Thread
- GearslipLog.w(
- "audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
- "head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
- )
- configIndex = 0
- ready = true
- CarAudio.attach(this)
+ useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms")
}, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer
timer.start()
}
+ @Synchronized
+ private fun useAdvertisedSink(why: String) {
+ if (ready) return
+ configTimer?.interrupt()
+ GearslipLog.i(
+ "audio: $why - using the sink the head unit advertised " +
+ "(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
+ )
+ configIndex = 0
+ ready = true
+ CarAudio.attach(this)
+ }
+
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
@@ -123,8 +129,10 @@ class AudioLink(
}
}
- // Whatever the head unit answers Setup with, we need its bytes to identify it.
- else -> {
+ // Whatever else the head unit answers Setup with, we need its bytes to identify it.
+ else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) {
+ useAdvertisedSink("<- empty Setup answer (id $messageId)")
+ } else {
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
GearslipLog.hex(" audio msg", body, limit = 64)
}
@@ -243,6 +251,7 @@ class AudioLink(
private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772
+ private const val MSG_SETUP_ANSWER = 255
private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1
private const val FOCUS_RELEASE = 4
@@ -251,7 +260,7 @@ class AudioLink(
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
- private const val CONFIG_WAIT_MS = 2_500L
+ private const val CONFIG_WAIT_MS = 1_000L
private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt
index 3eeae41..ed4b4f0 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt
@@ -27,6 +27,11 @@ object CarKeys {
private val _screenKeys = MutableSharedFlow(extraBufferCapacity = 8)
val screenKeys: SharedFlow = _screenKeys
+ private val _focusMoves = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /** A knob turn or D-pad press, so controls that hide when left alone can come back. */
+ val focusMoves: SharedFlow = _focusMoves
+
/** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
@@ -58,6 +63,7 @@ object CarKeys {
*/
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) {
+ _focusMoves.tryEmit(Unit)
focusSink?.invoke(keyCode, down)
return true
}
@@ -86,6 +92,7 @@ object CarKeys {
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) {
val sink = focusSink ?: return
+ _focusMoves.tryEmit(Unit)
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
sink(code, true)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
index 6669c07..5a4e7d0 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup {
return when (app.kind) {
"calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
+ // Template-only media apps may have no legacy browser/player tile to identify them.
+ "media" -> AppGroup.MUSIC
"messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
index cb8e114..6c6d920 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
@@ -33,6 +33,12 @@ object CarServices {
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
+ /** [nav], or null before the car screen has set things up. */
+ fun navOrNull(): CarAppConnection? = if (context != null) nav else null
+
+ /** [media], or null before the car screen has set things up: for code outside the car UI. */
+ fun mediaOrNull(): CarMedia? = if (context != null) media else null
+
/**
* A second, independent connection for templated apps that aren't navigation apps - Spotify's
* own Car App Library service is the first of these. Kept apart from [nav] so opening one
@@ -121,6 +127,19 @@ object CarServices {
if (CarSettings.pipeAudio.value) CarAudio.request()
}
+ /**
+ * Reopens the media app used last, for the car's media button when nothing is open. False
+ * when there's none on record or it's been uninstalled since.
+ */
+ suspend fun reopenLastMedia(): Boolean {
+ val ctx = context ?: return false
+ val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
+ val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
+ .firstOrNull { it.component == last } ?: return false
+ openMedia(app)
+ return true
+ }
+
/** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() {
if (media.now.value.playing) media.togglePlay()
@@ -194,6 +213,32 @@ object CarServices {
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
}
+ /**
+ * The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of
+ * the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
+ * headphones are, so the apps won't pause themselves. Every playing session is paused, not
+ * just the one Gearslip opened: anything the car was playing was going through the car.
+ */
+ fun pausePhoneMedia() {
+ val ctx = context ?: return
+ val manager = ctx.getSystemService(MediaSessionManager::class.java)
+ val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
+ val playing = try {
+ manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
+ } catch (e: SecurityException) {
+ null
+ }
+ if (playing == null) {
+ // No notification access: the app Gearslip itself is driving is all it can reach.
+ mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
+ return
+ }
+ playing.forEach { runCatching { it.transportControls.pause() } }
+ if (playing.isNotEmpty()) {
+ app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }} as the car left")
+ }
+ }
+
/** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
index 99616be..f59ddbd 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
+import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +28,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
+ private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
@@ -148,10 +150,20 @@ object CarSettings {
* Whether a media app's sound is captured and sent over the car link. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
- * somewhere to play it. Turned off, the app plays through the phone's normal output instead.
+ * somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
+ * phone's normal output instead, and Android never asks.
*/
val pipeAudio: StateFlow = pipeAudioFlow
+ /**
+ * Whether the driver has already been offered Bluetooth after turning down the capture
+ * dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
+ */
+ val audioChoiceAsked: Boolean
+ get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
+
+ fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
+
/**
* Off by default: a driver has to opt into experimental features as a whole before any of
* them - the voice assistant included - show up to turn on individually.
@@ -265,9 +277,15 @@ object CarSettings {
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
}
+ /**
+ * Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
+ * back asks for it again (or waits for the car, if none is connected).
+ */
fun setPipeAudio(on: Boolean) {
+ if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
+ if (on) CarAudio.request() else CarAudio.release()
}
fun setExperimentalFeaturesEnabled(on: Boolean) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
index fe1a617..6f0f53f 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
) { CarSettings.setAutoplay(it) }
ChoiceRow(
- "Media audio",
- listOf(false to "Phone audio", true to "Car audio"),
+ "Car audio",
+ listOf(false to "Bluetooth", true to "USB"),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
- "Car audio sends the phone's sound to the car over USB. Android asks for permission " +
- "when the car connects, and a few apps block it. Phone audio plays as the phone " +
- "normally would, for example over Bluetooth to the car.",
+ "USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
+ "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
+ "Android never asks.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt
new file mode 100644
index 0000000..fb877e7
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt
@@ -0,0 +1,62 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+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.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalAccessibilityManager
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+
+/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
+@Composable
+internal fun CarToastOverlay(modifier: Modifier = Modifier) {
+ val current by CarToasts.message.collectAsState()
+ val message = current ?: return
+ val accessibility = LocalAccessibilityManager.current
+ LaunchedEffect(message, accessibility) {
+ val timeout = accessibility?.calculateRecommendedTimeoutMillis(
+ message.durationMs, containsIcons = false, containsText = true, containsControls = false,
+ ) ?: message.durationMs
+ // The full time, unless another toast is waiting: then it gives way after the minimum.
+ delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
+ withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
+ CarToasts.waiting.first { it > 0 }
+ }
+ CarToasts.dismiss(message)
+ }
+
+ // A non-interactive overlay leaves the map's touch handling and focus intact.
+ Box(
+ modifier
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ .widthIn(max = 560.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .semantics { liveRegion = LiveRegionMode.Polite },
+ ) {
+ Text(
+ message.text,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt
new file mode 100644
index 0000000..6d3e300
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt
@@ -0,0 +1,65 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.annotation.MainThread
+import androidx.car.app.CarToast
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * One toast at a time across navigation and browse apps. A newer one waits its turn instead of
+ * replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
+ */
+@MainThread
+internal object CarToasts {
+ // Identity matters: repeating identical text must restart its display timer.
+ class Message(val owner: Any, val text: String, val durationMs: Long)
+
+ /** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
+ const val MIN_SHOWN_MS = 2_000L
+
+ /** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
+ private const val MAX_WAITING = 3
+
+ private val current = MutableStateFlow(null)
+ val message: StateFlow = current.asStateFlow()
+
+ private val queue = ArrayDeque()
+ private val _waiting = MutableStateFlow(0)
+ val waiting: StateFlow = _waiting.asStateFlow()
+
+ fun show(owner: Any, text: String, duration: Int) {
+ if (text.isBlank()) return
+ // Longer than a phone toast: the driver only glances at the screen now and then.
+ val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
+ val next = Message(owner, text, durationMs)
+ val shown = current.value
+ when {
+ shown == null -> current.value = next
+ // The same words again restart the one on screen rather than queueing a copy.
+ shown.text == text -> current.value = next
+ queue.lastOrNull()?.text == text -> Unit
+ else -> {
+ queue.addLast(next)
+ while (queue.size > MAX_WAITING) queue.removeFirst()
+ }
+ }
+ _waiting.value = queue.size
+ }
+
+ fun dismiss(message: Message) {
+ // An old timer must not dismiss a newer toast, even if its text is identical.
+ if (current.value === message) advance()
+ }
+
+ fun clear(owner: Any) {
+ // Disconnecting the map must not clear a toast belonging to the browse app.
+ queue.removeAll { it.owner === owner }
+ if (current.value?.owner === owner) advance() else _waiting.value = queue.size
+ }
+
+ private fun advance() {
+ current.value = queue.removeFirstOrNull()
+ _waiting.value = queue.size
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
index 03baae2..c72f681 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -43,6 +44,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalView
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -120,12 +125,16 @@ fun CarUi() {
controlsWake.tick()
}
}
+ // The knob counts as a touch: the map's buttons can't be found while they're faded out.
+ LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
+ // A new version gets one card in the top right, a few seconds in and only while parked.
+ LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -150,14 +159,27 @@ fun CarUi() {
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
else -> navigator.back()
}
- CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home()
- CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps()
+ // Home is the dashboard, over the map, so Back from it lands on the map.
+ CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
+ CarKeys.ScreenKey.MAP -> navigator.home()
+ CarKeys.ScreenKey.MEDIA ->
+ if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
+ else navigator.apps()
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
}
}
}
+ // The car screen is drawn on the phone, so every long press or text-handle drag would buzz
+ // the phone in its mount. Nobody feels that while driving; it's only noise.
+ val view = LocalView.current
+ DisposableEffect(view) {
+ view.isHapticFeedbackEnabled = false
+ onDispose { }
+ }
+
CompositionLocalProvider(
+ LocalHapticFeedback provides NoHaptics,
LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside,
@@ -165,6 +187,7 @@ fun CarUi() {
LocalGsTheme provides GsThemes.byId(uiThemeId),
) {
MaterialTheme(colorScheme = colors) {
+ CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
Surface(
Modifier
.fillMaxSize()
@@ -224,6 +247,8 @@ fun CarUi() {
}
}
}
+ CarToastOverlay(Modifier.align(Alignment.BottomCenter))
+ CallPromptOverlay()
PopupTimer()
// The dashboard is already listing them, so it needs no popup.
NotificationPopup(
@@ -252,6 +277,7 @@ fun CarUi() {
if (safetyWarningSeen) CarWhatsNew()
}
}
+ }
}
}
}
@@ -879,3 +905,8 @@ private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialT
drawPath(bolt, tint)
}
}
+
+/** Stands in for the phone's vibration motor on the car screen: does nothing. */
+private object NoHaptics : HapticFeedback {
+ override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt
new file mode 100644
index 0000000..fda6c4c
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt
@@ -0,0 +1,196 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.car.app.model.CarText
+import androidx.car.app.model.Distance
+import androidx.car.app.navigation.model.Maneuver
+import androidx.car.app.navigation.model.NavigationTemplate
+import androidx.car.app.navigation.model.RoutingInfo
+import androidx.car.app.navigation.model.Step
+import app.seb3thehacker.gearslip.GearslipLog
+import app.seb3thehacker.gearslip.Protobuf
+
+/**
+ * The next turn on the car's instrument cluster, over its NavigationStatusService (Service
+ * field 8), the way Android Auto feeds it:
+ *
+ * -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) }
+ * -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 }
+ * -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2;
+ * display_distance_e3 = 3; display_distance_unit = 4 }
+ *
+ * Newer cars also take a NavigationState message; the deprecated pair above is what a 2018
+ * Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands
+ * to clusters), or from its routing card when it sends no trip.
+ */
+class ClusterNav(
+ val channelId: Int,
+ private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
+) {
+ @Volatile private var running = false
+ private var sentStatus = -1
+ private var sentTurn: String? = null
+ private var sentDistance: String? = null
+
+ fun onMessage(messageId: Int, body: ByteArray) {
+ when (messageId) {
+ MSG_CHANNEL_OPEN_RESPONSE -> {
+ val status = Protobuf.readInt32Field(body, 1) ?: 0
+ GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
+ if (status == 0) start()
+ }
+ // The car saying when it wants turns. Sending all along is harmless, so these are only logged.
+ MSG_START -> GearslipLog.i("cluster: <- start")
+ MSG_STOP -> GearslipLog.i("cluster: <- stop")
+ else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
+ }
+ }
+
+ private fun start() {
+ if (running) return
+ running = true
+ Thread({
+ while (running) {
+ runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
+ Thread.sleep(TICK_MS)
+ }
+ }, "gearslip-cluster").start()
+ }
+
+ fun stop() {
+ running = false
+ }
+
+ /** One turn as the car wants it, from whichever source the map app fills. */
+ private class Turn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean)
+
+ private fun current(): Turn? {
+ val nav = CarServices.navOrNull() ?: return null
+ nav.trip.value?.let { trip ->
+ val step = trip.steps.firstOrNull() ?: return@let
+ val estimate = trip.stepTravelEstimates.firstOrNull()
+ return Turn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading)
+ }
+ val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
+ val step = info.currentStep ?: return null
+ return Turn(step, info.currentDistance, null, info.isLoading)
+ }
+
+ private fun tick() {
+ val turn = current()
+ val status = when {
+ turn == null -> STATUS_INACTIVE
+ turn.rerouting -> STATUS_REROUTING
+ else -> STATUS_ACTIVE
+ }
+ if (status != sentStatus) {
+ sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong()))
+ GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}")
+ sentStatus = status
+ if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null }
+ }
+ if (turn == null || status == STATUS_REROUTING) return
+ sendTurn(turn.step)
+ turn.distance?.let { sendDistance(it, turn.seconds) }
+ }
+
+ private fun sendTurn(step: Step) {
+ val maneuver = step.maneuver
+ val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
+ val road = step.road.text().ifBlank { step.cue.text() }
+ val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
+ if (key == sentTurn) return
+ sentTurn = key
+ var body = Protobuf.stringField(1, road) +
+ Protobuf.varintField(2, side.toLong()) +
+ Protobuf.varintField(3, event.toLong())
+ maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) }
+ maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) }
+ sendOnChannel(MSG_TURN, body)
+ GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"")
+ }
+
+ /**
+ * The Car App Library's distance units use the same numbers as the protocol's (1 metres ...
+ * 7 yards), so the app's own display choice passes straight through.
+ */
+ private fun sendDistance(distance: Distance, seconds: Long?) {
+ val meters = toMeters(distance)
+ val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}"
+ if (key == sentDistance) return
+ sentDistance = key
+ sendOnChannel(
+ MSG_DISTANCE,
+ Protobuf.varintField(1, meters.toLong()) +
+ Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) +
+ Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) +
+ Protobuf.varintField(4, distance.displayUnit.toLong()),
+ )
+ }
+
+ private fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) {
+ Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
+ Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
+ Distance.UNIT_FEET -> 0.3048
+ Distance.UNIT_YARDS -> 0.9144
+ else -> 1.0
+ }).toInt()
+
+ private fun CarText?.text() = this?.toString().orEmpty()
+
+ companion object {
+ private const val TICK_MS = 500L
+ private const val MSG_CHANNEL_OPEN_RESPONSE = 8
+ private const val MSG_START = 32769
+ private const val MSG_STOP = 32770
+ private const val MSG_STATUS = 32771
+ private const val MSG_TURN = 32772
+ private const val MSG_DISTANCE = 32773
+
+ private const val STATUS_ACTIVE = 1
+ private const val STATUS_INACTIVE = 2
+ private const val STATUS_REROUTING = 3
+ private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting")
+
+ private const val SIDE_LEFT = 1
+ private const val SIDE_RIGHT = 2
+ private const val SIDE_UNSPECIFIED = 3
+
+ private const val EVENT_UNKNOWN = 0
+
+ /** The car's turn icon and side for a Car App Library manoeuvre. */
+ fun eventFor(type: Int): Pair = when (type) {
+ Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT
+ Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT
+ Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT
+ Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT
+ Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT
+ Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT
+ Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT
+ Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT
+ Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
+ Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT
+ Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
+ Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT
+ Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT
+ Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT
+ Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT
+ Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT
+ Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT
+ Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT
+ Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
+ Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED
+ Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT
+ Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT
+ else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED
+ }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt
new file mode 100644
index 0000000..bc063c5
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt
@@ -0,0 +1,84 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.compose.foundation.IndicationNodeFactory
+import androidx.compose.foundation.interaction.FocusInteraction
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.material3.ripple
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.invalidateDraw
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+
+/**
+ * What every car button shows: the usual ripple when pressed, and a ring in the accent color
+ * while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes
+ * focus while the screen is in touch mode.
+ *
+ * It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that
+ * doesn't pass its own indication gets the ring for free.
+ */
+internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory {
+ private val ripple = ripple()
+
+ override fun create(interactionSource: InteractionSource): DelegatableNode =
+ FocusRingNode(ripple.create(interactionSource), interactionSource, color)
+
+ override fun equals(other: Any?) = other is FocusRingIndication && other.color == color
+ override fun hashCode() = color.hashCode()
+}
+
+private class FocusRingNode(
+ rippleNode: DelegatableNode,
+ private val source: InteractionSource,
+ private val color: Color,
+) : DelegatingNode(), DrawModifierNode {
+ private var focused = false
+
+ init { delegate(rippleNode) }
+
+ override fun onAttach() {
+ coroutineScope.launch {
+ var holding = 0
+ source.interactions.collect {
+ when (it) {
+ is FocusInteraction.Focus -> holding++
+ is FocusInteraction.Unfocus -> holding--
+ }
+ val now = holding > 0
+ if (now != focused) {
+ focused = now
+ invalidateDraw()
+ }
+ }
+ }
+ }
+
+ override fun ContentDrawScope.draw() {
+ drawContent()
+ if (!focused) return
+ // Inside the button's bounds, since most buttons clip to their shape: drawn outside, a
+ // rounded tile would cut its own ring off.
+ val width = RING_WIDTH.toPx()
+ val radius = minOf(CORNER.toPx(), size.minDimension / 2)
+ drawRoundRect(
+ color,
+ topLeft = Offset(width / 2, width / 2),
+ size = Size(size.width - width, size.height - width),
+ cornerRadius = CornerRadius(radius),
+ style = Stroke(width),
+ )
+ }
+
+ private companion object {
+ val RING_WIDTH = 3.dp
+ val CORNER = 16.dp
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
index cb21b59..a7ba655 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@@ -89,6 +90,31 @@ internal fun placeCall(context: Context, number: String) {
}
}
+/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
+internal object CallPrompt {
+ val pending = kotlinx.coroutines.flow.MutableStateFlow(null)
+
+ fun ask(name: String, number: String) {
+ pending.value = Contact(name, number)
+ }
+}
+
+/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */
+@Composable
+internal fun CallPromptOverlay() {
+ val context = LocalContext.current
+ val contact by CallPrompt.pending.collectAsState()
+ val c = contact ?: return
+ ConfirmCallOverlay(
+ c,
+ onConfirm = {
+ CallPrompt.pending.value = null
+ placeCall(context, c.number)
+ },
+ onDismiss = { CallPrompt.pending.value = null },
+ )
+}
+
private const val TAB_CONTACTS = 0
private const val TAB_KEYPAD = 1
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
index 0d07349..1111404 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
@@ -3,8 +3,10 @@ package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers
@@ -29,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning
@@ -231,7 +234,9 @@ internal fun RightEdgeControls(
// 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
+ // Kept up while the knob is on one of them, or the ring would fade out along with the button.
+ var holdsFocus by remember { mutableStateOf(false) }
+ val visible = awake || pan || holdsFocus
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),
@@ -240,6 +245,7 @@ internal fun RightEdgeControls(
Column(
modifier
.alpha(alpha)
+ .onFocusChanged { holdsFocus = it.hasFocus }
.let { base ->
if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope {
@@ -468,7 +474,10 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
val white = remember(bitmap) { bitmap.isPlainWhite() }
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
// text so it stays readable on a light one.
- val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white }
+ // An app's own tint is kept only if it can be seen: MAPS.ME asks for dark grey on dark
+ // buttons. Android Auto drops colours that fail contrast the same way.
+ val tint = icon?.tint?.color(dark, content)?.takeIf { it.readableOn(content) }
+ ?: content.takeIf { white || icon?.tint != null }
Image(
bitmap, contentDescription = null, modifier = modifier,
colorFilter = tint?.let { ColorFilter.tint(it) },
@@ -587,16 +596,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
// 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() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
+ val card = PlaceCard.of(rows)
Column(modifier) {
- rows.forEach { RowItem(it, large = true) }
+ card.heading?.let { PlaceHeading(it) }
+ if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
+ if (card.heading == null && card.buttons.isEmpty()) {
+ card.details.forEach { RowItem(it, large = true) }
+ } else {
+ card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
+ }
}
}
+/**
+ * A pane split the way a place page reads: a name on top, the things to do with it as buttons,
+ * then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
+ * shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
+ * heading and no buttons, and draws exactly as a plain list of rows.
+ */
+private class PlaceCard(val heading: CarRow?, val buttons: List, val details: List) {
+ companion object {
+ fun of(rows: List): PlaceCard {
+ // A short tappable row with an icon and nothing under it is a button in all but name:
+ // MAPS.ME's "Let's go" and "Save".
+ val buttons = rows.filter {
+ it.onClickDelegate != null && it.toggle == null && it.image != null &&
+ it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
+ }.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
+ // A first row that can't be tapped and has no icon is the place's name and kind.
+ val heading = rows.firstOrNull()?.takeIf {
+ buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
+ }
+ return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
+ }
+ }
+}
+
+@Composable
+private fun PlaceHeading(row: CarRow) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
+ Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ row.texts.orEmpty().take(2).forEach {
+ Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
+
+/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
+@Composable
+private fun RowButtons(rows: List, modifier: Modifier) {
+ val scheme = MaterialTheme.colorScheme
+ Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ rows.forEachIndexed { i, row ->
+ GsIconBox(
+ onClick = { row.onClickDelegate.click("row") },
+ modifier = Modifier.weight(1f),
+ colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
+ enabled = row.isEnabled,
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CarGlyph(row.image, Modifier.size(24.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * One fact about a place, tighter than a list row: a small label over its value, or just the
+ * value when the row has no label. A phone number gets a Call button that asks first.
+ */
+@Composable
+private fun DetailRow(row: CarRow, placeName: String) {
+ val context = LocalContext.current
+ val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
+ val label = row.title.text().takeIf { texts.isNotEmpty() }
+ val values = texts.ifEmpty { listOf(row.title.text()) }
+ val number = values.firstOrNull { it.isPhoneNumber() }
+ val canCall = number != null &&
+ context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
+ .padding(horizontal = 14.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ row.image?.let {
+ CarGlyph(it, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ label?.let {
+ Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ values.take(3).forEach {
+ Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ if (canCall) {
+ Spacer(Modifier.width(8.dp))
+ GsIconBox(
+ onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
+ colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
+ androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("Call", style = ChromeType.body)
+ }
+ }
+ }
+ ActionRow(row.actions.orEmpty())
+ }
+}
+
+/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
+private fun String.isPhoneNumber(): Boolean {
+ val t = trim()
+ if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
+ return t.count { it.isDigit() } in 7..15
+}
+
@Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
@@ -661,6 +795,17 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
/** 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)
+/**
+ * Whether this colour stands out against the background that [content] was chosen for: at least
+ * 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
+ */
+private fun Color.readableOn(content: Color): Boolean {
+ val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
+ val a = luminance() + 0.05f
+ val b = background.luminance() + 0.05f
+ return maxOf(a, b) / minOf(a, b) >= 3f
+}
+
internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt
index 868fbb7..7883fc1 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject
@@ -38,6 +40,7 @@ import org.json.JSONObject
fun WebApp(content: String) {
val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current
+ val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
var web by remember { mutableStateOf(null) }
var fieldText by remember { mutableStateOf(null) }
@@ -65,7 +68,7 @@ fun WebApp(content: String) {
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
- webViewClient = LoggingClient()
+ webViewClient = LoggingClient(ring)
addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField",
@@ -99,10 +102,11 @@ fun WebApp(content: String) {
}
}
-private class LoggingClient : WebViewClient() {
+private class LoggingClient(private val ring: String) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
+ view?.evaluateJavascript(focusRingJs(ring), null)
}
// onPageFinished fires for error pages too, so without this a blocked load (missing
@@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """
})();
"""
+/**
+ * The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many
+ * sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are.
+ */
+private fun focusRingJs(color: String) = """
+ (function() {
+ if (document.getElementById('gearslip-focus-ring')) return;
+ var style = document.createElement('style');
+ style.id = 'gearslip-focus-ring';
+ style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }';
+ (document.head || document.documentElement).appendChild(style);
+ })();
+"""
+
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
private const val FOCUS_TRACKING_JS = """
(function() {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt
index 44cafd3..7488389 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt
@@ -50,6 +50,8 @@ enum class HoldMarkerStyle {
* colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the
* dot whatever the theme says: a circle has no corner to peel.
*/
+private const val SHOW_HOLD_DOT = false
+
@Composable
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
if (style == HoldMarkerStyle.NONE) return
@@ -68,8 +70,9 @@ internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, sha
val side = min(size.width, size.height)
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
val round = r >= side * 0.4f
+ // The dot is off until it's redesigned: flat themes and round controls show nothing.
if (style == HoldMarkerStyle.DOT || round) {
- drawHoldDot(r, side, ink)
+ if (SHOW_HOLD_DOT) drawHoldDot(r, side, ink)
} else {
// Drawn for the bottom-right corner, then flipped up to the top-right.
scale(1f, -1f, pivot = center) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
index a9b2ca6..31ad0db 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt
@@ -78,5 +78,7 @@ object CarAppCatalog {
CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP,
+ // Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it.
+ "androidx.car.app.category.MEDIA",
)
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
index a332887..14d8e0a 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment
+import app.seb3thehacker.gearslip.car.CarToasts
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -9,6 +10,7 @@ import android.content.res.Configuration
import android.graphics.Rect
import android.location.Location
import android.net.Uri
+import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
@@ -80,6 +82,8 @@ class CarAppConnection(private val context: Context) {
val template: StateFlow = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper())
+ /** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */
+ private var toastOwner = Any()
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
@@ -139,7 +143,11 @@ class CarAppConnection(private val context: Context) {
// Replaced by another connect (or a disconnect) while waiting on the location service.
if (binding !== connection) return@bind
var bindError: Throwable? = null
- val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES
+ // BIND_ALLOW_ACTIVITY_STARTS lets the app start its own location service while
+ // Gearslip's car window is showing. Since Android 14 a bound app only inherits that
+ // from a visible host that opts in, and MapQuest crashes as navigation starts without it.
+ val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES or
+ (if (Build.VERSION.SDK_INT >= 34) Context.BIND_ALLOW_ACTIVITY_STARTS else 0)
val bound = runCatching {
// Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is
// all Gearslip is once the phone screen leaves it) only reaches "important
@@ -215,6 +223,7 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) {
+ clearToasts()
lent?.let { sendSurfaceDestroyed(it) }
lent = null
val carApp = this.carApp
@@ -285,7 +294,7 @@ class CarAppConnection(private val context: Context) {
?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) {
- app.onAppCreate(carHost, intent, carConfiguration(), it)
+ app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it)
}
}
@@ -558,14 +567,19 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into -----------------------------------------
- private val appHost = object : IAppHost.Stub() {
+ private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun invalidate() {
GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() }
}
override fun showToast(text: CharSequence?, duration: Int) {
- GearslipLog.i("host: toast \"$text\"")
+ val message = text?.toString() ?: return
+ main.post {
+ if (toastOwner !== owner) return@post
+ GearslipLog.i("host: toast \"$message\"")
+ CarToasts.show(owner, message, duration)
+ }
}
/**
@@ -587,10 +601,19 @@ class CarAppConnection(private val context: Context) {
override fun openMicrophone(request: Bundleable?): Bundleable? = null
}
+ private val _trip = MutableStateFlow(null)
+ /** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */
+ val trip: StateFlow = _trip.asStateFlow()
+
private val navigationHost = object : INavigationHost.Stub() {
override fun navigationStarted() = GearslipLog.i("host: navigation started")
- override fun navigationEnded() = GearslipLog.i("host: navigation ended")
- override fun updateTrip(trip: Bundleable?) = Unit
+ override fun navigationEnded() {
+ GearslipLog.i("host: navigation ended")
+ _trip.value = null
+ }
+ override fun updateTrip(trip: Bundleable?) {
+ _trip.value = runCatching { trip?.get() as? androidx.car.app.navigation.model.Trip }.getOrNull()
+ }
}
/**
@@ -656,7 +679,9 @@ class CarAppConnection(private val context: Context) {
override fun updateSuggestions(suggestions: Bundleable?) = Unit
}
- private val carHost = object : ICarHost.Stub() {
+ private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
+ private val appHost = createAppHost(owner)
+
override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
@@ -745,10 +770,16 @@ class CarAppConnection(private val context: Context) {
}
private fun fail(phase: Phase, detail: String) {
+ clearToasts()
GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) }
}
+ private fun clearToasts() {
+ CarToasts.clear(toastOwner)
+ toastOwner = Any()
+ }
+
private fun update(block: (Status) -> Status) {
_status.value = block(_status.value)
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt
index 79caa60..74289ee 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt
@@ -18,6 +18,8 @@ object KnownApps {
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
"com.metrolist.music", // Metrolist: browse, play, works
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
+ "com.mapquest.android.ace", // MapQuest: map, routing, navigation
+ "com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
)
fun works(packageName: String): Boolean = packageName in working
@@ -71,7 +73,6 @@ object KnownApps {
*/
private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
- "com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first
)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt
new file mode 100644
index 0000000..0c1088a
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt
@@ -0,0 +1,119 @@
+package app.seb3thehacker.gearslip.media
+
+import android.graphics.Bitmap
+import android.media.session.PlaybackState
+import app.seb3thehacker.gearslip.GearslipLog
+import app.seb3thehacker.gearslip.Protobuf
+import app.seb3thehacker.gearslip.car.CarServices
+import java.io.ByteArrayOutputStream
+
+/**
+ * Tells the car what's playing, so its own screens show it: the instrument cluster, the media
+ * source screen, the steering wheel display. Android Auto does the same over the car's
+ * MediaPlaybackStatusService (Service field 9):
+ *
+ * -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 }
+ * -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2;
+ * playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 }
+ *
+ * Read once a second from the media app Gearslip is driving. The track goes only when it changes;
+ * the status goes every second while playing, so the car's position counts along.
+ */
+class CarMediaStatus(
+ val channelId: Int,
+ private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
+) {
+ @Volatile private var running = false
+ private var sentTrack: String? = null
+ private var sentState = -1
+ private var inputsLogged = 0
+
+ fun onMessage(messageId: Int, body: ByteArray) {
+ when (messageId) {
+ MSG_CHANNEL_OPEN_RESPONSE -> {
+ val status = Protobuf.readInt32Field(body, 1) ?: 0
+ GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
+ if (status == 0) start()
+ }
+ // The car's own buttons for the media source (play, next...). Logged to learn what
+ // this car sends; the steering wheel keys already arrive on the input channel.
+ MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body))
+ else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)")
+ }
+ }
+
+ private fun start() {
+ if (running) return
+ running = true
+ Thread({
+ while (running) {
+ runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") }
+ Thread.sleep(TICK_MS)
+ }
+ }, "gearslip-media-status").start()
+ }
+
+ fun stop() {
+ running = false
+ }
+
+ private fun tick() {
+ val now = CarServices.mediaOrNull()?.now?.value
+ val active = now != null && now.isActive
+ val state = when {
+ !active -> STATE_STOPPED
+ now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED
+ else -> STATE_PLAYING
+ }
+ if (active) sendTrack(now!!)
+ // Nothing to say while stopped, beyond saying so once.
+ if (state == STATE_STOPPED && sentState == STATE_STOPPED) return
+ if (state == STATE_PAUSED && sentState == STATE_PAUSED) return
+ val source = CarServices.mediaApp.value?.label ?: "Gearslip"
+ val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0
+ sendOnChannel(
+ MSG_STATUS,
+ Protobuf.varintField(1, state.toLong()) +
+ Protobuf.stringField(2, source) +
+ Protobuf.varintField(3, seconds) +
+ Protobuf.varintField(4, 0L) +
+ Protobuf.varintField(5, 0L) +
+ Protobuf.varintField(6, 0L),
+ )
+ if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)")
+ sentState = state
+ }
+
+ private fun sendTrack(now: NowPlaying) {
+ val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
+ if (key == sentTrack) return
+ sentTrack = key
+ var body = Protobuf.stringField(1, now.title) +
+ Protobuf.stringField(2, now.artist) +
+ Protobuf.stringField(3, now.album)
+ now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } }
+ if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000)
+ sendOnChannel(MSG_METADATA, body)
+ GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}")
+ }
+
+ /** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */
+ private fun artBytes(art: Bitmap): ByteArray? = runCatching {
+ val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height))
+ val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art
+ ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray()
+ }.getOrNull()
+
+ companion object {
+ private const val TICK_MS = 1_000L
+ private const val ART_PX = 256
+ private const val MSG_CHANNEL_OPEN_RESPONSE = 8
+ private const val MSG_STATUS = 32769
+ private const val MSG_INPUT = 32770
+ private const val MSG_METADATA = 32771
+ private const val STATE_STOPPED = 1
+ private const val STATE_PLAYING = 2
+ private const val STATE_PAUSED = 3
+ private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused")
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
index d03b176..8c2c507 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
@@ -32,6 +32,8 @@ object UsageStats {
val android: Boolean = true,
val phone: Boolean = true,
val cars: Boolean = true,
+ /** How each car session went: how far it got, how and when it ended. Off until ticked. */
+ val failures: Boolean = false,
)
private const val PREFS = "usage_stats"
@@ -49,6 +51,10 @@ object UsageStats {
/** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
private const val OLD_JOB_ID = 4_207
private const val MAX_CARS = 10
+ /** Each session for the "Where connections fail" share, waiting for the next note. */
+ private const val KEY_SESSIONS = "sessions"
+ private const val KEY_LAST_SESSIONS = "last_sessions"
+ private const val MAX_SESSIONS = 20
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -69,20 +75,27 @@ object UsageStats {
android = p.getBoolean("share_android", true),
phone = p.getBoolean("share_phone", true),
cars = p.getBoolean("share_cars", true),
+ failures = p.getBoolean("share_failures", false),
)
}
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
+ val wasEnabled = enabled(context)
prefs(context).edit()
.putBoolean(KEY_ENABLED, enabled)
.putBoolean(KEY_ASKED, true)
.putBoolean("share_android", choices.android)
.putBoolean("share_phone", choices.phone)
.putBoolean("share_cars", choices.cars)
+ .putBoolean("share_failures", choices.failures)
.apply()
// Turning it on sends nothing yet: the driver may still be ticking boxes. The first note
// waits for [choicesClosed].
- if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply()
+ if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY)
+ .remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
+ if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
+ // Turned on anywhere - Settings, Home, or the ask itself - answers the one-time ask.
+ if (enabled && !wasEnabled) WorkedPrompt.onSharingOn(context)
}
/** The driver left the screen with the usage note choices: send today's note if it's due. */
@@ -90,10 +103,15 @@ object UsageStats {
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
}
- /** Gearslip was opened: send today's note unless one already went out. */
+ /**
+ * Gearslip was opened: send today's note unless one already went out, or sooner if a session
+ * is still waiting, like one that ended in a crash before its note could go.
+ */
fun onAppOpened(context: Context) {
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID)
- if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
+ val p = prefs(context)
+ val waiting = p.getString(KEY_CARS, "[]") != "[]" || p.getString(KEY_SESSIONS, "[]") != "[]"
+ if (waiting || p.getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
}
private val sending = AtomicBoolean(false)
@@ -134,13 +152,35 @@ object UsageStats {
outcome: String,
screen: String = "",
dpi: Int = 0,
+ session: JSONObject? = null,
) {
if (!enabled(context)) return
val p = prefs(context)
- synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
+ synchronized(carsLock) {
+ addCar(p, info, protocol, outcome, screen, dpi)
+ if (session != null && choices(context).failures) addSession(p, info, protocol, session)
+ }
sendSoon(context)
}
+ private fun addSession(
+ p: android.content.SharedPreferences,
+ info: ServiceDiscovery.HeadUnitInfo?,
+ protocol: String,
+ session: JSONObject,
+ ) {
+ val sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray())
+ if (sessions.length() >= MAX_SESSIONS) return
+ // The same car fields as the cars list, so the dashboard can line the two up.
+ session.put("name", info?.headUnitName.orEmpty())
+ .put("car", info?.carModel.orEmpty())
+ .put("year", info?.carYear.orEmpty())
+ .put("protocol", protocol)
+ sessions.put(session)
+ // Written at once: after a crash the app is about to die, and a deferred write would be lost.
+ p.edit().putString(KEY_SESSIONS, sessions.toString()).commit()
+ }
+
private fun addCar(
p: android.content.SharedPreferences,
info: ServiceDiscovery.HeadUnitInfo?,
@@ -162,15 +202,40 @@ object UsageStats {
// The same car and outcome twice in a day is one fact, not two.
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
if (!seen && cars.length() < MAX_CARS) cars.put(car)
- p.edit().putString(KEY_CARS, cars.toString()).apply()
+ p.edit().putString(KEY_CARS, cars.toString()).commit()
}
- // GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
- private fun isGrapheneOs(context: Context) =
- runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
+ /**
+ * Which Android this is: a privacy OS by name, or "Stock" with the phone's brand.
+ *
+ * GrapheneOS reports the same build fields as stock Pixel firmware, so its own setup app gives
+ * it away; /e/OS is told apart from the LineageOS it's built on by its app store. The rest set
+ * build properties of their own. The iodéOS and DivestOS checks are best guesses from their
+ * build names, not confirmed on a real phone.
+ */
+ fun osName(context: Context): String {
+ fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
+ val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
+ .joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
+ return when {
+ installed("app.grapheneos.setupwizard") -> "GrapheneOS"
+ prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
+ installed("foundation.e.apps") -> "/e/OS"
+ names.contains("iode", ignoreCase = true) -> "iodéOS"
+ names.contains("divest", ignoreCase = true) -> "DivestOS"
+ prop("ro.lineage.version").isNotEmpty() -> "LineageOS"
+ else -> "Stock (${Build.BRAND.replaceFirstChar { it.uppercase() }})"
+ }
+ }
+
+ /** A system property, or "" when it isn't set or can't be read. */
+ private fun prop(name: String): String = runCatching {
+ Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
+ .invoke(null, name) as String
+ }.getOrDefault("")
private fun details(context: Context, c: Choices) = JSONObject().apply {
- if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
+ if (c.android) put("android", Build.VERSION.RELEASE).put("os", osName(context))
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
}
@@ -203,9 +268,33 @@ object UsageStats {
}
note.put("cars", cars)
}
+ if (c.failures) {
+ var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray())
+ if (everything && sessions.length() == 0) {
+ sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray())
+ }
+ note.put("sessions", sessions)
+ }
return note
}
+ /**
+ * A made-up session, for "What gets sent" to show before the first real one: a 25-minute
+ * drive that ended with the car switched off.
+ */
+ val exampleSession: JSONObject
+ get() = JSONObject()
+ .put("stage", "streaming")
+ .put("ended", "byebye")
+ .put("code", "bye 1")
+ .put("failed_at", 1500)
+ .put("seconds", 1500)
+ .put("cert", "downloaded")
+ .put("name", "Head unit name")
+ .put("car", "Car model")
+ .put("year", "2021")
+ .put("protocol", "1.5")
+
/** Sends the note now; false when it didn't get through. */
private fun send(context: Context): Boolean {
if (!sends || !enabled(context)) return true
@@ -213,6 +302,7 @@ object UsageStats {
val p = prefs(context)
val note = synchronized(carsLock) { preview(context) }
val sentCars = note.optJSONArray("cars")?.toString()
+ val sentSessions = note.optJSONArray("sessions")?.toString()
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
val body = note.toString().toByteArray()
return runCatching {
@@ -234,6 +324,16 @@ object UsageStats {
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars)
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
+ if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions)
+ if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) {
+ edit.remove(KEY_SESSIONS)
+ } else if (sentSessions != "[]") {
+ // A session ended mid-send: keep only the ones this note didn't carry.
+ val now = JSONArray(p.getString(KEY_SESSIONS, "[]"))
+ val sent = JSONArray(sentSessions).length()
+ val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) }
+ edit.putString(KEY_SESSIONS, left.toString())
+ }
edit.apply()
}
GearslipLog.i("stats: note sent")
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt
new file mode 100644
index 0000000..0311be0
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt
@@ -0,0 +1,179 @@
+package app.seb3thehacker.gearslip.stats
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import app.seb3thehacker.gearslip.GearslipActivity
+import app.seb3thehacker.gearslip.GearslipLog
+import app.seb3thehacker.gearslip.ServiceDiscovery
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.json.JSONObject
+
+/**
+ * The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share
+ * usage notes. It comes after the first drive that worked, when a driver is happiest to help, as
+ * a notification the moment the car leaves and as a card on Home. Answering either clears both,
+ * and it never comes back.
+ *
+ * Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
+ * first note. Until the driver answers, the car waits here on the phone and is sent nowhere.
+ */
+object WorkedPrompt {
+
+ private const val PREFS = "worked_prompt"
+ private const val KEY_DONE = "done"
+ private const val KEY_CAR = "car"
+ private const val CHANNEL = "worked"
+ private const val NOTIFICATION_ID = 4_311
+ private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE"
+ private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW"
+
+ private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ private var loaded = false
+ private val flow = MutableStateFlow(null)
+
+ /** The car's name while the ask is waiting for an answer, otherwise null. */
+ fun pending(context: Context): StateFlow {
+ if (!loaded) {
+ loaded = true
+ flow.value = saved(context)?.let(::carName)
+ }
+ return flow
+ }
+
+ /** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */
+ fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) {
+ val p = prefs(context)
+ if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return
+ val car = JSONObject()
+ .put("name", info?.headUnitName.orEmpty())
+ .put("car", info?.carModel.orEmpty())
+ .put("year", info?.carYear.orEmpty())
+ .put("make", info?.headUnitMake.orEmpty())
+ .put("model", info?.headUnitModel.orEmpty())
+ .put("protocol", protocol)
+ .put("screen", screen)
+ .put("dpi", dpi)
+ p.edit().putString(KEY_CAR, car.toString()).apply()
+ loaded = true
+ flow.value = carName(car)
+ notify(context, carName(car))
+ }
+
+ /**
+ * True once a driver turns sharing on from the ask, until Home has thrown its confetti. It
+ * waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens.
+ */
+ private val celebrateFlow = MutableStateFlow(false)
+ val celebrate: StateFlow = celebrateFlow
+
+ fun celebrated() { celebrateFlow.value = false }
+
+ /** The driver answered, here or on Home. Either way it's never asked again. */
+ fun answer(context: Context, share: Boolean) {
+ if (share) {
+ // Turning sharing on lands in [onSharingOn], which clears the ask.
+ UsageStats.set(context, true)
+ return
+ }
+ clear(context)
+ GearslipLog.i("worked prompt: not now")
+ }
+
+ /**
+ * Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting,
+ * that answers it, and the car it was about goes in the first note.
+ */
+ internal fun onSharingOn(context: Context) {
+ val car = saved(context) ?: return
+ clear(context)
+ celebrateFlow.value = true
+ GearslipLog.i("worked prompt: usage notes turned on")
+ val info = ServiceDiscovery.HeadUnitInfo(
+ car.optString("name"), car.optString("car"), car.optString("year"),
+ car.optString("make"), car.optString("model"),
+ )
+ UsageStats.recordSession(
+ context, info, car.optString("protocol"), "connected",
+ car.optString("screen"), car.optInt("dpi"),
+ )
+ }
+
+ private fun clear(context: Context) {
+ prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply()
+ loaded = true
+ flow.value = null
+ NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
+ }
+
+ private fun saved(context: Context): JSONObject? =
+ prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
+
+ /** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
+ private fun carName(car: JSONObject): String {
+ val model = car.optString("car").trim()
+ val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
+ val unit = "${car.optString("make")} ${car.optString("model")}".trim()
+ return when {
+ model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
+ unit.isNotEmpty() -> "your $unit"
+ else -> "your car"
+ }
+ }
+
+ private fun notify(context: Context, car: String) {
+ if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT),
+ )
+ val open = PendingIntent.getActivity(
+ context, NOTIFICATION_ID,
+ Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ val text = "Share that so the next driver knows it works? No name, account, or location."
+ val notification = NotificationCompat.Builder(context, CHANNEL)
+ .setSmallIcon(android.R.drawable.ic_menu_send)
+ .setContentTitle(TITLE_START + car)
+ .setContentText(text)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setContentIntent(open)
+ .setAutoCancel(true)
+ .addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW))
+ .addAction(0, "Share", broadcast(context, ACTION_SHARE))
+ .build()
+ runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
+ .onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") }
+ .onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") }
+ }
+
+ private fun broadcast(context: Context, action: String): PendingIntent =
+ PendingIntent.getBroadcast(
+ context, action.hashCode(),
+ Intent(context, WorkedPromptReceiver::class.java).setAction(action),
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ internal fun onAction(context: Context, intent: Intent) {
+ when (intent.action) {
+ ACTION_SHARE -> answer(context, share = true)
+ ACTION_NOT_NOW -> answer(context, share = false)
+ }
+ }
+
+ const val TITLE_START = "Gearslip worked with "
+}
+
+/** The notification's Share and Not now buttons land here. */
+class WorkedPromptReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent)
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt
new file mode 100644
index 0000000..f53ecde
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt
@@ -0,0 +1,49 @@
+package app.seb3thehacker.gearslip.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.unit.dp
+import app.seb3thehacker.gearslip.BuildConfig
+
+/**
+ * Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog
+ * is the price of sending music over USB, and most people who refuse it would rather not see it
+ * again. Offers the two ways out: Bluetooth, or the one-time adb command.
+ */
+@Composable
+fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
+ var showCommand by remember { mutableStateOf(false) }
+ AlertDialog(
+ onDismissRequest = onKeepAsking,
+ title = { Text("Play car audio over Bluetooth?") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ "Android treats music sent over USB like sharing your screen, so it asks every " +
+ "drive. Over Bluetooth it never asks. You can change this later in Settings.",
+ )
+ if (showCommand) {
+ Text(
+ "Or keep USB and stop the question for good: plug the phone into a " +
+ "computer with USB debugging on, and run this once.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
+ } else {
+ TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
+ }
+ }
+ },
+ confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
+ dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
+ )
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt
index 870d7b6..37a2ad2 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt
@@ -60,7 +60,6 @@ fun CertSetupScreen(onDone: () -> Unit) {
val scope = rememberCoroutineScope()
var downloadState by remember { mutableStateOf(DownloadState.Idle) }
- var showImport by remember { mutableStateOf(false) }
var pendingUri by remember { mutableStateOf(null) }
var manualProgress by remember { mutableStateOf(false) }
@@ -91,7 +90,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded."
} else {
- "Cars accept only Google-issued certificates. Download one to connect."
+ "Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
@@ -131,10 +130,11 @@ fun CertSetupScreen(onDone: () -> Unit) {
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
- Text("Download certificate")
+ Text("Download from aasdk")
}
Text(
- "Works with most cars built before 2020. Newer cars reject it.",
+ "The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
+ "Most cars accept it; some from around 2020 on don't.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -177,16 +177,18 @@ fun CertSetupScreen(onDone: () -> Unit) {
Spacer(Modifier.height(16.dp))
- if (showImport) {
- OutlinedButton(
- onClick = { importLauncher.launch(arrayOf("*/*")) },
- modifier = Modifier.fillMaxWidth().height(52.dp),
- ) { Text("Import your own .p12 cert") }
- Spacer(Modifier.height(16.dp))
- } else {
- TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
- Spacer(Modifier.height(8.dp))
- }
+ OutlinedButton(
+ onClick = { importLauncher.launch(arrayOf("*/*")) },
+ modifier = Modifier.fillMaxWidth().height(52.dp),
+ ) { Text("Import my own file") }
+ Text(
+ "A .p12 file with its private key, from wherever you choose.",
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt
index f06c7f9..48716ca 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt
@@ -14,8 +14,17 @@ data class CertSummary(
val validUntil: String?,
) {
companion object {
+ /**
+ * The last [read], so Settings draws the certificate row at once instead of a second
+ * later, which shifted everything under it. Null until the first read.
+ */
+ @Volatile var cached: CertSummary? = null
+ private set
+
/** Blocking (parses a key store): call from a background dispatcher. */
- fun read(context: Context): CertSummary {
+ fun read(context: Context): CertSummary = readNow(context).also { cached = it }
+
+ private fun readNow(context: Context): CertSummary {
val identity = CertProvider.loadSupplied(context)
?: return CertSummary(
CertProvider.Kind.SELF_SIGNED,
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt
new file mode 100644
index 0000000..61ce7e4
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt
@@ -0,0 +1,88 @@
+package app.seb3thehacker.gearslip.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+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.drawscope.rotate
+import kotlin.math.sin
+import kotlin.random.Random
+
+private class Piece(
+ val x: Float, // across the screen, 0..1
+ val delay: Float, // seconds before it starts falling
+ val speed: Float, // screen heights per second
+ val sway: Float, // how far it drifts side to side, in screen widths
+ val swaySpeed: Float,
+ val spin: Float, // degrees per second
+ val width: Float, // dp
+ val height: Float,
+ val color: Color,
+)
+
+/**
+ * Confetti falling from the top of the screen once, for about three seconds. Draws over whatever
+ * is under it and takes no touches; [onDone] fires when the last piece has left the screen.
+ */
+@Composable
+internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) {
+ val scheme = MaterialTheme.colorScheme
+ val colors = listOf(
+ scheme.primary, scheme.tertiary, scheme.secondary,
+ Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3),
+ )
+ val pieces = remember {
+ List(PIECES) {
+ Piece(
+ x = Random.nextFloat(),
+ delay = Random.nextFloat() * 0.9f,
+ speed = 0.35f + Random.nextFloat() * 0.35f,
+ sway = 0.01f + Random.nextFloat() * 0.03f,
+ swaySpeed = 2f + Random.nextFloat() * 4f,
+ spin = (Random.nextFloat() - 0.5f) * 720f,
+ width = 4f + Random.nextFloat() * 4f,
+ height = 8f + Random.nextFloat() * 6f,
+ color = colors[Random.nextInt(colors.size)],
+ )
+ }
+ }
+ var t by remember { mutableFloatStateOf(0f) }
+ LaunchedEffect(Unit) {
+ val start = withFrameNanos { it }
+ // The slowest piece, starting last, clears the screen by this time.
+ val end = pieces.maxOf { it.delay + 1.1f / it.speed }
+ while (t < end) {
+ withFrameNanos { t = (it - start) / 1e9f }
+ }
+ onDone()
+ }
+ Canvas(modifier.fillMaxSize()) {
+ val density = density
+ for (p in pieces) {
+ val age = t - p.delay
+ if (age < 0f) continue
+ val y = (age * p.speed - 0.05f) * size.height
+ if (y > size.height + 40f) continue
+ val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width
+ rotate(age * p.spin, pivot = Offset(x, y)) {
+ drawRect(
+ p.color,
+ topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2),
+ size = Size(p.width * density, p.height * density),
+ )
+ }
+ }
+ }
+}
+
+private const val PIECES = 140
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
index 1a23888..9c5a3db 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
@@ -60,6 +60,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
},
)
}
+ // Home's usage notes row opens Settings at that section rather than at the top.
+ var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
BackHandler(enabled = screen != Screen.HOME) {
screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME
}
@@ -75,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
// The guide asks for this now, but anyone who finished it before that never saw the page.
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
+ // Reads the certificate once in the background, so Settings has it ready when opened.
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } }
+ }
LaunchedEffect(Unit) {
if (!needsSetupGuide &&
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@@ -95,18 +101,20 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
when (screen) {
Screen.HOME -> HomeScreen(
onOpenLogs = { screen = Screen.LOGS },
- onOpenSettings = { screen = Screen.SETTINGS },
+ onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onOpenHelp = { screen = Screen.HELP },
+ onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS },
onDisconnect = onDisconnect,
)
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(
onBack = { screen = Screen.HOME },
- onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
- onOpenWhatsNew = { screen = Screen.WHATS_NEW },
+ onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
+ onOpenWhatsNew = { settingsAtUsageNotes = false; screen = Screen.WHATS_NEW },
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
+ atUsageNotes = settingsAtUsageNotes,
)
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
index 53ecf42..c1b8bbd 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
@@ -45,10 +45,6 @@ private class Step(
private fun setupSteps(onRequestCallScreening: () -> Unit) = 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 a certificate.", "Open Settings, then download or import a certificate."),
Step(
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
index 767ab57..cc58673 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -31,6 +32,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.notify.TestMessage
+import app.seb3thehacker.gearslip.stats.WorkedPrompt
@Composable
fun HomeScreen(
@@ -48,81 +51,104 @@ fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onOpenHelp: () -> Unit,
+ onOpenUsageNotes: () -> Unit,
onDisconnect: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle()
+ val live = status.phase == SessionStatus.Phase.CONNECTING ||
+ status.phase == SessionStatus.Phase.PROJECTING
- Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
- Column(
- Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(horizontal = 24.dp),
- ) {
+ val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle()
+ Box {
+ Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
- .weight(1f)
- .verticalScroll(rememberScrollState()),
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 24.dp),
) {
- Spacer(Modifier.height(24.dp))
- Row(
- Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
+ Column(
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState()),
) {
- Text(
- "Gearslip",
- style = MaterialTheme.typography.displayLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onBackground,
- )
- IconButton(
- onClick = onOpenSettings,
- modifier = Modifier.size(56.dp),
+ Spacer(Modifier.height(24.dp))
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
) {
- Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
+ Text(
+ "Gearslip",
+ style = MaterialTheme.typography.displayLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ IconButton(
+ onClick = onOpenSettings,
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
+ }
}
- }
- Text(
- "v${LocalContext.current.appVersionName()}",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(16.dp))
- StatusCard(status)
- Spacer(Modifier.height(16.dp))
- if (status.phase != SessionStatus.Phase.PROJECTING) WirelessCard()
- }
-
- // Sits at the bottom, where a thumb reaches it.
- Column(Modifier.padding(vertical = 16.dp)) {
- val live = status.phase == SessionStatus.Phase.CONNECTING ||
- status.phase == SessionStatus.Phase.PROJECTING
- if (live) {
- Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
- Text("Disconnect")
+ Text(
+ "v${LocalContext.current.appVersionName()}",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+ StatusCard(status)
+ // The first drive that worked asks once to share it, right under the result.
+ val car = workedCar
+ if (car != null && !live) {
+ Spacer(Modifier.height(16.dp))
+ WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes)
}
Spacer(Modifier.height(16.dp))
+ UpdateCard()
+ if (status.phase != SessionStatus.Phase.PROJECTING) {
+ Spacer(Modifier.height(16.dp))
+ WirelessCard()
+ }
}
- Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
- Column {
- if (!live) {
- ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
- RowDivider()
+ // Sits at the bottom, where a thumb reaches it.
+ Column(Modifier.padding(vertical = 16.dp)) {
+ if (live) {
+ Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
+ Text("Disconnect")
}
- ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
- RowDivider()
- ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
- if (BuildConfig.DEBUG) {
- val context = LocalContext.current
+ Spacer(Modifier.height(16.dp))
+ }
+
+ // Not while the card above is asking the same thing.
+ if (workedCar == null || live) {
+ UsageNotesHomeCard(onOpen = onOpenUsageNotes)
+ Spacer(Modifier.height(16.dp))
+ }
+
+ Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
+ Column {
+ if (!live) {
+ ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
+ RowDivider()
+ }
+ ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
RowDivider()
- ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
+ ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
+ if (BuildConfig.DEBUG) {
+ RowDivider()
+ ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
+ }
}
}
}
}
}
+ // Sharing just went on from the "it worked" ask: a little thanks.
+ if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
index f6bba10..90dab03 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -55,8 +58,12 @@ import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
+import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@@ -66,7 +73,16 @@ fun SettingsScreen(
onOpenRecommendedApps: () -> Unit,
onOpenWhatsNew: () -> Unit,
onReplayTutorial: () -> Unit,
+ atUsageNotes: Boolean = false,
) {
+ val scroll = rememberScrollState()
+ // Opened from Home's usage notes row: usage notes is the last section, so start at the end.
+ if (atUsageNotes) {
+ LaunchedEffect(Unit) {
+ snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
+ scroll.scrollTo(scroll.maxValue)
+ }
+ }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
@@ -85,12 +101,13 @@ fun SettingsScreen(
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
- .verticalScroll(rememberScrollState()),
+ .verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Spacer(Modifier.height(0.dp))
AboutSection(onOpenWhatsNew, onReplayTutorial)
ConnectionSection()
+ CarAudioSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
CertificateSection()
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
@@ -106,7 +123,7 @@ private fun Arrow() = Icon(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
-/** What's new and the setup guide, one tap from the top. */
+/** What's new, updates and the setup guide, one tap from the top. */
@Composable
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
SettingsSection {
@@ -118,6 +135,12 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
trailing = { Arrow() },
)
SettingsDivider()
+ UpdateRow()
+ SettingsDivider()
+ UpdateAutoRow()
+ SettingsDivider()
+ UpdateBetaRow()
+ SettingsDivider()
SettingsRow(
"Replay the setup guide",
Modifier.clickable(onClick = onReplayTutorial),
@@ -147,6 +170,30 @@ private fun ConnectionSection() {
}
}
+/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */
+@Composable
+private fun CarAudioSection() {
+ val usb by CarSettings.pipeAudio.collectAsState()
+ SettingsSection(
+ "Car audio",
+ footer = "Run the command in Help once, and USB stops asking.",
+ ) {
+ SettingsRow(
+ "USB",
+ Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
+ subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
+ trailing = { RadioButton(selected = usb, onClick = null) },
+ )
+ SettingsDivider()
+ SettingsRow(
+ "Bluetooth",
+ Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
+ subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
+ trailing = { RadioButton(selected = !usb, onClick = null) },
+ )
+ }
+}
+
@Composable
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
SettingsSection("Apps") {
@@ -165,7 +212,8 @@ private fun CertificateSection() {
val scope = rememberCoroutineScope()
var refresh by remember { mutableIntStateOf(0) }
- val summary by produceState(null, refresh) {
+ // The cached summary shows at once; the read still runs, in case the cert changed since.
+ val summary by produceState(CertSummary.cached, refresh) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
@@ -208,7 +256,7 @@ private fun CertificateSection() {
}
SettingsDivider()
SettingsRow(
- if (downloading) "Downloading…" else "Download certificate",
+ if (downloading) "Downloading…" else "Download from aasdk",
Modifier.clickable(enabled = !downloading) {
downloading = true
scope.launch {
@@ -222,13 +270,14 @@ private fun CertificateSection() {
}
}
},
+ subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one",
icon = Icons.Filled.Refresh,
)
SettingsDivider()
SettingsRow(
- "Import a certificate",
+ "Import my own file",
Modifier.clickable { picker.launch(arrayOf("*/*")) },
- subtitle = "A .p12 file with its private key",
+ subtitle = "A .p12 file with its private key, from wherever you choose",
icon = Icons.Filled.Add,
)
val removable = when (s?.kind) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt
index 08bd3ce..5de6a36 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt
@@ -206,22 +206,6 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit): List s.release
+ is State.Downloading -> s.release
+ is State.Ready -> s.release
+ is State.Failed -> s.release
+ else -> null
+ } ?: return
+
+ Surface(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(Modifier.fillMaxWidth().padding(20.dp)) {
+ Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(4.dp))
+ when (val s = state) {
+ is State.Downloading -> {
+ Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.height(12.dp))
+ LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth())
+ }
+ is State.Ready -> {
+ val connected = UpdateChecker.carConnected
+ Text(
+ if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(Modifier.height(12.dp))
+ Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") }
+ }
+ else -> {
+ Text(
+ (s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { UpdateChecker.download(context, release) }) {
+ Text(if (s is State.Failed) "Try again" else "Update")
+ }
+ if (release.pageUrl.isNotBlank()) {
+ TextButton(onClick = {
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl)))
+ }) { Text("What's new") }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Room before whatever Home shows next; nothing when the card is hidden.
+ Spacer(Modifier.height(16.dp))
+}
+
+/** The Settings row: checks on tap, and carries on to download and install once something is found. */
+@Composable
+internal fun UpdateRow() {
+ val context = LocalContext.current
+ val state by UpdateChecker.state.collectAsStateWithLifecycle()
+ SessionStatus.state.collectAsStateWithLifecycle().value
+ val subtitle = when (val s = state) {
+ State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap" else "Gearslip checks once a day"
+ State.Checking -> "Checking…"
+ State.UpToDate -> "You have the latest version"
+ is State.Available -> "Version ${s.release.version} is out. Tap to download."
+ is State.Downloading -> "Downloading… ${(s.fraction * 100).toInt()}%"
+ is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Tap to install version ${s.release.version}"
+ is State.Failed -> s.message
+ }
+ SettingsRow(
+ "Check for updates",
+ Modifier.clickable {
+ when (val s = state) {
+ is State.Available -> UpdateChecker.download(context, s.release)
+ is State.Ready -> UpdateChecker.install(context)
+ is State.Failed -> s.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
+ else -> UpdateChecker.check(context)
+ }
+ },
+ subtitle = subtitle,
+ icon = Icons.Filled.CheckCircle,
+ )
+}
+
+/** Whether Gearslip checks for updates on its own. */
+@Composable
+internal fun UpdateAutoRow() {
+ val context = LocalContext.current
+ var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
+ SettingsRow(
+ "Check automatically",
+ Modifier.toggleable(on, role = Role.Switch) {
+ on = it
+ UpdateChecker.setAutoCheckEnabled(context, it)
+ },
+ subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates"
+ else if (on) "Once a day, with a note on the car screen and the phone when one is out"
+ else "Gearslip checks only when you tap Check for updates",
+ icon = Icons.Filled.Notifications,
+ trailing = { Switch(checked = on, onCheckedChange = null) },
+ )
+}
+
+/** Whether pre-releases count as updates. */
+@Composable
+internal fun UpdateBetaRow() {
+ val context = LocalContext.current
+ var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) }
+ SettingsRow(
+ "Beta updates",
+ Modifier.toggleable(on, role = Role.Switch) {
+ on = it
+ UpdateChecker.setBetaEnabled(context, it)
+ },
+ subtitle = "Get test versions before they're released. They may have more bugs.",
+ icon = Icons.Filled.Build,
+ trailing = { Switch(checked = on, onCheckedChange = null) },
+ )
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt
index 70fbc61..a6a5469 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt
@@ -1,14 +1,24 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.AnimatedVisibility
+import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.TextButton
+import app.seb3thehacker.gearslip.stats.WorkedPrompt
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -83,11 +93,7 @@ internal fun ColumnScope.UsageStatsRows() {
SettingsRow(
"Send usage notes",
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
- subtitle = when {
- BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
- BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
- else -> null
- },
+ subtitle = buildNote(),
icon = Icons.Filled.Send,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
@@ -100,12 +106,16 @@ internal fun ColumnScope.UsageStatsRows() {
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
)
- Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
+ Choice("Android version and system, such as GrapheneOS or stock Samsung", choices.android) { choices = choices.copy(android = it); save() }
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
choices = choices.copy(cars = it)
save()
}
+ Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) {
+ choices = choices.copy(failures = it)
+ save()
+ }
SettingsDivider()
SettingsRow(
"What gets sent",
@@ -122,7 +132,7 @@ internal fun ColumnScope.UsageStatsRows() {
)
AnimatedVisibility(noteOpen) {
// Rebuilt from the saved choices each time one changes, so it always matches.
- val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) }
+ val note = remember(choices) { notePreview(context) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = MaterialTheme.shapes.medium,
@@ -152,3 +162,109 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit)
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
}
}
+
+/** The note as it would go out now, with an example drive until a real one is in it. */
+private fun notePreview(context: Context): String {
+ val preview = UsageStats.preview(context, everything = true)
+ // Until the first drive the list is empty, so show what one entry looks like.
+ val example = if (preview.optJSONArray("sessions")?.length() == 0) {
+ "\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2)
+ } else ""
+ return preview.toString(2) + example
+}
+
+/** Why this build sends nothing, when it doesn't; null for a normal release. */
+private fun buildNote(): String? = when {
+ BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
+ BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
+ else -> null
+}
+
+/**
+ * The one-time ask after the first drive that worked, in the status card's place on Home. Share
+ * turns on usage notes like the switch does; the link shows exactly what that sends first.
+ */
+@Composable
+internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val scheme = MaterialTheme.colorScheme
+ Surface(
+ modifier,
+ color = scheme.primaryContainer,
+ contentColor = scheme.onPrimaryContainer,
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(Modifier.fillMaxWidth().padding(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Filled.Check, contentDescription = null)
+ Text(
+ WorkedPrompt.TITLE_START + car,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+ Text(
+ "Share that so the next driver knows it works? No name, account, or location.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ buildNote()?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
+ }
+ Row(
+ Modifier.fillMaxWidth().padding(top = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
+ Text("What gets sent")
+ }
+ Spacer(Modifier.weight(1f))
+ OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
+ Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
+ }
+ }
+ }
+}
+
+/**
+ * Usage notes on Home, one row with one action either way. While they're off, the row is the
+ * switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens
+ * Settings at its usage notes section, so a driver already sharing isn't pitched again.
+ */
+@Composable
+internal fun UsageNotesHomeCard(onOpen: () -> Unit, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ var on by remember { mutableStateOf(UsageStats.enabled(context)) }
+
+ SettingsCard(modifier) {
+ if (on) {
+ SettingsRow(
+ "Sharing usage notes",
+ Modifier.clickable(onClick = onOpen),
+ subtitle = buildNote(),
+ icon = Icons.Filled.Check,
+ trailing = {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ )
+ } else {
+ SettingsRow(
+ USAGE_STATS_TITLE,
+ Modifier.toggleable(false, role = Role.Switch) {
+ UsageStats.set(context, true, UsageStats.choices(context))
+ on = true
+ },
+ subtitle = buildNote() ?: ("A short note after each drive tells the next driver " +
+ "whether their car works. No name, account, or location"),
+ icon = Icons.Filled.Send,
+ trailing = { Switch(checked = false, onCheckedChange = null) },
+ )
+ }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
new file mode 100644
index 0000000..86a7990
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
@@ -0,0 +1,410 @@
+package app.seb3thehacker.gearslip.update
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.FileProvider
+import androidx.core.graphics.drawable.toBitmap
+import app.seb3thehacker.gearslip.BuildConfig
+import app.seb3thehacker.gearslip.GearslipActivity
+import app.seb3thehacker.gearslip.GearslipLog
+import app.seb3thehacker.gearslip.SessionStatus
+import app.seb3thehacker.gearslip.notify.CarMotion
+import app.seb3thehacker.gearslip.notify.CarNotification
+import app.seb3thehacker.gearslip.notify.CarNotifications
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+
+/**
+ * Looks for a newer Gearslip on GitHub and installs it through Android's own installer.
+ *
+ * Only GitHub's "latest" release counts, which leaves out pre-releases and drafts, unless the
+ * driver opted into betas: then pre-releases count too. The app checks at most once a day: when
+ * it opens, and when a drive ends. With automatic checks off it never asks GitHub on its own,
+ * only when the driver taps Check for updates. After a drive a new version also gets
+ * a notification, once per version, since the driver is parked and the app is likely closed. The
+ * car screen shows the same news once per version, at plug-in and only while parked.
+ * Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
+ * a browser makes for the release page. Dev builds never check on their own.
+ *
+ * The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an
+ * update signed with a different key, so a bad download can't replace the app.
+ */
+object UpdateChecker {
+
+ data class Release(val version: String, val apkUrl: String, val size: Long, val sha256: String?, val pageUrl: String)
+
+ sealed interface State {
+ data object Idle : State
+ data object Checking : State
+ data object UpToDate : State
+ data class Available(val release: Release) : State
+ data class Downloading(val release: Release, val fraction: Float) : State
+ data class Ready(val release: Release, val apk: File) : State
+ data class Failed(val message: String, val release: Release? = null) : State
+ }
+
+ private val flow = MutableStateFlow(State.Idle)
+ val state: StateFlow = flow.asStateFlow()
+
+ private const val PREFS = "updates"
+ private const val KEY_CHECKED_AT = "checked_at"
+ private const val KEY_FOUND = "found"
+ /** Named for the notifications toggle it grew out of; it now covers every automatic check. */
+ private const val KEY_AUTO = "notify"
+ private const val KEY_BETA = "beta"
+ private const val KEY_NOTIFIED = "notified_version"
+ private const val KEY_CAR_NOTIFIED = "car_notified_version"
+ /** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */
+ private const val CAR_DELAY_MS = 8_000L
+ private const val CHANNEL = "updates"
+ private const val NOTIFICATION_ID = 4100
+ private const val DAY_MS = 24 * 60 * 60 * 1000L
+ private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
+ /** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
+ private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
+
+ /** Set when the driver tapped Install but first had to allow installs from Gearslip. */
+ @Volatile private var installWhenAllowed = false
+
+ private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ /**
+ * Whether Gearslip checks on its own, and tells the driver what it finds on the phone and the
+ * car screen. On by default: the check sends nothing about the phone.
+ */
+ fun autoCheckEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO, true)
+
+ fun setAutoCheckEnabled(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean(KEY_AUTO, on).apply()
+ if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
+ }
+
+ /** Whether GitHub pre-releases count as updates. */
+ fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false)
+
+ /**
+ * Switching channels forgets what the old one found: a beta found earlier shouldn't still be
+ * offered once betas are off. Checks again right away if automatic checks are on.
+ */
+ fun setBetaEnabled(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
+ val current = flow.value
+ if (current is State.Checking || current is State.Downloading) return
+ updatesDir(context).deleteRecursively()
+ flow.value = State.Idle
+ if (autoCheckEnabled(context) && !BuildConfig.DEBUG) check(context)
+ }
+
+ private fun dueForCheck(context: Context) =
+ !BuildConfig.DEBUG && autoCheckEnabled(context) && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
+
+ /** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */
+ fun onAppOpened(context: Context) {
+ val current = flow.value
+ if (installWhenAllowed && current is State.Ready && context.packageManager.canRequestPackageInstalls()) {
+ installWhenAllowed = false
+ install(context)
+ return
+ }
+ if (current != State.Idle) return
+ // A release found earlier still shows after Android closed the app.
+ savedRelease(context)?.let { flow.value = State.Available(it) }
+ if (dueForCheck(context)) check(context)
+ }
+
+ /**
+ * A car session ended, so the driver is parked. Checks if a day has passed, then posts a
+ * notification for a new version they haven't been told about yet.
+ */
+ fun onDriveEnded(context: Context) {
+ if (!autoCheckEnabled(context)) return
+ val app = context.applicationContext
+ Thread({
+ val current = flow.value
+ if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
+ flow.value = State.Checking
+ flow.value = checkNow(app)
+ }
+ val release = foundRelease(app) ?: return@Thread
+ if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread
+ if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
+ }, "gearslip-update").start()
+ }
+
+ /**
+ * The car screen just came up. A new version gets a card in the top right, like a message,
+ * once per version. Skipped while moving; the next plug-in tries again.
+ */
+ fun onCarConnected(context: Context) {
+ if (!autoCheckEnabled(context)) return
+ val app = context.applicationContext
+ Thread({
+ val current = flow.value
+ if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
+ flow.value = State.Checking
+ flow.value = checkNow(app)
+ }
+ val release = foundRelease(app) ?: return@Thread
+ if (prefs(app).getString(KEY_CAR_NOTIFIED, null) == release.version) return@Thread
+ Thread.sleep(CAR_DELAY_MS)
+ if (!carConnected || CarMotion.moving) return@Thread
+ CarNotifications.post(
+ CarNotification(
+ key = "gearslip-update",
+ id = CarNotifications.nextId(),
+ packageName = app.packageName,
+ appLabel = "Gearslip",
+ title = "Gearslip ${release.version} is out",
+ text = "Update in the Gearslip app or from GitHub",
+ postedAt = System.currentTimeMillis(),
+ icon = runCatching { app.packageManager.getApplicationIcon(app.packageName).toBitmap(96, 96) }.getOrNull(),
+ reply = null,
+ ),
+ quiet = false,
+ )
+ prefs(app).edit().putString(KEY_CAR_NOTIFIED, release.version).apply()
+ GearslipLog.i("update: showed ${release.version} on the car screen")
+ }, "gearslip-update").start()
+ }
+
+ private fun foundRelease(app: Context): Release? = when (val s = flow.value) {
+ is State.Available -> s.release
+ is State.Downloading -> s.release
+ is State.Ready -> s.release
+ else -> savedRelease(app)
+ }
+
+ /** Posts "Gearslip X is out"; tapping it opens the app, where the Home card has the button. */
+ private fun notify(context: Context, release: Release): Boolean {
+ if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return false
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
+ )
+ val open = PendingIntent.getActivity(
+ context, NOTIFICATION_ID,
+ Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
+ PendingIntent.FLAG_IMMUTABLE,
+ )
+ val notification = NotificationCompat.Builder(context, CHANNEL)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentTitle("Gearslip ${release.version} is out")
+ .setContentText("Tap to update.")
+ .setContentIntent(open)
+ .setAutoCancel(true)
+ .build()
+ return runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
+ .onSuccess { GearslipLog.i("update: told the driver about ${release.version}") }
+ .onFailure { GearslipLog.w("update: could not post the notification (${it.message})") }
+ .isSuccess
+ }
+
+ /** Asks GitHub now. Ignored while a check or download is under way. */
+ fun check(context: Context) {
+ val current = flow.value
+ if (current is State.Checking || current is State.Downloading) return
+ val app = context.applicationContext
+ flow.value = State.Checking
+ Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
+ }
+
+ private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold(
+ onSuccess = { release ->
+ prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
+ if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
+ GearslipLog.i("update: ${release.version} is out (this is ${BuildConfig.VERSION_NAME})")
+ save(app, release)
+ State.Available(release)
+ } else {
+ prefs(app).edit().remove(KEY_FOUND).apply()
+ updatesDir(app).deleteRecursively()
+ State.UpToDate
+ }
+ },
+ onFailure = {
+ GearslipLog.w("update: could not check: ${it.message}")
+ State.Failed("Couldn't reach GitHub. Check the connection and try again.")
+ },
+ )
+
+ /** Downloads [release], then opens the installer once the file checks out. */
+ fun download(context: Context, release: Release) {
+ if (flow.value is State.Downloading) return
+ val app = context.applicationContext
+ flow.value = State.Downloading(release, 0f)
+ Thread({
+ val dir = updatesDir(app).apply { deleteRecursively(); mkdirs() }
+ val apk = File(dir, "Gearslip-${release.version}.apk")
+ flow.value = runCatching {
+ fetchApk(release, apk)
+ GearslipLog.i("update: downloaded ${release.version}")
+ State.Ready(release, apk)
+ }.getOrElse {
+ apk.delete()
+ GearslipLog.w("update: download failed: ${it.message}")
+ State.Failed(it.message ?: "The download failed. Try again.", release)
+ }
+ if (flow.value is State.Ready) install(app)
+ }, "gearslip-update").start()
+ }
+
+ /**
+ * Hands the downloaded APK to Android's installer. The first time, Android wants the driver to
+ * allow installs from Gearslip; that opens its settings page, and the install carries on when
+ * they come back.
+ */
+ fun install(context: Context) {
+ val ready = flow.value as? State.Ready ?: return
+ // Installing closes Gearslip, which would drop the car screen mid-drive.
+ if (carConnected) return
+ val pm = context.packageManager
+ if (!pm.canRequestPackageInstalls()) {
+ installWhenAllowed = true
+ context.startActivity(
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ return
+ }
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk)
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, "application/vnd.android.package-archive")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+
+ /** The newest release with an APK: the "latest" one, or with [beta] the newest of any kind but drafts. */
+ private fun fetchNewest(beta: Boolean): Release? {
+ val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 15_000
+ setRequestProperty("Accept", "application/vnd.github+json")
+ setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
+ }
+ try {
+ if (conn.responseCode == 404) return null // no stable release yet
+ if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ if (!beta) return release(JSONObject(body))
+ val list = org.json.JSONArray(body)
+ return (0 until list.length()).map { list.getJSONObject(it) }
+ .filterNot { it.optBoolean("draft") }
+ .mapNotNull(::release)
+ .reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /** A release as GitHub lists it, or null when it has no APK attached. */
+ private fun release(json: JSONObject): Release? {
+ val assets = json.optJSONArray("assets") ?: return null
+ val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
+ .firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
+ ?: return null
+ return Release(
+ version = json.getString("tag_name").removePrefix("v"),
+ apkUrl = apk.getString("browser_download_url"),
+ size = apk.optLong("size", -1),
+ sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
+ pageUrl = json.optString("html_url"),
+ )
+ }
+
+ private fun fetchApk(release: Release, into: File) {
+ val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 30_000
+ setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
+ }
+ try {
+ if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
+ val total = release.size.takeIf { it > 0 } ?: conn.contentLengthLong
+ val digest = MessageDigest.getInstance("SHA-256")
+ var done = 0L
+ var shown = -1
+ conn.inputStream.use { input ->
+ into.outputStream().use { output ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val n = input.read(buffer)
+ if (n < 0) break
+ output.write(buffer, 0, n)
+ digest.update(buffer, 0, n)
+ done += n
+ // Whole percents only, so the screen isn't redrawn for every buffer.
+ val percent = if (total > 0) (done * 100 / total).toInt() else 0
+ if (percent != shown) {
+ shown = percent
+ flow.value = State.Downloading(release, percent / 100f)
+ }
+ }
+ }
+ }
+ if (release.size > 0 && done != release.size) error("The download stopped early. Try again.")
+ val hex = digest.digest().joinToString("") { "%02x".format(it) }
+ if (release.sha256 != null && !hex.equals(release.sha256, ignoreCase = true)) {
+ error("The download came out damaged. Try again.")
+ }
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ /** Installing waits until the car is unplugged. */
+ val carConnected: Boolean
+ get() = SessionStatus.state.value.phase.let { it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING }
+
+ private fun updatesDir(context: Context) = File(context.cacheDir, "updates")
+
+ private fun save(context: Context, release: Release) {
+ val json = JSONObject()
+ .put("version", release.version)
+ .put("apkUrl", release.apkUrl)
+ .put("size", release.size)
+ .put("sha256", release.sha256 ?: "")
+ .put("pageUrl", release.pageUrl)
+ prefs(context).edit().putString(KEY_FOUND, json.toString()).apply()
+ }
+
+ /** The release found last time, if it is still newer than this build (it isn't after updating). */
+ private fun savedRelease(context: Context): Release? {
+ val json = prefs(context).getString(KEY_FOUND, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
+ val release = Release(
+ version = json.optString("version"),
+ apkUrl = json.optString("apkUrl"),
+ size = json.optLong("size", -1),
+ sha256 = json.optString("sha256").ifBlank { null },
+ pageUrl = json.optString("pageUrl"),
+ )
+ return release.takeIf { isNewer(it.version, BuildConfig.VERSION_NAME) }
+ }
+
+ /** "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. */
+ internal fun isNewer(candidate: String, installed: String): Boolean {
+ fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
+ val a = parts(candidate)
+ val b = parts(installed)
+ for (i in 0 until maxOf(a.size, b.size)) {
+ val x = a.getOrElse(i) { 0 }
+ val y = b.getOrElse(i) { 0 }
+ if (x != y) return x > y
+ }
+ return false
+ }
+}
diff --git a/gearslip/src/main/res/xml/share_paths.xml b/gearslip/src/main/res/xml/share_paths.xml
index 9d97a8c..4307a1f 100644
--- a/gearslip/src/main/res/xml/share_paths.xml
+++ b/gearslip/src/main/res/xml/share_paths.xml
@@ -1,5 +1,6 @@
-
+
+
diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt
new file mode 100644
index 0000000..2966b2e
--- /dev/null
+++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt
@@ -0,0 +1,136 @@
+package app.seb3thehacker.gearslip
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.nio.ByteBuffer
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ConnectionLifecycleTest {
+ private val version = message(1, byteArrayOf(0, 1, 0, 3))
+
+ private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
+ Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body,
+ )
+
+ private fun hello(): ByteArray {
+ val client = SSLContext.getDefault().createSSLEngine().apply {
+ useClientMode = true
+ enabledProtocols = arrayOf("TLSv1.2")
+ beginHandshake()
+ }
+ val packet = ByteBuffer.allocate(client.session.packetBufferSize)
+ client.wrap(ByteBuffer.allocate(0), packet)
+ return message(3, packet.array().copyOf(packet.position()))
+ }
+
+ private fun identity(): CertProvider.Identity {
+ val generated = SelfSignedCert.generate()
+ return CertProvider.Identity(
+ generated.keyStore, generated.certificate, SelfSignedCert.PASSWORD, "generated test identity",
+ )
+ }
+
+ private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
+
+ @Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
+ for (status in listOf(-2, -3)) {
+ runner(ByteArrayInputStream(
+ version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
+ message(4, Protobuf.varintField(1, 0)) + message(6) + version,
+ )).run()
+ assertEquals(
+ if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
+ SessionReport.category,
+ )
+ assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
+ assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
+ assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
+ }
+ }
+
+ @Test fun `TLS failure ignores later protocol messages`() {
+ val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
+ runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
+ assertEquals(SessionReport.Category.TLS, SessionReport.category)
+ assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
+ assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
+ }
+
+ @Test fun `authentication success before TLS completes is ignored`() {
+ runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
+ assertEquals(SessionReport.Category.TLS, SessionReport.category)
+ assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage)
+ }
+
+ @Test fun `stop followed by EOF does not report a handshake failure`() {
+ stopDuringRead { -1 }
+ }
+
+ @Test fun `stop followed by a read exception does not report a USB failure`() {
+ stopDuringRead { throw IOException("transport closed by owner") }
+ }
+
+ @Test fun `stop ignores bytes returned by an in-flight read`() {
+ stopDuringRead { bytes -> version.copyInto(bytes); version.size }
+ }
+
+ private fun stopDuringRead(result: (ByteArray) -> Int) {
+ lateinit var runner: GearslipRunner
+ val input = object : InputStream() {
+ override fun read(): Int = error("bulk reads only")
+ override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
+ runner.stop()
+ return result(bytes)
+ }
+ }
+ runner = runner(input)
+ runner.run()
+ assertEquals(SessionReport.Category.NONE, SessionReport.category)
+ assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
+ }
+
+ @Test fun `stop while preparing TLS leaves the session disconnected`() {
+ val releaseIdentity = CountDownLatch(1)
+ val runner = GearslipRunner(ByteArrayInputStream(version + hello()), ByteArrayOutputStream(), {
+ check(releaseIdentity.await(5, TimeUnit.SECONDS))
+ identity()
+ })
+ val reader = Thread { runner.run() }
+ reader.start()
+ try {
+ // The reader waits on FutureTask.get while the identity provider is held above.
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
+ while (reader.state != Thread.State.WAITING && reader.isAlive && System.nanoTime() < deadline) {
+ reader.join(1)
+ }
+ assertEquals(Thread.State.WAITING, reader.state)
+ runner.stop()
+ } finally {
+ releaseIdentity.countDown()
+ reader.join(5_000)
+ }
+ assertTrue("reader must finish", !reader.isAlive)
+ assertEquals(SessionReport.Category.NONE, SessionReport.category)
+ assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
+ }
+
+ @Test fun `EOF and transport failure shut down the writer`() {
+ val failing = object : InputStream() {
+ override fun read(): Int = throw IOException("test disconnect")
+ }
+ for (input in listOf(ByteArrayInputStream(version), failing)) {
+ val runner = runner(input)
+ runner.run()
+ val field = GearslipRunner::class.java.getDeclaredField("writer").apply { isAccessible = true }
+ assertTrue((field.get(runner) as ExecutorService).isShutdown)
+ }
+ }
+}
diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt
new file mode 100644
index 0000000..05c8c90
--- /dev/null
+++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt
@@ -0,0 +1,109 @@
+package app.seb3thehacker.gearslip.car
+
+import androidx.car.app.CarToast
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotSame
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+
+class CarToastsTest {
+ @Before
+ @After
+ fun clearToast() {
+ while (true) CarToasts.message.value?.let(CarToasts::dismiss) ?: break
+ }
+
+ @Test
+ fun aNewToastWaitsForTheOneOnScreen() {
+ val owner = Any()
+ CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
+ val first = requireNotNull(CarToasts.message.value)
+ CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
+
+ assertSame(first, CarToasts.message.value)
+ assertEquals(1, CarToasts.waiting.value)
+ CarToasts.dismiss(first)
+ assertEquals("Second", CarToasts.message.value?.text)
+ assertEquals(0, CarToasts.waiting.value)
+ }
+
+ @Test
+ fun expiredTimerCannotDismissItsReplacement() {
+ val owner = Any()
+ CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
+ val first = requireNotNull(CarToasts.message.value)
+ CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
+ CarToasts.dismiss(first)
+ val second = requireNotNull(CarToasts.message.value)
+
+ CarToasts.dismiss(first)
+ assertSame(second, CarToasts.message.value)
+ CarToasts.dismiss(second)
+ assertNull(CarToasts.message.value)
+ }
+
+ @Test
+ fun identicalMessagesStillRestartTheDisplayTimer() {
+ val owner = Any()
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val first = requireNotNull(CarToasts.message.value)
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val repeated = requireNotNull(CarToasts.message.value)
+
+ assertNotSame(first, repeated)
+ assertEquals(0, CarToasts.waiting.value)
+ CarToasts.dismiss(first)
+ assertSame(repeated, CarToasts.message.value)
+ }
+
+ @Test
+ fun disconnectOnlyClearsItsOwnToast() {
+ val nav = Any()
+ val browse = Any()
+ CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT)
+ CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT)
+
+ CarToasts.clear(nav)
+ assertEquals("Added to playlist", CarToasts.message.value?.text)
+ CarToasts.clear(browse)
+ assertNull(CarToasts.message.value)
+ }
+
+ @Test
+ fun aBurstKeepsOnlyTheNewestFewWaiting() {
+ val owner = Any()
+ listOf("1", "2", "3", "4", "5", "6").forEach { CarToasts.show(owner, it, CarToast.LENGTH_SHORT) }
+
+ assertEquals("1", CarToasts.message.value?.text)
+ assertEquals(3, CarToasts.waiting.value)
+ CarToasts.message.value?.let(CarToasts::dismiss)
+ assertEquals("4", CarToasts.message.value?.text)
+ }
+
+ @Test
+ fun blankRequestsDoNotReplaceAVisibleMessage() {
+ val owner = Any()
+ CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
+ val visible = CarToasts.message.value
+
+ CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG)
+ assertSame(visible, CarToasts.message.value)
+ assertEquals(0, CarToasts.waiting.value)
+ }
+
+ @Test
+ fun unknownDurationsFallBackToShortRatherThanStayingVisible() {
+ val owner = Any()
+ CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT)
+ assertEquals(4_000L, CarToasts.message.value?.durationMs)
+ CarToasts.message.value?.let(CarToasts::dismiss)
+ CarToasts.show(owner, "Long", CarToast.LENGTH_LONG)
+ assertEquals(7_000L, CarToasts.message.value?.durationMs)
+ CarToasts.message.value?.let(CarToasts::dismiss)
+ CarToasts.show(owner, "Unknown", Int.MAX_VALUE)
+ assertEquals(4_000L, CarToasts.message.value?.durationMs)
+ }
+}