Merge branch 'experimental' into feature/host-media-category

This commit is contained in:
Seb3thehacker authored and GitHub committed 2026-10-09 14:14:28 -07:00
commit 9c8b82d66a
27 files changed
+1701 -120

No files matched your search

+1 -1
View File
@@ -30,4 +30,4 @@ CLAUDE.md
# Other
archive/
.wrangler/
stats-worker/
stats-worker/
+2 -1
View File
@@ -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.
+6
View File
@@ -4,6 +4,7 @@
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -159,6 +160,11 @@
android:name=".notify.TestMessageReceiver"
android:exported="false" />
<!-- The one-time "Gearslip worked with your car" ask: its Share and Not now buttons. -->
<receiver
android:name=".stats.WorkedPromptReceiver"
android:exported="false" />
<service
android:name=".mirror.MirrorService"
android:exported="false"
@@ -152,6 +152,7 @@ class GearslipActivity : ComponentActivity(), Projection {
super.onStart()
Prefetch.warm(this)
UsageStats.onAppOpened(this)
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
}
override fun onNewIntent(intent: Intent) {
@@ -337,6 +338,8 @@ class GearslipActivity : ComponentActivity(), Projection {
}
override fun onProjectionStopped() {
// Before the car UI goes, while the media connection is still up.
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
runOnUiThread {
PhoneMirror.requestStop()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
@@ -3,6 +3,8 @@ package app.seb3thehacker.gearslip
import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys
@@ -68,6 +70,8 @@ class GearslipRunner(
@Volatile private var inputChannelId: Int = -1
@Volatile private var inputKeycodes: List<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null
@Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List<Int> = 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) {
@@ -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)
@@ -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;
@@ -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()
}
@@ -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
@@ -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",
@@ -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) }
@@ -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() }
@@ -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<Int, Int> = 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
}
}
}
@@ -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) {
@@ -587,10 +587,19 @@ class CarAppConnection(private val context: Context) {
override fun openMicrophone(request: Bundleable?): Bundleable? = null
}
private val _trip = MutableStateFlow<androidx.car.app.navigation.model.Trip?>(null)
/** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _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()
}
}
/**
@@ -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")
}
}
@@ -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")
@@ -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<String?>(null)
/** The car's name while the ask is waiting for an answer, otherwise null. */
fun pending(context: Context): StateFlow<String?> {
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<Boolean> = 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)
}
@@ -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,
@@ -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
@@ -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 })
@@ -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)
}
}
@@ -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<CertSummary?>(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) }
}
@@ -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) },
)
}
@@ -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) },
)
}
}
}
@@ -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>(State.Idle)
val state: StateFlow<State> = 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
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Only the log copies made for sharing; nothing else in the app is exposed. -->
<!-- Only log copies made for sharing and downloaded updates; nothing else in the app is exposed. -->
<cache-path name="shared_logs" path="shared/" />
<cache-path name="updates" path="updates/" />
</paths>