diff --git a/.gitignore b/.gitignore
index 1705c65..d72c470 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,4 +30,4 @@ CLAUDE.md
# Other
archive/
.wrangler/
-stats-worker/
\ No newline at end of file
+stats-worker/
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 4dc1ede..50dfbdb 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+
= 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
@@ -141,6 +145,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()
@@ -176,6 +184,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 +237,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")
}
@@ -304,7 +316,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. " +
@@ -318,7 +330,7 @@ class GearslipRunner(
state = State.DONE
}
-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 " +
@@ -331,7 +343,7 @@ class GearslipRunner(
state = State.DONE
}
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 " +
@@ -399,7 +411,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 +429,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 +492,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 +1101,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/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/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
index cb8e114..5f02ea4 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
@@ -194,6 +200,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/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
index 03baae2..261bcfd 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
@@ -126,6 +126,8 @@ fun CarUi() {
// 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() }
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/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/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
index a332887..d13c3e8 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
@@ -587,10 +587,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()
+ }
}
/**
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..7dd629c 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,7 +202,7 @@ 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.
@@ -203,9 +243,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 +277,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 +299,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/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/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
index a13d01f..bb3a79f 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,80 +51,100 @@ 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))
- }
-
- // 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()
}
- 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..0761ba8 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
@@ -57,6 +57,9 @@ import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider
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 +69,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,7 +97,7 @@ fun SettingsScreen(
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
- .verticalScroll(rememberScrollState()),
+ .verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Spacer(Modifier.height(0.dp))
@@ -106,7 +118,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 +130,10 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
trailing = { Arrow() },
)
SettingsDivider()
+ UpdateRow()
+ SettingsDivider()
+ UpdateNotifyRow()
+ SettingsDivider()
SettingsRow(
"Replay the setup guide",
Modifier.clickable(onClick = onReplayTutorial),
@@ -165,7 +181,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) }
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt
new file mode 100644
index 0000000..a5b23e5
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt
@@ -0,0 +1,148 @@
+package app.seb3thehacker.gearslip.ui
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material3.Button
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Surface
+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.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.seb3thehacker.gearslip.BuildConfig
+import app.seb3thehacker.gearslip.SessionStatus
+import app.seb3thehacker.gearslip.update.UpdateChecker
+import app.seb3thehacker.gearslip.update.UpdateChecker.State
+
+private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip."
+
+/** Shown on Home once a newer version is found; hidden otherwise. */
+@Composable
+fun UpdateCard() {
+ val context = LocalContext.current
+ val state by UpdateChecker.state.collectAsStateWithLifecycle()
+ SessionStatus.state.collectAsStateWithLifecycle().value // redraw when the car comes or goes
+ val release = when (val s = state) {
+ is State.Available -> 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 a drive's end may notify about a new version. */
+@Composable
+internal fun UpdateNotifyRow() {
+ val context = LocalContext.current
+ var on by remember { mutableStateOf(UpdateChecker.notifyEnabled(context)) }
+ SettingsRow(
+ "Update notifications",
+ Modifier.toggleable(on, role = Role.Switch) {
+ on = it
+ UpdateChecker.setNotifyEnabled(context, it)
+ },
+ subtitle = "On the car screen when you plug in, and on the phone after a drive",
+ icon = Icons.Filled.Notifications,
+ 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..49346f6 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) },
)
@@ -106,6 +112,10 @@ internal fun ColumnScope.UsageStatsRows() {
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..eeb304d
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
@@ -0,0 +1,374 @@
+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. The app checks
+ * at most once a day: when it opens, and when a drive ends. 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"
+ private const val KEY_NOTIFY = "notify"
+ 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"
+
+ /** 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 a drive's end may post a notification about a new version. On by default: it sends nothing. */
+ fun notifyEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_NOTIFY, true)
+
+ fun setNotifyEnabled(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean(KEY_NOTIFY, on).apply()
+ if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
+ }
+
+ private fun dueForCheck(context: Context) =
+ !BuildConfig.DEBUG && 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 (!notifyEnabled(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 (!notifyEnabled(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 { fetchLatest() }.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),
+ )
+ }
+
+ private fun fetchLatest(): Release? {
+ val conn = (URL(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 json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
+ 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"),
+ )
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ 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 @@
-
+
+