mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9620dab758 | ||
|
|
e318b651bb | ||
|
|
3ac815eee4 | ||
|
|
10cc17bb66 | ||
|
|
ca09d6adb8 | ||
|
|
af058609dd | ||
|
|
a4ab5b005e | ||
|
|
672a68cd1b | ||
|
|
24d7e4c415 | ||
|
|
1127d4baf7 | ||
|
|
dc550ab735 | ||
|
|
5380cf0660 | ||
|
|
7cc6b64ce2 | ||
|
|
b5ff4ec22b | ||
|
|
302dd6c344 | ||
|
|
62fb8babad | ||
|
|
fe44576b06 | ||
|
|
355eb8bffd | ||
|
|
7d197bf021 | ||
|
|
cf0c324d80 | ||
|
|
5a3bbe82a0 | ||
|
|
b6bd6497b2 | ||
|
|
0eeb486d88 | ||
|
|
1adeb6f7e1 | ||
|
|
bc0d6302d3 | ||
|
|
1341b4a08a | ||
|
|
c6b7f0e178 | ||
|
|
d3592457cf | ||
|
|
024850bf48 | ||
|
|
96d16d048c | ||
|
|
1b44d2d798 | ||
|
|
e4725233dd | ||
|
|
913e4af133 | ||
|
|
07d164ac74 |
No files matched your search
+3
-3
@@ -15,13 +15,12 @@ keystore.properties
|
||||
.cxx/
|
||||
|
||||
# Testing
|
||||
/docs/cert-sweep.sh
|
||||
extraction/
|
||||
/cloned-repos
|
||||
docs-internal/
|
||||
|
||||
# Internal
|
||||
*.pdf
|
||||
todo
|
||||
todo.md
|
||||
logs/
|
||||
|
||||
# For AI code
|
||||
@@ -30,3 +29,4 @@ CLAUDE.md
|
||||
|
||||
# Other
|
||||
archive/
|
||||
.wrangler/
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Gearslip
|
||||
|
||||
Bring any app to your car screen.
|
||||
An Android Auto alternative built for everyone.
|
||||
|
||||
## What Gearslip is
|
||||
|
||||
@@ -13,23 +13,36 @@ and Gearslip renders that template natively on the car screen. Nothing is mirror
|
||||
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
||||
mode.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. |
|
||||
|  |  |
|
||||
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||
call goes out.
|
||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen; Organic
|
||||
Maps and OsmAnd both work today.
|
||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
|
||||
edges.
|
||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||
Playing view, an Up Next queue, and lyrics.
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||
doesn't ship a car interface of its own.
|
||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||
the current and coming weather.
|
||||
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
||||
each one aloud, and you reply by voice or with a quick reply. Speech runs on the phone,
|
||||
so it needs no Google account.
|
||||
each one aloud, and you reply by voice or with a quick reply. Music pauses while
|
||||
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
|
||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||
The rest wait on the dashboard.
|
||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||
messaging, and the rest. Press and hold an app to move it.
|
||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||
|
||||
@@ -48,7 +61,7 @@ mode.
|
||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||
Gearslip's own player.
|
||||
- **For now, Gearslip works with cars from before 2020.** Newer head units reject its
|
||||
- **Gearslip works best with cars from before 2020.** Some newer head units reject its
|
||||
certificate.
|
||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||
car to hear calls through its speakers.
|
||||
@@ -61,6 +74,27 @@ mode.
|
||||
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||
visible while audio plays.
|
||||
|
||||
## Usage notes
|
||||
|
||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||
turn them on, in setup or in Settings.
|
||||
|
||||
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
|
||||
Nothing runs in the background.
|
||||
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version
|
||||
and whether the phone runs GrapheneOS, the phone model, whether a car connected that
|
||||
day, and each car's head unit, model, year, screen size, and connection result.
|
||||
Settings shows the exact note.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
anything you type or play.
|
||||
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||
only a salted hash of it.
|
||||
- **Where they go.** To a Cloudflare Worker, whose code is in
|
||||
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note,
|
||||
as it would for any website; the Worker doesn't store it.
|
||||
|
||||
Turn the notes off, and they stop at once.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -32,9 +32,12 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 7
|
||||
versionName = "0.1.10-alpha"
|
||||
versionCode = 8
|
||||
versionName = "0.2.00-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
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". -->
|
||||
<package android:name="app.grapheneos.setupwizard" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -123,6 +125,7 @@
|
||||
android:name=".GearslipActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||
|
||||
<intent-filter>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||
*/
|
||||
object CarFocus {
|
||||
|
||||
private val projectedFlow = MutableStateFlow(true)
|
||||
|
||||
/** False while the car shows its own interface. */
|
||||
val projected: StateFlow<Boolean> = projectedFlow
|
||||
|
||||
@Volatile internal var request: (() -> Unit)? = null
|
||||
|
||||
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||
fun exitToCar() {
|
||||
request?.invoke()
|
||||
}
|
||||
|
||||
internal fun set(projected: Boolean) {
|
||||
projectedFlow.value = projected
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,10 @@ object CertProvider {
|
||||
val kind: Kind = Kind.SELF_SIGNED,
|
||||
)
|
||||
|
||||
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
||||
/**
|
||||
* The identity for the next connection. A newly imported or pushed cert takes effect on the
|
||||
* next connect: see [loadFromFiles] for how the cache notices.
|
||||
*/
|
||||
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||
val generated = SelfSignedCert.generate()
|
||||
Identity(
|
||||
@@ -65,7 +68,38 @@ object CertProvider {
|
||||
*/
|
||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
||||
|
||||
/**
|
||||
* Unlocking a PKCS#12 file takes about a second on a Pixel 6, which a head unit waiting for
|
||||
* its first reply may not wait out. So the unlocked identity is kept, keyed on the files it
|
||||
* could come from: importing, downloading, removing or pushing a cert changes the key.
|
||||
*/
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
private fun sourcesKey(context: Context): String {
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
val files = listOf(
|
||||
importedFile(context, P12_NAME),
|
||||
downloadedFile(context, P12_NAME),
|
||||
dir?.let { File(it, P12_NAME) },
|
||||
dir?.let { File(it, PASS_NAME) },
|
||||
)
|
||||
return files.joinToString("|") { f -> f?.let { "${it.path}:${it.lastModified()}:${it.length()}" } ?: "-" }
|
||||
}
|
||||
|
||||
/** Unlocks the certificate ahead of time, so the first connection doesn't wait on it. */
|
||||
fun warm(context: Context) {
|
||||
runCatching { loadFromFiles(context) }
|
||||
}
|
||||
|
||||
private fun loadFromFiles(context: Context): Identity? {
|
||||
val key = sourcesKey(context)
|
||||
cached?.let { (k, identity) -> if (k == key) return identity }
|
||||
val identity = unlockFromFiles(context) ?: return null
|
||||
cached = key to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
private fun unlockFromFiles(context: Context): Identity? {
|
||||
val imported = importedFile(context, P12_NAME)
|
||||
if (imported.isFile) {
|
||||
runCatching {
|
||||
|
||||
@@ -10,6 +10,27 @@ object Changelog {
|
||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||
|
||||
val releases = listOf(
|
||||
Release(
|
||||
8, "0.2.00-alpha",
|
||||
listOf(
|
||||
"Exit takes you to the car's own screen. Tap Android Auto on the car to come back.",
|
||||
"Gearslip listens through the car's microphone when the car has one, so voice works with the phone in a pocket.",
|
||||
"A voice reply sends when its bar runs out, even over road noise or the radio. Say \"cancel\" or \"change it\" before then.",
|
||||
"Maps turn dark when the car switches to night mode.",
|
||||
"A moving map no longer makes the car screen fall behind your taps.",
|
||||
"Gearslip asks to send sound to the car as soon as you connect, so videos in the web browser play in the car too.",
|
||||
"Cars that offer more than one kind of music audio, such as the 2022 Dacia Jogger, play sound again.",
|
||||
"When the car shows a keyboard, you can type on the phone instead. The car's keyboard is taller too.",
|
||||
"Every car screen has a back button.",
|
||||
"App icons show whether an app works in Gearslip. Metrolist works and shows the green check.",
|
||||
"LIVI connects again, and touch works on cars with a second screen.",
|
||||
"The phone's Settings screen has a new layout, with What's new and the setup guide at the top.",
|
||||
"You can choose to send short usage notes that help the project. They hold no name, account, or location, and they're off unless you turn them on.",
|
||||
"Searching inside a media app moved to Settings > Experimental features.",
|
||||
"Under Settings > Media audio, the choices are now Phone audio and Car audio.",
|
||||
"Logs leave out your location and the car's serial number.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
7, "0.1.10-alpha",
|
||||
listOf(
|
||||
|
||||
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -40,6 +41,8 @@ import java.io.FileOutputStream
|
||||
* rotation or theme change from recreating it and dropping the connection.
|
||||
*/
|
||||
class GearslipActivity : ComponentActivity(), Projection {
|
||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var runner: GearslipRunner? = null
|
||||
@@ -148,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -323,7 +327,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||
mainHandler.postAtFrontOfQueue {
|
||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||
import app.seb3thehacker.gearslip.audio.CarMic
|
||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
@@ -10,6 +11,7 @@ import app.seb3thehacker.gearslip.car.CarServices
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.FutureTask
|
||||
|
||||
/**
|
||||
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
||||
@@ -38,7 +40,13 @@ class GearslipRunner(
|
||||
|
||||
private val parser = Frames.Parser()
|
||||
private val assembler = Frames.Assembler()
|
||||
private lateinit var tls: PhoneTls
|
||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||
|
||||
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
|
||||
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||
@@ -51,9 +59,12 @@ class GearslipRunner(
|
||||
@Volatile private var videoStarted = false
|
||||
private var videoSource: VideoSource? = null
|
||||
@Volatile private var inFlight = 0
|
||||
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
|
||||
private val ackLock = Object()
|
||||
/** Time the encoder spent waiting for acks since the last stats line. */
|
||||
@Volatile private var ackWaitMs = 0L
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var framesSent = 0
|
||||
@Volatile private var dropped = 0
|
||||
@Volatile private var inputChannelId: Int = -1
|
||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||
@Volatile private var sensorChannelId: Int = -1
|
||||
@@ -76,14 +87,19 @@ class GearslipRunner(
|
||||
SessionStatus.connecting("Connected to the head unit")
|
||||
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
tls = PhoneTls(identity.keyStore, identity.password)
|
||||
// The head unit speaks first, and some give up if the phone doesn't read within about a
|
||||
// second. The version exchange needs no certificate, so read at once and unlock the
|
||||
// certificate alongside; the TLS handshake waits for it.
|
||||
identityTask = FutureTask {
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
PhoneTls(identity.keyStore, identity.password)
|
||||
}.also { Thread(it, "gearslip-identity").start() }
|
||||
|
||||
startWatchdog()
|
||||
|
||||
@@ -115,11 +131,18 @@ class GearslipRunner(
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
writer.shutdownNow()
|
||||
CarFocus.request = null
|
||||
CarFocus.set(true)
|
||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||
videoSource?.stop()
|
||||
videoSource = null
|
||||
audioLink?.close()
|
||||
audioLink = null
|
||||
CarSensors.clear()
|
||||
CarMic.current = null
|
||||
lastSensorNight = null
|
||||
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||
projection?.onProjectionStopped()
|
||||
SessionStatus.disconnected()
|
||||
}
|
||||
@@ -153,6 +176,8 @@ class GearslipRunner(
|
||||
onInputMessage(messageId, body)
|
||||
} else if (frame.channel == sensorChannelId) {
|
||||
onSensorMessage(messageId, body)
|
||||
} else if (frame.channel == CarMic.current?.channelId) {
|
||||
CarMic.current?.onMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
// The audio channel is the one still being reverse-engineered, and its first few
|
||||
// replies are what tell us why. Raw bytes for those, then quiet.
|
||||
@@ -327,7 +352,12 @@ class GearslipRunner(
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
||||
// Field 5 is the car's serial number, and the Bluetooth service carries the car's
|
||||
// Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
|
||||
val described = Protobuf.describe(body)
|
||||
.replace(Regex("(?m)^ #5 string = .*$"), " #5 string = (car serial, left out)")
|
||||
.replace(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"), "(Bluetooth address, left out)")
|
||||
log.i("head unit describes itself as:\n" + described)
|
||||
log.verdict(
|
||||
"VIABLE",
|
||||
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
||||
@@ -338,6 +368,7 @@ class GearslipRunner(
|
||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
SessionReport.headUnitInfo = info
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
@@ -374,7 +405,7 @@ class GearslipRunner(
|
||||
videoChannelId = video.serviceId
|
||||
videoConfigs = video.configs
|
||||
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName} display=${video.displayId}")
|
||||
video.configs.forEachIndexed { index, config ->
|
||||
log.i(" config[$index] = $config")
|
||||
}
|
||||
@@ -385,8 +416,9 @@ class GearslipRunner(
|
||||
openChannel(video.serviceId)
|
||||
startAudioChannel(serviceDiscoveryResponse)
|
||||
startSensorChannel(serviceDiscoveryResponse)
|
||||
startMicChannel(serviceDiscoveryResponse)
|
||||
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||
if (input == null) {
|
||||
log.w("no input service advertised - projection will be output-only")
|
||||
} else {
|
||||
@@ -411,13 +443,16 @@ class GearslipRunner(
|
||||
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
||||
)
|
||||
}
|
||||
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
|
||||
val media = ServiceDiscovery.pickMediaSink(sinks) ?: run {
|
||||
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||
SessionReport.audio(
|
||||
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (media.codecType != ServiceDiscovery.CODEC_PCM) {
|
||||
log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence")
|
||||
}
|
||||
val first = media.configs.firstOrNull()
|
||||
SessionReport.audio(
|
||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||
@@ -431,6 +466,18 @@ class GearslipRunner(
|
||||
openChannel(media.serviceId)
|
||||
}
|
||||
|
||||
/** The car's microphone, for voice: opened now, recording only while something listens. */
|
||||
private fun startMicChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val mic = ServiceDiscovery.findMicService(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no microphone advertised - voice uses the phone's")
|
||||
return
|
||||
}
|
||||
val config = mic.configs.first()
|
||||
log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}")
|
||||
CarMic.current = CarMic(mic) { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) }
|
||||
openChannel(mic.serviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -451,6 +498,48 @@ class GearslipRunner(
|
||||
openChannel(sensors.serviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The car may stream sensors many times a second, on the same thread that reads its audio and
|
||||
* video messages. Log each type's first reading in full, then only a count every
|
||||
* [SENSOR_LOG_EVERY_MS], so the log can never hold that thread up.
|
||||
*/
|
||||
private val sensorTypesLogged = HashSet<Int>()
|
||||
private var sensorEventsSinceLog = 0
|
||||
private var sensorLoggedAt = 0L
|
||||
|
||||
private fun logSensorEvent(body: ByteArray) {
|
||||
val types = Wire.fields(body).map { it.number }.toSet()
|
||||
if (sensorTypesLogged.addAll(types)) {
|
||||
// Type 1 is the car's GPS position; logs get shared, so say it arrived and nothing more.
|
||||
if (SENSOR_LOCATION in types) log.i("<- SensorEvent (first of its kind): types=$types, location left out")
|
||||
else log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body))
|
||||
}
|
||||
sensorEventsSinceLog++
|
||||
val now = System.currentTimeMillis()
|
||||
if (sensorLoggedAt == 0L) sensorLoggedAt = now
|
||||
if (now - sensorLoggedAt >= SENSOR_LOG_EVERY_MS) {
|
||||
log.i("<- $sensorEventsSinceLog sensor events in the last ${(now - sensorLoggedAt) / 1000}s")
|
||||
sensorEventsSinceLog = 0
|
||||
sensorLoggedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's own day/night signal (its headlights or light sensor): NightData { bool is_night = 1; }.
|
||||
* It replaces the clock guess, so maps go dark when the car does.
|
||||
*/
|
||||
private fun onNightSensor(body: ByteArray) {
|
||||
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
|
||||
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
|
||||
if (isNight != lastSensorNight) {
|
||||
lastSensorNight = isNight
|
||||
log.i("car says it's ${if (isNight) "night" else "day"}")
|
||||
CarEnvironment.setSensorNight(isNight)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSensorNight: Boolean? = null
|
||||
|
||||
private fun onSensorMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
@@ -460,10 +549,12 @@ class GearslipRunner(
|
||||
log.w("head unit refused the sensor channel (status=$status)")
|
||||
return
|
||||
}
|
||||
// SensorRequest { required int32 sensor_type = 1; }
|
||||
// SensorRequest { required SensorType type = 1; required int64 min_update_period = 2; }
|
||||
// A period of 0 asks for every update the car sends.
|
||||
sensorTypes.forEach { type ->
|
||||
send(
|
||||
MSG_SENSOR_START_REQUEST, Protobuf.varintField(1, type.toLong()),
|
||||
MSG_SENSOR_START_REQUEST,
|
||||
Protobuf.varintField(1, type.toLong()) + Protobuf.varintField(2, 0L),
|
||||
encrypted = true, channel = sensorChannelId,
|
||||
)
|
||||
log.i("-> SensorStartRequest(sensor_type=$type)")
|
||||
@@ -475,9 +566,15 @@ class GearslipRunner(
|
||||
|
||||
MSG_SENSOR_EVENT_INDICATION -> {
|
||||
CarSensors.onEvent(body)
|
||||
log.i("<- SensorEvent:\n" + Protobuf.describe(body))
|
||||
logSensorEvent(body)
|
||||
onNightSensor(body)
|
||||
}
|
||||
|
||||
// SensorError { required SensorType sensor_type = 1; required SensorErrorType sensor_error_type = 2; }
|
||||
MSG_SENSOR_ERROR -> log.w(
|
||||
"<- SensorError: type=${Protobuf.readInt32Field(body, 1)} error=${Protobuf.readInt32Field(body, 2)}",
|
||||
)
|
||||
|
||||
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
@@ -518,6 +615,12 @@ class GearslipRunner(
|
||||
|
||||
MSG_INPUT_REPORT -> {
|
||||
val report = Wire.fields(body)
|
||||
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||
@@ -547,6 +650,8 @@ class GearslipRunner(
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
@@ -649,22 +754,28 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
// The head unit repeats this while it waits for frames; only act once.
|
||||
if (!videoStarted) {
|
||||
videoStarted = true
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
}
|
||||
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||
onVideoFocus(mode)
|
||||
}
|
||||
|
||||
// Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
|
||||
MSG_VIDEO_FOCUS_REQUEST -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
|
||||
}
|
||||
|
||||
MSG_MEDIA_ACK -> {
|
||||
val fields = Wire.fields(body)
|
||||
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
}
|
||||
@@ -707,6 +818,7 @@ class GearslipRunner(
|
||||
log.e("unsupported resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) }
|
||||
|
||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||
Protobuf.varintField(2, index.toLong())
|
||||
@@ -727,6 +839,7 @@ class GearslipRunner(
|
||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
onCodecConfig = { csd ->
|
||||
lastCodecConfig = csd
|
||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
log.hex(" csd", csd, limit = 64)
|
||||
@@ -746,39 +859,34 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow control: the head unit allows max_unacked frames in flight (LIVI allows one). When
|
||||
* the window is full this waits for an ack instead of dropping the frame. A dropped frame
|
||||
* breaks the decoder's chain, so it cost a keyframe to repair; keyframes are twenty times
|
||||
* the size, so they caused more drops, and the stream sank into a storm of them (345 in a
|
||||
* minute on LIVI, at 5-12 fps). Waiting here holds the encoder back instead, which simply
|
||||
* makes it produce fewer frames, each one whole.
|
||||
*/
|
||||
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||
// Flow control: the head unit told us max_unacked in its Config reply.
|
||||
if (inFlight >= maxUnacked) {
|
||||
// A dropped or never-sent ack would otherwise wedge the stream forever, which
|
||||
// looks identical to a frozen picture. Assume the window cleared and carry on.
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > ACK_STALL_MS) {
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
if (dropped % 30 == 1) {
|
||||
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
||||
// The car is showing its own screen and would throw these away.
|
||||
if (!videoProjected) return
|
||||
synchronized(ackLock) {
|
||||
val waitStart = System.currentTimeMillis()
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
// A lost ack would otherwise hold the stream forever, which looks like a frozen
|
||||
// picture. After a long silence, assume the window cleared and carry on.
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > ACK_STALL_MS) {
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||
break
|
||||
}
|
||||
// A dropped frame breaks the decoder's reference chain on the head unit -
|
||||
// every frame after it is a P-frame assuming a picture that never arrived.
|
||||
// Without a resync the picture free-runs on corrupted state indefinitely,
|
||||
// which is the smeared/ghosted look.
|
||||
//
|
||||
// Ask on EVERY drop, not just the first in a streak: the resync keyframe we
|
||||
// asked for is itself just another frame, and can be dropped by this exact
|
||||
// same check. A one-shot request that gets swallowed by the burst that
|
||||
// provoked it leaves the stream corrupted for good, with no further request
|
||||
// ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log,
|
||||
// dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated
|
||||
// setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it
|
||||
// just keeps the "next frame is a keyframe" flag set.
|
||||
resyncRequested = true
|
||||
resyncAttempts++
|
||||
videoSource?.requestSyncFrame()
|
||||
return
|
||||
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||
}
|
||||
ackWaitMs += System.currentTimeMillis() - waitStart
|
||||
}
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
@@ -791,15 +899,11 @@ class GearslipRunner(
|
||||
resyncAttempts = 0
|
||||
lastKeyframeSentAt = System.currentTimeMillis()
|
||||
}
|
||||
try {
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
// Counted before it's queued: the writer may send it and the ack come back before this
|
||||
// thread runs again, and an ack counted ahead of its frame would leave the window full.
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
reportTouchLatency(presentationTimeUs)
|
||||
framesSent++
|
||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||
log.i(
|
||||
@@ -809,6 +913,84 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the car is showing Gearslip; false while it shows its own interface. */
|
||||
@Volatile private var videoProjected = true
|
||||
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
|
||||
@Volatile private var lastCodecConfig: ByteArray? = null
|
||||
|
||||
/**
|
||||
* The car says who has its screen. The first grant starts the video. After that the car may
|
||||
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
|
||||
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
|
||||
* keyframe, since the car's decoder may have been reset in between.
|
||||
*/
|
||||
private fun onVideoFocus(mode: Int) {
|
||||
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (!videoStarted) {
|
||||
// The head unit repeats this while it waits for frames; only start once.
|
||||
if (!projected) return
|
||||
videoStarted = true
|
||||
CarFocus.request = ::requestNativeFocus
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
return
|
||||
}
|
||||
if (projected == videoProjected) return
|
||||
videoProjected = projected
|
||||
CarFocus.set(projected)
|
||||
if (projected) {
|
||||
log.i("the car handed its screen back - restarting the stream")
|
||||
// Frames sent just before the car took over may never be acked.
|
||||
synchronized(ackLock) {
|
||||
inFlight = 0
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||
Protobuf.varintField(2, (selectedConfigIndex ?: 0).toLong())
|
||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> Start(session_id=$SESSION_ID)")
|
||||
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||
videoSource?.requestSyncFrame()
|
||||
} else {
|
||||
// A phone ends the stream when the car takes its screen. LIVI switches on the focus
|
||||
// answer alone, but the 2018 Uconnect kept showing our last frame until it got Stop.
|
||||
send(MSG_MEDIA_STOP, Protobuf.varintField(1, SESSION_ID.toLong()), encrypted = true, channel = videoChannelId)
|
||||
log.i("the car is showing its own screen - video stopped, audio carries on")
|
||||
}
|
||||
}
|
||||
|
||||
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||
private fun requestNativeFocus() {
|
||||
if (!videoStarted) return
|
||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. The
|
||||
// display's channel is deprecated, but the 2018 Uconnect (protocol 1.3) predates that:
|
||||
// without it, it agreed to show its own screen and never did. LIVI ignores the field.
|
||||
val request = Protobuf.varintField(1, videoChannelId.toLong()) +
|
||||
Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
|
||||
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> VideoFocusRequest(display=$videoChannelId, mode=NATIVE, reason=LAUNCH_NATIVE)")
|
||||
}
|
||||
|
||||
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
|
||||
@Volatile private var touchAtUs = 0L
|
||||
|
||||
/**
|
||||
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
|
||||
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
|
||||
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
|
||||
* and display come on top and can't be seen from here.
|
||||
*/
|
||||
private fun reportTouchLatency(presentationTimeUs: Long) {
|
||||
val touchAt = touchAtUs
|
||||
if (touchAt == 0L || presentationTimeUs <= touchAt) return
|
||||
touchAtUs = 0L
|
||||
val nowUs = System.nanoTime() / 1000
|
||||
val draw = (presentationTimeUs - touchAt) / 1000
|
||||
val encode = (nowUs - presentationTimeUs) / 1000
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
value < 0 -> 0f
|
||||
@@ -835,35 +1017,49 @@ class GearslipRunner(
|
||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||
) + body
|
||||
|
||||
synchronized(output) {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
// Every write goes through one thread, in order. The reading thread must never wait on
|
||||
// a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
|
||||
// behind a big video frame (to answer a ping) and a head unit stuck writing that ack
|
||||
// held each other up until something timed out, about two seconds a time.
|
||||
try {
|
||||
writer.execute {
|
||||
try {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
output.flush()
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("send failed, stopping: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
output.flush()
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
// The session is over; nothing more goes out.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,9 +1130,13 @@ class GearslipRunner(
|
||||
val sent = framesSent
|
||||
val fps = (sent - lastFrames) / 5.0
|
||||
lastFrames = sent
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
|
||||
// Time the encoder spent held back waiting for acks: near 5000ms means the
|
||||
// head unit's acks, not the phone, set the frame rate.
|
||||
val waited = ackWaitMs
|
||||
ackWaitMs = 0
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||
log.i(
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||
)
|
||||
|
||||
@@ -988,13 +1188,21 @@ class GearslipRunner(
|
||||
|
||||
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
||||
// starts at 0x8000" numbering as media/input above.
|
||||
const val MSG_SENSOR_START_REQUEST = 32768
|
||||
const val MSG_SENSOR_START_RESPONSE = 32769
|
||||
const val MSG_SENSOR_EVENT_INDICATION = 32770
|
||||
// aasdk SensorMessageId: REQUEST, RESPONSE, BATCH, ERROR.
|
||||
const val MSG_SENSOR_START_REQUEST = 32769
|
||||
const val MSG_SENSOR_START_RESPONSE = 32770
|
||||
const val MSG_SENSOR_EVENT_INDICATION = 32771
|
||||
const val MSG_SENSOR_ERROR = 32772
|
||||
private const val SENSOR_LOCATION = 1
|
||||
private const val SENSOR_NIGHT = 10
|
||||
private const val SENSOR_LOG_EVERY_MS = 10_000L
|
||||
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_H264_BP = 3
|
||||
const val VIDEO_FOCUS_PROJECTED = 1
|
||||
const val VIDEO_FOCUS_NATIVE = 2
|
||||
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
|
||||
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
|
||||
const val SESSION_ID = 1
|
||||
const val MSG_INPUT_REPORT = 32769
|
||||
const val MSG_KEY_BINDING_REQUEST = 32770
|
||||
|
||||
@@ -51,7 +51,8 @@ object ServiceDiscovery {
|
||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||
}
|
||||
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
|
||||
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
|
||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||
}
|
||||
|
||||
@@ -98,6 +99,48 @@ object ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
|
||||
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
|
||||
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
|
||||
* first media sink is the best there is.
|
||||
*/
|
||||
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
|
||||
val media = sinks.filter { it.streamType == STREAM_MEDIA }
|
||||
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's microphone, from MediaSourceService (Service field 5):
|
||||
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
||||
* bool available_while_in_call = 3; }
|
||||
* Null when the head unit has none.
|
||||
*/
|
||||
fun findMicService(response: ByteArray): AudioService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||
return AudioService(
|
||||
serviceId = id,
|
||||
streamType = 0,
|
||||
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
|
||||
configs = listOf(
|
||||
AudioConfig(
|
||||
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
|
||||
bits = Wire.varint(f, 2)?.toInt() ?: 16,
|
||||
channels = Wire.varint(f, 3)?.toInt() ?: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_PCM = 1
|
||||
|
||||
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||
|
||||
private val RESOLUTIONS = mapOf(
|
||||
@@ -131,9 +174,23 @@ object ServiceDiscovery {
|
||||
val touchWidth: Int,
|
||||
val touchHeight: Int,
|
||||
val keycodesSupported: List<Int> = emptyList(),
|
||||
/** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */
|
||||
val displayId: Int = 0,
|
||||
)
|
||||
|
||||
fun findInputService(response: ByteArray): InputService? {
|
||||
/**
|
||||
* The input for the screen Gearslip projects to ([displayId]). A head unit with two screens
|
||||
* offers an input service for each: LIVI lists an empty one for its second screen first,
|
||||
* and taking that one left every touch on the real touchscreen ignored.
|
||||
*/
|
||||
fun findInputService(response: ByteArray, displayId: Int = 0): InputService? {
|
||||
val inputs = inputServices(response)
|
||||
return inputs.firstOrNull { it.displayId == displayId }
|
||||
?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 }
|
||||
?: inputs.firstOrNull()
|
||||
}
|
||||
|
||||
private fun inputServices(response: ByteArray): List<InputService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
@@ -147,9 +204,8 @@ object ServiceDiscovery {
|
||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
return InputService(id, width, height, keycodes)
|
||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||
@@ -169,7 +225,16 @@ object ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main screen's video sink. A head unit with a second screen, such as LIVI with a
|
||||
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
|
||||
*/
|
||||
fun findVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||
}
|
||||
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
@@ -190,15 +255,14 @@ object ServiceDiscovery {
|
||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
return VideoService(id, codecType, configs)
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit's sensor feed, from SensorSourceService (Service field 5):
|
||||
* SensorSourceService { repeated SensorType sensors = 1; }
|
||||
* SensorType { required int32 type = 1; }
|
||||
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
|
||||
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||
* Sensor { required SensorType sensor_type = 1; }
|
||||
*
|
||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||
@@ -210,7 +274,7 @@ object ServiceDiscovery {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val sensorSource = Wire.bytes(service, 5) ?: continue
|
||||
val sensorSource = Wire.bytes(service, 2) ?: continue
|
||||
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,10 @@ object SessionReport {
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
@@ -59,6 +63,8 @@ object SessionReport {
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnitInfo = null
|
||||
carScreen = ""; carDpi = 0
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
@@ -68,6 +74,14 @@ object SessionReport {
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
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
|
||||
|
||||
/** 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 profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
@@ -121,6 +135,18 @@ object SessionReport {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
appContext?.let { context ->
|
||||
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
}
|
||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,19 @@ class VideoSource(
|
||||
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
||||
// and spares the link a big frame every second.
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
||||
if (mode == Mode.SURFACE) {
|
||||
// A surface encoder only makes a frame when the screen changes, and many decoders
|
||||
// show a frame only once the next one arrives. So the last change before the
|
||||
// screen goes still (a menu closing) sat in the car's decoder until something
|
||||
// else moved. Repeating the last frame after a short pause pushes it through;
|
||||
// a repeat of an unchanged screen costs a few bytes. scrcpy does the same.
|
||||
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_AFTER_US)
|
||||
// An app that draws nonstop (a moving map) renders at the phone's refresh rate,
|
||||
// faster than the car acks. Every extra frame queued inside the encoder, and the
|
||||
// car's screen fell 25 seconds behind. Capping the input drops the extras before
|
||||
// they're encoded, so the car always gets the newest frame.
|
||||
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
|
||||
}
|
||||
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||
if (tuned) {
|
||||
@@ -215,6 +228,7 @@ class VideoSource(
|
||||
const val MIN_BIT_RATE = 4_000_000
|
||||
const val MAX_BIT_RATE = 12_000_000
|
||||
const val I_FRAME_INTERVAL_S = 5
|
||||
private const val REPEAT_AFTER_US = 100_000L
|
||||
|
||||
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||
val BAR_COLOURS = listOf(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The car's own microphone, which most head units offer over the same link as the screen:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { config_index } <- Config
|
||||
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
|
||||
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
|
||||
* -> MicrophoneRequest { open = false }
|
||||
*
|
||||
* The channel opens with the session, but the car only records between [start] and [stop].
|
||||
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
|
||||
* one that doesn't isn't used, so voice falls back to the phone's microphone.
|
||||
*/
|
||||
class CarMic(
|
||||
private val service: ServiceDiscovery.AudioService,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private val config = service.configs.first()
|
||||
private val step = config.sampleRate / RATE
|
||||
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
|
||||
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
|
||||
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var listening = false
|
||||
@Volatile private var session = 0
|
||||
private var packets = 0
|
||||
private val chunks = LinkedBlockingQueue<ShortArray>()
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||
ready = usable
|
||||
if (!usable) {
|
||||
GearslipLog.w(
|
||||
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
|
||||
"which speech can't use - voice stays on the phone's microphone",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
|
||||
|
||||
MSG_MIC_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
session = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
|
||||
}
|
||||
|
||||
MSG_DATA -> {
|
||||
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
|
||||
if (!listening || body.size <= TIMESTAMP_BYTES) return
|
||||
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
|
||||
chunks.offer(toSpeechRate(body))
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
|
||||
private fun toSpeechRate(body: ByteArray): ShortArray {
|
||||
val frameBytes = 2 * config.channels
|
||||
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
|
||||
val out = ShortArray(frames / step)
|
||||
for (i in out.indices) {
|
||||
var sum = 0
|
||||
for (s in 0 until step) {
|
||||
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
|
||||
for (c in 0 until config.channels) {
|
||||
val o = at + 2 * c
|
||||
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
|
||||
}
|
||||
}
|
||||
out[i] = (sum / (step * config.channels)).toShort()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
|
||||
fun start(): Boolean {
|
||||
if (!ready) return false
|
||||
chunks.clear()
|
||||
packets = 0
|
||||
listening = true
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(open)")
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
|
||||
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
fun stop() {
|
||||
if (!listening) return
|
||||
listening = false
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The connected car's microphone, or null with no car or one that has none. */
|
||||
@Volatile var current: CarMic? = null
|
||||
|
||||
const val RATE = 16_000
|
||||
private const val TIMESTAMP_BYTES = 8
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_MIC_REQUEST = 32773
|
||||
private const val MSG_MIC_RESPONSE = 32774
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
||||
import app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -119,15 +120,19 @@ object CarAssistant {
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
return
|
||||
}
|
||||
appContext?.let(VoiceFocus::acquire)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||
}
|
||||
@@ -138,17 +143,19 @@ object CarAssistant {
|
||||
*/
|
||||
fun say(text: String, onDone: () -> Unit) {
|
||||
val engine = tts
|
||||
if (engine == null || !ttsReady || text.isBlank()) {
|
||||
val context = appContext
|
||||
if (engine == null || context == null || !ttsReady || text.isBlank()) {
|
||||
mainHandler.post(onDone)
|
||||
return
|
||||
}
|
||||
VoiceFocus.acquire(context)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { mainHandler.post(onDone) }
|
||||
override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) { mainHandler.post(onDone) }
|
||||
override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
// Stopped part way: whoever stopped it has already moved on.
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) {}
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Today")
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
|
||||
@@ -27,6 +27,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||
@@ -72,64 +79,77 @@ fun CarKeyboard(
|
||||
onDispose { CarKeyboardVisibility.state.value = false }
|
||||
}
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||
val latestChange by rememberUpdatedState(onTextChange)
|
||||
val latestSubmit by rememberUpdatedState(onSubmit)
|
||||
val latestDismiss by rememberUpdatedState(onDismiss)
|
||||
val phoneField = remember {
|
||||
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
|
||||
}
|
||||
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||
|
||||
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||
}
|
||||
rows.last().forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||
}
|
||||
rows.last().forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
Key("⌫", Modifier.weight(1.6f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
}
|
||||
Key("⌫", Modifier.weight(1.6f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
}
|
||||
|
||||
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
|
||||
// last key, bottom-right corner of the keyboard.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
|
||||
// last key, bottom-right corner of the keyboard.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +160,7 @@ fun CarKeyboard(
|
||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -163,7 +183,7 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -181,7 +201,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -199,10 +219,12 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
}
|
||||
|
||||
/**
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
|
||||
*/
|
||||
private val KEY_HEIGHT = 34.dp
|
||||
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp)
|
||||
|
||||
private val LocalKeyHeight = compositionLocalOf { 36.dp }
|
||||
|
||||
private val LETTER_ROWS = listOf(
|
||||
"qwertyuiop".map { it.toString() },
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -47,6 +48,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
@@ -164,7 +167,7 @@ internal fun rememberAllEntries(): List<Entry> {
|
||||
}
|
||||
|
||||
/** The launcher's groups, in the order they're laid out. */
|
||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
|
||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
|
||||
|
||||
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
entry.builtIn?.let {
|
||||
@@ -172,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
BuiltInApps.Phone -> AppGroup.PHONE
|
||||
BuiltInApps.Web -> AppGroup.WEB
|
||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||
BuiltInApps.Exit -> AppGroup.EXIT
|
||||
else -> AppGroup.SETTINGS
|
||||
}
|
||||
}
|
||||
@@ -253,7 +257,7 @@ fun CarLauncher() {
|
||||
val id = entry.componentId
|
||||
return Tile(
|
||||
entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
verified = pkg != null && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
|
||||
verified = pkg != null && !KnownApps.isPartial(pkg) && (KnownApps.works(pkg) || (entry.media != null && KnownApps.playerWorks(pkg))),
|
||||
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||
partial = pkg != null && KnownApps.isPartial(pkg),
|
||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||
@@ -350,16 +354,28 @@ private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
||||
}
|
||||
}
|
||||
|
||||
/** Trial: where a tile says whether its app works. See [AppTileBody]. */
|
||||
private enum class BadgeStyle { ON_ICON, OUTLINE }
|
||||
private val BADGE_STYLE = BadgeStyle.ON_ICON
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile) {
|
||||
val status = when {
|
||||
tile.verified -> Color(0xFF43A047)
|
||||
tile.partial -> Color(0xFFF9A825)
|
||||
tile.broken -> Color(0xFFD32F2F)
|
||||
else -> null
|
||||
}
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||
// shown, here and on the nav bar.
|
||||
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||
GsIconBox(
|
||||
onClick = tile.onClick,
|
||||
onLongClick = tile.onLongClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
modifier = Modifier.height(120.dp).then(
|
||||
if (BADGE_STYLE == BadgeStyle.OUTLINE && status != null) Modifier.border(2.5.dp, status, shape) else Modifier,
|
||||
),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = shape,
|
||||
) {
|
||||
@@ -369,29 +385,44 @@ private fun AppTile(tile: Tile) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
Box {
|
||||
TileIcon(tile)
|
||||
if (BADGE_STYLE == BadgeStyle.ON_ICON) {
|
||||
// Pinned to the icon's lower corner, ringed in the tile's colour so it
|
||||
// reads as sitting on top of the icon.
|
||||
val ring = Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
|
||||
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
when {
|
||||
tile.verified -> VerifiedBadge(22.dp, ring)
|
||||
tile.broken -> BrokenBadge(22.dp, ring)
|
||||
tile.partial -> PartialBadge(22.dp, ring)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (tile.verified) {
|
||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
}
|
||||
if (tile.broken) {
|
||||
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
}
|
||||
if (tile.partial) {
|
||||
PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
TileLabel(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileIcon(tile: Tile) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
tile.label,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -87,8 +87,8 @@ object SensorType {
|
||||
18 to "Tire pressure",
|
||||
19 to "Accelerometer",
|
||||
20 to "Gyroscope",
|
||||
21 to "Gravity",
|
||||
22 to "Wheel tick / distance",
|
||||
21 to "GPS satellites",
|
||||
22 to "Toll card",
|
||||
)
|
||||
|
||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||
|
||||
@@ -38,6 +38,7 @@ object CarSettings {
|
||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||
private const val KEY_MEDIA_SEARCH = "car_media_search_enabled"
|
||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||
private const val KEY_UNITS = "car_units"
|
||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||
@@ -71,6 +72,7 @@ object CarSettings {
|
||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val mediaSearchFlow = MutableStateFlow(false)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
|
||||
@@ -171,6 +173,13 @@ object CarSettings {
|
||||
*/
|
||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||
|
||||
/**
|
||||
* Searching inside a media app is experimental and off by default: many apps claim to take
|
||||
* searches and then ignore them or play something else. The media screen's search button
|
||||
* shows only once this and [experimentalFeaturesEnabled] are both on.
|
||||
*/
|
||||
val mediaSearchEnabled: StateFlow<Boolean> = mediaSearchFlow
|
||||
|
||||
/**
|
||||
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
||||
@@ -207,6 +216,7 @@ object CarSettings {
|
||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "false") == "true"
|
||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||
unitsFlow.value = runCatching {
|
||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||
@@ -275,6 +285,11 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||
}
|
||||
|
||||
fun setMediaSearchEnabled(on: Boolean) {
|
||||
mediaSearchFlow.value = on
|
||||
AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString())
|
||||
}
|
||||
|
||||
fun setNightMode(mode: NightMode) {
|
||||
nightFlow.value = mode
|
||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||
|
||||
@@ -60,6 +60,7 @@ fun CarSettingsScreen() {
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
val context = LocalContext.current
|
||||
@@ -74,7 +75,7 @@ fun CarSettingsScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Settings")
|
||||
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
@@ -126,14 +127,13 @@ fun CarSettingsScreen() {
|
||||
|
||||
ChoiceRow(
|
||||
"Media audio",
|
||||
listOf(false to "Phone output", true to "Through Gearslip"),
|
||||
listOf(false to "Phone audio", true to "Car audio"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
||||
"example over Bluetooth to the car.",
|
||||
"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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -148,7 +148,7 @@ fun CarSettingsScreen() {
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
if (experimentalFeatures) {
|
||||
ExperimentalFeaturesList(voiceAssistant, carSensors)
|
||||
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -205,7 +205,7 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
||||
* so the list reads fine whether there's one entry or several.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean) {
|
||||
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -228,6 +228,13 @@ private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolea
|
||||
"guess, not a fact, until you've watched one change for yourself.",
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
|
||||
ExperimentalFeatureRow(
|
||||
title = "Media search",
|
||||
description = "Adds a search button to the media screen. Some apps ignore the search " +
|
||||
"or play something else.",
|
||||
checked = mediaSearch,
|
||||
) { CarSettings.setMediaSearchEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,10 @@ fun CarUi() {
|
||||
}
|
||||
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() }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||
|
||||
@@ -262,13 +266,15 @@ fun CarUi() {
|
||||
@Composable
|
||||
private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// The card and its button share one width, capped so neither sprawls across a wide screen.
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(24.dp),
|
||||
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
|
||||
@@ -30,12 +30,14 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -69,6 +71,9 @@ import app.seb3thehacker.gearslip.media.MediaEntry
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||
import android.media.session.PlaybackState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
||||
/**
|
||||
* A media app on the car screen: what it is playing and its controls on the left, the tree it
|
||||
@@ -84,7 +89,10 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
val browse by media.browse.collectAsStateWithLifecycle()
|
||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||
val search by media.search.collectAsStateWithLifecycle()
|
||||
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
@@ -128,6 +136,29 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
return@Surface
|
||||
}
|
||||
|
||||
if (searching) {
|
||||
SearchPanel(
|
||||
appLabel = app.label,
|
||||
media = media,
|
||||
canPickForYou = now.canSearch,
|
||||
onPlayed = {
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onOpened = {
|
||||
searching = false
|
||||
tab = Tab.BROWSE
|
||||
},
|
||||
onPickForYou = { query ->
|
||||
media.search(query)
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onClose = { searching = false },
|
||||
)
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
@@ -151,7 +182,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
if (searchAllowed && (now.canSearch || media.canSearchLibrary)) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
"Search ${app.label}",
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
@@ -192,6 +234,135 @@ private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier =
|
||||
|
||||
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
||||
|
||||
// --- search ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Types a search for the app to play. The app picks the result and plays it, the way it would
|
||||
* for "play X" from a voice assistant, so there's no list to choose from.
|
||||
*/
|
||||
/**
|
||||
* Search inside the media app. It asks the app's library for matches half a second after the
|
||||
* driver stops typing and lists them, so the driver picks exactly what plays: a song plays, an
|
||||
* album or artist opens in Browse. The search key hides the keyboard to show more results; the
|
||||
* field brings it back. An app with no library search can still be told to play its own best
|
||||
* match, which is all the panel could do before.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchPanel(
|
||||
appLabel: String,
|
||||
media: CarMedia,
|
||||
canPickForYou: Boolean,
|
||||
onPlayed: () -> Unit,
|
||||
onOpened: () -> Unit,
|
||||
onPickForYou: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var typing by remember { mutableStateOf(true) }
|
||||
val results by media.results.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(query) {
|
||||
delay(SEARCH_PAUSE_MS)
|
||||
media.searchLibrary(query)
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { media.clearResults() } }
|
||||
|
||||
val trimmed = query.trim()
|
||||
val current = results?.takeIf { it.query == trimmed }
|
||||
val unsupported = current?.supported == false
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||
SearchField(
|
||||
query,
|
||||
"Search $appLabel",
|
||||
Modifier.weight(1f).clickable { typing = true },
|
||||
onClear = {
|
||||
query = ""
|
||||
typing = true
|
||||
},
|
||||
)
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
val entries = current?.entries
|
||||
when {
|
||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||
unsupported && canPickForYou ->
|
||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||
entries == null -> SearchNote("Searching $appLabel…")
|
||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries) { entry ->
|
||||
EntryRow(entry, media) {
|
||||
if (entry.browsable) {
|
||||
media.select(entry)
|
||||
onOpened()
|
||||
} else {
|
||||
media.playAll(entry)
|
||||
onPlayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typing) {
|
||||
CarKeyboard(
|
||||
text = query,
|
||||
onTextChange = { query = it },
|
||||
onSubmit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
unsupported && canPickForYou -> onPickForYou(trimmed)
|
||||
else -> typing = false
|
||||
}
|
||||
},
|
||||
onDismiss = { if (trimmed.isEmpty()) onClose() else typing = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchNote(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long typing has to pause before the library is searched. */
|
||||
private const val SEARCH_PAUSE_MS = 1_000L
|
||||
|
||||
/** A line under the tabs while the app works on a search, and if it plays nothing for it. */
|
||||
@Composable
|
||||
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onDismiss,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
||||
else "Searching $appLabel for \"${search.query}\"…",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||
@Composable
|
||||
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
@@ -490,13 +661,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.select(entry) },
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -40,16 +40,34 @@ import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.CarFocus
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
|
||||
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||
|
||||
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
||||
/** Material's "directions car", drawn here because only the core icon set is bundled. */
|
||||
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
|
||||
PathParser().parsePathString(
|
||||
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
|
||||
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
|
||||
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
|
||||
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
|
||||
"M5,11l1.5,-4.5h11L19,11L5,11z",
|
||||
).toNodes(),
|
||||
fill = SolidColor(Color.Black),
|
||||
).build()
|
||||
|
||||
/**
|
||||
* One of Gearslip's own apps: a screen, with nothing running behind it once you leave. [screen] is
|
||||
* null for an action like Exit, which has no screen and so never shows as open.
|
||||
*/
|
||||
internal class BuiltInApp(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val glyph: ImageVector,
|
||||
val screen: CarScreen,
|
||||
val screen: CarScreen?,
|
||||
val open: (CarNavigator) -> Unit,
|
||||
)
|
||||
|
||||
@@ -60,7 +78,10 @@ internal object BuiltInApps {
|
||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
|
||||
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||
}
|
||||
|
||||
@@ -110,7 +131,7 @@ internal fun rememberRunningApps(): Map<String, RunningSlot> {
|
||||
else -> false
|
||||
}
|
||||
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
|
||||
BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
||||
BuiltInApps.all.firstOrNull { it.screen != null && it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
||||
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
ScreenHeader("Screen sharing")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (!TouchRelayService.enabled) {
|
||||
TouchRelayWarning()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// The tabs share the title's row, so the list and keypad get the height back.
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Phone",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ScreenHeader("Phone") {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The text field the car keyboard is typing into, shared with the phone so a passenger can type
|
||||
* on the phone's own keyboard instead. Whichever [CarKeyboard] is on screen registers here; the
|
||||
* phone app shows the same text while one is open, and its edits go back through the car field's
|
||||
* own callbacks, so the car screen can't tell which keyboard typed.
|
||||
*/
|
||||
object PhoneTyping {
|
||||
|
||||
class Field internal constructor(
|
||||
val id: Int,
|
||||
val text: String,
|
||||
internal val onTextChange: (String) -> Unit,
|
||||
internal val onSubmit: () -> Unit,
|
||||
internal val onDismiss: (() -> Unit)?,
|
||||
)
|
||||
|
||||
private val fieldFlow = MutableStateFlow<Field?>(null)
|
||||
|
||||
/** The car's open field, or null when no car keyboard is showing. */
|
||||
val field: StateFlow<Field?> = fieldFlow.asStateFlow()
|
||||
|
||||
private var nextId = 0
|
||||
|
||||
internal fun open(text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, onDismiss: (() -> Unit)?): Int {
|
||||
val id = ++nextId
|
||||
fieldFlow.value = Field(id, text, onTextChange, onSubmit, onDismiss)
|
||||
return id
|
||||
}
|
||||
|
||||
/** The car field's text changed, from either keyboard. */
|
||||
internal fun carText(id: Int, text: String) {
|
||||
val f = fieldFlow.value ?: return
|
||||
if (f.id == id && f.text != text) fieldFlow.value = Field(id, text, f.onTextChange, f.onSubmit, f.onDismiss)
|
||||
}
|
||||
|
||||
internal fun close(id: Int) {
|
||||
if (fieldFlow.value?.id == id) fieldFlow.value = null
|
||||
}
|
||||
|
||||
/** From the phone: typed text goes to the car field as if typed there. */
|
||||
fun type(text: String) {
|
||||
fieldFlow.value?.onTextChange?.invoke(text)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fieldFlow.value?.onSubmit?.invoke()
|
||||
}
|
||||
|
||||
/** Closes the car keyboard where it can be closed; elsewhere only the phone's field goes. */
|
||||
fun dismiss() {
|
||||
val f = fieldFlow.value ?: return
|
||||
f.onDismiss?.invoke() ?: close(f.id)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Also unlocks the certificate,
|
||||
* so a head unit's first message is answered at once. Each screen paints from these
|
||||
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||
* network or the calendar provider.
|
||||
*
|
||||
@@ -28,6 +30,7 @@ object Prefetch {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
val start = System.currentTimeMillis()
|
||||
step("certificate") { CertProvider.warm(app) }
|
||||
step("launcher apps") { warmLauncherApps(app) }
|
||||
step("phone apps") { PhoneApps.warm(app) }
|
||||
step("weather and calendar") { warmDashboard(app) }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
|
||||
/**
|
||||
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||
* so this is the way out of a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
Row(
|
||||
modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
||||
* [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
internal fun SearchField(
|
||||
query: String,
|
||||
hint: String,
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
|
||||
val readings by CarSensors.readings.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Vehicle data")
|
||||
Text(
|
||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||
|
||||
@@ -41,7 +41,7 @@ object VoiceReply {
|
||||
LISTENING,
|
||||
/**
|
||||
* Reading the reply back, then counting down to [State.until] while listening for
|
||||
* "cancel" or "change it". [State.until] is 0 while the countdown is held.
|
||||
* "cancel" or "change it". The countdown runs out whatever it hears.
|
||||
*/
|
||||
CONFIRMING,
|
||||
SENT,
|
||||
@@ -103,7 +103,7 @@ object VoiceReply {
|
||||
val until = SystemClock.uptimeMillis() + OFFER_MS
|
||||
_state.value = _state.value.copy(until = until, span = OFFER_MS)
|
||||
// The recognizer gives up on its own; this only covers one that doesn't.
|
||||
main.postAtTime({ if (id == run && _state.value.until != 0L) cancel() }, until + OFFER_GRACE_MS)
|
||||
main.postAtTime({ if (id == run && _state.value.until != 0L) { CarAssistant.stopListening(); close() } }, until + OFFER_GRACE_MS)
|
||||
CarAssistant.dictate(
|
||||
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||
onSpeaking = {
|
||||
@@ -118,11 +118,11 @@ object VoiceReply {
|
||||
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
||||
} else {
|
||||
finish()
|
||||
close()
|
||||
}
|
||||
},
|
||||
// No answer: the card has already offered Reply and the quick replies all along.
|
||||
onError = { if (id == run) finish() },
|
||||
// No answer counts as no: the message has been heard, so the card goes.
|
||||
onError = { if (id == run) close() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -193,43 +193,27 @@ object VoiceReply {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends in [ms]. After a hold the bar carries on from where it stopped instead of jumping back
|
||||
* to full: the span is stretched so the bar still runs out exactly at [ms].
|
||||
*/
|
||||
/** Sends in [ms]. */
|
||||
private fun startCountdown(id: Int, ms: Long) {
|
||||
val sendAt = SystemClock.uptimeMillis() + ms
|
||||
val held = _state.value.held
|
||||
val span = if (held > 0f) (ms / held).toLong() else ms
|
||||
_state.value = _state.value.copy(until = sendAt, span = span, held = 0f)
|
||||
_state.value = _state.value.copy(until = sendAt, span = ms, held = 0f)
|
||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens through the countdown for "cancel", "change it" or "send". The countdown holds from
|
||||
* the first word, so a reply can't go out while the driver is still saying "cancel".
|
||||
* Listens through the countdown for "cancel", "change it" or "send". The countdown never
|
||||
* pauses: in a noisy car it held for road noise and the radio, and the reply never went.
|
||||
*/
|
||||
private fun listenForCommand(id: Int) {
|
||||
CarAssistant.dictate(
|
||||
onPartial = {},
|
||||
cue = false,
|
||||
onSpeaking = {
|
||||
if (id != run) return@dictate
|
||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||
val s = _state.value
|
||||
val left = if (s.until > 0L && s.span > 0L) {
|
||||
((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
||||
} else {
|
||||
s.held
|
||||
}
|
||||
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
||||
},
|
||||
onResult = { text ->
|
||||
if (id != run) return@dictate
|
||||
when (command(text)) {
|
||||
Command.CANCEL -> {
|
||||
finish()
|
||||
close()
|
||||
CarAssistant.say("Not sent.") {}
|
||||
}
|
||||
Command.CHANGE -> {
|
||||
@@ -238,11 +222,17 @@ object VoiceReply {
|
||||
ask(id)
|
||||
}
|
||||
Command.SEND -> send()
|
||||
// Talk that wasn't meant for us, a passenger or the radio: carry on counting.
|
||||
null -> startCountdown(id, RESUME_MS)
|
||||
// Talk that wasn't meant for us, a passenger or the radio: keep listening, so a
|
||||
// "cancel" after it still lands before the countdown runs out.
|
||||
null -> listenForCommand(id)
|
||||
}
|
||||
},
|
||||
onError = { if (id == run && _state.value.until == 0L) startCountdown(id, RESUME_MS) },
|
||||
// Silence ends the recognizer before the countdown does: listen again until it sends.
|
||||
onError = {
|
||||
if (id != run) return@dictate
|
||||
// A short gap, so a recognizer that fails at once can't spin.
|
||||
main.postDelayed({ if (id == run) listenForCommand(id) }, RELISTEN_MS)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -308,11 +298,20 @@ object VoiceReply {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops wherever it is. Nothing is sent. */
|
||||
/** Stops wherever it is. Nothing is sent, and the message card closes. */
|
||||
fun cancel() {
|
||||
if (!active) return
|
||||
CarAssistant.stopListening()
|
||||
CarAssistant.stopSpeaking()
|
||||
close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends without sending and takes the message card with it. Without this the card's own popup
|
||||
* came back with a fresh bar, as if the message had just arrived.
|
||||
*/
|
||||
private fun close() {
|
||||
_state.value.target?.let { CarNotifications.dismissPopup(it.id) }
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -378,6 +377,5 @@ object VoiceReply {
|
||||
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
|
||||
private const val OFFER_MS = 6_500L
|
||||
private const val OFFER_GRACE_MS = 1_500L
|
||||
/** After talk that wasn't a command, the reply waits this long again before it goes. */
|
||||
private const val RESUME_MS = 3_000L
|
||||
private const val RELISTEN_MS = 400L
|
||||
}
|
||||
@@ -84,11 +84,13 @@ object Weather {
|
||||
return
|
||||
}
|
||||
val data = runCatching { fetch(context, location) }
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
||||
// Only the kind of error: some messages carry the request URL, and that holds the location.
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.javaClass.simpleName}") }
|
||||
.getOrNull()
|
||||
if (data != null) {
|
||||
flow.value = WeatherState.Ready(data)
|
||||
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
||||
// No place name: logs get shared in bug reports.
|
||||
GearslipLog.i("weather: loaded")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ fun WeatherScreen() {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Weather")
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
AndroidView(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
factory = { context ->
|
||||
|
||||
@@ -315,6 +315,7 @@ fun GsIconBox(
|
||||
shape: Shape = gsShape(),
|
||||
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
||||
latched: Boolean = false,
|
||||
/** Also marks the control with the theme's hold marker, so the hold can be found. */
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
@@ -327,6 +328,7 @@ fun GsIconBox(
|
||||
.gsSurface(c.container, shape, c.border, pressed, latched),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (onLongClick != null && enabled) GsHoldMarker(gsLook().holdMarker, c.container, shape, source)
|
||||
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package app.seb3thehacker.gearslip.car.theme
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* How a control says it also does something when held. [GsIconBox] draws it on its own for any
|
||||
* control given an onLongClick, so callers never place it by hand.
|
||||
*/
|
||||
enum class HoldMarkerStyle {
|
||||
/** The top layer peels back from the top-right corner, further while held. Needs depth. */
|
||||
PEEL,
|
||||
|
||||
/** A plain dot in the top-right corner. It doesn't react to a hold. */
|
||||
DOT,
|
||||
|
||||
/** Nothing: the hold stays undiscovered. For themes that mark it some other way. */
|
||||
NONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold marker for one control, drawn under its content. [container] is the control's own
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
|
||||
if (style == HoldMarkerStyle.NONE) return
|
||||
val pressed by press.collectIsPressedAsState()
|
||||
val timeout = LocalViewConfiguration.current.longPressTimeoutMillis.toInt()
|
||||
val lift = remember { Animatable(0f) }
|
||||
if (style == HoldMarkerStyle.PEEL) {
|
||||
LaunchedEffect(pressed) {
|
||||
if (pressed) lift.animateTo(1f, tween(timeout, easing = LinearEasing)) else lift.animateTo(0f, tween(220))
|
||||
}
|
||||
}
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val accent = MaterialTheme.colorScheme.primaryContainer
|
||||
val onAccent = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
val side = min(size.width, size.height)
|
||||
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
|
||||
val round = r >= side * 0.4f
|
||||
if (style == HoldMarkerStyle.DOT || round) {
|
||||
drawHoldDot(r, side, ink)
|
||||
} else {
|
||||
// Drawn for the bottom-right corner, then flipped up to the top-right.
|
||||
scale(1f, -1f, pivot = center) {
|
||||
drawPeel(r, side, container, accent, onAccent, lift.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Depth of a corner's curve along its diagonal: a fold shallower than this only shaves the curve. */
|
||||
private fun curveDepth(r: Float) = r * (2f - 1.41421f)
|
||||
|
||||
private fun DrawScope.drawHoldDot(r: Float, side: Float, ink: Color) {
|
||||
val dot = (side * 0.06f).coerceIn(5.dp.toPx(), 7.dp.toPx())
|
||||
// Tucked into the corner, clear of its curve.
|
||||
val inset = r * (1f - 0.7071f) + (side * 0.05f).coerceIn(4.dp.toPx(), 6.dp.toPx()) + dot / 2
|
||||
drawCircle(ink.copy(alpha = 0.6f), radius = dot / 2, center = Offset(size.width - inset, inset))
|
||||
}
|
||||
|
||||
/** The part of a rounded rectangle beyond the fold line x + y = c, as a closed outline. */
|
||||
private fun cornerRegion(w: Float, h: Float, r: Float, c: Float): List<Offset> {
|
||||
// Walk the bottom-right boundary: down the right edge, round the corner, along the bottom.
|
||||
val reach = (h + w - c) + 1f
|
||||
val edge = ArrayList<Offset>()
|
||||
edge += Offset(w, h - maxOf(r, reach))
|
||||
if (r > 0f) {
|
||||
for (i in 0..24) {
|
||||
val a = (i / 24f) * (PI / 2).toFloat()
|
||||
edge += Offset(w - r + r * cos(a), h - r + r * sin(a))
|
||||
}
|
||||
} else {
|
||||
edge += Offset(w, h)
|
||||
}
|
||||
edge += Offset(w - maxOf(r, reach), h)
|
||||
// Keep what lies beyond the fold, adding the exact crossing points.
|
||||
val out = ArrayList<Offset>()
|
||||
for (i in 0 until edge.size - 1) {
|
||||
val p = edge[i]
|
||||
val q = edge[i + 1]
|
||||
val ps = p.x + p.y - c
|
||||
val qs = q.x + q.y - c
|
||||
if (ps >= 0) out += p
|
||||
if ((ps >= 0) != (qs >= 0)) {
|
||||
val t = ps / (ps - qs)
|
||||
out += Offset(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)
|
||||
}
|
||||
}
|
||||
val last = edge.last()
|
||||
if (last.x + last.y - c >= 0) out += last
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pathOf(points: List<Offset>): Path = Path().apply {
|
||||
if (points.isEmpty()) return@apply
|
||||
moveTo(points[0].x, points[0].y)
|
||||
for (p in points.drop(1)) lineTo(p.x, p.y)
|
||||
close()
|
||||
}
|
||||
|
||||
/**
|
||||
* The top layer peeling back from the corner, showing a second layer underneath with the same
|
||||
* rounded corner, so the control's outline never changes. Sized from the control: a launcher
|
||||
* tile folds about 27dp at rest and 64dp held, a nav bar button far less.
|
||||
*/
|
||||
private fun DrawScope.drawPeel(r: Float, side: Float, face: Color, accent: Color, onAccent: Color, lift: Float) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val rest = curveDepth(r) + (side * 0.1f).coerceIn(4.dp.toPx(), 11.dp.toPx())
|
||||
val held = rest + min(37.dp.toPx(), side * 0.35f)
|
||||
val e = rest + (held - rest) * lift
|
||||
val c = w + h - e
|
||||
val region = cornerRegion(w, h, r, c)
|
||||
if (region.size < 3) return
|
||||
val mid = Offset(w - e / 2, h - e / 2)
|
||||
// 1. The second layer, turning the accent colour as the hold completes.
|
||||
drawPath(pathOf(region), lerp(lerp(face, Color.Black, 0.26f), accent, lift))
|
||||
// The lifted edge of the top layer shades the second layer just beside it.
|
||||
drawPath(
|
||||
pathOf(region),
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Black.copy(alpha = 0.35f), Color.Transparent),
|
||||
start = mid,
|
||||
end = mid + Offset(e * 0.25f, e * 0.25f),
|
||||
),
|
||||
)
|
||||
// 2. "More" waits on the second layer, readable once the peel is well back.
|
||||
if (lift > 0.35f && side > 80.dp.toPx()) {
|
||||
val a = ((lift - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||
val centre = Offset(w - e * 0.3f, h - e * 0.3f)
|
||||
val gap = 5.5.dp.toPx()
|
||||
for (k in -1..1) {
|
||||
drawCircle(onAccent.copy(alpha = a), radius = 2.2.dp.toPx(), center = centre + Offset(k * gap * 0.71f, -k * gap * 0.71f))
|
||||
}
|
||||
}
|
||||
// 3. The flap: the same corner folded back over the face, its rounded tip and all.
|
||||
val flap = region.map { Offset(c - it.y, c - it.x) }
|
||||
translate(-1.5.dp.toPx(), -1.5.dp.toPx()) {
|
||||
drawPath(pathOf(flap), Color.Black.copy(alpha = 0.30f))
|
||||
}
|
||||
drawPath(
|
||||
pathOf(flap),
|
||||
Brush.linearGradient(
|
||||
listOf(lerp(face, Color.White, 0.30f), lerp(face, Color.White, 0.08f)),
|
||||
start = Offset(c - h, c - w),
|
||||
end = mid,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,11 @@ data class ControlLook(
|
||||
val pressedDim: Float = 0.22f,
|
||||
/** Scale while pressed, 1 for none. */
|
||||
val pressedScale: Float = 0.95f,
|
||||
/**
|
||||
* How a control with a press-and-hold action says so. The peel needs a raised face to lift
|
||||
* from; a flat look takes the dot.
|
||||
*/
|
||||
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
|
||||
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||
val light: ControlLook? = null,
|
||||
)
|
||||
@@ -67,7 +72,7 @@ object GsThemes {
|
||||
val Keycap = GsTheme(
|
||||
id = "keycap",
|
||||
name = "Keycap",
|
||||
control = ControlLook(lip = 4.dp, pressedScale = 1f),
|
||||
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||
)
|
||||
|
||||
val Tactile = GsTheme(
|
||||
@@ -83,6 +88,7 @@ object GsThemes {
|
||||
insetAlpha = 0.30f,
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
// A wide soft shadow bands once the car's video stream compresses it on a light
|
||||
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||
light = ControlLook(
|
||||
@@ -95,6 +101,7 @@ object GsThemes {
|
||||
insetAlpha = 0.30f,
|
||||
pressedDim = 0.12f,
|
||||
pressedScale = 1f,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -102,7 +109,7 @@ object GsThemes {
|
||||
val Flat = GsTheme(
|
||||
id = "flat",
|
||||
name = "Flat",
|
||||
control = ControlLook(),
|
||||
control = ControlLook(holdMarker = HoldMarkerStyle.DOT),
|
||||
)
|
||||
|
||||
val default = Tactile
|
||||
|
||||
@@ -16,7 +16,7 @@ object KnownApps {
|
||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||
"com.mapquest.android.ace", // MapQuest: map, search, routing - works well
|
||||
"com.metrolist.music", // Metrolist: browse, play, works
|
||||
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||
)
|
||||
|
||||
@@ -65,9 +65,15 @@ object KnownApps {
|
||||
|
||||
fun isBroken(packageName: String): Boolean = packageName in broken
|
||||
|
||||
/** Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to call working. */
|
||||
/**
|
||||
* Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to
|
||||
* call working. Wins over [works] and [playerWorks] for the badge.
|
||||
*/
|
||||
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
|
||||
)
|
||||
|
||||
fun isPartial(packageName: String): Boolean = packageName in partial
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.Rating
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
@@ -75,6 +76,9 @@ class NowPlaying(
|
||||
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** The app says it takes searches. Spotify says so and then ignores Gearslip's, so [CarMedia.search] checks. */
|
||||
val canSearch get() = (actions and PlaybackState.ACTION_PLAY_FROM_SEARCH) != 0L
|
||||
|
||||
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
||||
val likeAction
|
||||
get() = custom.firstOrNull { a ->
|
||||
@@ -164,6 +168,24 @@ class CarMedia(private val context: Context) {
|
||||
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
||||
|
||||
/** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */
|
||||
class Search(val query: String, val ignored: Boolean = false)
|
||||
|
||||
/**
|
||||
* What the app's own library search found for [query]. [entries] is null while it looks.
|
||||
* [supported] is false when the app has no library search, so the panel can fall back to
|
||||
* "play the best match" (see [search]).
|
||||
*/
|
||||
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
|
||||
|
||||
private val _results = MutableStateFlow<Results?>(null)
|
||||
val results: StateFlow<Results?> = _results.asStateFlow()
|
||||
private var resultsQuery: String? = null
|
||||
|
||||
private val _search = MutableStateFlow<Search?>(null)
|
||||
val search: StateFlow<Search?> = _search.asStateFlow()
|
||||
private var searchBefore: NowPlaying? = null
|
||||
|
||||
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
||||
class Modes(
|
||||
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
||||
@@ -276,6 +298,7 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
val mb = browser ?: return
|
||||
searchComponent = app.component
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
attach(MediaController(context, mb.sessionToken))
|
||||
rootId = mb.root
|
||||
@@ -326,6 +349,7 @@ class CarMedia(private val context: Context) {
|
||||
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
|
||||
} else null,
|
||||
)
|
||||
checkSearch()
|
||||
}
|
||||
|
||||
// --- browsing ------------------------------------------------------------------------
|
||||
@@ -401,6 +425,141 @@ class CarMedia(private val context: Context) {
|
||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||
|
||||
/**
|
||||
* Asks the app to find and play [query]: a song, artist, album or playlist. This goes through
|
||||
* the app's playback session, so it works for apps that keep their library from Gearslip, and
|
||||
* the app stays in the background. Apps may still ignore it (Spotify only takes Google's), so
|
||||
* if nothing new plays within [SEARCH_WAIT_MS] the search is marked ignored.
|
||||
*/
|
||||
fun search(query: String) {
|
||||
val c = controller ?: return
|
||||
main.removeCallbacks(searchTimeout)
|
||||
searchBefore = _now.value
|
||||
_search.value = Search(query)
|
||||
GearslipLog.i("media: -> playFromSearch")
|
||||
c.transportControls.playFromSearch(query, Bundle())
|
||||
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
|
||||
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
|
||||
* ViVi answer with songs from the library first, then YouTube Music.
|
||||
*/
|
||||
fun searchLibrary(query: String) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) {
|
||||
clearResults()
|
||||
return
|
||||
}
|
||||
val component = searchComponent
|
||||
if (component == null) {
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
return
|
||||
}
|
||||
resultsQuery = q
|
||||
_results.value = Results(q)
|
||||
val sb = searchBrowser
|
||||
if (sb != null && sb.isConnected) {
|
||||
runSearch(sb, q)
|
||||
return
|
||||
}
|
||||
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
|
||||
// The framework MediaBrowser has no search; the support library's does, over the same
|
||||
// service. Opened on first use only, so apps nobody searches never see a second client.
|
||||
lateinit var created: MediaBrowserCompat
|
||||
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
|
||||
override fun onConnected() {
|
||||
resultsQuery?.let { runSearch(created, it) }
|
||||
}
|
||||
|
||||
override fun onConnectionFailed() {
|
||||
GearslipLog.w("media: no library connection for search")
|
||||
searchBrowser = null
|
||||
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
|
||||
}
|
||||
}, null)
|
||||
searchBrowser = created
|
||||
runCatching { created.connect() }.onFailure {
|
||||
searchBrowser = null
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSearch(sb: MediaBrowserCompat, q: String) {
|
||||
runCatching {
|
||||
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
|
||||
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: library search found ${items.size}")
|
||||
_results.value = Results(query, items.map(::entryOfCompat))
|
||||
}
|
||||
|
||||
override fun onError(query: String, extras: Bundle?) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: the app has no library search")
|
||||
_results.value = Results(query, emptyList(), supported = false)
|
||||
}
|
||||
})
|
||||
}.onFailure {
|
||||
GearslipLog.w("media: library search failed: ${it.message}")
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
|
||||
val d = item.description
|
||||
return MediaEntry(
|
||||
id = item.mediaId.orEmpty(),
|
||||
title = d.title?.toString().orEmpty(),
|
||||
subtitle = d.subtitle?.toString().orEmpty(),
|
||||
iconUri = d.iconUri,
|
||||
iconBitmap = d.iconBitmap,
|
||||
browsable = item.isBrowsable,
|
||||
playable = item.isPlayable,
|
||||
)
|
||||
}
|
||||
|
||||
/** The connected app's service, for the search connection; null when nothing is connected. */
|
||||
private var searchComponent: android.content.ComponentName? = null
|
||||
private var searchBrowser: MediaBrowserCompat? = null
|
||||
|
||||
fun clearResults() {
|
||||
resultsQuery = null
|
||||
_results.value = null
|
||||
}
|
||||
|
||||
/** Whether there's a library to search through, the better of the two searches. */
|
||||
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
|
||||
|
||||
fun dismissSearch() {
|
||||
main.removeCallbacks(searchTimeout)
|
||||
main.removeCallbacks(clearSearch)
|
||||
_search.value = null
|
||||
}
|
||||
|
||||
private val searchTimeout = Runnable {
|
||||
val pending = _search.value ?: return@Runnable
|
||||
if (pending.ignored) return@Runnable
|
||||
GearslipLog.w("media: nothing new played after a search")
|
||||
_search.value = Search(pending.query, ignored = true)
|
||||
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
||||
}
|
||||
|
||||
private val clearSearch = Runnable { _search.value = null }
|
||||
|
||||
/** Something new is playing: another track, or the same one started from a pause. */
|
||||
private fun checkSearch() {
|
||||
val pending = _search.value ?: return
|
||||
val before = searchBefore ?: return
|
||||
val now = _now.value
|
||||
if (pending.ignored || !now.playing) return
|
||||
if (now.title != before.title || !before.playing) {
|
||||
GearslipLog.i("media: search started playback")
|
||||
dismissSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
|
||||
fun toggleLike() {
|
||||
val now = _now.value
|
||||
@@ -431,6 +590,10 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
fun disconnect() {
|
||||
connecting = null
|
||||
runCatching { searchBrowser?.disconnect() }
|
||||
searchBrowser = null
|
||||
searchComponent = null
|
||||
clearResults()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||
compat = null
|
||||
@@ -444,6 +607,7 @@ class CarMedia(private val context: Context) {
|
||||
_phase.value = Phase.IDLE
|
||||
_now.value = NowPlaying()
|
||||
_queue.value = emptyList()
|
||||
dismissSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,5 +643,8 @@ class CarMedia(private val context: Context) {
|
||||
const val LAUNCH_WAIT_MS = 1_500L
|
||||
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||
const val SESSION_RETRIES_LAUNCHED = 16
|
||||
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||
const val SEARCH_WAIT_MS = 6_000L
|
||||
const val SEARCH_NOTICE_MS = 6_000L
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.audio.CarMic
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.log10
|
||||
@@ -25,7 +26,8 @@ import kotlin.math.sqrt
|
||||
* none). whisper.cpp runs FUTO's ACFT Whisper model, which are tuned for short phrases: a reply
|
||||
* of a few seconds comes back in well under a second on a recent phone.
|
||||
*
|
||||
* Whisper needs the whole phrase before it starts, so this records from the phone's microphone,
|
||||
* Whisper needs the whole phrase before it starts, so this records from the car's microphone (the
|
||||
* phone's when the car has none or sends nothing),
|
||||
* decides when the driver has stopped talking (a stretch of quiet after speech), then transcribes
|
||||
* once. Everything runs on one worker thread; callbacks arrive on the main thread.
|
||||
*/
|
||||
@@ -45,6 +47,9 @@ object SpeechEngine {
|
||||
private const val CALIBRATE_MS = 300L
|
||||
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
|
||||
private const val CUE_VOLUME = 80
|
||||
/** How long the car gets to start sending once asked, before the phone's microphone takes over. */
|
||||
private const val CAR_FIRST_AUDIO_MS = 1_500L
|
||||
private const val CAR_GAP_MS = 1_000L
|
||||
private const val CUE_MS = 150
|
||||
/** The car plays what the phone sends a moment late; waiting this out keeps the beep off the recording. */
|
||||
private const val CUE_TAIL_MS = 250L
|
||||
@@ -85,13 +90,16 @@ object SpeechEngine {
|
||||
worker.execute {
|
||||
if (id != run) return@execute
|
||||
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
|
||||
if (cue) cue()
|
||||
if (id != run) return@execute
|
||||
VoiceFocus.acquire(app)
|
||||
val audio = try {
|
||||
if (cue) cue()
|
||||
if (id != run) return@execute
|
||||
record(id) { post(onSpeaking) }
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("speech: recording failed", t)
|
||||
return@execute post { onError("The microphone isn't available right now.") }
|
||||
} finally {
|
||||
VoiceFocus.release()
|
||||
}
|
||||
if (id != run) return@execute
|
||||
if (audio == null) return@execute post { onError("Didn't catch that.") }
|
||||
@@ -129,14 +137,73 @@ object SpeechEngine {
|
||||
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
|
||||
* loud cabin needs a louder voice than a quiet one to count as talking.
|
||||
*/
|
||||
/** Where a phrase is recorded from: [read] fills [frame] and returns how many samples it got. */
|
||||
private interface Mic {
|
||||
fun read(frame: ShortArray): Int
|
||||
fun close()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // checked in listen()
|
||||
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
|
||||
private fun phoneMic(): Mic {
|
||||
val min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
val recorder = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
|
||||
)
|
||||
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
|
||||
recorder.startRecording()
|
||||
return object : Mic {
|
||||
override fun read(frame: ShortArray) = recorder.read(frame, 0, frame.size)
|
||||
override fun close() {
|
||||
runCatching { recorder.stop() }
|
||||
recorder.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's microphone, which hears the driver better than a phone in a cup holder. The car
|
||||
* sends audio in its own sized pieces; they're cut to [FRAME]s here. Null when there's no car
|
||||
* microphone, or it sends nothing at first, so the phone's is used instead.
|
||||
*/
|
||||
private fun carMic(): Mic? {
|
||||
val car = CarMic.current ?: return null
|
||||
if (!car.start()) return null
|
||||
val first = car.read(CAR_FIRST_AUDIO_MS)
|
||||
if (first == null) {
|
||||
car.stop()
|
||||
GearslipLog.w("speech: the car's microphone sent nothing - using the phone's")
|
||||
return null
|
||||
}
|
||||
GearslipLog.i("speech: listening through the car's microphone")
|
||||
return object : Mic {
|
||||
var pending = first
|
||||
var at = 0
|
||||
override fun read(frame: ShortArray): Int {
|
||||
var filled = 0
|
||||
while (filled < frame.size) {
|
||||
val chunk = pending ?: car.read(CAR_GAP_MS) ?: break
|
||||
pending = chunk
|
||||
val n = minOf(frame.size - filled, chunk.size - at)
|
||||
System.arraycopy(chunk, at, frame, filled, n)
|
||||
filled += n
|
||||
at += n
|
||||
if (at == chunk.size) { pending = null; at = 0 }
|
||||
}
|
||||
// The car stopped sending mid-phrase: end the recording rather than wait forever.
|
||||
return if (filled == 0) -1 else filled
|
||||
}
|
||||
override fun close() = car.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records until the driver stops talking. Returns the phrase from just before the first word,
|
||||
* or null when nobody spoke. The speech threshold follows the noise in the first 300 ms, so a
|
||||
* loud cabin needs a louder voice than a quiet one to count as talking.
|
||||
*/
|
||||
private fun record(id: Int, onSpeaking: () -> Unit): FloatArray? {
|
||||
val mic = carMic() ?: phoneMic()
|
||||
val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
|
||||
var count = 0
|
||||
val frame = ShortArray(FRAME)
|
||||
@@ -144,11 +211,11 @@ object SpeechEngine {
|
||||
var noiseFrames = 0
|
||||
var speechStart = -1
|
||||
var lastSpeech = 0
|
||||
recorder.startRecording()
|
||||
try {
|
||||
while (id == run && count + FRAME <= samples.size) {
|
||||
val n = recorder.read(frame, 0, FRAME)
|
||||
if (n <= 0) continue
|
||||
val n = mic.read(frame)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
var sum = 0.0
|
||||
for (i in 0 until n) {
|
||||
val v = frame[i] / 32768f
|
||||
@@ -175,8 +242,7 @@ object SpeechEngine {
|
||||
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
|
||||
}
|
||||
} finally {
|
||||
runCatching { recorder.stop() }
|
||||
recorder.release()
|
||||
mic.close()
|
||||
}
|
||||
if (speechStart < 0) return null
|
||||
return samples.copyOfRange(speechStart, count)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app.seb3thehacker.gearslip.speech
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Pauses the music while Gearslip talks or listens. Without it the microphone heard the song and
|
||||
* Whisper wrote down lyrics, and a message read aloud had to compete with the music.
|
||||
*
|
||||
* Transient exclusive focus makes music apps pause rather than duck, and they resume on their
|
||||
* own once it's given back. The release waits a moment, so a read-aloud message followed by
|
||||
* "Do you want to reply?" and the microphone is one pause, not three.
|
||||
*/
|
||||
object VoiceFocus {
|
||||
|
||||
private const val RELEASE_DELAY_MS = 1_500L
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var request: AudioFocusRequest? = null
|
||||
private var audio: AudioManager? = null
|
||||
private var holders = 0
|
||||
|
||||
private val releaseNow = Runnable {
|
||||
val r = request ?: return@Runnable
|
||||
audio?.abandonAudioFocusRequest(r)
|
||||
request = null
|
||||
GearslipLog.i("voice: gave audio focus back")
|
||||
}
|
||||
|
||||
/** Pauses the music until a matching [release]. Safe from any thread. */
|
||||
fun acquire(context: Context) {
|
||||
val app = context.applicationContext
|
||||
main.post {
|
||||
holders++
|
||||
main.removeCallbacks(releaseNow)
|
||||
if (request != null) return@post
|
||||
val manager = app.getSystemService(AudioManager::class.java) ?: return@post
|
||||
val r = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setOnAudioFocusChangeListener { }
|
||||
.build()
|
||||
val granted = manager.requestAudioFocus(r) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
GearslipLog.i("voice: audio focus ${if (granted) "granted, music paused" else "refused"}")
|
||||
if (granted) {
|
||||
audio = manager
|
||||
request = r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the music come back shortly, unless something else takes focus again first. */
|
||||
fun release() {
|
||||
main.post {
|
||||
if (holders == 0) return@post
|
||||
holders--
|
||||
if (holders == 0) main.postDelayed(releaseNow, RELEASE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package app.seb3thehacker.gearslip.stats
|
||||
|
||||
import android.app.job.JobScheduler
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Opt-in usage counts: a short note to the stats Worker (stats-worker/ in the repo). Off until the
|
||||
* driver turns it on in setup or Settings. Only the app version is always sent; everything else
|
||||
* is one of the driver's [Choices]. A random install ID counts a phone once; nothing ties it to a
|
||||
* person, and the Worker keeps no IP addresses.
|
||||
*
|
||||
* Notes go out only while Gearslip is in use: the first time the app is opened each day, and
|
||||
* after every car session. Nothing runs in the background, so a phone that stops using Gearslip
|
||||
* goes quiet. A note that fails waits for the next one. With no [BuildConfig.STATS_URL] set at
|
||||
* build time, nothing is sent.
|
||||
*/
|
||||
object UsageStats {
|
||||
|
||||
/** What the driver agreed to share beyond the app version. */
|
||||
data class Choices(
|
||||
val android: Boolean = true,
|
||||
val phone: Boolean = true,
|
||||
val cars: Boolean = true,
|
||||
)
|
||||
|
||||
private const val PREFS = "usage_stats"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_ASKED = "asked"
|
||||
private const val KEY_ID = "install_id"
|
||||
/** The phone details the server last got, and when, so they go again only on a change. */
|
||||
private const val KEY_SENT_DETAILS = "sent_details"
|
||||
private const val KEY_DETAILS_DAY = "details_day"
|
||||
private const val DETAILS_REFRESH_DAYS = 30L
|
||||
private const val KEY_CARS = "cars"
|
||||
/** The cars the last note carried, so Settings can show them after the queue empties. */
|
||||
private const val KEY_LAST_CARS = "last_cars"
|
||||
private const val KEY_SENT_DAY = "sent_day"
|
||||
/** 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
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Dev builds show every switch and screen, so they can be tested, but never send anything:
|
||||
* testing mustn't muddy the numbers.
|
||||
*/
|
||||
val sends: Boolean get() = !BuildConfig.DEBUG && BuildConfig.STATS_URL.isNotBlank()
|
||||
|
||||
fun enabled(context: Context): Boolean = prefs(context).getBoolean(KEY_ENABLED, false)
|
||||
|
||||
/** Whether the driver has answered the question in setup, either way. */
|
||||
fun asked(context: Context): Boolean = prefs(context).getBoolean(KEY_ASKED, false)
|
||||
|
||||
fun choices(context: Context): Choices {
|
||||
val p = prefs(context)
|
||||
return Choices(
|
||||
android = p.getBoolean("share_android", true),
|
||||
phone = p.getBoolean("share_phone", true),
|
||||
cars = p.getBoolean("share_cars", true),
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, enabled: Boolean, choices: Choices = choices(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)
|
||||
.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()
|
||||
}
|
||||
|
||||
/** The driver left the screen with the usage note choices: send today's note if it's due. */
|
||||
fun choicesClosed(context: Context) {
|
||||
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. */
|
||||
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)
|
||||
}
|
||||
|
||||
private val sending = AtomicBoolean(false)
|
||||
@Volatile private var sendAgain = false
|
||||
/** Guards the saved car list, which a session's end and a finished send both rewrite. */
|
||||
private val carsLock = Any()
|
||||
|
||||
/**
|
||||
* Sends off the calling thread. A call while a note is in flight sends once more after it, so
|
||||
* a car recorded mid-send still goes out.
|
||||
*/
|
||||
private fun sendSoon(context: Context) {
|
||||
if (!sends || !enabled(context)) return
|
||||
if (!sending.compareAndSet(false, true)) { sendAgain = true; return }
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
try {
|
||||
do { sendAgain = false } while (send(app) && sendAgain)
|
||||
} finally {
|
||||
sending.set(false)
|
||||
}
|
||||
}, "gearslip-stats").start()
|
||||
}
|
||||
|
||||
private fun installId(context: Context): String {
|
||||
val p = prefs(context)
|
||||
return p.getString(KEY_ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(KEY_ID, it).apply() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A car session ended. [info] is null when the car hung up before it said who it is, for
|
||||
* instance after rejecting the certificate; the protocol version and [outcome] still say a lot.
|
||||
*/
|
||||
fun recordSession(
|
||||
context: Context,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String = "",
|
||||
dpi: Int = 0,
|
||||
) {
|
||||
if (!enabled(context)) return
|
||||
val p = prefs(context)
|
||||
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
|
||||
sendSoon(context)
|
||||
}
|
||||
|
||||
private fun addCar(
|
||||
p: android.content.SharedPreferences,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String,
|
||||
dpi: Int,
|
||||
) {
|
||||
val cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||
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("result", outcome)
|
||||
if (screen.isNotEmpty()) car.put("screen", screen).put("dpi", dpi)
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
/**
|
||||
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
|
||||
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
|
||||
*/
|
||||
fun preview(context: Context, everything: Boolean = false): JSONObject {
|
||||
val c = choices(context)
|
||||
val p = prefs(context)
|
||||
val note = JSONObject()
|
||||
.put("id", installId(context))
|
||||
.put("version", BuildConfig.VERSION_NAME)
|
||||
.put("code", BuildConfig.VERSION_CODE)
|
||||
.put("build", if (BuildConfig.DEBUG) "dev" else "release")
|
||||
// Phone details change rarely, so they ride along only when they differ from what the
|
||||
// server last got, or once a month in case it lost them. Unticked ones are left out, and
|
||||
// the server keeps the last value it had.
|
||||
val details = details(context, c)
|
||||
val stale = p.getString(KEY_DETAILS_DAY, null)?.let { LocalDate.parse(it).plusDays(DETAILS_REFRESH_DAYS) <= LocalDate.now() } ?: true
|
||||
if (details.length() > 0 && (everything || stale || details.toString() != p.getString(KEY_SENT_DETAILS, null))) {
|
||||
details.keys().forEach { note.put(it, details.get(it)) }
|
||||
}
|
||||
if (c.cars) {
|
||||
var cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||
// A car joins the queue when a drive ends and goes out straight away, so the queue
|
||||
// is nearly always empty. Settings shows the last cars sent instead of a bare [].
|
||||
if (everything && cars.length() == 0) {
|
||||
cars = runCatching { JSONArray(p.getString(KEY_LAST_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||
}
|
||||
note.put("cars", cars)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
/** Sends the note now; false when it didn't get through. */
|
||||
private fun send(context: Context): Boolean {
|
||||
if (!sends || !enabled(context)) return true
|
||||
val endpoint = BuildConfig.STATS_URL
|
||||
val p = prefs(context)
|
||||
val note = synchronized(carsLock) { preview(context) }
|
||||
val sentCars = note.optJSONArray("cars")?.toString()
|
||||
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
|
||||
val body = note.toString().toByteArray()
|
||||
return runCatching {
|
||||
val conn = (URL("$endpoint/ping").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
conn.outputStream.use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
if (code in 200..299) {
|
||||
// Sent: the cars start over, unless a session ended meanwhile and added one, which
|
||||
// stays for the next note.
|
||||
synchronized(carsLock) {
|
||||
val edit = p.edit().putString(KEY_SENT_DAY, LocalDate.now().toString())
|
||||
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)
|
||||
edit.apply()
|
||||
}
|
||||
GearslipLog.i("stats: note sent")
|
||||
true
|
||||
} else {
|
||||
GearslipLog.w("stats: the server answered $code")
|
||||
false
|
||||
}
|
||||
}.getOrElse {
|
||||
GearslipLog.w("stats: could not send: ${it.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,9 @@ 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.stats.UsageStats
|
||||
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP, WHATS_NEW }
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
@@ -53,11 +54,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
// Set up before usage notes existed: ask once, on the setup guide's own page.
|
||||
!UsageStats.asked(context) -> Screen.USAGE_NOTES
|
||||
else -> Screen.HOME
|
||||
},
|
||||
)
|
||||
}
|
||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
||||
BackHandler(enabled = screen != Screen.HOME) {
|
||||
screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME
|
||||
}
|
||||
|
||||
// Home stays upright; every other screen turns with the phone.
|
||||
LaunchedEffect(screen) {
|
||||
@@ -93,17 +98,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||
onOpenHelp = { screen = Screen.HELP },
|
||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||
onDisconnect = onDisconnect,
|
||||
)
|
||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
onBack = { screen = Screen.HOME },
|
||||
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
|
||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
)
|
||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.HELP -> HelpScreen(
|
||||
onBack = { screen = Screen.HOME },
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
)
|
||||
Screen.CERT_SETUP -> CertSetupScreen(
|
||||
onDone = {
|
||||
@@ -114,8 +123,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
onDone = { screen = Screen.HOME },
|
||||
)
|
||||
Screen.USAGE_NOTES -> SetupGuideScreen(
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
onDone = { screen = Screen.HOME },
|
||||
onlyUsageNotes = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
// A car keyboard is open: offer the phone's own, for a passenger.
|
||||
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
|
||||
}
|
||||
|
||||
CompatWarning()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -61,14 +62,14 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
||||
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
||||
"still works, but you have to tap Start once each time you set off.",
|
||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||
),
|
||||
Step(
|
||||
"Optional: keep notifications readable while music plays.",
|
||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
||||
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
Step(
|
||||
"Optional: let the car screen answer and decline calls.",
|
||||
@@ -85,7 +86,7 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||
* here too so it can be reread without repeating the guide itself. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||
|
||||
Scaffold(
|
||||
@@ -109,10 +110,7 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayT
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||
Text("Replay the guided tutorial")
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
steps.forEachIndexed { index, step ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -49,7 +48,6 @@ fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
@@ -115,8 +113,6 @@ fun HomeScreen(
|
||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||
RowDivider()
|
||||
ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew)
|
||||
if (BuildConfig.DEBUG) {
|
||||
val context = LocalContext.current
|
||||
RowDivider()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.PhoneTyping
|
||||
|
||||
/**
|
||||
* While the car screen shows its keyboard, the phone shows the same field with its own keyboard
|
||||
* up, so a passenger can type comfortably. Each keystroke lands on the car screen at once.
|
||||
*/
|
||||
@Composable
|
||||
fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
val field by PhoneTyping.field.collectAsState()
|
||||
val open = field ?: return
|
||||
var value by remember(open.id) { mutableStateOf(TextFieldValue(open.text, TextRange(open.text.length))) }
|
||||
// The car keyboard typed too: take its text, cursor at the end.
|
||||
if (open.text != value.text) value = TextFieldValue(open.text, TextRange(open.text.length))
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(open.id) { runCatching { focus.requestFocus() } }
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = PhoneTyping::dismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
PhoneTyping.type(it.text)
|
||||
},
|
||||
label = { Text("Type for the car screen") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
)
|
||||
IconButton(onClick = PhoneTyping::submit) {
|
||||
Icon(Icons.Filled.Search, contentDescription = "Search on the car screen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/** A free app that runs on Gearslip's car screen, and where to get it. */
|
||||
private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String)
|
||||
|
||||
private val recommended = listOf(
|
||||
"Maps" to listOf(
|
||||
RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."),
|
||||
RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."),
|
||||
),
|
||||
"Music" to listOf(
|
||||
RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."),
|
||||
RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."),
|
||||
),
|
||||
)
|
||||
|
||||
/** Opened from Settings: the same list the setup guide ends with. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecommendedAppsScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Recommended apps") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
RECOMMENDED_INTRO,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
RecommendedAppsList()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hidden for now: off, neither Settings nor the setup guide shows the list. The code stays. */
|
||||
internal const val SHOW_RECOMMENDED_APPS = false
|
||||
|
||||
internal const val RECOMMENDED_INTRO =
|
||||
"These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub."
|
||||
|
||||
/** The same green as the launcher's "works" check. */
|
||||
private val INSTALLED_GREEN = Color(0xFF43A047)
|
||||
|
||||
/** The apps by section, each with a GitHub button. Installed ones say so in green. */
|
||||
@Composable
|
||||
internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
for ((section, apps) in recommended) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
section,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
for (app in apps) AppRow(app) { openPage(context, app.url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val icon = remember(app.packageName) { installedIcon(context, app.packageName) }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(app.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
"Installed",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = INSTALLED_GREEN,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = onOpen) { Text("GitHub") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun installedIcon(context: Context, packageName: String): Drawable? =
|
||||
runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull()
|
||||
|
||||
private fun openPage(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/*
|
||||
* The pieces every phone settings screen is built from, so they all share one shape, colour and
|
||||
* spacing: a section label, a card holding rows, and the rows themselves, with a footer note
|
||||
* under the card when a section needs one.
|
||||
*/
|
||||
|
||||
/** A titled group: label above, rows in one card, an optional note below. */
|
||||
@Composable
|
||||
internal fun SettingsSection(
|
||||
title: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
header: AnnotatedString? = null,
|
||||
footer: String? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
title?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
header?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
SettingsCard(content = content)
|
||||
footer?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The card behind a group of rows; also used on its own, as in the setup guide. */
|
||||
@Composable
|
||||
internal fun SettingsCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: an optional icon, a title with an optional line under it, and whatever sits at the
|
||||
* end (a switch, a checkbox, an arrow). [modifier] carries the click or toggle, so the whole
|
||||
* row is the touch target.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsRow(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
tint: Color = MaterialTheme.colorScheme.primary,
|
||||
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||
subtitle?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
trailing?.let { Box { it() } }
|
||||
}
|
||||
}
|
||||
|
||||
/** A thin line between rows in one card. */
|
||||
@Composable
|
||||
internal fun SettingsDivider() {
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
@@ -3,30 +3,37 @@ package app.seb3thehacker.gearslip.ui
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -39,14 +46,14 @@ import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -54,7 +61,12 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenRecommendedApps: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onReplayTutorial: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
@@ -74,48 +86,76 @@ fun SettingsScreen(onBack: () -> Unit) {
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
GearslipEnabledSection()
|
||||
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||
ConnectionSection()
|
||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||
CertificateSection()
|
||||
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
private fun Arrow() = Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
/** What's new and the setup guide, one tap from the top. */
|
||||
@Composable
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||
SettingsSection {
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
||||
subtitle = "Version ${BuildConfig.VERSION_NAME}",
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
Modifier.clickable(onClick = onReplayTutorial),
|
||||
subtitle = "Go through the first-run steps again",
|
||||
icon = Icons.Filled.Refresh,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
|
||||
@Composable
|
||||
private fun GearslipEnabledSection() {
|
||||
private fun ConnectionSection() {
|
||||
val context = LocalContext.current
|
||||
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionTitle("Gearslip")
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Switch(checked = enabled, onCheckedChange = {
|
||||
SettingsSection("Connection") {
|
||||
SettingsRow(
|
||||
"Use Gearslip in the car",
|
||||
Modifier.toggleable(enabled, role = Role.Switch) {
|
||||
enabled = it
|
||||
AppSettings.setGearslipEnabled(context, it)
|
||||
})
|
||||
}
|
||||
if (!enabled) {
|
||||
Text(
|
||||
"Off - stock Android Auto can take the USB connection instead.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead",
|
||||
icon = Icons.Filled.Settings,
|
||||
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||
SettingsSection("Apps") {
|
||||
SettingsRow(
|
||||
"Recommended apps",
|
||||
Modifier.clickable(onClick = onOpenRecommendedApps),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,37 +171,49 @@ private fun CertificateSection() {
|
||||
|
||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var notice by remember { mutableStateOf<String?>(null) }
|
||||
var downloading by remember { mutableStateOf(false) }
|
||||
var detailsOpen by remember { mutableStateOf(false) }
|
||||
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) pendingUri = uri
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
SectionTitle("Certificate")
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val s = summary
|
||||
if (s == null) {
|
||||
Text("Checking…", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
Text(s.headline, style = MaterialTheme.typography.bodyLarge)
|
||||
s.subject?.let { Detail("Subject", it) }
|
||||
s.issuer?.let { Detail("Issuer", it) }
|
||||
s.validUntil?.let { Detail("Valid until", it) }
|
||||
SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
|
||||
val s = summary
|
||||
val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED
|
||||
val hasDetails = s?.subject != null || s?.issuer != null
|
||||
SettingsRow(
|
||||
s?.headline ?: "Checking…",
|
||||
if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
|
||||
subtitle = s?.validUntil?.let { "Valid until $it" },
|
||||
icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning,
|
||||
tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
trailing = if (hasDetails) {
|
||||
{
|
||||
Icon(
|
||||
if (detailsOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (detailsOpen) "Hide details" else "Show details",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
)
|
||||
AnimatedVisibility(detailsOpen && hasDetails) {
|
||||
Column(
|
||||
Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
s?.subject?.let { Detail("Subject", it) }
|
||||
s?.issuer?.let { Detail("Issuer", it) }
|
||||
}
|
||||
}
|
||||
|
||||
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||
Text("Import certificate (.p12)")
|
||||
}
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
if (downloading) "Downloading…" else "Download certificate",
|
||||
Modifier.clickable(enabled = !downloading) {
|
||||
downloading = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { CertProvider.downloadAndImport(context) }
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
|
||||
downloading = false
|
||||
result.onSuccess {
|
||||
notice = "Certificate downloaded."
|
||||
refresh++
|
||||
@@ -170,39 +222,34 @@ private fun CertificateSection() {
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text("Download certificate")
|
||||
}
|
||||
|
||||
if (summary?.kind == CertProvider.Kind.IMPORTED) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
CertProvider.removeImported(context)
|
||||
notice = "Removed the imported certificate."
|
||||
refresh++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Remove imported certificate") }
|
||||
}
|
||||
|
||||
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
CertProvider.removeDownloaded(context)
|
||||
notice = "Removed the downloaded certificate."
|
||||
refresh++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) { Text("Remove downloaded certificate") }
|
||||
}
|
||||
|
||||
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
Text(
|
||||
"Changes take effect on the next connection.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
icon = Icons.Filled.Refresh,
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Import a certificate",
|
||||
Modifier.clickable { picker.launch(arrayOf("*/*")) },
|
||||
subtitle = "A .p12 file with its private key",
|
||||
icon = Icons.Filled.Add,
|
||||
)
|
||||
val removable = when (s?.kind) {
|
||||
CertProvider.Kind.IMPORTED -> "Remove imported certificate"
|
||||
CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate"
|
||||
else -> null
|
||||
}
|
||||
if (removable != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
removable,
|
||||
Modifier.clickable {
|
||||
if (s?.kind == CertProvider.Kind.IMPORTED) CertProvider.removeImported(context) else CertProvider.removeDownloaded(context)
|
||||
notice = if (s?.kind == CertProvider.Kind.IMPORTED) "Removed the imported certificate." else "Removed the downloaded certificate."
|
||||
refresh++
|
||||
},
|
||||
icon = Icons.Filled.Delete,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingUri?.let { uri ->
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.key
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import android.Manifest
|
||||
import android.app.AppOpsManager
|
||||
import android.app.NotificationManager
|
||||
@@ -39,14 +45,16 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
/**
|
||||
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
||||
@@ -56,11 +64,14 @@ import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
*/
|
||||
private class GuidePage(
|
||||
val title: String,
|
||||
val explanation: String,
|
||||
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
||||
val explanation: CharSequence,
|
||||
val permissions: Array<String>? = null,
|
||||
val command: String? = null,
|
||||
val actionLabel: String? = null,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
/** Drawn under [explanation], inside the card. */
|
||||
val content: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
|
||||
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
||||
@@ -89,6 +100,21 @@ private fun hasNotificationAccess(context: Context): Boolean {
|
||||
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The usage notes question: the setup guide's last page, and on its own the one screen people
|
||||
* who set up before the notes existed see once. Leaving it without turning them on is a no.
|
||||
*/
|
||||
private fun usageNotesPage(context: Context) = GuidePage(
|
||||
title = USAGE_STATS_TITLE,
|
||||
explanation = USAGE_STATS_INTRO,
|
||||
content = {
|
||||
UsageStatsChoices()
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { if (!UsageStats.asked(context)) UsageStats.set(context, false) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||
add(
|
||||
@@ -102,13 +128,14 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
)
|
||||
|
||||
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
||||
if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||
// Asked whether or not car audio is on: voice reply and the assistant need it too.
|
||||
if (!granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Microphone",
|
||||
explanation = "Android will not pass a music app's audio to the car without this " +
|
||||
"permission. Gearslip does not listen to you: it copies what is already " +
|
||||
"playing, nothing else.",
|
||||
explanation = "Gearslip needs the microphone for two jobs: passing your music " +
|
||||
"app's sound to the car, and hearing you when you reply to a message or ask " +
|
||||
"the assistant. It listens only then, and your speech stays on the phone.",
|
||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
),
|
||||
)
|
||||
@@ -234,7 +261,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
||||
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
||||
"and everything still works, but you tap Start once each time you set off.",
|
||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||
command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -245,7 +272,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
||||
"hides what your notifications say. This command switches that off only while " +
|
||||
"music is going to the car, then puts it back.",
|
||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||
command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -261,6 +288,21 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Apps worth installing, once everything above is set up -----------------------
|
||||
if (SHOW_RECOMMENDED_APPS) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Recommended apps",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The very end, every time: usage notes. On a replay, the switch shows what was
|
||||
// chosen before.
|
||||
add(usageNotesPage(context))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,9 +312,9 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
* driver takes on trust, with "Skip" sitting right next to it.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
|
||||
val context = LocalContext.current
|
||||
val pages = remember { pages(context, onRequestCallScreening) }
|
||||
val pages = remember { if (onlyUsageNotes) listOf(usageNotesPage(context)) else pages(context, onRequestCallScreening) }
|
||||
var index by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||
@@ -307,40 +349,55 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (index + 1f) / pages.size },
|
||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"${index + 1} of ${pages.size}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
page.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
page.explanation,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
page.command?.let { CopyableCommand(it) }
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// A single page has no progress to show.
|
||||
if (pages.size > 1) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (index + 1f) / pages.size },
|
||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"${index + 1} of ${pages.size}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// The page scrolls between the progress bar and the buttons, so a tall page (the usage
|
||||
// note's choices, say) never pushes Next off the screen. Short pages stay centred.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
key(index) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(min = maxHeight)
|
||||
.padding(vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
page.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
page.command?.let { CopyableCommand(it) }
|
||||
page.content?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
when {
|
||||
page.permissions != null && alreadyGranted -> {
|
||||
Text(
|
||||
@@ -372,10 +429,13 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
||||
}
|
||||
else -> {
|
||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||
Button(onClick = { index += 1 }, modifier = it) {
|
||||
Text(if (index == pages.lastIndex) "Done" else "Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
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.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||
|
||||
private const val INTRO_START =
|
||||
"Gearslip can't tell which cars it works in unless drivers say so. Turn this on, and Gearslip " +
|
||||
"sends a short note when you open it and after each drive, never in the background. The " +
|
||||
"note carries the Gearslip version and whatever you choose to share. "
|
||||
private const val INTRO_PRIVACY =
|
||||
"It holds no name, account, or location, only a random number so your phone counts once. " +
|
||||
"Cloudflare carries the note, and the server keeps no IP address."
|
||||
private const val INTRO_END =
|
||||
" Nothing helps the project more. The notes show which cars work, so the next driver knows " +
|
||||
"before installing. When a car fails, the note says where the connection broke, which is " +
|
||||
"often enough to fix a car no one working on Gearslip owns."
|
||||
|
||||
/** The pitch for the usage notes, with the privacy promise in bold so it isn't missed. */
|
||||
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||
append(INTRO_START)
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||
append(INTRO_END)
|
||||
}
|
||||
|
||||
/** The usage notes card for the setup guide: the same rows Settings shows. */
|
||||
@Composable
|
||||
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
||||
SettingsCard(modifier) { UsageStatsRows() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The on switch, what to share, and the note itself, saved as they change.
|
||||
* Rows only: the caller puts them in a card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.UsageStatsRows() {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
|
||||
var choices by remember { mutableStateOf(UsageStats.choices(context)) }
|
||||
var noteOpen by remember { mutableStateOf(false) }
|
||||
fun save() = UsageStats.set(context, on, choices)
|
||||
// Nothing goes out while the choices are on screen; leaving them sends the first note.
|
||||
DisposableEffect(Unit) { onDispose { UsageStats.choicesClosed(context) } }
|
||||
|
||||
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
|
||||
},
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
AnimatedVisibility(on) {
|
||||
Column {
|
||||
SettingsDivider()
|
||||
Text(
|
||||
"Also share",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
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("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()
|
||||
}
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
Modifier.clickable { noteOpen = !noteOpen },
|
||||
subtitle = "Each car goes after the drive ends. The Android version and phone model go only when they change, and once a month",
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = {
|
||||
Icon(
|
||||
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
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) }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
note,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
|
||||
@Composable
|
||||
private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange)
|
||||
.padding(start = 56.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,10 @@ import org.junit.Test
|
||||
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
|
||||
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
|
||||
* audio simply never arrives.
|
||||
*
|
||||
* The 2022 Dacia Jogger (LGE ULC 4.5, protocol 1.5) offers four: AAC media on channel 3, AAC
|
||||
* guidance on 4, PCM media on 5 and PCM guidance on 6. Gearslip sends PCM, so it has to pick
|
||||
* channel 5 even though channel 3 comes first; PCM pushed into the AAC sink plays as silence.
|
||||
*/
|
||||
class ServiceDiscoveryAudioTest {
|
||||
|
||||
@@ -91,4 +96,34 @@ class ServiceDiscoveryAudioTest {
|
||||
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
||||
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
|
||||
}
|
||||
|
||||
private fun daciaResponse(): ByteArray =
|
||||
audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2)) +
|
||||
audioSink(id = 4, codec = 4, audioType = 1, config = audioConfig(16_000, 16, 1)) +
|
||||
audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) +
|
||||
audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
|
||||
|
||||
@Test
|
||||
fun `the Uconnect's only media sink is picked`() {
|
||||
assertEquals(5, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(uconnectResponse()))!!.serviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a PCM media sink wins over an earlier AAC one`() {
|
||||
val sink = ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(daciaResponse()))!!
|
||||
assertEquals(5, sink.serviceId)
|
||||
assertEquals("AUDIO_PCM", sink.codecName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with only an AAC media sink, that one is used`() {
|
||||
val aacOnly = audioSink(id = 3, codec = 4, audioType = 3, config = audioConfig(48_000, 16, 2))
|
||||
assertEquals(3, ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(aacOnly))!!.serviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no media sink means none is picked`() {
|
||||
val guidanceOnly = audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1))
|
||||
assertNull(ServiceDiscovery.pickMediaSink(ServiceDiscovery.findAudioServices(guidanceOnly)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* LIVI 2.0.1 offers a second screen alongside the main one, and lists that screen's input first:
|
||||
* channel 3: video sink, no display id (the main screen)
|
||||
* channel 19: video sink, display_id = 1, display_type = 1 (a cluster)
|
||||
* channel 20: input service with only display_id = 1
|
||||
* channel 8: input service with a 1280x720 touchscreen and keys (the main screen)
|
||||
* Taking the first input service left every touch on the main screen ignored.
|
||||
*/
|
||||
class ServiceDiscoveryDisplayTest {
|
||||
|
||||
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
|
||||
|
||||
private fun lenField(field: Int, body: ByteArray): ByteArray =
|
||||
Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body
|
||||
|
||||
private fun videoSink(id: Int, displayId: Int?): ByteArray {
|
||||
val config = varintField(1, 2L) + varintField(2, 1L) + varintField(5, 180L)
|
||||
var sink = varintField(1, 3L) + lenField(4, config)
|
||||
if (displayId != null) sink += varintField(6, displayId.toLong()) + varintField(7, 1L)
|
||||
return lenField(1, varintField(1, id.toLong()) + lenField(3, sink))
|
||||
}
|
||||
|
||||
private fun input(id: Int, body: ByteArray) = lenField(1, varintField(1, id.toLong()) + lenField(4, body))
|
||||
|
||||
private fun liviResponse(): ByteArray =
|
||||
videoSink(3, null) +
|
||||
videoSink(19, 1) +
|
||||
input(20, varintField(5, 1L)) +
|
||||
input(8, lenField(1, Protobuf.varint(3L) + Protobuf.varint(4L)) + lenField(2, varintField(1, 1280L) + varintField(2, 720L)))
|
||||
|
||||
@Test
|
||||
fun picksTheMainScreensVideo() {
|
||||
val video = ServiceDiscovery.findVideoService(liviResponse())!!
|
||||
assertEquals(3, video.serviceId)
|
||||
assertEquals(0, video.displayId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun picksTheInputForTheProjectedScreen() {
|
||||
val input = ServiceDiscovery.findInputService(liviResponse(), displayId = 0)!!
|
||||
assertEquals(8, input.serviceId)
|
||||
assertEquals(1280, input.touchWidth)
|
||||
assertEquals(720, input.touchHeight)
|
||||
assertEquals(listOf(3, 4), input.keycodesSupported)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The 2018 Uconnect's sensor and mic services, from `archive/spike/car-run-2026-09-16-run2.log`.
|
||||
* Channel 7 offers sensors in Service field 2; channel 3 is the mic in field 5. Reading the wrong
|
||||
* field finds nothing at all, so the sensor channel never opens.
|
||||
*/
|
||||
class ServiceDiscoverySensorTest {
|
||||
|
||||
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
|
||||
|
||||
private fun lenField(field: Int, body: ByteArray): ByteArray =
|
||||
Protobuf.varint(((field shl 3) or 2).toLong()) + Protobuf.varint(body.size.toLong()) + body
|
||||
|
||||
private fun micSource() = lenField(
|
||||
1,
|
||||
varintField(1, 3L) + lenField(5, varintField(1, 1L) + lenField(2, varintField(1, 16_000L))),
|
||||
)
|
||||
|
||||
private fun sensorSource(types: List<Int>): ByteArray {
|
||||
val sensors = types.fold(ByteArray(0)) { acc, t -> acc + lenField(1, varintField(1, t.toLong())) }
|
||||
return lenField(1, varintField(1, 7L) + lenField(2, sensors + varintField(2, 256L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findsUconnectSensorsInFieldTwo() {
|
||||
val response = micSource() + sensorSource(listOf(1, 3, 10, 13, 20, 21))
|
||||
val sensors = ServiceDiscovery.findSensorService(response)
|
||||
assertNotNull(sensors)
|
||||
sensors!!
|
||||
assertEquals(7, sensors.serviceId)
|
||||
assertEquals(listOf(1, 3, 10, 13, 20, 21), sensors.types)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -21,4 +21,6 @@ kotlin.code.style=official
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.dependency.useConstraints=false
|
||||
android.dependency.useConstraints=false
|
||||
# Where opted-in phones send their daily usage note (stats-worker/). Blank sends nothing.
|
||||
gearslipStatsUrl=https://gearslip-stats.gearslip.workers.dev
|
||||
@@ -0,0 +1,3 @@
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
node_modules/
|
||||
@@ -0,0 +1,52 @@
|
||||
-- One row per phone per day: the first note that day counts, later ones update it.
|
||||
CREATE TABLE IF NOT EXISTS pings (
|
||||
day TEXT NOT NULL, -- YYYY-MM-DD, UTC
|
||||
uid TEXT NOT NULL, -- salted hash of the phone's random install ID
|
||||
version TEXT NOT NULL,
|
||||
code INTEGER,
|
||||
build TEXT,
|
||||
android TEXT, -- these four are from older builds; details now live in phones
|
||||
os TEXT,
|
||||
phone TEXT,
|
||||
car_today INTEGER,
|
||||
PRIMARY KEY (day, uid)
|
||||
);
|
||||
|
||||
-- Each car a phone saw that day, and how the session ended.
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
day TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
name TEXT, car TEXT, year TEXT, make TEXT, model TEXT,
|
||||
protocol TEXT,
|
||||
result TEXT,
|
||||
screen TEXT, -- usable pixels, e.g. 1280x720
|
||||
dpi INTEGER,
|
||||
PRIMARY KEY (day, uid, name, car, year, protocol, result)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid);
|
||||
|
||||
-- GitHub's running download count for each release's APK, saved once a day.
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
day TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (day, tag)
|
||||
);
|
||||
|
||||
-- Which releases are pre-releases, so the dashboard can pick out the latest full release.
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
tag TEXT PRIMARY KEY,
|
||||
prerelease INTEGER NOT NULL,
|
||||
published TEXT
|
||||
);
|
||||
|
||||
-- Each phone's latest details. Phones send them only when they change (and once a month), so
|
||||
-- a field a note leaves out keeps its last value.
|
||||
CREATE TABLE IF NOT EXISTS phones (
|
||||
uid TEXT PRIMARY KEY,
|
||||
android TEXT,
|
||||
os TEXT,
|
||||
phone TEXT,
|
||||
updated TEXT
|
||||
);
|
||||
@@ -0,0 +1,299 @@
|
||||
// Gearslip usage notes: opt-in phones POST one short note a day, and a private dashboard counts
|
||||
// them. No IP address is stored, and the phone's random ID is kept only as a salted hash.
|
||||
|
||||
const MAX_BODY = 8 * 1024
|
||||
const MAX_CARS = 10
|
||||
const short = (v, n = 80) => (typeof v === 'string' ? v.slice(0, n) : null)
|
||||
|
||||
// The tables as schema.sql has them, plus the columns added since, so a database made by an
|
||||
// older version catches up on its own. Each runs once per Worker instance; an ALTER whose
|
||||
// column already exists fails, and that's fine.
|
||||
const SCHEMA = [
|
||||
`CREATE TABLE IF NOT EXISTS pings (day TEXT NOT NULL, uid TEXT NOT NULL, version TEXT NOT NULL,
|
||||
code INTEGER, build TEXT, android TEXT, os TEXT, phone TEXT, car_today INTEGER, PRIMARY KEY (day, uid))`,
|
||||
`CREATE TABLE IF NOT EXISTS cars (day TEXT NOT NULL, uid TEXT NOT NULL, name TEXT, car TEXT, year TEXT,
|
||||
make TEXT, model TEXT, protocol TEXT, result TEXT, screen TEXT, dpi INTEGER,
|
||||
PRIMARY KEY (day, uid, name, car, year, protocol, result))`,
|
||||
`CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid)`,
|
||||
`CREATE TABLE IF NOT EXISTS downloads (day TEXT NOT NULL, tag TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (day, tag))`,
|
||||
`CREATE TABLE IF NOT EXISTS releases (tag TEXT PRIMARY KEY, prerelease INTEGER NOT NULL, published TEXT)`,
|
||||
// Each phone's latest details. Phones send them only when they change, so a field a note
|
||||
// leaves out keeps its last value.
|
||||
`CREATE TABLE IF NOT EXISTS phones (uid TEXT PRIMARY KEY, android TEXT, os TEXT, phone TEXT, updated TEXT)`,
|
||||
]
|
||||
const ADDED_COLUMNS = [
|
||||
`ALTER TABLE pings ADD COLUMN os TEXT`,
|
||||
`ALTER TABLE cars ADD COLUMN screen TEXT`,
|
||||
`ALTER TABLE cars ADD COLUMN dpi INTEGER`,
|
||||
// Older builds sent details in every note: carry the latest of those over once.
|
||||
`INSERT OR IGNORE INTO phones (uid, android, os, phone, updated)
|
||||
SELECT uid, android, os, phone, day FROM pings p
|
||||
WHERE day = (SELECT MAX(day) FROM pings q WHERE q.uid = p.uid)
|
||||
AND (android IS NOT NULL OR phone IS NOT NULL)`,
|
||||
]
|
||||
let schemaReady = null
|
||||
function ensureSchema(env) {
|
||||
schemaReady ??= (async () => {
|
||||
for (const sql of SCHEMA) await env.DB.prepare(sql).run()
|
||||
for (const sql of ADDED_COLUMNS) await env.DB.prepare(sql).run().catch(() => {})
|
||||
})().catch((e) => { schemaReady = null; throw e })
|
||||
return schemaReady
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
await ensureSchema(env)
|
||||
const url = new URL(request.url)
|
||||
if (request.method === 'POST' && url.pathname === '/ping') return ping(request, env)
|
||||
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/api/summary')) {
|
||||
if (!env.DASHBOARD_KEY || url.searchParams.get('key') !== env.DASHBOARD_KEY) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
// Opening the dashboard refreshes today's counts, at most every few minutes, so it matches
|
||||
// GitHub instead of showing the morning's numbers. GitHub allows 60 requests an hour.
|
||||
const stale = Date.now() - lastFetched > REFRESH_MS
|
||||
await snapshotDownloads(env, stale).catch((e) => console.log('github:', e.message))
|
||||
const summary = await summarize(env)
|
||||
if (url.pathname === '/api/summary') return Response.json(summary)
|
||||
return new Response(dashboard(summary), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
||||
}
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
|
||||
async scheduled(event, env) {
|
||||
await ensureSchema(env)
|
||||
await snapshotDownloads(env, true)
|
||||
},
|
||||
}
|
||||
|
||||
// Saves each release's APK download count for today. GitHub only keeps a running total, so
|
||||
// downloads per day come from the difference between days.
|
||||
let lastFetched = 0
|
||||
const REFRESH_MS = 5 * 60 * 1000
|
||||
|
||||
async function snapshotDownloads(env, force) {
|
||||
if (!env.GITHUB_REPO) return
|
||||
const day = today()
|
||||
// Today's counts and the release list both have to be there; either missing means fetch again.
|
||||
if (!force && (await env.DB.prepare(`SELECT 1 WHERE EXISTS (SELECT 1 FROM downloads WHERE day = ?)
|
||||
AND EXISTS (SELECT 1 FROM releases)`).bind(day).first())) return
|
||||
const headers = { 'user-agent': 'gearslip-stats', accept: 'application/vnd.github+json' }
|
||||
if (env.GITHUB_TOKEN) headers.authorization = `Bearer ${env.GITHUB_TOKEN}`
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/releases?per_page=100`, { headers })
|
||||
if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
|
||||
const statements = (await res.json()).filter((release) => !release.draft).flatMap((release) => {
|
||||
const count = release.assets.filter((a) => a.name.endsWith('.apk')).reduce((n, a) => n + a.download_count, 0)
|
||||
return [
|
||||
env.DB.prepare('INSERT OR REPLACE INTO downloads (day, tag, count) VALUES (?, ?, ?)').bind(day, release.tag_name, count),
|
||||
env.DB.prepare('INSERT OR REPLACE INTO releases (tag, prerelease, published) VALUES (?, ?, ?)')
|
||||
.bind(release.tag_name, release.prerelease ? 1 : 0, release.published_at),
|
||||
]
|
||||
})
|
||||
if (statements.length) await env.DB.batch(statements)
|
||||
lastFetched = Date.now()
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
// Tags are "v0.1.10-alpha" or "0.0.05-alpha"; the app reports "0.1.10-alpha".
|
||||
const versionOf = (tag) => tag.replace(/^v/, '')
|
||||
|
||||
async function ping(request, env) {
|
||||
const text = await request.text()
|
||||
if (text.length > MAX_BODY) return new Response('Too large', { status: 413 })
|
||||
let note
|
||||
try {
|
||||
note = JSON.parse(text)
|
||||
} catch {
|
||||
return new Response('Bad JSON', { status: 400 })
|
||||
}
|
||||
const id = short(note.id, 64)
|
||||
const version = short(note.version, 32)
|
||||
if (!id || !version) return new Response('Missing id or version', { status: 400 })
|
||||
|
||||
const uid = await hash(`${env.SALT}:${id}`)
|
||||
const day = today()
|
||||
const android = short(note.android, 16), os = short(note.os, 16), phone = short(note.phone)
|
||||
const statements = [
|
||||
env.DB.prepare(
|
||||
`INSERT INTO pings (day, uid, version, code, build) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (day, uid) DO UPDATE SET version = excluded.version, code = excluded.code, build = excluded.build`,
|
||||
).bind(day, uid, version, Number.isInteger(note.code) ? note.code : null, short(note.build, 16)),
|
||||
]
|
||||
if (android || os || phone) {
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`INSERT INTO phones (uid, android, os, phone, updated) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (uid) DO UPDATE SET android = COALESCE(excluded.android, android),
|
||||
os = COALESCE(excluded.os, os), phone = COALESCE(excluded.phone, phone), updated = excluded.updated`,
|
||||
).bind(uid, android, os, phone, day),
|
||||
)
|
||||
}
|
||||
for (const car of Array.isArray(note.cars) ? note.cars.slice(0, MAX_CARS) : []) {
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO cars (day, uid, name, car, year, make, model, protocol, result, screen, dpi)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).bind(day, uid, short(car.name) ?? '', short(car.car) ?? '', short(car.year, 8) ?? '',
|
||||
short(car.make) ?? '', short(car.model) ?? '', short(car.protocol, 8) ?? '', short(car.result) ?? '',
|
||||
short(car.screen, 16), Number.isInteger(car.dpi) ? car.dpi : null),
|
||||
)
|
||||
}
|
||||
await env.DB.batch(statements)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
async function hash(text) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
||||
return [...new Uint8Array(digest)].slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function summarize(env) {
|
||||
const all = async (sql) => (await env.DB.prepare(sql).all()).results
|
||||
const one = async (sql) => (await env.DB.prepare(sql).first()) ?? {}
|
||||
const latest = await all(`SELECT d.tag, d.count, r.prerelease, r.published FROM downloads d
|
||||
LEFT JOIN releases r ON r.tag = d.tag
|
||||
WHERE d.day = (SELECT MAX(day) FROM downloads)`)
|
||||
const seen = Object.fromEntries((await all(`SELECT version, COUNT(DISTINCT uid) AS n FROM pings
|
||||
WHERE build = 'release' GROUP BY version`)).map((r) => [r.version, r.n]))
|
||||
// The newest full release, the one most people are sent to; pre-releases don't count.
|
||||
const newest = await one(`SELECT tag FROM releases WHERE prerelease = 0 ORDER BY published DESC LIMIT 1`)
|
||||
const latestRelease = newest.tag ? { tag: newest.tag, downloads: latest.find((r) => r.tag === newest.tag)?.count ?? 0 } : null
|
||||
const releases = latest.map((r) => ({
|
||||
tag: r.tag,
|
||||
kind: r.prerelease ? 'Pre-release' : r.prerelease === 0 ? 'Release' : '',
|
||||
published: (r.published ?? '').slice(0, 10),
|
||||
downloads: r.count,
|
||||
phones: seen[versionOf(r.tag)] ?? 0,
|
||||
}))
|
||||
.map((r) => ({ ...r, share: r.phones && r.downloads ? `${Math.round((100 * r.phones) / r.downloads)}%` : '' }))
|
||||
.sort((a, b) => b.published.localeCompare(a.published))
|
||||
// Only releases that have sent anything can say what share of people send; older ones predate the notes.
|
||||
const counted = releases.filter((r) => r.phones > 0)
|
||||
const countedDownloads = counted.reduce((n, r) => n + r.downloads, 0)
|
||||
const countedPhones = counted.reduce((n, r) => n + r.phones, 0)
|
||||
const totals = await all(`SELECT day, SUM(count) AS total FROM downloads GROUP BY day ORDER BY day`)
|
||||
const newPhones = Object.fromEntries((await all(`SELECT first_day AS day, COUNT(*) AS n FROM
|
||||
(SELECT uid, MIN(day) AS first_day FROM pings WHERE build = 'release' GROUP BY uid)
|
||||
GROUP BY first_day`)).map((r) => [r.day, r.n]))
|
||||
const perDay = totals.slice(1).map((r, i) => ({ day: r.day, downloads: r.total - totals[i].total, new_phones: newPhones[r.day] ?? 0 }))
|
||||
return {
|
||||
latestRelease,
|
||||
releases,
|
||||
totalDownloads: releases.reduce((n, r) => n + r.downloads, 0),
|
||||
share: countedDownloads ? Math.round((100 * countedPhones) / countedDownloads) : null,
|
||||
perDay: perDay.slice(-60).reverse(),
|
||||
daily: await all(`SELECT p.day, COUNT(*) AS phones,
|
||||
SUM(EXISTS (SELECT 1 FROM cars c WHERE c.uid = p.uid AND c.day = p.day
|
||||
AND c.result LIKE 'connected%')) AS in_a_car,
|
||||
SUM(p.day = f.first_day) AS new_phones
|
||||
FROM pings p JOIN (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid) f
|
||||
ON f.uid = p.uid
|
||||
WHERE p.day >= date('now', '-60 days') GROUP BY p.day ORDER BY p.day`),
|
||||
// Android runs no code at uninstall, so a phone that stopped sending is the nearest sign.
|
||||
new30: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid)
|
||||
WHERE first_day >= date('now', '-30 days')`)).n ?? 0,
|
||||
quiet: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MAX(day) AS last_day FROM pings GROUP BY uid)
|
||||
WHERE last_day < date('now', '-14 days')`)).n ?? 0,
|
||||
everyone: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings`)).n ?? 0,
|
||||
active7: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-7 days')`)).n ?? 0,
|
||||
active30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-30 days')`)).n ?? 0,
|
||||
driving30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM cars
|
||||
WHERE result LIKE 'connected%' AND day >= date('now', '-30 days')`)).n ?? 0,
|
||||
versions: await all(`SELECT version, COUNT(DISTINCT uid) AS phones FROM pings
|
||||
WHERE day >= date('now', '-7 days') GROUP BY version ORDER BY phones DESC`),
|
||||
// Details come from each phone's latest, counted for phones that sent anything in 30 days.
|
||||
android: await all(`SELECT android, COUNT(*) AS phones FROM phones
|
||||
WHERE android IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY android ORDER BY phones DESC`),
|
||||
screens: await all(`SELECT screen, COUNT(DISTINCT uid) AS phones FROM cars
|
||||
WHERE screen IS NOT NULL GROUP BY screen ORDER BY phones DESC`),
|
||||
os: await all(`SELECT os, COUNT(*) AS phones FROM phones
|
||||
WHERE os IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY os ORDER BY phones DESC`),
|
||||
phones: await all(`SELECT phone, COUNT(*) AS phones FROM phones
|
||||
WHERE phone IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY phone ORDER BY phones DESC LIMIT 30`),
|
||||
cars: await all(`SELECT name, car, year, make, model, protocol,
|
||||
GROUP_CONCAT(DISTINCT screen || CASE WHEN dpi THEN ' @ ' || dpi || ' dpi' ELSE '' END) AS screen,
|
||||
SUM(result = 'connected') AS connected,
|
||||
SUM(result != 'connected') AS failed,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN result != 'connected' THEN result END) AS failures,
|
||||
COUNT(DISTINCT uid) AS phones, MAX(day) AS last_seen
|
||||
FROM cars GROUP BY name, car, year, make, model, protocol
|
||||
ORDER BY phones DESC, connected DESC LIMIT 200`),
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||
|
||||
function table(rows, columns) {
|
||||
if (!rows.length) return '<p class="muted">Nothing yet.</p>'
|
||||
const head = columns.map(([, label]) => `<th>${esc(label)}</th>`).join('')
|
||||
const body = rows.map((r) => `<tr>${columns.map(([key]) => `<td>${esc(r[key])}</td>`).join('')}</tr>`).join('')
|
||||
return `<div class="scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||
}
|
||||
|
||||
function chart(daily) {
|
||||
if (!daily.length) return '<p class="muted">No notes yet.</p>'
|
||||
const w = 600, h = 160, max = Math.max(...daily.map((d) => d.phones), 1), bw = w / daily.length
|
||||
const bars = daily.map((d, i) => {
|
||||
const ph = (d.phones / max) * (h - 20), ch = ((d.in_a_car ?? 0) / max) * (h - 20)
|
||||
return `<rect x="${i * bw + 1}" y="${h - ph}" width="${Math.max(bw - 2, 1)}" height="${ph}" class="all"><title>${esc(d.day)}: ${d.phones} phones</title></rect>` +
|
||||
`<rect x="${i * bw + 1}" y="${h - ch}" width="${Math.max(bw - 2, 1)}" height="${ch}" class="car"><title>${esc(d.day)}: ${d.in_a_car ?? 0} in a car</title></rect>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Phones per day">${bars}</svg>
|
||||
<p class="muted"><span class="key all"></span> phones that day <span class="key car"></span> of those, in a car</p>`
|
||||
}
|
||||
|
||||
function dashboard(s) {
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Gearslip usage</title>
|
||||
<style>
|
||||
:root { --bg:#fafafa; --fg:#1a1a1a; --muted:#666; --line:#ddd; --all:#9db4d6; --car:#2e7d32; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#121212; --fg:#eee; --muted:#999; --line:#333; --all:#3d5a80; --car:#66bb6a; } }
|
||||
body { background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; margin:0 auto; padding:16px; max-width:960px; }
|
||||
h1 { font-size:1.5rem; margin:0 0 4px; } h2 { font-size:1.1rem; margin:28px 0 8px; }
|
||||
.muted { color:var(--muted); font-size:.9rem; }
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; }
|
||||
.stat.big { margin-bottom:12px; padding:20px; } .stat.big b { font-size:3.2rem; line-height:1.1; }
|
||||
.stat { border:1px solid var(--line); border-radius:12px; padding:12px; } .stat b { display:block; font-size:1.8rem; }
|
||||
svg { width:100%; height:auto; } rect.all { fill:var(--all); } rect.car { fill:var(--car); }
|
||||
.key { display:inline-block; width:10px; height:10px; border-radius:2px; margin:0 4px 0 12px; }
|
||||
.key.all { background:var(--all); } .key.car { background:var(--car); }
|
||||
.scroll { overflow-x:auto; } table { border-collapse:collapse; width:100%; font-size:.9rem; }
|
||||
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
|
||||
</style></head><body>
|
||||
<h1>Gearslip usage</h1><p class="muted">From phones that opted in to daily notes.</p>
|
||||
${s.latestRelease ? `<div class="stat big"><b>${s.latestRelease.downloads}</b>downloads of ${esc(s.latestRelease.tag)}, the latest release</div>` : ''}
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${s.active7}</b>phones, last 7 days</div>
|
||||
<div class="stat"><b>${s.active30}</b>phones, last 30 days</div>
|
||||
<div class="stat"><b>${s.driving30}</b>used in a car, last 30 days</div>
|
||||
<div class="stat"><b>${s.new30}</b>new phones, last 30 days</div>
|
||||
<div class="stat"><b>${s.quiet}</b>gone quiet (nothing in 14 days)</div>
|
||||
<div class="stat"><b>${s.everyone}</b>phones ever</div>
|
||||
</div>
|
||||
<p class="muted">Android tells an app nothing when it's uninstalled, so "gone quiet" is the nearest
|
||||
sign: phones that sent notes before and stopped. Turning the notes off looks the same.</p>
|
||||
<h2>Downloads and phones</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${s.totalDownloads}</b>APK downloads on GitHub, all releases</div>
|
||||
<div class="stat"><b>${s.share == null ? '–' : s.share + '%'}</b>of downloads send notes</div>
|
||||
</div>
|
||||
<p class="muted">The share counts only releases that have sent at least one note, and only release builds.
|
||||
A download isn't always a new person: updating means downloading again, and some people download
|
||||
twice. So read it as a rough guide, and multiply the phones above by about ${s.share ? Math.round(100 / s.share) : '–'} to estimate everyone.</p>
|
||||
${table(s.releases, [['tag', 'Release'], ['kind', 'Type'], ['published', 'Published'], ['downloads', 'Downloads'], ['phones', 'Phones sending'], ['share', 'Share']])}
|
||||
<h2>Downloads per day</h2>${s.perDay.length ? table(s.perDay, [['day', 'Day'], ['downloads', 'Downloads'], ['new_phones', 'New phones']])
|
||||
: '<p class="muted">Counts are saved once a day, so this fills in from tomorrow.</p>'}
|
||||
<h2>Phones per day</h2>${chart(s.daily)}
|
||||
<h2>New phones per day</h2>${table(s.daily.filter((d) => d.new_phones > 0).slice().reverse(), [['day', 'Day'], ['new_phones', 'New phones']])}
|
||||
<h2>Cars</h2>${table(s.cars, [['name', 'Head unit'], ['car', 'Car'], ['year', 'Year'], ['make', 'Maker'], ['model', 'Unit model'],
|
||||
['protocol', 'Protocol'], ['screen', 'Screen'], ['connected', 'Connected'], ['failed', 'Failed'], ['failures', 'Why it failed'], ['phones', 'Phones'], ['last_seen', 'Last seen']])}
|
||||
<h2>Car screens</h2>${table(s.screens, [['screen', 'Usable size'], ['phones', 'Phones']])}
|
||||
<h2>Versions, last 7 days</h2>${table(s.versions, [['version', 'Version'], ['phones', 'Phones']])}
|
||||
<h2>Phones</h2>${table(s.phones, [['phone', 'Phone'], ['phones', 'Count']])}
|
||||
<h2>GrapheneOS, last 30 days</h2>${table(s.os, [['os', 'System'], ['phones', 'Phones']])}
|
||||
<h2>Android</h2>${table(s.android, [['android', 'Android'], ['phones', 'Count']])}
|
||||
</body></html>`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Gearslip's opt-in usage notes. Deploy with `npx wrangler deploy` from this folder.
|
||||
# Setup, once:
|
||||
# npx wrangler d1 create gearslip-stats (paste the id it prints below)
|
||||
# npx wrangler d1 execute gearslip-stats --remote --file schema.sql
|
||||
# npx wrangler secret put SALT (any long random string; keep it)
|
||||
# npx wrangler secret put DASHBOARD_KEY (the key for the dashboard link)
|
||||
name = "gearslip-stats"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2026-10-01"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "gearslip-stats"
|
||||
database_id = "201521a7-0b4a-4fff-ab11-d575b821528a"
|
||||
|
||||
# The public repo whose release downloads the dashboard compares against. If GitHub starts
|
||||
# refusing (60 requests an hour without a token), add one: npx wrangler secret put GITHUB_TOKEN
|
||||
[vars]
|
||||
GITHUB_REPO = "Seb3thehacker/gearslip"
|
||||
|
||||
# Save the download counts once a day, so the dashboard can show downloads per day.
|
||||
[triggers]
|
||||
crons = ["17 6 * * *"]
|
||||
Reference in new issue
Block a user