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