diff --git a/.gitignore b/.gitignore index 1705c65..d72c470 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ CLAUDE.md # Other archive/ .wrangler/ -stats-worker/ \ No newline at end of file +stats-worker/ diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index 0f8635e..6eed1ad 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -33,7 +33,8 @@ android { minSdk = 31 targetSdk = 37 versionCode = 8 - versionName = "0.2.00-alpha" + // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. + versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.2.00-alpha" manifestPlaceholders["appLabel"] = "Gearslip" // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 4dc1ede..50dfbdb 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + = emptyList() @Volatile private var sensorChannelId: Int = -1 + @Volatile private var mediaStatus: CarMediaStatus? = null + @Volatile private var clusterNav: ClusterNav? = null private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null private var audioMessagesSeen = 0 @@ -141,6 +145,10 @@ class GearslipRunner( audioLink = null CarSensors.clear() CarMic.current = null + mediaStatus?.stop() + mediaStatus = null + clusterNav?.stop() + clusterNav = null lastSensorNight = null CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise projection?.onProjectionStopped() @@ -176,6 +184,10 @@ class GearslipRunner( onInputMessage(messageId, body) } else if (frame.channel == sensorChannelId) { onSensorMessage(messageId, body) + } else if (frame.channel == clusterNav?.channelId) { + clusterNav?.onMessage(messageId, body) + } else if (frame.channel == mediaStatus?.channelId) { + mediaStatus?.onMessage(messageId, body) } else if (frame.channel == CarMic.current?.channelId) { CarMic.current?.onMessage(messageId, body) } else if (frame.channel == audioLink?.channelId) { @@ -225,7 +237,7 @@ class GearslipRunner( } log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.hex(" byebye raw", body) - SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name) + SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason") send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true) log.i("-> ByeByeResponse") } @@ -304,7 +316,7 @@ class GearslipRunner( sendServiceDiscoveryRequest() } -2 -> { - SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name) + SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2") log.verdict( "NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "The head unit explicitly rejected the phone's self-signed certificate. " + @@ -318,7 +330,7 @@ class GearslipRunner( state = State.DONE } -3 -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3") log.verdict( "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "Authentication rejected. Broader than a pure certificate error, but the " + @@ -331,7 +343,7 @@ class GearslipRunner( state = State.DONE } else -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status") log.verdict( "INCONCLUSIVE (AuthComplete status=$status)", "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + @@ -399,7 +411,7 @@ class GearslipRunner( val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) if (video == null) { log.w("no video service in the discovery response - cannot start Phase A") - SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name) + SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service") return } videoChannelId = video.serviceId @@ -417,6 +429,8 @@ class GearslipRunner( startAudioChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse) + startMediaStatusChannel(serviceDiscoveryResponse) + startClusterNavChannel(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { @@ -478,6 +492,28 @@ class GearslipRunner( openChannel(mic.serviceId) } + /** What's playing, for the car's own screens: the cluster, the media source page. */ + private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no media status channel - the car's own screens won't show what's playing") + return + } + log.i("media status channel: $channel") + mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) } + openChannel(channel) + } + + /** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */ + private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no navigation status channel - the cluster won't show turns") + return + } + log.i("navigation status channel: $channel") + clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) } + openChannel(channel) + } + /** * Every sensor type the head unit advertised, subscribed to at once - there is no screen * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers @@ -1065,10 +1101,10 @@ class GearslipRunner( private fun reportDisconnect() { when (state) { - State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name) - State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name) - State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name) - State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name) + State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed") + State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed") + State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed") + State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed") State.DONE -> {} } when (state) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt index e253147..c651284 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -31,6 +31,9 @@ object Protobuf { return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes } + fun bytesField(field: Int, bytes: ByteArray): ByteArray = + varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes + fun varintField(field: Int, value: Long): ByteArray = varint((field shl 3).toLong()) + varint(value) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 3cf57e6..2aad847 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -110,6 +110,21 @@ object ServiceDiscovery { return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() } + /** + * The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9), + * which feeds its cluster and media screens. It carries no settings. Null when the car has none. + */ + fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) + + /** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */ + fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8) + + private fun findChannelWith(response: ByteArray, serviceField: Int): Int? = + Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> + val service = Wire.fields(channel) + if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null + } + /** * The car's microphone, from MediaSourceService (Service field 5): * MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2; diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt index fe2a01a..65737e4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt @@ -29,12 +29,30 @@ object SessionReport { UNKNOWN("unclassified"), } + /** + * How far a session got, in the order a connection is built. The usage note's "Where + * connections fail" share sends the furthest one reached, so a failure says where it broke. + */ + enum class Stage(val key: String) { + USB("usb"), // the link opened; the car may have said nothing + VERSIONS("versions"), // the car said which protocol it speaks + TLS("tls"), // the secure link is up + ACCEPTED("accepted"), // the car accepted the certificate + CHANNELS("channels"), // the car listed its channels and said who it is + VIDEO("video"), // the car's screen is set up for Gearslip + STREAMING("streaming"), // video frames are reaching the car + } + private var appVersion = "?" private var appCode = 0L @Volatile private var startedAt = 0L @Volatile var category = Category.NONE; private set @Volatile private var failure = "" + @Volatile var stage = Stage.USB; private set + /** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */ + @Volatile private var failCode = "" + @Volatile private var failedAfter = 0L @Volatile private var protocolVersion = "" @Volatile private var certSource = "" @Volatile private var certIssuer = "" @@ -62,6 +80,7 @@ object SessionReport { startedAt = System.currentTimeMillis() category = Category.NONE failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" + stage = Stage.USB; failCode = ""; failedAfter = 0 tlsProtocol = ""; tlsCipher = ""; authStatus = null headUnitInfo = null carScreen = ""; carDpi = 0 @@ -69,34 +88,87 @@ object SessionReport { printed = false } - fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" } + /** Moves the session's furthest stage on; never back. */ + private fun reached(next: Stage) { if (next > stage) stage = next } + + fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) } fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer } - fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } - fun auth(status: Int) { authStatus = status } + fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) } + fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) } fun headUnit(info: String) { headUnit = info } /** Who the car said it is, for the opt-in usage note; null until service discovery. */ @Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null + set(value) { field = value; if (value != null) reached(Stage.CHANNELS) } /** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */ @Volatile var carScreen = ""; private set @Volatile var carDpi = 0; private set - fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi } + fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) } fun profile(name: String?) { profile = name ?: "none matched" } fun video(summary: String) { video = summary } fun audio(summary: String) { audio = summary } - fun stream(summary: String) { lastStream = summary } + fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) } - /** Records the first failure only: later ones are usually fallout from it. */ - fun fail(category: Category, detail: String, state: String = "") { + /** + * Records the first failure only: later ones are usually fallout from it. [code] is the short + * thing the car or the phone said, like "auth -3" or "bye 1", for the usage note. + */ + fun fail(category: Category, detail: String, state: String = "", code: String = "") { if (this.category != Category.NONE) return this.category = category failure = detail lastState = state + failCode = code + failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 } fun fail(category: Category, detail: String, t: Throwable, state: String = "") = - fail(category, "$detail (${GearslipLog.rootCause(t)})", state) + fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t)) + + /** + * A short code from an exception, never its whole message: the TLS alert's name, the errno + * of a USB failure, or else the exception's class. + */ + private fun codeOf(t: Throwable): String { + var root = t + while (root.cause != null && root.cause !== root) root = root.cause!! + val text = "${t.message} ${root.message}" + Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let { + return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}" + } + Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] } + return root.javaClass.simpleName.take(40) + } + + /** + * One session for the usage note's "Where connections fail" share: how far it got, how and + * when it ended, and with which kind of certificate. Counts and codes only, nothing personal. + */ + fun sessionNote(): org.json.JSONObject { + val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 + val source = certSource.lowercase() + return org.json.JSONObject() + .put("stage", stage.key) + .put("ended", category.name.lowercase()) + .put("code", failCode) + .put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter)) + .put("seconds", rounded(seconds)) + .put("cert", when { + "imported" in source -> "imported" + "downloaded" in source -> "downloaded" + "self" in source -> "self-signed" + source.isEmpty() -> "" + else -> "other" + }) + } + + /** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */ + private fun rounded(s: Long): Long = when { + s < 60 -> (s + 2) / 5 * 5 + s < 3600 -> (s + 30) / 60 * 60 + else -> (s + 150) / 300 * 300 + } /** The report as plain text, for the log file and for anything that gets sent. */ fun render(): String { @@ -145,7 +217,13 @@ object SessionReport { category == Category.NONE -> "ended before video" else -> category.label } - runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) } + runCatching { + UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote()) + } + if (outcome == "connected") runCatching { + app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi) + } + runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) } } GearslipLog.flush() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt index 8a7aa81..02b74f4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt @@ -45,7 +45,8 @@ class VideoSource( private var frameIndex = 0L fun start() { - val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE) + val level = levelFor(width, height, frameRate) + val bitRate = defaultBitRate(width, height, frameRate, level) fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { setInteger( MediaFormat.KEY_COLOR_FORMAT, @@ -74,7 +75,7 @@ class VideoSource( setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat()) } setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) - setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) + setInteger(MediaFormat.KEY_LEVEL, level.codecLevel) if (tuned) { // Hints only: a car screen wants each frame now, not the best frame a moment later. setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) @@ -98,7 +99,7 @@ class VideoSource( codec = encoder running = true - log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode") + log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline level ${level.label}, mode=$mode") worker = Thread { runCatching { pump(encoder) } @@ -223,10 +224,40 @@ class VideoSource( const val TIMEOUT_US = 10_000L const val SURFACE_TIMEOUT_US = 100_000L - /** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */ - const val BITS_PER_PIXEL = 0.3 - const val MIN_BIT_RATE = 4_000_000 - const val MAX_BIT_RATE = 12_000_000 + /** + * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving + * video in the browser stays sharp. Bigger screens get more, but less than in proportion, + * since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x, + * because frames that close together are mostly alike. + */ + const val BASE_BIT_RATE = 8_000_000.0 + const val BASE_PIXELS = 800.0 * 480 + /** No car's decoder is asked for more than this, whatever its level allows. */ + const val MAX_BIT_RATE = 25_000_000 + + /** + * The H.264 levels a car screen can need, smallest first: macroblocks a second each + * decodes, and the Baseline bitrate it must handle. The encoder declares the smallest + * that fits, and the bitrate stays within it, so a car decoder isn't asked for more than + * the level it was told about. + */ + enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) { + L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000), + L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000), + L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000), + L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000), + } + + fun levelFor(width: Int, height: Int, frameRate: Int): Level { + val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate + return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42 + } + + fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int { + val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75) + val rate = if (frameRate > 30) 1.5 else 1.0 + return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE)) + } const val I_FRAME_INTERVAL_S = 5 private const val REPEAT_AFTER_US = 100_000L diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt index 46fdeb9..d904be6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt @@ -60,14 +60,14 @@ class AudioLink( } /** - * The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no - * body) rather than the AVChannelSetupResponse the video channel gets, so the sink never - * reaches READY and no audio is ever sent. + * The 2018 Uconnect and a Dacia ULC 4.5 both answer our Setup with a two-byte message we + * cannot identify (id 255, no body) rather than the AVChannelSetupResponse the video channel + * gets, so the sink never reaches READY. [MSG_SETUP_ANSWER] takes that as the go-ahead. * - * Service discovery already told us everything that message would have: the sink is PCM at - * 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a - * message that is not coming, assume the advertised configuration and carry on. If that guess - * is wrong the head unit simply will not ack, which the write path already reports. + * Service discovery already told us everything a Config would have: the sink's rate, channels + * and its one configuration. So for a car that answers nothing at all, wait a moment and then + * assume the advertised configuration. If that guess is wrong the head unit simply will not + * ack, which the write path already reports. */ private fun armConfigFallback() { val timer = Thread({ @@ -76,19 +76,25 @@ class AudioLink( } catch (e: InterruptedException) { return@Thread } - if (ready) return@Thread - GearslipLog.w( - "audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " + - "head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)", - ) - configIndex = 0 - ready = true - CarAudio.attach(this) + useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms") }, "gearslip-audio-config").apply { isDaemon = true } configTimer = timer timer.start() } + @Synchronized + private fun useAdvertisedSink(why: String) { + if (ready) return + configTimer?.interrupt() + GearslipLog.i( + "audio: $why - using the sink the head unit advertised " + + "(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)", + ) + configIndex = 0 + ready = true + CarAudio.attach(this) + } + fun onMessage(messageId: Int, body: ByteArray) { when (messageId) { MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0) @@ -123,8 +129,10 @@ class AudioLink( } } - // Whatever the head unit answers Setup with, we need its bytes to identify it. - else -> { + // Whatever else the head unit answers Setup with, we need its bytes to identify it. + else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) { + useAdvertisedSink("<- empty Setup answer (id $messageId)") + } else { GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)") GearslipLog.hex(" audio msg", body, limit = 64) } @@ -243,6 +251,7 @@ class AudioLink( private const val MSG_STOP = 32770 private const val MSG_CONFIG = 32771 private const val MSG_ACK = 32772 + private const val MSG_SETUP_ANSWER = 255 private const val STATUS_READY = 2 private const val FOCUS_GAIN = 1 private const val FOCUS_RELEASE = 4 @@ -251,7 +260,7 @@ class AudioLink( private const val SESSION_ID = 2 private const val STALL_MS = 2_000L private const val CHUNK_LOG_EVERY = 200 // about every four seconds - private const val CONFIG_WAIT_MS = 2_500L + private const val CONFIG_WAIT_MS = 1_000L private val FOCUS_STATES = mapOf( 1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index cb8e114..5f02ea4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -33,6 +33,12 @@ object CarServices { val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + /** [nav], or null before the car screen has set things up. */ + fun navOrNull(): CarAppConnection? = if (context != null) nav else null + + /** [media], or null before the car screen has set things up: for code outside the car UI. */ + fun mediaOrNull(): CarMedia? = if (context != null) media else null + /** * A second, independent connection for templated apps that aren't navigation apps - Spotify's * own Car App Library service is the first of these. Kept apart from [nav] so opening one @@ -194,6 +200,32 @@ object CarServices { return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } } } + /** + * The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of + * the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way + * headphones are, so the apps won't pause themselves. Every playing session is paused, not + * just the one Gearslip opened: anything the car was playing was going through the car. + */ + fun pausePhoneMedia() { + val ctx = context ?: return + val manager = ctx.getSystemService(MediaSessionManager::class.java) + val listener = ComponentName(ctx, GearslipNotificationListener::class.java) + val playing = try { + manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } + } catch (e: SecurityException) { + null + } + if (playing == null) { + // No notification access: the app Gearslip itself is driving is all it can reach. + mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() } + return + } + playing.forEach { runCatching { it.transportControls.pause() } } + if (playing.isNotEmpty()) { + app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }} as the car left") + } + } + /** The car went away: nothing should keep running for a screen that no longer exists. */ fun shutdown() { context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 03baae2..261bcfd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -126,6 +126,8 @@ fun CarUi() { // a video in the web browser or a map's voice needs it too. Asks once, after the car offers // somewhere to play it. LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() } + // A new version gets one card in the top right, a few seconds in and only while parked. + LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) } // The map follows the light outside, whatever the app's own theme is set to. LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt new file mode 100644 index 0000000..fda6c4c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt @@ -0,0 +1,196 @@ +package app.seb3thehacker.gearslip.car + +import androidx.car.app.model.CarText +import androidx.car.app.model.Distance +import androidx.car.app.navigation.model.Maneuver +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.car.app.navigation.model.RoutingInfo +import androidx.car.app.navigation.model.Step +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf + +/** + * The next turn on the car's instrument cluster, over its NavigationStatusService (Service + * field 8), the way Android Auto feeds it: + * + * -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) } + * -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 } + * -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2; + * display_distance_e3 = 3; display_distance_unit = 4 } + * + * Newer cars also take a NavigationState message; the deprecated pair above is what a 2018 + * Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands + * to clusters), or from its routing card when it sends no trip. + */ +class ClusterNav( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sentStatus = -1 + private var sentTurn: String? = null + private var sentDistance: String? = null + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) ?: 0 + GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + // The car saying when it wants turns. Sending all along is harmless, so these are only logged. + MSG_START -> GearslipLog.i("cluster: <- start") + MSG_STOP -> GearslipLog.i("cluster: <- stop") + else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-cluster").start() + } + + fun stop() { + running = false + } + + /** One turn as the car wants it, from whichever source the map app fills. */ + private class Turn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean) + + private fun current(): Turn? { + val nav = CarServices.navOrNull() ?: return null + nav.trip.value?.let { trip -> + val step = trip.steps.firstOrNull() ?: return@let + val estimate = trip.stepTravelEstimates.firstOrNull() + return Turn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading) + } + val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null + val step = info.currentStep ?: return null + return Turn(step, info.currentDistance, null, info.isLoading) + } + + private fun tick() { + val turn = current() + val status = when { + turn == null -> STATUS_INACTIVE + turn.rerouting -> STATUS_REROUTING + else -> STATUS_ACTIVE + } + if (status != sentStatus) { + sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong())) + GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}") + sentStatus = status + if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null } + } + if (turn == null || status == STATUS_REROUTING) return + sendTurn(turn.step) + turn.distance?.let { sendDistance(it, turn.seconds) } + } + + private fun sendTurn(step: Step) { + val maneuver = step.maneuver + val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED) + val road = step.road.text().ifBlank { step.cue.text() } + val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}" + if (key == sentTurn) return + sentTurn = key + var body = Protobuf.stringField(1, road) + + Protobuf.varintField(2, side.toLong()) + + Protobuf.varintField(3, event.toLong()) + maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) } + maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) } + sendOnChannel(MSG_TURN, body) + GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"") + } + + /** + * The Car App Library's distance units use the same numbers as the protocol's (1 metres ... + * 7 yards), so the app's own display choice passes straight through. + */ + private fun sendDistance(distance: Distance, seconds: Long?) { + val meters = toMeters(distance) + val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}" + if (key == sentDistance) return + sentDistance = key + sendOnChannel( + MSG_DISTANCE, + Protobuf.varintField(1, meters.toLong()) + + Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) + + Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) + + Protobuf.varintField(4, distance.displayUnit.toLong()), + ) + } + + private fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) { + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0 + Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344 + Distance.UNIT_FEET -> 0.3048 + Distance.UNIT_YARDS -> 0.9144 + else -> 1.0 + }).toInt() + + private fun CarText?.text() = this?.toString().orEmpty() + + companion object { + private const val TICK_MS = 500L + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_START = 32769 + private const val MSG_STOP = 32770 + private const val MSG_STATUS = 32771 + private const val MSG_TURN = 32772 + private const val MSG_DISTANCE = 32773 + + private const val STATUS_ACTIVE = 1 + private const val STATUS_INACTIVE = 2 + private const val STATUS_REROUTING = 3 + private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting") + + private const val SIDE_LEFT = 1 + private const val SIDE_RIGHT = 2 + private const val SIDE_UNSPECIFIED = 3 + + private const val EVENT_UNKNOWN = 0 + + /** The car's turn icon and side for a Car App Library manoeuvre. */ + fun eventFor(type: Int): Pair = when (type) { + Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED + Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED + Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT + Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT + Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT + Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT + Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT + Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT + Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT + Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT + Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT, + Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT + Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT, + Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT + Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT + Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT + Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT + Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT + Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT + Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT + Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE, + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED + Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED + Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED + Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED + Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED + Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT + Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT + else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt index 44cafd3..7488389 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt @@ -50,6 +50,8 @@ enum class HoldMarkerStyle { * colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the * dot whatever the theme says: a circle has no corner to peel. */ +private const val SHOW_HOLD_DOT = false + @Composable internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) { if (style == HoldMarkerStyle.NONE) return @@ -68,8 +70,9 @@ internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, sha val side = min(size.width, size.height) val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f val round = r >= side * 0.4f + // The dot is off until it's redesigned: flat themes and round controls show nothing. if (style == HoldMarkerStyle.DOT || round) { - drawHoldDot(r, side, ink) + if (SHOW_HOLD_DOT) drawHoldDot(r, side, ink) } else { // Drawn for the bottom-right corner, then flipped up to the top-right. scale(1f, -1f, pivot = center) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index a332887..d13c3e8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -587,10 +587,19 @@ class CarAppConnection(private val context: Context) { override fun openMicrophone(request: Bundleable?): Bundleable? = null } + private val _trip = MutableStateFlow(null) + /** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */ + val trip: StateFlow = _trip.asStateFlow() + private val navigationHost = object : INavigationHost.Stub() { override fun navigationStarted() = GearslipLog.i("host: navigation started") - override fun navigationEnded() = GearslipLog.i("host: navigation ended") - override fun updateTrip(trip: Bundleable?) = Unit + override fun navigationEnded() { + GearslipLog.i("host: navigation ended") + _trip.value = null + } + override fun updateTrip(trip: Bundleable?) { + _trip.value = runCatching { trip?.get() as? androidx.car.app.navigation.model.Trip }.getOrNull() + } } /** diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt new file mode 100644 index 0000000..0c1088a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt @@ -0,0 +1,119 @@ +package app.seb3thehacker.gearslip.media + +import android.graphics.Bitmap +import android.media.session.PlaybackState +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.car.CarServices +import java.io.ByteArrayOutputStream + +/** + * Tells the car what's playing, so its own screens show it: the instrument cluster, the media + * source screen, the steering wheel display. Android Auto does the same over the car's + * MediaPlaybackStatusService (Service field 9): + * + * -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 } + * -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2; + * playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 } + * + * Read once a second from the media app Gearslip is driving. The track goes only when it changes; + * the status goes every second while playing, so the car's position counts along. + */ +class CarMediaStatus( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sentTrack: String? = null + private var sentState = -1 + private var inputsLogged = 0 + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) ?: 0 + GearslipLog.i("media status: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + // The car's own buttons for the media source (play, next...). Logged to learn what + // this car sends; the steering wheel keys already arrive on the input channel. + MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body)) + else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-media-status").start() + } + + fun stop() { + running = false + } + + private fun tick() { + val now = CarServices.mediaOrNull()?.now?.value + val active = now != null && now.isActive + val state = when { + !active -> STATE_STOPPED + now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED + else -> STATE_PLAYING + } + if (active) sendTrack(now!!) + // Nothing to say while stopped, beyond saying so once. + if (state == STATE_STOPPED && sentState == STATE_STOPPED) return + if (state == STATE_PAUSED && sentState == STATE_PAUSED) return + val source = CarServices.mediaApp.value?.label ?: "Gearslip" + val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0 + sendOnChannel( + MSG_STATUS, + Protobuf.varintField(1, state.toLong()) + + Protobuf.stringField(2, source) + + Protobuf.varintField(3, seconds) + + Protobuf.varintField(4, 0L) + + Protobuf.varintField(5, 0L) + + Protobuf.varintField(6, 0L), + ) + if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)") + sentState = state + } + + private fun sendTrack(now: NowPlaying) { + val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}" + if (key == sentTrack) return + sentTrack = key + var body = Protobuf.stringField(1, now.title) + + Protobuf.stringField(2, now.artist) + + Protobuf.stringField(3, now.album) + now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } } + if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000) + sendOnChannel(MSG_METADATA, body) + GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}") + } + + /** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */ + private fun artBytes(art: Bitmap): ByteArray? = runCatching { + val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height)) + val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art + ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + }.getOrNull() + + companion object { + private const val TICK_MS = 1_000L + private const val ART_PX = 256 + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_STATUS = 32769 + private const val MSG_INPUT = 32770 + private const val MSG_METADATA = 32771 + private const val STATE_STOPPED = 1 + private const val STATE_PLAYING = 2 + private const val STATE_PAUSED = 3 + private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused") + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt index d03b176..7dd629c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt @@ -32,6 +32,8 @@ object UsageStats { val android: Boolean = true, val phone: Boolean = true, val cars: Boolean = true, + /** How each car session went: how far it got, how and when it ended. Off until ticked. */ + val failures: Boolean = false, ) private const val PREFS = "usage_stats" @@ -49,6 +51,10 @@ object UsageStats { /** The daily background job of earlier builds, cancelled wherever it's still scheduled. */ private const val OLD_JOB_ID = 4_207 private const val MAX_CARS = 10 + /** Each session for the "Where connections fail" share, waiting for the next note. */ + private const val KEY_SESSIONS = "sessions" + private const val KEY_LAST_SESSIONS = "last_sessions" + private const val MAX_SESSIONS = 20 private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -69,20 +75,27 @@ object UsageStats { android = p.getBoolean("share_android", true), phone = p.getBoolean("share_phone", true), cars = p.getBoolean("share_cars", true), + failures = p.getBoolean("share_failures", false), ) } fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) { + val wasEnabled = enabled(context) prefs(context).edit() .putBoolean(KEY_ENABLED, enabled) .putBoolean(KEY_ASKED, true) .putBoolean("share_android", choices.android) .putBoolean("share_phone", choices.phone) .putBoolean("share_cars", choices.cars) + .putBoolean("share_failures", choices.failures) .apply() // Turning it on sends nothing yet: the driver may still be ticking boxes. The first note // waits for [choicesClosed]. - if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply() + if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY) + .remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply() + if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply() + // Turned on anywhere - Settings, Home, or the ask itself - answers the one-time ask. + if (enabled && !wasEnabled) WorkedPrompt.onSharingOn(context) } /** The driver left the screen with the usage note choices: send today's note if it's due. */ @@ -90,10 +103,15 @@ object UsageStats { if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } - /** Gearslip was opened: send today's note unless one already went out. */ + /** + * Gearslip was opened: send today's note unless one already went out, or sooner if a session + * is still waiting, like one that ended in a crash before its note could go. + */ fun onAppOpened(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID) - if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) + val p = prefs(context) + val waiting = p.getString(KEY_CARS, "[]") != "[]" || p.getString(KEY_SESSIONS, "[]") != "[]" + if (waiting || p.getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } private val sending = AtomicBoolean(false) @@ -134,13 +152,35 @@ object UsageStats { outcome: String, screen: String = "", dpi: Int = 0, + session: JSONObject? = null, ) { if (!enabled(context)) return val p = prefs(context) - synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) } + synchronized(carsLock) { + addCar(p, info, protocol, outcome, screen, dpi) + if (session != null && choices(context).failures) addSession(p, info, protocol, session) + } sendSoon(context) } + private fun addSession( + p: android.content.SharedPreferences, + info: ServiceDiscovery.HeadUnitInfo?, + protocol: String, + session: JSONObject, + ) { + val sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (sessions.length() >= MAX_SESSIONS) return + // The same car fields as the cars list, so the dashboard can line the two up. + session.put("name", info?.headUnitName.orEmpty()) + .put("car", info?.carModel.orEmpty()) + .put("year", info?.carYear.orEmpty()) + .put("protocol", protocol) + sessions.put(session) + // Written at once: after a crash the app is about to die, and a deferred write would be lost. + p.edit().putString(KEY_SESSIONS, sessions.toString()).commit() + } + private fun addCar( p: android.content.SharedPreferences, info: ServiceDiscovery.HeadUnitInfo?, @@ -162,7 +202,7 @@ object UsageStats { // The same car and outcome twice in a day is one fact, not two. val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() } if (!seen && cars.length() < MAX_CARS) cars.put(car) - p.edit().putString(KEY_CARS, cars.toString()).apply() + p.edit().putString(KEY_CARS, cars.toString()).commit() } // GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away. @@ -203,9 +243,33 @@ object UsageStats { } note.put("cars", cars) } + if (c.failures) { + var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (everything && sessions.length() == 0) { + sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + } + note.put("sessions", sessions) + } return note } + /** + * A made-up session, for "What gets sent" to show before the first real one: a 25-minute + * drive that ended with the car switched off. + */ + val exampleSession: JSONObject + get() = JSONObject() + .put("stage", "streaming") + .put("ended", "byebye") + .put("code", "bye 1") + .put("failed_at", 1500) + .put("seconds", 1500) + .put("cert", "downloaded") + .put("name", "Head unit name") + .put("car", "Car model") + .put("year", "2021") + .put("protocol", "1.5") + /** Sends the note now; false when it didn't get through. */ private fun send(context: Context): Boolean { if (!sends || !enabled(context)) return true @@ -213,6 +277,7 @@ object UsageStats { val p = prefs(context) val note = synchronized(carsLock) { preview(context) } val sentCars = note.optJSONArray("cars")?.toString() + val sentSessions = note.optJSONArray("sessions")?.toString() val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null val body = note.toString().toByteArray() return runCatching { @@ -234,6 +299,16 @@ object UsageStats { if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString()) if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars) if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS) + if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions) + if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) { + edit.remove(KEY_SESSIONS) + } else if (sentSessions != "[]") { + // A session ended mid-send: keep only the ones this note didn't carry. + val now = JSONArray(p.getString(KEY_SESSIONS, "[]")) + val sent = JSONArray(sentSessions).length() + val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) } + edit.putString(KEY_SESSIONS, left.toString()) + } edit.apply() } GearslipLog.i("stats: note sent") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt new file mode 100644 index 0000000..0311be0 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt @@ -0,0 +1,179 @@ +package app.seb3thehacker.gearslip.stats + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import app.seb3thehacker.gearslip.GearslipActivity +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.ServiceDiscovery +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.json.JSONObject + +/** + * The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share + * usage notes. It comes after the first drive that worked, when a driver is happiest to help, as + * a notification the moment the car leaves and as a card on Home. Answering either clears both, + * and it never comes back. + * + * Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the + * first note. Until the driver answers, the car waits here on the phone and is sent nowhere. + */ +object WorkedPrompt { + + private const val PREFS = "worked_prompt" + private const val KEY_DONE = "done" + private const val KEY_CAR = "car" + private const val CHANNEL = "worked" + private const val NOTIFICATION_ID = 4_311 + private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE" + private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW" + + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private var loaded = false + private val flow = MutableStateFlow(null) + + /** The car's name while the ask is waiting for an answer, otherwise null. */ + fun pending(context: Context): StateFlow { + if (!loaded) { + loaded = true + flow.value = saved(context)?.let(::carName) + } + return flow + } + + /** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */ + fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) { + val p = prefs(context) + if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return + val car = JSONObject() + .put("name", info?.headUnitName.orEmpty()) + .put("car", info?.carModel.orEmpty()) + .put("year", info?.carYear.orEmpty()) + .put("make", info?.headUnitMake.orEmpty()) + .put("model", info?.headUnitModel.orEmpty()) + .put("protocol", protocol) + .put("screen", screen) + .put("dpi", dpi) + p.edit().putString(KEY_CAR, car.toString()).apply() + loaded = true + flow.value = carName(car) + notify(context, carName(car)) + } + + /** + * True once a driver turns sharing on from the ask, until Home has thrown its confetti. It + * waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens. + */ + private val celebrateFlow = MutableStateFlow(false) + val celebrate: StateFlow = celebrateFlow + + fun celebrated() { celebrateFlow.value = false } + + /** The driver answered, here or on Home. Either way it's never asked again. */ + fun answer(context: Context, share: Boolean) { + if (share) { + // Turning sharing on lands in [onSharingOn], which clears the ask. + UsageStats.set(context, true) + return + } + clear(context) + GearslipLog.i("worked prompt: not now") + } + + /** + * Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting, + * that answers it, and the car it was about goes in the first note. + */ + internal fun onSharingOn(context: Context) { + val car = saved(context) ?: return + clear(context) + celebrateFlow.value = true + GearslipLog.i("worked prompt: usage notes turned on") + val info = ServiceDiscovery.HeadUnitInfo( + car.optString("name"), car.optString("car"), car.optString("year"), + car.optString("make"), car.optString("model"), + ) + UsageStats.recordSession( + context, info, car.optString("protocol"), "connected", + car.optString("screen"), car.optInt("dpi"), + ) + } + + private fun clear(context: Context) { + prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply() + loaded = true + flow.value = null + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun saved(context: Context): JSONObject? = + prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() } + + /** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */ + private fun carName(car: JSONObject): String { + val model = car.optString("car").trim() + val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" } + val unit = "${car.optString("make")} ${car.optString("model")}".trim() + return when { + model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ") + unit.isNotEmpty() -> "your $unit" + else -> "your car" + } + } + + private fun notify(context: Context, car: String) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT), + ) + val open = PendingIntent.getActivity( + context, NOTIFICATION_ID, + Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + val text = "Share that so the next driver knows it works? No name, account, or location." + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.ic_menu_send) + .setContentTitle(TITLE_START + car) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(open) + .setAutoCancel(true) + .addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW)) + .addAction(0, "Share", broadcast(context, ACTION_SHARE)) + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + .onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") } + .onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") } + } + + private fun broadcast(context: Context, action: String): PendingIntent = + PendingIntent.getBroadcast( + context, action.hashCode(), + Intent(context, WorkedPromptReceiver::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE, + ) + + internal fun onAction(context: Context, intent: Intent) { + when (intent.action) { + ACTION_SHARE -> answer(context, share = true) + ACTION_NOT_NOW -> answer(context, share = false) + } + } + + const val TITLE_START = "Gearslip worked with " +} + +/** The notification's Share and Not now buttons land here. */ +class WorkedPromptReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt index f06c7f9..48716ca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -14,8 +14,17 @@ data class CertSummary( val validUntil: String?, ) { companion object { + /** + * The last [read], so Settings draws the certificate row at once instead of a second + * later, which shifted everything under it. Null until the first read. + */ + @Volatile var cached: CertSummary? = null + private set + /** Blocking (parses a key store): call from a background dispatcher. */ - fun read(context: Context): CertSummary { + fun read(context: Context): CertSummary = readNow(context).also { cached = it } + + private fun readNow(context: Context): CertSummary { val identity = CertProvider.loadSupplied(context) ?: return CertSummary( CertProvider.Kind.SELF_SIGNED, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt new file mode 100644 index 0000000..61ce7e4 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt @@ -0,0 +1,88 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.sin +import kotlin.random.Random + +private class Piece( + val x: Float, // across the screen, 0..1 + val delay: Float, // seconds before it starts falling + val speed: Float, // screen heights per second + val sway: Float, // how far it drifts side to side, in screen widths + val swaySpeed: Float, + val spin: Float, // degrees per second + val width: Float, // dp + val height: Float, + val color: Color, +) + +/** + * Confetti falling from the top of the screen once, for about three seconds. Draws over whatever + * is under it and takes no touches; [onDone] fires when the last piece has left the screen. + */ +@Composable +internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val colors = listOf( + scheme.primary, scheme.tertiary, scheme.secondary, + Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3), + ) + val pieces = remember { + List(PIECES) { + Piece( + x = Random.nextFloat(), + delay = Random.nextFloat() * 0.9f, + speed = 0.35f + Random.nextFloat() * 0.35f, + sway = 0.01f + Random.nextFloat() * 0.03f, + swaySpeed = 2f + Random.nextFloat() * 4f, + spin = (Random.nextFloat() - 0.5f) * 720f, + width = 4f + Random.nextFloat() * 4f, + height = 8f + Random.nextFloat() * 6f, + color = colors[Random.nextInt(colors.size)], + ) + } + } + var t by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + val start = withFrameNanos { it } + // The slowest piece, starting last, clears the screen by this time. + val end = pieces.maxOf { it.delay + 1.1f / it.speed } + while (t < end) { + withFrameNanos { t = (it - start) / 1e9f } + } + onDone() + } + Canvas(modifier.fillMaxSize()) { + val density = density + for (p in pieces) { + val age = t - p.delay + if (age < 0f) continue + val y = (age * p.speed - 0.05f) * size.height + if (y > size.height + 40f) continue + val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width + rotate(age * p.spin, pivot = Offset(x, y)) { + drawRect( + p.color, + topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2), + size = Size(p.width * density, p.height * density), + ) + } + } + } +} + +private const val PIECES = 140 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 1a23888..9c5a3db 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -60,6 +60,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { }, ) } + // Home's usage notes row opens Settings at that section rather than at the top. + var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = screen != Screen.HOME) { screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME } @@ -75,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { // The guide asks for this now, but anyone who finished it before that never saw the page. val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + // Reads the certificate once in the background, so Settings has it ready when opened. + LaunchedEffect(Unit) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } } + } LaunchedEffect(Unit) { if (!needsSetupGuide && context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -95,18 +101,20 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { when (screen) { Screen.HOME -> HomeScreen( onOpenLogs = { screen = Screen.LOGS }, - onOpenSettings = { screen = Screen.SETTINGS }, + onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS }, onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, onOpenHelp = { screen = Screen.HELP }, + onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS }, onDisconnect = onDisconnect, ) Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS }) Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) Screen.SETTINGS -> SettingsScreen( onBack = { screen = Screen.HOME }, - onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS }, - onOpenWhatsNew = { screen = Screen.WHATS_NEW }, + onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS }, + onOpenWhatsNew = { settingsAtUsageNotes = false; screen = Screen.WHATS_NEW }, onReplayTutorial = { screen = Screen.SETUP_GUIDE }, + atUsageNotes = settingsAtUsageNotes, ) Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index a13d01f..bb3a79f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.notify.TestMessage +import app.seb3thehacker.gearslip.stats.WorkedPrompt @Composable fun HomeScreen( @@ -48,80 +51,100 @@ fun HomeScreen( onOpenSettings: () -> Unit, onOpenCarPreview: () -> Unit, onOpenHelp: () -> Unit, + onOpenUsageNotes: () -> Unit, onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle() + val live = status.phase == SessionStatus.Phase.CONNECTING || + status.phase == SessionStatus.Phase.PROJECTING - Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp), - ) { + val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle() + Box { + Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Column( Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp), ) { - Spacer(Modifier.height(24.dp)) - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), ) { - Text( - "Gearslip", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - IconButton( - onClick = onOpenSettings, - modifier = Modifier.size(56.dp), + Spacer(Modifier.height(24.dp)) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + IconButton( + onClick = onOpenSettings, + modifier = Modifier.size(56.dp), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + } } - } - Text( - "v${LocalContext.current.appVersionName()}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - StatusCard(status) - Spacer(Modifier.height(16.dp)) - } - - // Sits at the bottom, where a thumb reaches it. - Column(Modifier.padding(vertical = 16.dp)) { - val live = status.phase == SessionStatus.Phase.CONNECTING || - status.phase == SessionStatus.Phase.PROJECTING - if (live) { - Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Disconnect") + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + StatusCard(status) + // The first drive that worked asks once to share it, right under the result. + val car = workedCar + if (car != null && !live) { + Spacer(Modifier.height(16.dp)) + WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes) } Spacer(Modifier.height(16.dp)) + UpdateCard() } - Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { - Column { - if (!live) { - ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) - RowDivider() + // Sits at the bottom, where a thumb reaches it. + Column(Modifier.padding(vertical = 16.dp)) { + if (live) { + Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("Disconnect") } - ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) - RowDivider() - ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) - if (BuildConfig.DEBUG) { - val context = LocalContext.current + Spacer(Modifier.height(16.dp)) + } + + // Not while the card above is asking the same thing. + if (workedCar == null || live) { + UsageNotesHomeCard(onOpen = onOpenUsageNotes) + Spacer(Modifier.height(16.dp)) + } + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { + Column { + if (!live) { + ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) + RowDivider() + } + ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) RowDivider() - ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) + if (BuildConfig.DEBUG) { + RowDivider() + ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + } } } } } } + // Sharing just went on from the "it worked" ask: a little thanks. + if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index f6bba10..0761ba8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -57,6 +57,9 @@ import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @@ -66,7 +69,16 @@ fun SettingsScreen( onOpenRecommendedApps: () -> Unit, onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, + atUsageNotes: Boolean = false, ) { + val scroll = rememberScrollState() + // Opened from Home's usage notes row: usage notes is the last section, so start at the end. + if (atUsageNotes) { + LaunchedEffect(Unit) { + snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE } + scroll.scrollTo(scroll.maxValue) + } + } Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -85,7 +97,7 @@ fun SettingsScreen( .fillMaxSize() .padding(padding) .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scroll), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Spacer(Modifier.height(0.dp)) @@ -106,7 +118,7 @@ private fun Arrow() = Icon( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) -/** What's new and the setup guide, one tap from the top. */ +/** What's new, updates and the setup guide, one tap from the top. */ @Composable private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) { SettingsSection { @@ -118,6 +130,10 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni trailing = { Arrow() }, ) SettingsDivider() + UpdateRow() + SettingsDivider() + UpdateNotifyRow() + SettingsDivider() SettingsRow( "Replay the setup guide", Modifier.clickable(onClick = onReplayTutorial), @@ -165,7 +181,8 @@ private fun CertificateSection() { val scope = rememberCoroutineScope() var refresh by remember { mutableIntStateOf(0) } - val summary by produceState(null, refresh) { + // The cached summary shows at once; the read still runs, in case the cert changed since. + val summary by produceState(CertSummary.cached, refresh) { value = withContext(Dispatchers.Default) { CertSummary.read(context) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt new file mode 100644 index 0000000..a5b23e5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt @@ -0,0 +1,148 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.BuildConfig +import app.seb3thehacker.gearslip.SessionStatus +import app.seb3thehacker.gearslip.update.UpdateChecker +import app.seb3thehacker.gearslip.update.UpdateChecker.State + +private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip." + +/** Shown on Home once a newer version is found; hidden otherwise. */ +@Composable +fun UpdateCard() { + val context = LocalContext.current + val state by UpdateChecker.state.collectAsStateWithLifecycle() + SessionStatus.state.collectAsStateWithLifecycle().value // redraw when the car comes or goes + val release = when (val s = state) { + is State.Available -> s.release + is State.Downloading -> s.release + is State.Ready -> s.release + is State.Failed -> s.release + else -> null + } ?: return + + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + when (val s = state) { + is State.Downloading -> { + Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth()) + } + is State.Ready -> { + val connected = UpdateChecker.carConnected + Text( + if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") } + } + else -> { + Text( + (s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { UpdateChecker.download(context, release) }) { + Text(if (s is State.Failed) "Try again" else "Update") + } + if (release.pageUrl.isNotBlank()) { + TextButton(onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl))) + }) { Text("What's new") } + } + } + } + } + } + } + // Room before whatever Home shows next; nothing when the card is hidden. + Spacer(Modifier.height(16.dp)) +} + +/** The Settings row: checks on tap, and carries on to download and install once something is found. */ +@Composable +internal fun UpdateRow() { + val context = LocalContext.current + val state by UpdateChecker.state.collectAsStateWithLifecycle() + SessionStatus.state.collectAsStateWithLifecycle().value + val subtitle = when (val s = state) { + State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap" else "Gearslip checks once a day" + State.Checking -> "Checking…" + State.UpToDate -> "You have the latest version" + is State.Available -> "Version ${s.release.version} is out. Tap to download." + is State.Downloading -> "Downloading… ${(s.fraction * 100).toInt()}%" + is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Tap to install version ${s.release.version}" + is State.Failed -> s.message + } + SettingsRow( + "Check for updates", + Modifier.clickable { + when (val s = state) { + is State.Available -> UpdateChecker.download(context, s.release) + is State.Ready -> UpdateChecker.install(context) + is State.Failed -> s.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context) + else -> UpdateChecker.check(context) + } + }, + subtitle = subtitle, + icon = Icons.Filled.CheckCircle, + ) +} + +/** Whether a drive's end may notify about a new version. */ +@Composable +internal fun UpdateNotifyRow() { + val context = LocalContext.current + var on by remember { mutableStateOf(UpdateChecker.notifyEnabled(context)) } + SettingsRow( + "Update notifications", + Modifier.toggleable(on, role = Role.Switch) { + on = it + UpdateChecker.setNotifyEnabled(context, it) + }, + subtitle = "On the car screen when you plug in, and on the phone after a drive", + icon = Icons.Filled.Notifications, + trailing = { Switch(checked = on, onCheckedChange = null) }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index 70fbc61..49346f6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -1,14 +1,24 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.animation.AnimatedVisibility +import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import app.seb3thehacker.gearslip.stats.WorkedPrompt import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -83,11 +93,7 @@ internal fun ColumnScope.UsageStatsRows() { SettingsRow( "Send usage notes", Modifier.toggleable(on, role = Role.Switch) { on = it; save() }, - subtitle = when { - BuildConfig.DEBUG -> "Dev build: nothing is ever sent" - BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing" - else -> null - }, + subtitle = buildNote(), icon = Icons.Filled.Send, trailing = { Switch(checked = on, onCheckedChange = null) }, ) @@ -106,6 +112,10 @@ internal fun ColumnScope.UsageStatsRows() { choices = choices.copy(cars = it) save() } + Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) { + choices = choices.copy(failures = it) + save() + } SettingsDivider() SettingsRow( "What gets sent", @@ -122,7 +132,7 @@ internal fun ColumnScope.UsageStatsRows() { ) AnimatedVisibility(noteOpen) { // Rebuilt from the saved choices each time one changes, so it always matches. - val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) } + val note = remember(choices) { notePreview(context) } Surface( color = MaterialTheme.colorScheme.surfaceContainerLowest, shape = MaterialTheme.shapes.medium, @@ -152,3 +162,109 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp)) } } + +/** The note as it would go out now, with an example drive until a real one is in it. */ +private fun notePreview(context: Context): String { + val preview = UsageStats.preview(context, everything = true) + // Until the first drive the list is empty, so show what one entry looks like. + val example = if (preview.optJSONArray("sessions")?.length() == 0) { + "\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2) + } else "" + return preview.toString(2) + example +} + +/** Why this build sends nothing, when it doesn't; null for a normal release. */ +private fun buildNote(): String? = when { + BuildConfig.DEBUG -> "Dev build: nothing is ever sent" + BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing" + else -> null +} + +/** + * The one-time ask after the first drive that worked, in the status card's place on Home. Share + * turns on usage notes like the switch does; the link shows exactly what that sends first. + */ +@Composable +internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + val scheme = MaterialTheme.colorScheme + Surface( + modifier, + color = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Check, contentDescription = null) + Text( + WorkedPrompt.TITLE_START + car, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + Text( + "Share that so the next driver knows it works? No name, account, or location.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + buildNote()?.let { + Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) + } + Row( + Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) { + Text("What gets sent") + } + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") } + Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") } + } + } + } +} + +/** + * Usage notes on Home, one row with one action either way. While they're off, the row is the + * switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens + * Settings at its usage notes section, so a driver already sharing isn't pitched again. + */ +@Composable +internal fun UsageNotesHomeCard(onOpen: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + var on by remember { mutableStateOf(UsageStats.enabled(context)) } + + SettingsCard(modifier) { + if (on) { + SettingsRow( + "Sharing usage notes", + Modifier.clickable(onClick = onOpen), + subtitle = buildNote(), + icon = Icons.Filled.Check, + trailing = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } else { + SettingsRow( + USAGE_STATS_TITLE, + Modifier.toggleable(false, role = Role.Switch) { + UsageStats.set(context, true, UsageStats.choices(context)) + on = true + }, + subtitle = buildNote() ?: ("A short note after each drive tells the next driver " + + "whether their car works. No name, account, or location"), + icon = Icons.Filled.Send, + trailing = { Switch(checked = false, onCheckedChange = null) }, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt new file mode 100644 index 0000000..eeb304d --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt @@ -0,0 +1,374 @@ +package app.seb3thehacker.gearslip.update + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.FileProvider +import androidx.core.graphics.drawable.toBitmap +import app.seb3thehacker.gearslip.BuildConfig +import app.seb3thehacker.gearslip.GearslipActivity +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.SessionStatus +import app.seb3thehacker.gearslip.notify.CarMotion +import app.seb3thehacker.gearslip.notify.CarNotification +import app.seb3thehacker.gearslip.notify.CarNotifications +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * Looks for a newer Gearslip on GitHub and installs it through Android's own installer. + * + * Only GitHub's "latest" release counts, which leaves out pre-releases and drafts. The app checks + * at most once a day: when it opens, and when a drive ends. After a drive a new version also gets + * a notification, once per version, since the driver is parked and the app is likely closed. The + * car screen shows the same news once per version, at plug-in and only while parked. + * Nothing runs in the background otherwise, and nothing else is sent: the request is the same one + * a browser makes for the release page. Dev builds never check on their own. + * + * The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an + * update signed with a different key, so a bad download can't replace the app. + */ +object UpdateChecker { + + data class Release(val version: String, val apkUrl: String, val size: Long, val sha256: String?, val pageUrl: String) + + sealed interface State { + data object Idle : State + data object Checking : State + data object UpToDate : State + data class Available(val release: Release) : State + data class Downloading(val release: Release, val fraction: Float) : State + data class Ready(val release: Release, val apk: File) : State + data class Failed(val message: String, val release: Release? = null) : State + } + + private val flow = MutableStateFlow(State.Idle) + val state: StateFlow = flow.asStateFlow() + + private const val PREFS = "updates" + private const val KEY_CHECKED_AT = "checked_at" + private const val KEY_FOUND = "found" + private const val KEY_NOTIFY = "notify" + private const val KEY_NOTIFIED = "notified_version" + private const val KEY_CAR_NOTIFIED = "car_notified_version" + /** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */ + private const val CAR_DELAY_MS = 8_000L + private const val CHANNEL = "updates" + private const val NOTIFICATION_ID = 4100 + private const val DAY_MS = 24 * 60 * 60 * 1000L + private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest" + + /** Set when the driver tapped Install but first had to allow installs from Gearslip. */ + @Volatile private var installWhenAllowed = false + + private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** Whether a drive's end may post a notification about a new version. On by default: it sends nothing. */ + fun notifyEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_NOTIFY, true) + + fun setNotifyEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean(KEY_NOTIFY, on).apply() + if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun dueForCheck(context: Context) = + !BuildConfig.DEBUG && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS + + /** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */ + fun onAppOpened(context: Context) { + val current = flow.value + if (installWhenAllowed && current is State.Ready && context.packageManager.canRequestPackageInstalls()) { + installWhenAllowed = false + install(context) + return + } + if (current != State.Idle) return + // A release found earlier still shows after Android closed the app. + savedRelease(context)?.let { flow.value = State.Available(it) } + if (dueForCheck(context)) check(context) + } + + /** + * A car session ended, so the driver is parked. Checks if a day has passed, then posts a + * notification for a new version they haven't been told about yet. + */ + fun onDriveEnded(context: Context) { + if (!notifyEnabled(context)) return + val app = context.applicationContext + Thread({ + val current = flow.value + if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) { + flow.value = State.Checking + flow.value = checkNow(app) + } + val release = foundRelease(app) ?: return@Thread + if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread + if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply() + }, "gearslip-update").start() + } + + /** + * The car screen just came up. A new version gets a card in the top right, like a message, + * once per version. Skipped while moving; the next plug-in tries again. + */ + fun onCarConnected(context: Context) { + if (!notifyEnabled(context)) return + val app = context.applicationContext + Thread({ + val current = flow.value + if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) { + flow.value = State.Checking + flow.value = checkNow(app) + } + val release = foundRelease(app) ?: return@Thread + if (prefs(app).getString(KEY_CAR_NOTIFIED, null) == release.version) return@Thread + Thread.sleep(CAR_DELAY_MS) + if (!carConnected || CarMotion.moving) return@Thread + CarNotifications.post( + CarNotification( + key = "gearslip-update", + id = CarNotifications.nextId(), + packageName = app.packageName, + appLabel = "Gearslip", + title = "Gearslip ${release.version} is out", + text = "Update in the Gearslip app or from GitHub", + postedAt = System.currentTimeMillis(), + icon = runCatching { app.packageManager.getApplicationIcon(app.packageName).toBitmap(96, 96) }.getOrNull(), + reply = null, + ), + quiet = false, + ) + prefs(app).edit().putString(KEY_CAR_NOTIFIED, release.version).apply() + GearslipLog.i("update: showed ${release.version} on the car screen") + }, "gearslip-update").start() + } + + private fun foundRelease(app: Context): Release? = when (val s = flow.value) { + is State.Available -> s.release + is State.Downloading -> s.release + is State.Ready -> s.release + else -> savedRelease(app) + } + + /** Posts "Gearslip X is out"; tapping it opens the app, where the Home card has the button. */ + private fun notify(context: Context, release: Release): Boolean { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return false + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT), + ) + val open = PendingIntent.getActivity( + context, NOTIFICATION_ID, + Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Gearslip ${release.version} is out") + .setContentText("Tap to update.") + .setContentIntent(open) + .setAutoCancel(true) + .build() + return runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + .onSuccess { GearslipLog.i("update: told the driver about ${release.version}") } + .onFailure { GearslipLog.w("update: could not post the notification (${it.message})") } + .isSuccess + } + + /** Asks GitHub now. Ignored while a check or download is under way. */ + fun check(context: Context) { + val current = flow.value + if (current is State.Checking || current is State.Downloading) return + val app = context.applicationContext + flow.value = State.Checking + Thread({ flow.value = checkNow(app) }, "gearslip-update").start() + } + + private fun checkNow(app: Context): State = runCatching { fetchLatest() }.fold( + onSuccess = { release -> + prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply() + if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) { + GearslipLog.i("update: ${release.version} is out (this is ${BuildConfig.VERSION_NAME})") + save(app, release) + State.Available(release) + } else { + prefs(app).edit().remove(KEY_FOUND).apply() + updatesDir(app).deleteRecursively() + State.UpToDate + } + }, + onFailure = { + GearslipLog.w("update: could not check: ${it.message}") + State.Failed("Couldn't reach GitHub. Check the connection and try again.") + }, + ) + + /** Downloads [release], then opens the installer once the file checks out. */ + fun download(context: Context, release: Release) { + if (flow.value is State.Downloading) return + val app = context.applicationContext + flow.value = State.Downloading(release, 0f) + Thread({ + val dir = updatesDir(app).apply { deleteRecursively(); mkdirs() } + val apk = File(dir, "Gearslip-${release.version}.apk") + flow.value = runCatching { + fetchApk(release, apk) + GearslipLog.i("update: downloaded ${release.version}") + State.Ready(release, apk) + }.getOrElse { + apk.delete() + GearslipLog.w("update: download failed: ${it.message}") + State.Failed(it.message ?: "The download failed. Try again.", release) + } + if (flow.value is State.Ready) install(app) + }, "gearslip-update").start() + } + + /** + * Hands the downloaded APK to Android's installer. The first time, Android wants the driver to + * allow installs from Gearslip; that opens its settings page, and the install carries on when + * they come back. + */ + fun install(context: Context) { + val ready = flow.value as? State.Ready ?: return + // Installing closes Gearslip, which would drop the car screen mid-drive. + if (carConnected) return + val pm = context.packageManager + if (!pm.canRequestPackageInstalls()) { + installWhenAllowed = true + context.startActivity( + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk) + context.startActivity( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + + private fun fetchLatest(): Release? { + val conn = (URL(LATEST).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 15_000 + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}") + } + try { + if (conn.responseCode == 404) return null // no stable release yet + if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}") + val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() }) + val assets = json.optJSONArray("assets") ?: return null + val apk = (0 until assets.length()).map { assets.getJSONObject(it) } + .firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") } + ?: return null + return Release( + version = json.getString("tag_name").removePrefix("v"), + apkUrl = apk.getString("browser_download_url"), + size = apk.optLong("size", -1), + sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"), + pageUrl = json.optString("html_url"), + ) + } finally { + conn.disconnect() + } + } + + private fun fetchApk(release: Release, into: File) { + val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}") + } + try { + if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}") + val total = release.size.takeIf { it > 0 } ?: conn.contentLengthLong + val digest = MessageDigest.getInstance("SHA-256") + var done = 0L + var shown = -1 + conn.inputStream.use { input -> + into.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + val n = input.read(buffer) + if (n < 0) break + output.write(buffer, 0, n) + digest.update(buffer, 0, n) + done += n + // Whole percents only, so the screen isn't redrawn for every buffer. + val percent = if (total > 0) (done * 100 / total).toInt() else 0 + if (percent != shown) { + shown = percent + flow.value = State.Downloading(release, percent / 100f) + } + } + } + } + if (release.size > 0 && done != release.size) error("The download stopped early. Try again.") + val hex = digest.digest().joinToString("") { "%02x".format(it) } + if (release.sha256 != null && !hex.equals(release.sha256, ignoreCase = true)) { + error("The download came out damaged. Try again.") + } + } finally { + conn.disconnect() + } + } + + /** Installing waits until the car is unplugged. */ + val carConnected: Boolean + get() = SessionStatus.state.value.phase.let { it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING } + + private fun updatesDir(context: Context) = File(context.cacheDir, "updates") + + private fun save(context: Context, release: Release) { + val json = JSONObject() + .put("version", release.version) + .put("apkUrl", release.apkUrl) + .put("size", release.size) + .put("sha256", release.sha256 ?: "") + .put("pageUrl", release.pageUrl) + prefs(context).edit().putString(KEY_FOUND, json.toString()).apply() + } + + /** The release found last time, if it is still newer than this build (it isn't after updating). */ + private fun savedRelease(context: Context): Release? { + val json = prefs(context).getString(KEY_FOUND, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + val release = Release( + version = json.optString("version"), + apkUrl = json.optString("apkUrl"), + size = json.optLong("size", -1), + sha256 = json.optString("sha256").ifBlank { null }, + pageUrl = json.optString("pageUrl"), + ) + return release.takeIf { isNewer(it.version, BuildConfig.VERSION_NAME) } + } + + /** "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. */ + internal fun isNewer(candidate: String, installed: String): Boolean { + fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 } + val a = parts(candidate) + val b = parts(installed) + for (i in 0 until maxOf(a.size, b.size)) { + val x = a.getOrElse(i) { 0 } + val y = b.getOrElse(i) { 0 } + if (x != y) return x > y + } + return false + } +} diff --git a/gearslip/src/main/res/xml/share_paths.xml b/gearslip/src/main/res/xml/share_paths.xml index 9d97a8c..4307a1f 100644 --- a/gearslip/src/main/res/xml/share_paths.xml +++ b/gearslip/src/main/res/xml/share_paths.xml @@ -1,5 +1,6 @@ - + +