mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Merge branch 'experimental' into wireless
# Conflicts: # gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt # gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
This commit is contained in:
commit
669bf1f7ce
54 files changed
+2819
-206
No files matched your search
@@ -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
|
||||
@@ -88,8 +89,10 @@
|
||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||
whether the package is installed at all. -->
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". -->
|
||||
<!-- The opt-in usage note names the OS: GrapheneOS's setup app and /e/OS's app store give
|
||||
them away (UsageStats.osName). -->
|
||||
<package android:name="app.grapheneos.setupwizard" />
|
||||
<package android:name="foundation.e.apps" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -173,6 +176,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"
|
||||
|
||||
@@ -15,6 +15,8 @@ import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import android.content.res.Configuration
|
||||
@@ -87,9 +89,13 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
} else {
|
||||
GearslipLog.w("audio: capture consent declined")
|
||||
CarAudio.denied()
|
||||
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
|
||||
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
|
||||
private val detachReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
||||
@@ -134,6 +140,20 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole)
|
||||
val offer by offerBluetooth.collectAsState()
|
||||
if (offer) {
|
||||
fun close() {
|
||||
CarSettings.markAudioChoiceAsked()
|
||||
offerBluetooth.value = false
|
||||
}
|
||||
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
|
||||
onUseBluetooth = {
|
||||
CarSettings.setPipeAudio(false)
|
||||
close()
|
||||
},
|
||||
onKeepAsking = ::close,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +178,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) {
|
||||
@@ -371,6 +392,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
|
||||
@@ -47,6 +49,8 @@ class GearslipRunner(
|
||||
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
private var authenticationFailed = false
|
||||
@Volatile private var stopped = false
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||
@@ -68,6 +72,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
|
||||
@@ -107,6 +113,7 @@ class GearslipRunner(
|
||||
try {
|
||||
while (running) {
|
||||
val read = input.read(chunk)
|
||||
if (stopped) return
|
||||
if (read < 0) {
|
||||
log.w("head unit closed the connection (EOF) while in state $state")
|
||||
reportDisconnect()
|
||||
@@ -120,16 +127,19 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (stopped) return
|
||||
log.e("transport failed in state $state", t)
|
||||
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
writer.shutdownNow()
|
||||
log.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopped = true
|
||||
running = false
|
||||
writer.shutdownNow()
|
||||
CarFocus.request = null
|
||||
@@ -141,6 +151,10 @@ class GearslipRunner(
|
||||
audioLink = null
|
||||
CarSensors.clear()
|
||||
CarMic.current = null
|
||||
mediaStatus?.stop()
|
||||
mediaStatus = null
|
||||
clusterNav?.stop()
|
||||
clusterNav = null
|
||||
lastSensorNight = null
|
||||
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||
projection?.onProjectionStopped()
|
||||
@@ -148,6 +162,8 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun handleFrame(frame: Frames.Frame) {
|
||||
// A rejected handshake cannot consume later messages as if authentication succeeded.
|
||||
if (authenticationFailed || stopped) return
|
||||
val payload = if (frame.encrypted) {
|
||||
if (!tls.handshakeComplete) {
|
||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
@@ -176,6 +192,10 @@ class GearslipRunner(
|
||||
onInputMessage(messageId, body)
|
||||
} else if (frame.channel == sensorChannelId) {
|
||||
onSensorMessage(messageId, body)
|
||||
} else if (frame.channel == clusterNav?.channelId) {
|
||||
clusterNav?.onMessage(messageId, body)
|
||||
} else if (frame.channel == mediaStatus?.channelId) {
|
||||
mediaStatus?.onMessage(messageId, body)
|
||||
} else if (frame.channel == CarMic.current?.channelId) {
|
||||
CarMic.current?.onMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
@@ -225,7 +245,7 @@ class GearslipRunner(
|
||||
}
|
||||
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
|
||||
log.hex(" byebye raw", body)
|
||||
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name)
|
||||
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
|
||||
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
|
||||
log.i("-> ByeByeResponse")
|
||||
}
|
||||
@@ -262,10 +282,14 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun onHandshake(body: ByteArray) {
|
||||
if (state != State.TLS_HANDSHAKE && state != State.WAIT_AUTH) return
|
||||
log.i("<- EncapsulatedSSL (${body.size} bytes)")
|
||||
val replies = try {
|
||||
tls.pumpHandshake(body)
|
||||
val preparedTls = tls
|
||||
if (stopped) return
|
||||
preparedTls.pumpHandshake(body)
|
||||
} catch (t: Throwable) {
|
||||
if (stopped) return
|
||||
log.e("TLS handshake threw", t)
|
||||
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
||||
log.verdict(
|
||||
@@ -278,9 +302,11 @@ class GearslipRunner(
|
||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
SessionReport.print()
|
||||
return
|
||||
}
|
||||
if (stopped) return
|
||||
replies.forEach {
|
||||
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
|
||||
log.i("-> EncapsulatedSSL (${it.size} bytes)")
|
||||
@@ -293,6 +319,7 @@ class GearslipRunner(
|
||||
|
||||
private fun onAuthComplete(body: ByteArray) {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
|
||||
log.i("<- AuthComplete: status=$status")
|
||||
status?.let { SessionReport.auth(it) }
|
||||
|
||||
@@ -304,7 +331,7 @@ class GearslipRunner(
|
||||
sendServiceDiscoveryRequest()
|
||||
}
|
||||
-2 -> {
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
|
||||
"The head unit explicitly rejected the phone's self-signed certificate. " +
|
||||
@@ -316,9 +343,10 @@ class GearslipRunner(
|
||||
"The head unit refused the phone's certificate (certificate error).",
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
}
|
||||
-3 -> {
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3")
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
|
||||
"Authentication rejected. Broader than a pure certificate error, but the " +
|
||||
@@ -329,9 +357,10 @@ class GearslipRunner(
|
||||
"Authentication failed. The head unit does not trust the certificate in use.",
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
}
|
||||
else -> {
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
|
||||
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status")
|
||||
log.verdict(
|
||||
"INCONCLUSIVE (AuthComplete status=$status)",
|
||||
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
|
||||
@@ -339,6 +368,7 @@ class GearslipRunner(
|
||||
)
|
||||
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +429,7 @@ class GearslipRunner(
|
||||
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
|
||||
if (video == null) {
|
||||
log.w("no video service in the discovery response - cannot start Phase A")
|
||||
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name)
|
||||
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service")
|
||||
return
|
||||
}
|
||||
videoChannelId = video.serviceId
|
||||
@@ -417,6 +447,8 @@ class GearslipRunner(
|
||||
startAudioChannel(serviceDiscoveryResponse)
|
||||
startSensorChannel(serviceDiscoveryResponse)
|
||||
startMicChannel(serviceDiscoveryResponse)
|
||||
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||
startClusterNavChannel(serviceDiscoveryResponse)
|
||||
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||
if (input == null) {
|
||||
@@ -478,6 +510,28 @@ class GearslipRunner(
|
||||
openChannel(mic.serviceId)
|
||||
}
|
||||
|
||||
/** What's playing, for the car's own screens: the cluster, the media source page. */
|
||||
private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no media status channel - the car's own screens won't show what's playing")
|
||||
return
|
||||
}
|
||||
log.i("media status channel: $channel")
|
||||
mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
||||
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no navigation status channel - the cluster won't show turns")
|
||||
return
|
||||
}
|
||||
log.i("navigation status channel: $channel")
|
||||
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sensor type the head unit advertised, subscribed to at once - there is no screen
|
||||
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
|
||||
@@ -1065,10 +1119,10 @@ class GearslipRunner(
|
||||
|
||||
private fun reportDisconnect() {
|
||||
when (state) {
|
||||
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name)
|
||||
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name)
|
||||
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name)
|
||||
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name)
|
||||
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed")
|
||||
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed")
|
||||
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed")
|
||||
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed")
|
||||
State.DONE -> {}
|
||||
}
|
||||
when (state) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) {
|
||||
*/
|
||||
fun dispatchKey(keyCode: Int, down: Boolean) {
|
||||
val target = root ?: return
|
||||
// Keys handed straight to the view skip the step where Android leaves touch mode, and in
|
||||
// touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
|
||||
if (target.isInTouchMode) {
|
||||
target.requestFocusFromTouch()
|
||||
GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
|
||||
}
|
||||
// Nothing on the display takes keys until something in it holds focus.
|
||||
if (target.findFocus() == null) target.requestFocus()
|
||||
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,11 @@ object CarKeys {
|
||||
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
|
||||
val screenKeys: SharedFlow<ScreenKey> = _screenKeys
|
||||
|
||||
private val _focusMoves = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
/** A knob turn or D-pad press, so controls that hide when left alone can come back. */
|
||||
val focusMoves: SharedFlow<Unit> = _focusMoves
|
||||
|
||||
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
||||
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||
|
||||
@@ -58,6 +63,7 @@ object CarKeys {
|
||||
*/
|
||||
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
||||
if (keyCode in FOCUS_KEYS) {
|
||||
_focusMoves.tryEmit(Unit)
|
||||
focusSink?.invoke(keyCode, down)
|
||||
return true
|
||||
}
|
||||
@@ -86,6 +92,7 @@ object CarKeys {
|
||||
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
||||
fun onRotate(delta: Int) {
|
||||
val sink = focusSink ?: return
|
||||
_focusMoves.tryEmit(Unit)
|
||||
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
||||
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
||||
sink(code, true)
|
||||
|
||||
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
return when (app.kind) {
|
||||
"calling" -> AppGroup.PHONE
|
||||
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||
// Template-only media apps may have no legacy browser/player tile to identify them.
|
||||
"media" -> AppGroup.MUSIC
|
||||
"messaging" -> AppGroup.MESSAGING
|
||||
"settings" -> AppGroup.SETTINGS
|
||||
else -> AppGroup.OTHER
|
||||
|
||||
@@ -33,6 +33,12 @@ object CarServices {
|
||||
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
|
||||
|
||||
/** [nav], or null before the car screen has set things up. */
|
||||
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
|
||||
|
||||
/** [media], or null before the car screen has set things up: for code outside the car UI. */
|
||||
fun mediaOrNull(): CarMedia? = if (context != null) media else null
|
||||
|
||||
/**
|
||||
* A second, independent connection for templated apps that aren't navigation apps - Spotify's
|
||||
* own Car App Library service is the first of these. Kept apart from [nav] so opening one
|
||||
@@ -121,6 +127,19 @@ object CarServices {
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the media app used last, for the car's media button when nothing is open. False
|
||||
* when there's none on record or it's been uninstalled since.
|
||||
*/
|
||||
suspend fun reopenLastMedia(): Boolean {
|
||||
val ctx = context ?: return false
|
||||
val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
|
||||
val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == last } ?: return false
|
||||
openMedia(app)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
||||
fun closeMedia() {
|
||||
if (media.now.value.playing) media.togglePlay()
|
||||
@@ -194,6 +213,32 @@ object CarServices {
|
||||
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of
|
||||
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
|
||||
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
|
||||
* just the one Gearslip opened: anything the car was playing was going through the car.
|
||||
*/
|
||||
fun pausePhoneMedia() {
|
||||
val ctx = context ?: return
|
||||
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||
val playing = try {
|
||||
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
|
||||
} catch (e: SecurityException) {
|
||||
null
|
||||
}
|
||||
if (playing == null) {
|
||||
// No notification access: the app Gearslip itself is driving is all it can reach.
|
||||
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
|
||||
return
|
||||
}
|
||||
playing.forEach { runCatching { it.transportControls.pause() } }
|
||||
if (playing.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }} as the car left")
|
||||
}
|
||||
}
|
||||
|
||||
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||
fun shutdown() {
|
||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTheme
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,6 +28,7 @@ object CarSettings {
|
||||
private const val KEY_OPEN = "car_open_on_connect"
|
||||
private const val KEY_NIGHT = "car_night_mode"
|
||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
||||
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
||||
private const val KEY_LAST_NAV = "car_last_nav"
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
@@ -148,10 +150,20 @@ object CarSettings {
|
||||
* Whether a media app's sound is captured and sent over the car link. On by default, since
|
||||
* sound in the car is the point of the exercise; the platform allows the capture only behind a
|
||||
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
|
||||
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
|
||||
* somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
|
||||
* phone's normal output instead, and Android never asks.
|
||||
*/
|
||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||
|
||||
/**
|
||||
* Whether the driver has already been offered Bluetooth after turning down the capture
|
||||
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
|
||||
*/
|
||||
val audioChoiceAsked: Boolean
|
||||
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
|
||||
|
||||
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
|
||||
|
||||
/**
|
||||
* Off by default: a driver has to opt into experimental features as a whole before any of
|
||||
* them - the voice assistant included - show up to turn on individually.
|
||||
@@ -265,9 +277,15 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
|
||||
* back asks for it again (or waits for the car, if none is connected).
|
||||
*/
|
||||
fun setPipeAudio(on: Boolean) {
|
||||
if (pipeAudioFlow.value == on) return
|
||||
pipeAudioFlow.value = on
|
||||
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
||||
if (on) CarAudio.request() else CarAudio.release()
|
||||
}
|
||||
|
||||
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
||||
|
||||
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Media audio",
|
||||
listOf(false to "Phone audio", true to "Car audio"),
|
||||
"Car audio",
|
||||
listOf(false to "Bluetooth", true to "USB"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
|
||||
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
|
||||
"normally would, for example over Bluetooth to the car.",
|
||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||
"Android never asks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalAccessibilityManager
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
|
||||
@Composable
|
||||
internal fun CarToastOverlay(modifier: Modifier = Modifier) {
|
||||
val current by CarToasts.message.collectAsState()
|
||||
val message = current ?: return
|
||||
val accessibility = LocalAccessibilityManager.current
|
||||
LaunchedEffect(message, accessibility) {
|
||||
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
|
||||
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
|
||||
) ?: message.durationMs
|
||||
// The full time, unless another toast is waiting: then it gives way after the minimum.
|
||||
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
|
||||
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
|
||||
CarToasts.waiting.first { it > 0 }
|
||||
}
|
||||
CarToasts.dismiss(message)
|
||||
}
|
||||
|
||||
// A non-interactive overlay leaves the map's touch handling and focus intact.
|
||||
Box(
|
||||
modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
) {
|
||||
Text(
|
||||
message.text,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.car.app.CarToast
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
|
||||
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
|
||||
*/
|
||||
@MainThread
|
||||
internal object CarToasts {
|
||||
// Identity matters: repeating identical text must restart its display timer.
|
||||
class Message(val owner: Any, val text: String, val durationMs: Long)
|
||||
|
||||
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
|
||||
const val MIN_SHOWN_MS = 2_000L
|
||||
|
||||
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
|
||||
private const val MAX_WAITING = 3
|
||||
|
||||
private val current = MutableStateFlow<Message?>(null)
|
||||
val message: StateFlow<Message?> = current.asStateFlow()
|
||||
|
||||
private val queue = ArrayDeque<Message>()
|
||||
private val _waiting = MutableStateFlow(0)
|
||||
val waiting: StateFlow<Int> = _waiting.asStateFlow()
|
||||
|
||||
fun show(owner: Any, text: String, duration: Int) {
|
||||
if (text.isBlank()) return
|
||||
// Longer than a phone toast: the driver only glances at the screen now and then.
|
||||
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
|
||||
val next = Message(owner, text, durationMs)
|
||||
val shown = current.value
|
||||
when {
|
||||
shown == null -> current.value = next
|
||||
// The same words again restart the one on screen rather than queueing a copy.
|
||||
shown.text == text -> current.value = next
|
||||
queue.lastOrNull()?.text == text -> Unit
|
||||
else -> {
|
||||
queue.addLast(next)
|
||||
while (queue.size > MAX_WAITING) queue.removeFirst()
|
||||
}
|
||||
}
|
||||
_waiting.value = queue.size
|
||||
}
|
||||
|
||||
fun dismiss(message: Message) {
|
||||
// An old timer must not dismiss a newer toast, even if its text is identical.
|
||||
if (current.value === message) advance()
|
||||
}
|
||||
|
||||
fun clear(owner: Any) {
|
||||
// Disconnecting the map must not clear a toast belonging to the browse app.
|
||||
queue.removeAll { it.owner === owner }
|
||||
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
|
||||
}
|
||||
|
||||
private fun advance() {
|
||||
current.value = queue.removeFirstOrNull()
|
||||
_waiting.value = queue.size
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -43,6 +44,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -120,12 +125,16 @@ fun CarUi() {
|
||||
controlsWake.tick()
|
||||
}
|
||||
}
|
||||
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
|
||||
LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
|
||||
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
||||
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||
// somewhere to play it.
|
||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||
// A new version gets one card in the top right, a few seconds in and only while parked.
|
||||
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||
|
||||
@@ -150,14 +159,27 @@ fun CarUi() {
|
||||
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
|
||||
else -> navigator.back()
|
||||
}
|
||||
CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home()
|
||||
CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps()
|
||||
// Home is the dashboard, over the map, so Back from it lands on the map.
|
||||
CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
|
||||
CarKeys.ScreenKey.MAP -> navigator.home()
|
||||
CarKeys.ScreenKey.MEDIA ->
|
||||
if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
|
||||
else navigator.apps()
|
||||
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The car screen is drawn on the phone, so every long press or text-handle drag would buzz
|
||||
// the phone in its mount. Nobody feels that while driving; it's only noise.
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
view.isHapticFeedbackEnabled = false
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalHapticFeedback provides NoHaptics,
|
||||
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||
LocalCarNavigator provides navigator,
|
||||
LocalDarkOutside provides darkOutside,
|
||||
@@ -165,6 +187,7 @@ fun CarUi() {
|
||||
LocalGsTheme provides GsThemes.byId(uiThemeId),
|
||||
) {
|
||||
MaterialTheme(colorScheme = colors) {
|
||||
CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -224,6 +247,8 @@ fun CarUi() {
|
||||
}
|
||||
}
|
||||
}
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
||||
CallPromptOverlay()
|
||||
PopupTimer()
|
||||
// The dashboard is already listing them, so it needs no popup.
|
||||
NotificationPopup(
|
||||
@@ -252,6 +277,7 @@ fun CarUi() {
|
||||
if (safetyWarningSeen) CarWhatsNew()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -879,3 +905,8 @@ private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialT
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
||||
private object NoHaptics : HapticFeedback {
|
||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.IndicationNodeFactory
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.node.DelegatableNode
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.DrawModifierNode
|
||||
import androidx.compose.ui.node.invalidateDraw
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* What every car button shows: the usual ripple when pressed, and a ring in the accent color
|
||||
* while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes
|
||||
* focus while the screen is in touch mode.
|
||||
*
|
||||
* It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that
|
||||
* doesn't pass its own indication gets the ring for free.
|
||||
*/
|
||||
internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory {
|
||||
private val ripple = ripple()
|
||||
|
||||
override fun create(interactionSource: InteractionSource): DelegatableNode =
|
||||
FocusRingNode(ripple.create(interactionSource), interactionSource, color)
|
||||
|
||||
override fun equals(other: Any?) = other is FocusRingIndication && other.color == color
|
||||
override fun hashCode() = color.hashCode()
|
||||
}
|
||||
|
||||
private class FocusRingNode(
|
||||
rippleNode: DelegatableNode,
|
||||
private val source: InteractionSource,
|
||||
private val color: Color,
|
||||
) : DelegatingNode(), DrawModifierNode {
|
||||
private var focused = false
|
||||
|
||||
init { delegate(rippleNode) }
|
||||
|
||||
override fun onAttach() {
|
||||
coroutineScope.launch {
|
||||
var holding = 0
|
||||
source.interactions.collect {
|
||||
when (it) {
|
||||
is FocusInteraction.Focus -> holding++
|
||||
is FocusInteraction.Unfocus -> holding--
|
||||
}
|
||||
val now = holding > 0
|
||||
if (now != focused) {
|
||||
focused = now
|
||||
invalidateDraw()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun ContentDrawScope.draw() {
|
||||
drawContent()
|
||||
if (!focused) return
|
||||
// Inside the button's bounds, since most buttons clip to their shape: drawn outside, a
|
||||
// rounded tile would cut its own ring off.
|
||||
val width = RING_WIDTH.toPx()
|
||||
val radius = minOf(CORNER.toPx(), size.minDimension / 2)
|
||||
drawRoundRect(
|
||||
color,
|
||||
topLeft = Offset(width / 2, width / 2),
|
||||
size = Size(size.width - width, size.height - width),
|
||||
cornerRadius = CornerRadius(radius),
|
||||
style = Stroke(width),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val RING_WIDTH = 3.dp
|
||||
val CORNER = 16.dp
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -89,6 +90,31 @@ internal fun placeCall(context: Context, number: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
|
||||
internal object CallPrompt {
|
||||
val pending = kotlinx.coroutines.flow.MutableStateFlow<Contact?>(null)
|
||||
|
||||
fun ask(name: String, number: String) {
|
||||
pending.value = Contact(name, number)
|
||||
}
|
||||
}
|
||||
|
||||
/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */
|
||||
@Composable
|
||||
internal fun CallPromptOverlay() {
|
||||
val context = LocalContext.current
|
||||
val contact by CallPrompt.pending.collectAsState()
|
||||
val c = contact ?: return
|
||||
ConfirmCallOverlay(
|
||||
c,
|
||||
onConfirm = {
|
||||
CallPrompt.pending.value = null
|
||||
placeCall(context, c.number)
|
||||
},
|
||||
onDismiss = { CallPrompt.pending.value = null },
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAB_CONTACTS = 0
|
||||
private const val TAB_KEYPAD = 1
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ package app.seb3thehacker.gearslip.car
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
@@ -231,7 +234,9 @@ internal fun RightEdgeControls(
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
val awake = LocalMapControlsWake.current.awake
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val visible = awake || pan
|
||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
||||
var holdsFocus by remember { mutableStateOf(false) }
|
||||
val visible = awake || pan || holdsFocus
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||
@@ -240,6 +245,7 @@ internal fun RightEdgeControls(
|
||||
Column(
|
||||
modifier
|
||||
.alpha(alpha)
|
||||
.onFocusChanged { holdsFocus = it.hasFocus }
|
||||
.let { base ->
|
||||
if (visible) base else base.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
@@ -468,7 +474,10 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
||||
val white = remember(bitmap) { bitmap.isPlainWhite() }
|
||||
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
|
||||
// text so it stays readable on a light one.
|
||||
val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white }
|
||||
// An app's own tint is kept only if it can be seen: MAPS.ME asks for dark grey on dark
|
||||
// buttons. Android Auto drops colours that fail contrast the same way.
|
||||
val tint = icon?.tint?.color(dark, content)?.takeIf { it.readableOn(content) }
|
||||
?: content.takeIf { white || icon?.tint != null }
|
||||
Image(
|
||||
bitmap, contentDescription = null, modifier = modifier,
|
||||
colorFilter = tint?.let { ColorFilter.tint(it) },
|
||||
@@ -587,16 +596,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val card = PlaceCard.of(rows)
|
||||
Column(modifier) {
|
||||
rows.forEach { RowItem(it, large = true) }
|
||||
card.heading?.let { PlaceHeading(it) }
|
||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
if (card.heading == null && card.buttons.isEmpty()) {
|
||||
card.details.forEach { RowItem(it, large = true) }
|
||||
} else {
|
||||
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
||||
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
||||
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
||||
* heading and no buttons, and draws exactly as a plain list of rows.
|
||||
*/
|
||||
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
||||
companion object {
|
||||
fun of(rows: List<CarRow>): PlaceCard {
|
||||
// A short tappable row with an icon and nothing under it is a button in all but name:
|
||||
// MAPS.ME's "Let's go" and "Save".
|
||||
val buttons = rows.filter {
|
||||
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
||||
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
||||
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
||||
// A first row that can't be tapped and has no icon is the place's name and kind.
|
||||
val heading = rows.firstOrNull()?.takeIf {
|
||||
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
||||
}
|
||||
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceHeading(row: CarRow) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
||||
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
row.texts.orEmpty().take(2).forEach {
|
||||
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
||||
@Composable
|
||||
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rows.forEachIndexed { i, row ->
|
||||
GsIconBox(
|
||||
onClick = { row.onClickDelegate.click("row") },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||
enabled = row.isEnabled,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CarGlyph(row.image, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
||||
* value when the row has no label. A phone number gets a Call button that asks first.
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailRow(row: CarRow, placeName: String) {
|
||||
val context = LocalContext.current
|
||||
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
||||
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
||||
val values = texts.ifEmpty { listOf(row.title.text()) }
|
||||
val number = values.firstOrNull { it.isPhoneNumber() }
|
||||
val canCall = number != null &&
|
||||
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
label?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
values.take(3).forEach {
|
||||
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (canCall) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GsIconBox(
|
||||
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Call", style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
||||
private fun String.isPhoneNumber(): Boolean {
|
||||
val t = trim()
|
||||
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
||||
return t.count { it.isDigit() } in 7..15
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||
val toggle = row.toggle
|
||||
@@ -661,6 +795,17 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
|
||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||
|
||||
/**
|
||||
* Whether this colour stands out against the background that [content] was chosen for: at least
|
||||
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
||||
*/
|
||||
private fun Color.readableOn(content: Color): Boolean {
|
||||
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
||||
val a = luminance() + 0.05f
|
||||
val b = background.luminance() + 0.05f
|
||||
return maxOf(a, b) / minOf(a, b) >= 3f
|
||||
}
|
||||
|
||||
internal fun Color.luminanceIsDark(): Boolean =
|
||||
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
@@ -38,6 +40,7 @@ import org.json.JSONObject
|
||||
fun WebApp(content: String) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val darkOutside = LocalDarkOutside.current
|
||||
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
|
||||
var web by remember { mutableStateOf<WebView?>(null) }
|
||||
var fieldText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -65,7 +68,7 @@ fun WebApp(content: String) {
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient()
|
||||
webViewClient = LoggingClient(ring)
|
||||
addJavascriptInterface(
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
@@ -99,10 +102,11 @@ fun WebApp(content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient : WebViewClient() {
|
||||
private class LoggingClient(private val ring: String) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
view?.evaluateJavascript(focusRingJs(ring), null)
|
||||
}
|
||||
|
||||
// onPageFinished fires for error pages too, so without this a blocked load (missing
|
||||
@@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """
|
||||
})();
|
||||
"""
|
||||
|
||||
/**
|
||||
* The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many
|
||||
* sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are.
|
||||
*/
|
||||
private fun focusRingJs(color: String) = """
|
||||
(function() {
|
||||
if (document.getElementById('gearslip-focus-ring')) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = 'gearslip-focus-ring';
|
||||
style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }';
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
})();
|
||||
"""
|
||||
|
||||
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
|
||||
private const val FOCUS_TRACKING_JS = """
|
||||
(function() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -78,5 +78,7 @@ object CarAppCatalog {
|
||||
CarAppService.CATEGORY_SETTINGS_APP,
|
||||
CarAppService.CATEGORY_IOT_APP,
|
||||
CarAppService.CATEGORY_WEATHER_APP,
|
||||
// Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it.
|
||||
"androidx.car.app.category.MEDIA",
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarToasts
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -9,6 +10,7 @@ import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
@@ -80,6 +82,8 @@ class CarAppConnection(private val context: Context) {
|
||||
val template: StateFlow<Template?> = _template.asStateFlow()
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
/** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */
|
||||
private var toastOwner = Any()
|
||||
|
||||
private var carApp: ICarApp? = null
|
||||
private var appManager: IAppManager? = null
|
||||
@@ -139,7 +143,11 @@ class CarAppConnection(private val context: Context) {
|
||||
// Replaced by another connect (or a disconnect) while waiting on the location service.
|
||||
if (binding !== connection) return@bind
|
||||
var bindError: Throwable? = null
|
||||
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES
|
||||
// BIND_ALLOW_ACTIVITY_STARTS lets the app start its own location service while
|
||||
// Gearslip's car window is showing. Since Android 14 a bound app only inherits that
|
||||
// from a visible host that opts in, and MapQuest crashes as navigation starts without it.
|
||||
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES or
|
||||
(if (Build.VERSION.SDK_INT >= 34) Context.BIND_ALLOW_ACTIVITY_STARTS else 0)
|
||||
val bound = runCatching {
|
||||
// Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is
|
||||
// all Gearslip is once the phone screen leaves it) only reaches "important
|
||||
@@ -215,6 +223,7 @@ class CarAppConnection(private val context: Context) {
|
||||
fun disconnect() = disconnect(keepLocation = false)
|
||||
|
||||
private fun disconnect(keepLocation: Boolean) {
|
||||
clearToasts()
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
val carApp = this.carApp
|
||||
@@ -285,7 +294,7 @@ class CarAppConnection(private val context: Context) {
|
||||
?: Intent(Intent.ACTION_MAIN).setComponent(component)
|
||||
pendingDestination = null
|
||||
call("onAppCreate", onValue = { onCreated() }) {
|
||||
app.onAppCreate(carHost, intent, carConfiguration(), it)
|
||||
app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,14 +567,19 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
// --- host interfaces the app calls back into -----------------------------------------
|
||||
|
||||
private val appHost = object : IAppHost.Stub() {
|
||||
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
|
||||
override fun invalidate() {
|
||||
GearslipLog.i("host: app invalidated its template")
|
||||
main.post { requestTemplate() }
|
||||
}
|
||||
|
||||
override fun showToast(text: CharSequence?, duration: Int) {
|
||||
GearslipLog.i("host: toast \"$text\"")
|
||||
val message = text?.toString() ?: return
|
||||
main.post {
|
||||
if (toastOwner !== owner) return@post
|
||||
GearslipLog.i("host: toast \"$message\"")
|
||||
CarToasts.show(owner, message, duration)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,10 +601,19 @@ class CarAppConnection(private val context: Context) {
|
||||
override fun openMicrophone(request: Bundleable?): Bundleable? = null
|
||||
}
|
||||
|
||||
private val _trip = MutableStateFlow<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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -656,7 +679,9 @@ class CarAppConnection(private val context: Context) {
|
||||
override fun updateSuggestions(suggestions: Bundleable?) = Unit
|
||||
}
|
||||
|
||||
private val carHost = object : ICarHost.Stub() {
|
||||
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
|
||||
private val appHost = createAppHost(owner)
|
||||
|
||||
override fun getHost(type: String?): IBinder? = when (type) {
|
||||
CarContext.APP_SERVICE -> appHost.asBinder()
|
||||
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
|
||||
@@ -745,10 +770,16 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun fail(phase: Phase, detail: String) {
|
||||
clearToasts()
|
||||
GearslipLog.e("host: $detail")
|
||||
update { it.copy(phase = phase, detail = detail) }
|
||||
}
|
||||
|
||||
private fun clearToasts() {
|
||||
CarToasts.clear(toastOwner)
|
||||
toastOwner = Any()
|
||||
}
|
||||
|
||||
private fun update(block: (Status) -> Status) {
|
||||
_status.value = block(_status.value)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ object KnownApps {
|
||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||
"com.metrolist.music", // Metrolist: browse, play, works
|
||||
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||
"com.mapquest.android.ace", // MapQuest: map, routing, navigation
|
||||
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
|
||||
)
|
||||
|
||||
fun works(packageName: String): Boolean = packageName in working
|
||||
@@ -71,7 +73,6 @@ object KnownApps {
|
||||
*/
|
||||
private val partial = setOf(
|
||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
||||
"com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
|
||||
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
|
||||
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first
|
||||
)
|
||||
|
||||
@@ -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,15 +202,40 @@ object UsageStats {
|
||||
// The same car and outcome twice in a day is one fact, not two.
|
||||
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
|
||||
if (!seen && cars.length() < MAX_CARS) cars.put(car)
|
||||
p.edit().putString(KEY_CARS, cars.toString()).apply()
|
||||
p.edit().putString(KEY_CARS, cars.toString()).commit()
|
||||
}
|
||||
|
||||
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
|
||||
private fun isGrapheneOs(context: Context) =
|
||||
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
|
||||
/**
|
||||
* Which Android this is: a privacy OS by name, or "Stock" with the phone's brand.
|
||||
*
|
||||
* GrapheneOS reports the same build fields as stock Pixel firmware, so its own setup app gives
|
||||
* it away; /e/OS is told apart from the LineageOS it's built on by its app store. The rest set
|
||||
* build properties of their own. The iodéOS and DivestOS checks are best guesses from their
|
||||
* build names, not confirmed on a real phone.
|
||||
*/
|
||||
fun osName(context: Context): String {
|
||||
fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
|
||||
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
|
||||
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
|
||||
return when {
|
||||
installed("app.grapheneos.setupwizard") -> "GrapheneOS"
|
||||
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
|
||||
installed("foundation.e.apps") -> "/e/OS"
|
||||
names.contains("iode", ignoreCase = true) -> "iodéOS"
|
||||
names.contains("divest", ignoreCase = true) -> "DivestOS"
|
||||
prop("ro.lineage.version").isNotEmpty() -> "LineageOS"
|
||||
else -> "Stock (${Build.BRAND.replaceFirstChar { it.uppercase() }})"
|
||||
}
|
||||
}
|
||||
|
||||
/** A system property, or "" when it isn't set or can't be read. */
|
||||
private fun prop(name: String): String = runCatching {
|
||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
||||
.invoke(null, name) as String
|
||||
}.getOrDefault("")
|
||||
|
||||
private fun details(context: Context, c: Choices) = JSONObject().apply {
|
||||
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
|
||||
if (c.android) put("android", Build.VERSION.RELEASE).put("os", osName(context))
|
||||
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
@@ -203,9 +268,33 @@ object UsageStats {
|
||||
}
|
||||
note.put("cars", cars)
|
||||
}
|
||||
if (c.failures) {
|
||||
var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray())
|
||||
if (everything && sessions.length() == 0) {
|
||||
sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray())
|
||||
}
|
||||
note.put("sessions", sessions)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
/**
|
||||
* A made-up session, for "What gets sent" to show before the first real one: a 25-minute
|
||||
* drive that ended with the car switched off.
|
||||
*/
|
||||
val exampleSession: JSONObject
|
||||
get() = JSONObject()
|
||||
.put("stage", "streaming")
|
||||
.put("ended", "byebye")
|
||||
.put("code", "bye 1")
|
||||
.put("failed_at", 1500)
|
||||
.put("seconds", 1500)
|
||||
.put("cert", "downloaded")
|
||||
.put("name", "Head unit name")
|
||||
.put("car", "Car model")
|
||||
.put("year", "2021")
|
||||
.put("protocol", "1.5")
|
||||
|
||||
/** Sends the note now; false when it didn't get through. */
|
||||
private fun send(context: Context): Boolean {
|
||||
if (!sends || !enabled(context)) return true
|
||||
@@ -213,6 +302,7 @@ object UsageStats {
|
||||
val p = prefs(context)
|
||||
val note = synchronized(carsLock) { preview(context) }
|
||||
val sentCars = note.optJSONArray("cars")?.toString()
|
||||
val sentSessions = note.optJSONArray("sessions")?.toString()
|
||||
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
|
||||
val body = note.toString().toByteArray()
|
||||
return runCatching {
|
||||
@@ -234,6 +324,16 @@ object UsageStats {
|
||||
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
|
||||
if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars)
|
||||
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
|
||||
if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions)
|
||||
if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) {
|
||||
edit.remove(KEY_SESSIONS)
|
||||
} else if (sentSessions != "[]") {
|
||||
// A session ended mid-send: keep only the ones this note didn't carry.
|
||||
val now = JSONArray(p.getString(KEY_SESSIONS, "[]"))
|
||||
val sent = JSONArray(sentSessions).length()
|
||||
val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) }
|
||||
edit.putString(KEY_SESSIONS, left.toString())
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
GearslipLog.i("stats: note sent")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
|
||||
/**
|
||||
* Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog
|
||||
* is the price of sending music over USB, and most people who refuse it would rather not see it
|
||||
* again. Offers the two ways out: Bluetooth, or the one-time adb command.
|
||||
*/
|
||||
@Composable
|
||||
fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
|
||||
var showCommand by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepAsking,
|
||||
title = { Text("Play car audio over Bluetooth?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Android treats music sent over USB like sharing your screen, so it asks every " +
|
||||
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
|
||||
)
|
||||
if (showCommand) {
|
||||
Text(
|
||||
"Or keep USB and stop the question for good: plug the phone into a " +
|
||||
"computer with USB debugging on, and run this once.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
|
||||
} else {
|
||||
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
|
||||
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
|
||||
)
|
||||
}
|
||||
@@ -60,7 +60,6 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
|
||||
var showImport by remember { mutableStateOf(false) }
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var manualProgress by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -91,7 +90,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
if (installed) {
|
||||
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||
} else {
|
||||
"Cars accept only Google-issued certificates. Download one to connect."
|
||||
"Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -131,10 +130,11 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text("Download certificate")
|
||||
Text("Download from aasdk")
|
||||
}
|
||||
Text(
|
||||
"Works with most cars built before 2020. Newer cars reject it.",
|
||||
"The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
|
||||
"Most cars accept it; some from around 2020 on don't.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -177,16 +177,18 @@ fun CertSetupScreen(onDone: () -> Unit) {
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (showImport) {
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import your own .p12 cert") }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
} else {
|
||||
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Import my own file") }
|
||||
Text(
|
||||
"A .p12 file with its private key, from wherever you choose.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -45,10 +45,6 @@ private class Step(
|
||||
|
||||
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
|
||||
Step(
|
||||
"On GrapheneOS, exempt Gearslip from exploit protections.",
|
||||
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
|
||||
),
|
||||
Step("Disable or uninstall the Android Auto app."),
|
||||
Step("Load a certificate.", "Open Settings, then download or import a certificate."),
|
||||
Step(
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -31,6 +32,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.notify.TestMessage
|
||||
import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
@@ -48,81 +51,104 @@ fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onOpenUsageNotes: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle()
|
||||
val live = status.phase == SessionStatus.Phase.CONNECTING ||
|
||||
status.phase == SessionStatus.Phase.PROJECTING
|
||||
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle()
|
||||
Box {
|
||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.size(56.dp),
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
|
||||
Text(
|
||||
"Gearslip",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (status.phase != SessionStatus.Phase.PROJECTING) WirelessCard()
|
||||
}
|
||||
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
val live = status.phase == SessionStatus.Phase.CONNECTING ||
|
||||
status.phase == SessionStatus.Phase.PROJECTING
|
||||
if (live) {
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
Text(
|
||||
"v${LocalContext.current.appVersionName()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
// The first drive that worked asks once to share it, right under the result.
|
||||
val car = workedCar
|
||||
if (car != null && !live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
if (status.phase != SessionStatus.Phase.PROJECTING) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
WirelessCard()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column {
|
||||
if (!live) {
|
||||
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
|
||||
RowDivider()
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
if (live) {
|
||||
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||
if (BuildConfig.DEBUG) {
|
||||
val context = LocalContext.current
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Not while the card above is asking the same thing.
|
||||
if (workedCar == null || live) {
|
||||
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column {
|
||||
if (!live) {
|
||||
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
|
||||
RowDivider()
|
||||
}
|
||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||
if (BuildConfig.DEBUG) {
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sharing just went on from the "it worked" ask: a little thanks.
|
||||
if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -55,8 +58,12 @@ import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -66,7 +73,16 @@ fun SettingsScreen(
|
||||
onOpenRecommendedApps: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onReplayTutorial: () -> Unit,
|
||||
atUsageNotes: Boolean = false,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
// Opened from Home's usage notes row: usage notes is the last section, so start at the end.
|
||||
if (atUsageNotes) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
|
||||
scroll.scrollTo(scroll.maxValue)
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
@@ -85,12 +101,13 @@ fun SettingsScreen(
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(scroll),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||
ConnectionSection()
|
||||
CarAudioSection()
|
||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||
CertificateSection()
|
||||
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
||||
@@ -106,7 +123,7 @@ private fun Arrow() = Icon(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
/** What's new and the setup guide, one tap from the top. */
|
||||
/** What's new, updates and the setup guide, one tap from the top. */
|
||||
@Composable
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||
SettingsSection {
|
||||
@@ -118,6 +135,12 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
UpdateRow()
|
||||
SettingsDivider()
|
||||
UpdateAutoRow()
|
||||
SettingsDivider()
|
||||
UpdateBetaRow()
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
Modifier.clickable(onClick = onReplayTutorial),
|
||||
@@ -147,6 +170,30 @@ private fun ConnectionSection() {
|
||||
}
|
||||
}
|
||||
|
||||
/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */
|
||||
@Composable
|
||||
private fun CarAudioSection() {
|
||||
val usb by CarSettings.pipeAudio.collectAsState()
|
||||
SettingsSection(
|
||||
"Car audio",
|
||||
footer = "Run the command in Help once, and USB stops asking.",
|
||||
) {
|
||||
SettingsRow(
|
||||
"USB",
|
||||
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||
subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
|
||||
trailing = { RadioButton(selected = usb, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Bluetooth",
|
||||
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
||||
subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
|
||||
trailing = { RadioButton(selected = !usb, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||
SettingsSection("Apps") {
|
||||
@@ -165,7 +212,8 @@ private fun CertificateSection() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
val summary by produceState<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) }
|
||||
}
|
||||
|
||||
@@ -208,7 +256,7 @@ private fun CertificateSection() {
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
if (downloading) "Downloading…" else "Download certificate",
|
||||
if (downloading) "Downloading…" else "Download from aasdk",
|
||||
Modifier.clickable(enabled = !downloading) {
|
||||
downloading = true
|
||||
scope.launch {
|
||||
@@ -222,13 +270,14 @@ private fun CertificateSection() {
|
||||
}
|
||||
}
|
||||
},
|
||||
subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one",
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Import a certificate",
|
||||
"Import my own file",
|
||||
Modifier.clickable { picker.launch(arrayOf("*/*")) },
|
||||
subtitle = "A .p12 file with its private key",
|
||||
subtitle = "A .p12 file with its private key, from wherever you choose",
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
val removable = when (s?.kind) {
|
||||
|
||||
@@ -206,22 +206,6 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
)
|
||||
}
|
||||
|
||||
// --- GrapheneOS ------------------------------------------------------------------
|
||||
add(
|
||||
GuidePage(
|
||||
title = "GrapheneOS exploit protection",
|
||||
explanation = "On GrapheneOS only: open Gearslip's app settings and turn on Exploit " +
|
||||
"protection compatibility mode. Gearslip's native USB and video code trips " +
|
||||
"GrapheneOS's hardened memory checks otherwise. Skip this on any other Android.",
|
||||
actionLabel = "Open app settings",
|
||||
onAction = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// --- Android Auto ------------------------------------------------------------------
|
||||
val androidAutoPackage = AndroidAuto.installedPackage(context)
|
||||
?.takeIf { AndroidAuto.isActive(context, it) }
|
||||
@@ -306,8 +290,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-run guide, one page at a time: permissions, then GrapheneOS's exploit-protection
|
||||
* toggle, then removing Android Auto if it is still installed, then the optional extras. Nothing
|
||||
* The first-run guide, one page at a time: permissions, then removing Android Auto if it is still installed, then the optional extras. Nothing
|
||||
* here can be verified as done except a permission grant, so every other page is a "Next" a
|
||||
* driver takes on trust, with "Skip" sitting right next to it.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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.material.icons.filled.Build
|
||||
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 Gearslip checks for updates on its own. */
|
||||
@Composable
|
||||
internal fun UpdateAutoRow() {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
|
||||
SettingsRow(
|
||||
"Check automatically",
|
||||
Modifier.toggleable(on, role = Role.Switch) {
|
||||
on = it
|
||||
UpdateChecker.setAutoCheckEnabled(context, it)
|
||||
},
|
||||
subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates"
|
||||
else if (on) "Once a day, with a note on the car screen and the phone when one is out"
|
||||
else "Gearslip checks only when you tap Check for updates",
|
||||
icon = Icons.Filled.Notifications,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether pre-releases count as updates. */
|
||||
@Composable
|
||||
internal fun UpdateBetaRow() {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) }
|
||||
SettingsRow(
|
||||
"Beta updates",
|
||||
Modifier.toggleable(on, role = Role.Switch) {
|
||||
on = it
|
||||
UpdateChecker.setBetaEnabled(context, it)
|
||||
},
|
||||
subtitle = "Get test versions before they're released. They may have more bugs.",
|
||||
icon = Icons.Filled.Build,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
@@ -83,11 +93,7 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
SettingsRow(
|
||||
"Send usage notes",
|
||||
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||
subtitle = when {
|
||||
BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
|
||||
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
|
||||
else -> null
|
||||
},
|
||||
subtitle = buildNote(),
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
@@ -100,12 +106,16 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Android version and system, such as GrapheneOS or stock Samsung", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
|
||||
choices = choices.copy(cars = it)
|
||||
save()
|
||||
}
|
||||
Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) {
|
||||
choices = choices.copy(failures = it)
|
||||
save()
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
@@ -122,7 +132,7 @@ internal fun ColumnScope.UsageStatsRows() {
|
||||
)
|
||||
AnimatedVisibility(noteOpen) {
|
||||
// Rebuilt from the saved choices each time one changes, so it always matches.
|
||||
val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) }
|
||||
val note = remember(choices) { notePreview(context) }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
@@ -152,3 +162,109 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit)
|
||||
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** The note as it would go out now, with an example drive until a real one is in it. */
|
||||
private fun notePreview(context: Context): String {
|
||||
val preview = UsageStats.preview(context, everything = true)
|
||||
// Until the first drive the list is empty, so show what one entry looks like.
|
||||
val example = if (preview.optJSONArray("sessions")?.length() == 0) {
|
||||
"\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2)
|
||||
} else ""
|
||||
return preview.toString(2) + example
|
||||
}
|
||||
|
||||
/** Why this build sends nothing, when it doesn't; null for a normal release. */
|
||||
private fun buildNote(): String? = when {
|
||||
BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
|
||||
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time ask after the first drive that worked, in the status card's place on Home. Share
|
||||
* turns on usage notes like the switch does; the link shows exactly what that sends first.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier,
|
||||
color = scheme.primaryContainer,
|
||||
contentColor = scheme.onPrimaryContainer,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Text(
|
||||
WorkedPrompt.TITLE_START + car,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Share that so the next driver knows it works? No name, account, or location.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
buildNote()?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
|
||||
Text("What gets sent")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
|
||||
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage notes on Home, one row with one action either way. While they're off, the row is the
|
||||
* switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens
|
||||
* Settings at its usage notes section, so a driver already sharing isn't pitched again.
|
||||
*/
|
||||
@Composable
|
||||
internal fun UsageNotesHomeCard(onOpen: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
|
||||
|
||||
SettingsCard(modifier) {
|
||||
if (on) {
|
||||
SettingsRow(
|
||||
"Sharing usage notes",
|
||||
Modifier.clickable(onClick = onOpen),
|
||||
subtitle = buildNote(),
|
||||
icon = Icons.Filled.Check,
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
SettingsRow(
|
||||
USAGE_STATS_TITLE,
|
||||
Modifier.toggleable(false, role = Role.Switch) {
|
||||
UsageStats.set(context, true, UsageStats.choices(context))
|
||||
on = true
|
||||
},
|
||||
subtitle = buildNote() ?: ("A short note after each drive tells the next driver " +
|
||||
"whether their car works. No name, account, or location"),
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = false, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package app.seb3thehacker.gearslip.update
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.GearslipActivity
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.SessionStatus
|
||||
import app.seb3thehacker.gearslip.notify.CarMotion
|
||||
import app.seb3thehacker.gearslip.notify.CarNotification
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Looks for a newer Gearslip on GitHub and installs it through Android's own installer.
|
||||
*
|
||||
* Only GitHub's "latest" release counts, which leaves out pre-releases and drafts, unless the
|
||||
* driver opted into betas: then pre-releases count too. The app checks at most once a day: when
|
||||
* it opens, and when a drive ends. With automatic checks off it never asks GitHub on its own,
|
||||
* only when the driver taps Check for updates. After a drive a new version also gets
|
||||
* a notification, once per version, since the driver is parked and the app is likely closed. The
|
||||
* car screen shows the same news once per version, at plug-in and only while parked.
|
||||
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
|
||||
* a browser makes for the release page. Dev builds never check on their own.
|
||||
*
|
||||
* The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an
|
||||
* update signed with a different key, so a bad download can't replace the app.
|
||||
*/
|
||||
object UpdateChecker {
|
||||
|
||||
data class Release(val version: String, val apkUrl: String, val size: Long, val sha256: String?, val pageUrl: String)
|
||||
|
||||
sealed interface State {
|
||||
data object Idle : State
|
||||
data object Checking : State
|
||||
data object UpToDate : State
|
||||
data class Available(val release: Release) : State
|
||||
data class Downloading(val release: Release, val fraction: Float) : State
|
||||
data class Ready(val release: Release, val apk: File) : State
|
||||
data class Failed(val message: String, val release: Release? = null) : State
|
||||
}
|
||||
|
||||
private val flow = MutableStateFlow<State>(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"
|
||||
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
|
||||
private const val KEY_AUTO = "notify"
|
||||
private const val KEY_BETA = "beta"
|
||||
private const val KEY_NOTIFIED = "notified_version"
|
||||
private const val KEY_CAR_NOTIFIED = "car_notified_version"
|
||||
/** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */
|
||||
private const val CAR_DELAY_MS = 8_000L
|
||||
private const val CHANNEL = "updates"
|
||||
private const val NOTIFICATION_ID = 4100
|
||||
private const val DAY_MS = 24 * 60 * 60 * 1000L
|
||||
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
|
||||
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
|
||||
private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
|
||||
|
||||
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
|
||||
@Volatile private var installWhenAllowed = false
|
||||
|
||||
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Whether Gearslip checks on its own, and tells the driver what it finds on the phone and the
|
||||
* car screen. On by default: the check sends nothing about the phone.
|
||||
*/
|
||||
fun autoCheckEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO, true)
|
||||
|
||||
fun setAutoCheckEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_AUTO, on).apply()
|
||||
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** Whether GitHub pre-releases count as updates. */
|
||||
fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false)
|
||||
|
||||
/**
|
||||
* Switching channels forgets what the old one found: a beta found earlier shouldn't still be
|
||||
* offered once betas are off. Checks again right away if automatic checks are on.
|
||||
*/
|
||||
fun setBetaEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
|
||||
val current = flow.value
|
||||
if (current is State.Checking || current is State.Downloading) return
|
||||
updatesDir(context).deleteRecursively()
|
||||
flow.value = State.Idle
|
||||
if (autoCheckEnabled(context) && !BuildConfig.DEBUG) check(context)
|
||||
}
|
||||
|
||||
private fun dueForCheck(context: Context) =
|
||||
!BuildConfig.DEBUG && autoCheckEnabled(context) && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
|
||||
|
||||
/** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */
|
||||
fun onAppOpened(context: Context) {
|
||||
val current = flow.value
|
||||
if (installWhenAllowed && current is State.Ready && context.packageManager.canRequestPackageInstalls()) {
|
||||
installWhenAllowed = false
|
||||
install(context)
|
||||
return
|
||||
}
|
||||
if (current != State.Idle) return
|
||||
// A release found earlier still shows after Android closed the app.
|
||||
savedRelease(context)?.let { flow.value = State.Available(it) }
|
||||
if (dueForCheck(context)) check(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* A car session ended, so the driver is parked. Checks if a day has passed, then posts a
|
||||
* notification for a new version they haven't been told about yet.
|
||||
*/
|
||||
fun onDriveEnded(context: Context) {
|
||||
if (!autoCheckEnabled(context)) return
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val current = flow.value
|
||||
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
|
||||
flow.value = State.Checking
|
||||
flow.value = checkNow(app)
|
||||
}
|
||||
val release = foundRelease(app) ?: return@Thread
|
||||
if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread
|
||||
if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
|
||||
}, "gearslip-update").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* The car screen just came up. A new version gets a card in the top right, like a message,
|
||||
* once per version. Skipped while moving; the next plug-in tries again.
|
||||
*/
|
||||
fun onCarConnected(context: Context) {
|
||||
if (!autoCheckEnabled(context)) return
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val current = flow.value
|
||||
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
|
||||
flow.value = State.Checking
|
||||
flow.value = checkNow(app)
|
||||
}
|
||||
val release = foundRelease(app) ?: return@Thread
|
||||
if (prefs(app).getString(KEY_CAR_NOTIFIED, null) == release.version) return@Thread
|
||||
Thread.sleep(CAR_DELAY_MS)
|
||||
if (!carConnected || CarMotion.moving) return@Thread
|
||||
CarNotifications.post(
|
||||
CarNotification(
|
||||
key = "gearslip-update",
|
||||
id = CarNotifications.nextId(),
|
||||
packageName = app.packageName,
|
||||
appLabel = "Gearslip",
|
||||
title = "Gearslip ${release.version} is out",
|
||||
text = "Update in the Gearslip app or from GitHub",
|
||||
postedAt = System.currentTimeMillis(),
|
||||
icon = runCatching { app.packageManager.getApplicationIcon(app.packageName).toBitmap(96, 96) }.getOrNull(),
|
||||
reply = null,
|
||||
),
|
||||
quiet = false,
|
||||
)
|
||||
prefs(app).edit().putString(KEY_CAR_NOTIFIED, release.version).apply()
|
||||
GearslipLog.i("update: showed ${release.version} on the car screen")
|
||||
}, "gearslip-update").start()
|
||||
}
|
||||
|
||||
private fun foundRelease(app: Context): Release? = when (val s = flow.value) {
|
||||
is State.Available -> s.release
|
||||
is State.Downloading -> s.release
|
||||
is State.Ready -> s.release
|
||||
else -> savedRelease(app)
|
||||
}
|
||||
|
||||
/** Posts "Gearslip X is out"; tapping it opens the app, where the Home card has the button. */
|
||||
private fun notify(context: Context, release: Release): Boolean {
|
||||
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return false
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
|
||||
)
|
||||
val open = PendingIntent.getActivity(
|
||||
context, NOTIFICATION_ID,
|
||||
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Gearslip ${release.version} is out")
|
||||
.setContentText("Tap to update.")
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
return runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
|
||||
.onSuccess { GearslipLog.i("update: told the driver about ${release.version}") }
|
||||
.onFailure { GearslipLog.w("update: could not post the notification (${it.message})") }
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
/** Asks GitHub now. Ignored while a check or download is under way. */
|
||||
fun check(context: Context) {
|
||||
val current = flow.value
|
||||
if (current is State.Checking || current is State.Downloading) return
|
||||
val app = context.applicationContext
|
||||
flow.value = State.Checking
|
||||
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
|
||||
}
|
||||
|
||||
private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold(
|
||||
onSuccess = { release ->
|
||||
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
|
||||
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
|
||||
GearslipLog.i("update: ${release.version} is out (this is ${BuildConfig.VERSION_NAME})")
|
||||
save(app, release)
|
||||
State.Available(release)
|
||||
} else {
|
||||
prefs(app).edit().remove(KEY_FOUND).apply()
|
||||
updatesDir(app).deleteRecursively()
|
||||
State.UpToDate
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
GearslipLog.w("update: could not check: ${it.message}")
|
||||
State.Failed("Couldn't reach GitHub. Check the connection and try again.")
|
||||
},
|
||||
)
|
||||
|
||||
/** Downloads [release], then opens the installer once the file checks out. */
|
||||
fun download(context: Context, release: Release) {
|
||||
if (flow.value is State.Downloading) return
|
||||
val app = context.applicationContext
|
||||
flow.value = State.Downloading(release, 0f)
|
||||
Thread({
|
||||
val dir = updatesDir(app).apply { deleteRecursively(); mkdirs() }
|
||||
val apk = File(dir, "Gearslip-${release.version}.apk")
|
||||
flow.value = runCatching {
|
||||
fetchApk(release, apk)
|
||||
GearslipLog.i("update: downloaded ${release.version}")
|
||||
State.Ready(release, apk)
|
||||
}.getOrElse {
|
||||
apk.delete()
|
||||
GearslipLog.w("update: download failed: ${it.message}")
|
||||
State.Failed(it.message ?: "The download failed. Try again.", release)
|
||||
}
|
||||
if (flow.value is State.Ready) install(app)
|
||||
}, "gearslip-update").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the downloaded APK to Android's installer. The first time, Android wants the driver to
|
||||
* allow installs from Gearslip; that opens its settings page, and the install carries on when
|
||||
* they come back.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val ready = flow.value as? State.Ready ?: return
|
||||
// Installing closes Gearslip, which would drop the car screen mid-drive.
|
||||
if (carConnected) return
|
||||
val pm = context.packageManager
|
||||
if (!pm.canRequestPackageInstalls()) {
|
||||
installWhenAllowed = true
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
return
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk)
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
|
||||
/** The newest release with an APK: the "latest" one, or with [beta] the newest of any kind but drafts. */
|
||||
private fun fetchNewest(beta: Boolean): Release? {
|
||||
val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
setRequestProperty("Accept", "application/vnd.github+json")
|
||||
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
try {
|
||||
if (conn.responseCode == 404) return null // no stable release yet
|
||||
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!beta) return release(JSONObject(body))
|
||||
val list = org.json.JSONArray(body)
|
||||
return (0 until list.length()).map { list.getJSONObject(it) }
|
||||
.filterNot { it.optBoolean("draft") }
|
||||
.mapNotNull(::release)
|
||||
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** A release as GitHub lists it, or null when it has no APK attached. */
|
||||
private fun release(json: JSONObject): Release? {
|
||||
val assets = json.optJSONArray("assets") ?: return null
|
||||
val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
.firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
|
||||
?: return null
|
||||
return Release(
|
||||
version = json.getString("tag_name").removePrefix("v"),
|
||||
apkUrl = apk.getString("browser_download_url"),
|
||||
size = apk.optLong("size", -1),
|
||||
sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
|
||||
pageUrl = json.optString("html_url"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchApk(release: Release, into: File) {
|
||||
val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
try {
|
||||
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
||||
val total = release.size.takeIf { it > 0 } ?: conn.contentLengthLong
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
conn.inputStream.use { input ->
|
||||
into.outputStream().use { output ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
output.write(buffer, 0, n)
|
||||
digest.update(buffer, 0, n)
|
||||
done += n
|
||||
// Whole percents only, so the screen isn't redrawn for every buffer.
|
||||
val percent = if (total > 0) (done * 100 / total).toInt() else 0
|
||||
if (percent != shown) {
|
||||
shown = percent
|
||||
flow.value = State.Downloading(release, percent / 100f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (release.size > 0 && done != release.size) error("The download stopped early. Try again.")
|
||||
val hex = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (release.sha256 != null && !hex.equals(release.sha256, ignoreCase = true)) {
|
||||
error("The download came out damaged. Try again.")
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Installing waits until the car is unplugged. */
|
||||
val carConnected: Boolean
|
||||
get() = SessionStatus.state.value.phase.let { it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING }
|
||||
|
||||
private fun updatesDir(context: Context) = File(context.cacheDir, "updates")
|
||||
|
||||
private fun save(context: Context, release: Release) {
|
||||
val json = JSONObject()
|
||||
.put("version", release.version)
|
||||
.put("apkUrl", release.apkUrl)
|
||||
.put("size", release.size)
|
||||
.put("sha256", release.sha256 ?: "")
|
||||
.put("pageUrl", release.pageUrl)
|
||||
prefs(context).edit().putString(KEY_FOUND, json.toString()).apply()
|
||||
}
|
||||
|
||||
/** The release found last time, if it is still newer than this build (it isn't after updating). */
|
||||
private fun savedRelease(context: Context): Release? {
|
||||
val json = prefs(context).getString(KEY_FOUND, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
|
||||
val release = Release(
|
||||
version = json.optString("version"),
|
||||
apkUrl = json.optString("apkUrl"),
|
||||
size = json.optLong("size", -1),
|
||||
sha256 = json.optString("sha256").ifBlank { null },
|
||||
pageUrl = json.optString("pageUrl"),
|
||||
)
|
||||
return release.takeIf { isNewer(it.version, BuildConfig.VERSION_NAME) }
|
||||
}
|
||||
|
||||
/** "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. */
|
||||
internal fun isNewer(candidate: String, installed: String): Boolean {
|
||||
fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
|
||||
val a = parts(candidate)
|
||||
val b = parts(installed)
|
||||
for (i in 0 until maxOf(a.size, b.size)) {
|
||||
val x = a.getOrElse(i) { 0 }
|
||||
val y = b.getOrElse(i) { 0 }
|
||||
if (x != y) return x > y
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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