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/
|
.cxx/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
/docs/cert-sweep.sh
|
|
||||||
extraction/
|
|
||||||
/cloned-repos
|
/cloned-repos
|
||||||
|
docs-internal/
|
||||||
|
|
||||||
# Internal
|
# Internal
|
||||||
*.pdf
|
*.pdf
|
||||||
todo
|
todo.md
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# For AI code
|
# For AI code
|
||||||
@@ -30,3 +29,4 @@ CLAUDE.md
|
|||||||
|
|
||||||
# Other
|
# Other
|
||||||
archive/
|
archive/
|
||||||
|
.wrangler/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Gearslip
|
# Gearslip
|
||||||
|
|
||||||
Bring any app to your car screen.
|
An Android Auto alternative built for everyone.
|
||||||
|
|
||||||
## What Gearslip is
|
## 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
|
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
||||||
mode.
|
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
|
## Features
|
||||||
|
|
||||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||||
call goes out.
|
call goes out.
|
||||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen; Organic
|
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||||
Maps and OsmAnd both work today.
|
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
|
- **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
|
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||||
doesn't ship a car interface of its own.
|
doesn't ship a car interface of its own.
|
||||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||||
the current and coming weather.
|
the current and coming weather.
|
||||||
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
- **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,
|
each one aloud, and you reply by voice or with a quick reply. Music pauses while
|
||||||
so it needs no Google account.
|
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.
|
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||||
The rest wait on the dashboard.
|
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.
|
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
- **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
|
- **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
|
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||||
Gearslip's own player.
|
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.
|
certificate.
|
||||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||||
car to hear calls through its speakers.
|
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
|
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||||
visible while audio plays.
|
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
|
## 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"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 7
|
versionCode = 8
|
||||||
versionName = "0.1.10-alpha"
|
versionName = "0.2.00-alpha"
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
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 {
|
signingConfigs {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@
|
|||||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||||
whether the package is installed at all. -->
|
whether the package is installed at all. -->
|
||||||
<package android:name="com.google.android.projection.gearhead" />
|
<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>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
android:name=".GearslipActivity"
|
android:name=".GearslipActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||||
|
|
||||||
<intent-filter>
|
<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,
|
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 {
|
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||||
val generated = SelfSignedCert.generate()
|
val generated = SelfSignedCert.generate()
|
||||||
Identity(
|
Identity(
|
||||||
@@ -65,7 +68,38 @@ object CertProvider {
|
|||||||
*/
|
*/
|
||||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
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? {
|
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)
|
val imported = importedFile(context, P12_NAME)
|
||||||
if (imported.isFile) {
|
if (imported.isFile) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|||||||
@@ -10,6 +10,27 @@ object Changelog {
|
|||||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||||
|
|
||||||
val releases = listOf(
|
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(
|
Release(
|
||||||
7, "0.1.10-alpha",
|
7, "0.1.10-alpha",
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
|||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import app.seb3thehacker.gearslip.car.CarUi
|
import app.seb3thehacker.gearslip.car.CarUi
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
@@ -40,6 +41,8 @@ import java.io.FileOutputStream
|
|||||||
* rotation or theme change from recreating it and dropping the connection.
|
* rotation or theme change from recreating it and dropping the connection.
|
||||||
*/
|
*/
|
||||||
class GearslipActivity : ComponentActivity(), Projection {
|
class GearslipActivity : ComponentActivity(), Projection {
|
||||||
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
|
||||||
|
|
||||||
private var descriptor: ParcelFileDescriptor? = null
|
private var descriptor: ParcelFileDescriptor? = null
|
||||||
private var runner: GearslipRunner? = null
|
private var runner: GearslipRunner? = null
|
||||||
@@ -148,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
Prefetch.warm(this)
|
Prefetch.warm(this)
|
||||||
|
UsageStats.onAppOpened(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
@@ -323,7 +327,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
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.
|
// 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) }
|
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||||
import app.seb3thehacker.gearslip.car.CarKeys
|
import app.seb3thehacker.gearslip.car.CarKeys
|
||||||
@@ -10,6 +11,7 @@ import app.seb3thehacker.gearslip.car.CarServices
|
|||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.util.concurrent.FutureTask
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
* 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 parser = Frames.Parser()
|
||||||
private val assembler = Frames.Assembler()
|
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 state = State.WAIT_VERSION
|
||||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||||
@@ -51,9 +59,12 @@ class GearslipRunner(
|
|||||||
@Volatile private var videoStarted = false
|
@Volatile private var videoStarted = false
|
||||||
private var videoSource: VideoSource? = null
|
private var videoSource: VideoSource? = null
|
||||||
@Volatile private var inFlight = 0
|
@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 maxUnacked = 4
|
||||||
@Volatile private var framesSent = 0
|
@Volatile private var framesSent = 0
|
||||||
@Volatile private var dropped = 0
|
|
||||||
@Volatile private var inputChannelId: Int = -1
|
@Volatile private var inputChannelId: Int = -1
|
||||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||||
@Volatile private var sensorChannelId: Int = -1
|
@Volatile private var sensorChannelId: Int = -1
|
||||||
@@ -76,6 +87,10 @@ class GearslipRunner(
|
|||||||
SessionStatus.connecting("Connected to the head unit")
|
SessionStatus.connecting("Connected to the head unit")
|
||||||
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||||
|
|
||||||
|
// 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 identity = identityProvider()
|
||||||
val cert = identity.certificate
|
val cert = identity.certificate
|
||||||
log.i("phone certificate source: ${identity.source}")
|
log.i("phone certificate source: ${identity.source}")
|
||||||
@@ -83,7 +98,8 @@ class GearslipRunner(
|
|||||||
log.i(" subject = ${cert.subjectX500Principal}")
|
log.i(" subject = ${cert.subjectX500Principal}")
|
||||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||||
tls = PhoneTls(identity.keyStore, identity.password)
|
PhoneTls(identity.keyStore, identity.password)
|
||||||
|
}.also { Thread(it, "gearslip-identity").start() }
|
||||||
|
|
||||||
startWatchdog()
|
startWatchdog()
|
||||||
|
|
||||||
@@ -115,11 +131,18 @@ class GearslipRunner(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
writer.shutdownNow()
|
||||||
|
CarFocus.request = null
|
||||||
|
CarFocus.set(true)
|
||||||
|
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||||
videoSource?.stop()
|
videoSource?.stop()
|
||||||
videoSource = null
|
videoSource = null
|
||||||
audioLink?.close()
|
audioLink?.close()
|
||||||
audioLink = null
|
audioLink = null
|
||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
|
CarMic.current = null
|
||||||
|
lastSensorNight = null
|
||||||
|
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||||
projection?.onProjectionStopped()
|
projection?.onProjectionStopped()
|
||||||
SessionStatus.disconnected()
|
SessionStatus.disconnected()
|
||||||
}
|
}
|
||||||
@@ -153,6 +176,8 @@ class GearslipRunner(
|
|||||||
onInputMessage(messageId, body)
|
onInputMessage(messageId, body)
|
||||||
} else if (frame.channel == sensorChannelId) {
|
} else if (frame.channel == sensorChannelId) {
|
||||||
onSensorMessage(messageId, body)
|
onSensorMessage(messageId, body)
|
||||||
|
} else if (frame.channel == CarMic.current?.channelId) {
|
||||||
|
CarMic.current?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == audioLink?.channelId) {
|
} else if (frame.channel == audioLink?.channelId) {
|
||||||
// The audio channel is the one still being reverse-engineered, and its first few
|
// 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.
|
// replies are what tell us why. Raw bytes for those, then quiet.
|
||||||
@@ -327,7 +352,12 @@ class GearslipRunner(
|
|||||||
|
|
||||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
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(
|
log.verdict(
|
||||||
"VIABLE",
|
"VIABLE",
|
||||||
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
||||||
@@ -338,6 +368,7 @@ class GearslipRunner(
|
|||||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||||
log.i("head unit: $info")
|
log.i("head unit: $info")
|
||||||
SessionReport.headUnit(info.toString())
|
SessionReport.headUnit(info.toString())
|
||||||
|
SessionReport.headUnitInfo = info
|
||||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||||
val profile = vehicleProfile
|
val profile = vehicleProfile
|
||||||
SessionReport.profile(profile?.name)
|
SessionReport.profile(profile?.name)
|
||||||
@@ -374,7 +405,7 @@ class GearslipRunner(
|
|||||||
videoChannelId = video.serviceId
|
videoChannelId = video.serviceId
|
||||||
videoConfigs = video.configs
|
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 ->
|
video.configs.forEachIndexed { index, config ->
|
||||||
log.i(" config[$index] = $config")
|
log.i(" config[$index] = $config")
|
||||||
}
|
}
|
||||||
@@ -385,8 +416,9 @@ class GearslipRunner(
|
|||||||
openChannel(video.serviceId)
|
openChannel(video.serviceId)
|
||||||
startAudioChannel(serviceDiscoveryResponse)
|
startAudioChannel(serviceDiscoveryResponse)
|
||||||
startSensorChannel(serviceDiscoveryResponse)
|
startSensorChannel(serviceDiscoveryResponse)
|
||||||
|
startMicChannel(serviceDiscoveryResponse)
|
||||||
|
|
||||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
log.w("no input service advertised - projection will be output-only")
|
log.w("no input service advertised - projection will be output-only")
|
||||||
} else {
|
} else {
|
||||||
@@ -411,13 +443,16 @@ class GearslipRunner(
|
|||||||
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
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")
|
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||||
SessionReport.audio(
|
SessionReport.audio(
|
||||||
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
||||||
)
|
)
|
||||||
return
|
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()
|
val first = media.configs.firstOrNull()
|
||||||
SessionReport.audio(
|
SessionReport.audio(
|
||||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||||
@@ -431,6 +466,18 @@ class GearslipRunner(
|
|||||||
openChannel(media.serviceId)
|
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
|
* 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
|
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
|
||||||
@@ -451,6 +498,48 @@ class GearslipRunner(
|
|||||||
openChannel(sensors.serviceId)
|
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) {
|
private fun onSensorMessage(messageId: Int, body: ByteArray) {
|
||||||
when (messageId) {
|
when (messageId) {
|
||||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
@@ -460,10 +549,12 @@ class GearslipRunner(
|
|||||||
log.w("head unit refused the sensor channel (status=$status)")
|
log.w("head unit refused the sensor channel (status=$status)")
|
||||||
return
|
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 ->
|
sensorTypes.forEach { type ->
|
||||||
send(
|
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,
|
encrypted = true, channel = sensorChannelId,
|
||||||
)
|
)
|
||||||
log.i("-> SensorStartRequest(sensor_type=$type)")
|
log.i("-> SensorStartRequest(sensor_type=$type)")
|
||||||
@@ -475,9 +566,15 @@ class GearslipRunner(
|
|||||||
|
|
||||||
MSG_SENSOR_EVENT_INDICATION -> {
|
MSG_SENSOR_EVENT_INDICATION -> {
|
||||||
CarSensors.onEvent(body)
|
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)")
|
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +615,12 @@ class GearslipRunner(
|
|||||||
|
|
||||||
MSG_INPUT_REPORT -> {
|
MSG_INPUT_REPORT -> {
|
||||||
val report = Wire.fields(body)
|
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, 4)?.let(::onKeyEvent)
|
||||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||||
@@ -547,6 +650,8 @@ class GearslipRunner(
|
|||||||
val at = points[actionIndex]
|
val at = points[actionIndex]
|
||||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
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)
|
projection?.onTouch(action, actionIndex, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,22 +754,28 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||||
|
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||||
// The head unit repeats this while it waits for frames; only act once.
|
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||||
if (!videoStarted) {
|
onVideoFocus(mode)
|
||||||
videoStarted = true
|
|
||||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
|
||||||
startVideoSource()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 -> {
|
MSG_MEDIA_ACK -> {
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
||||||
|
synchronized(ackLock) {
|
||||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
acksSeen++
|
acksSeen++
|
||||||
lastAckAt = System.currentTimeMillis()
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||||
}
|
}
|
||||||
@@ -707,6 +818,7 @@ class GearslipRunner(
|
|||||||
log.e("unsupported resolution ${config.resolutionName}")
|
log.e("unsupported resolution ${config.resolutionName}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) }
|
||||||
|
|
||||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||||
Protobuf.varintField(2, index.toLong())
|
Protobuf.varintField(2, index.toLong())
|
||||||
@@ -727,6 +839,7 @@ class GearslipRunner(
|
|||||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
frameRate = if (config.frameRate == 1) 60 else 30,
|
||||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||||
onCodecConfig = { csd ->
|
onCodecConfig = { csd ->
|
||||||
|
lastCodecConfig = csd
|
||||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||||
log.hex(" csd", csd, limit = 64)
|
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) {
|
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||||
// Flow control: the head unit told us max_unacked in its Config reply.
|
// The car is showing its own screen and would throw these away.
|
||||||
if (inFlight >= maxUnacked) {
|
if (!videoProjected) return
|
||||||
// A dropped or never-sent ack would otherwise wedge the stream forever, which
|
synchronized(ackLock) {
|
||||||
// looks identical to a frozen picture. Assume the window cleared and carry on.
|
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
|
val since = System.currentTimeMillis() - lastAckAt
|
||||||
if (since > ACK_STALL_MS) {
|
if (since > ACK_STALL_MS) {
|
||||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||||
inFlight = 0
|
inFlight = 0
|
||||||
} else {
|
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||||
dropped++
|
break
|
||||||
if (dropped % 30 == 1) {
|
|
||||||
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
|
||||||
}
|
}
|
||||||
// A dropped frame breaks the decoder's reference chain on the head unit -
|
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
ackWaitMs += System.currentTimeMillis() - waitStart
|
||||||
}
|
}
|
||||||
|
if (!running) return
|
||||||
val timestamp = ByteArray(8)
|
val timestamp = ByteArray(8)
|
||||||
for (i in 0 until 8) {
|
for (i in 0 until 8) {
|
||||||
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||||
@@ -791,15 +899,11 @@ class GearslipRunner(
|
|||||||
resyncAttempts = 0
|
resyncAttempts = 0
|
||||||
lastKeyframeSentAt = System.currentTimeMillis()
|
lastKeyframeSentAt = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
try {
|
// 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)
|
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||||
} catch (e: java.io.IOException) {
|
reportTouchLatency(presentationTimeUs)
|
||||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
|
||||||
running = false
|
|
||||||
videoSource?.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlight++
|
|
||||||
framesSent++
|
framesSent++
|
||||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||||
log.i(
|
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 {
|
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||||
limit == null -> value.toFloat()
|
limit == null -> value.toFloat()
|
||||||
value < 0 -> 0f
|
value < 0 -> 0f
|
||||||
@@ -835,7 +1017,13 @@ class GearslipRunner(
|
|||||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||||
) + body
|
) + body
|
||||||
|
|
||||||
synchronized(output) {
|
// 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) {
|
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||||
@@ -864,6 +1052,14 @@ class GearslipRunner(
|
|||||||
splitMessages++
|
splitMessages++
|
||||||
}
|
}
|
||||||
output.flush()
|
output.flush()
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
if (running) log.w("send failed, stopping: ${e.message}")
|
||||||
|
running = false
|
||||||
|
videoSource?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||||
|
// The session is over; nothing more goes out.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,9 +1130,13 @@ class GearslipRunner(
|
|||||||
val sent = framesSent
|
val sent = framesSent
|
||||||
val fps = (sent - lastFrames) / 5.0
|
val fps = (sent - lastFrames) / 5.0
|
||||||
lastFrames = sent
|
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(
|
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",
|
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -988,13 +1188,21 @@ class GearslipRunner(
|
|||||||
|
|
||||||
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
||||||
// starts at 0x8000" numbering as media/input above.
|
// starts at 0x8000" numbering as media/input above.
|
||||||
const val MSG_SENSOR_START_REQUEST = 32768
|
// aasdk SensorMessageId: REQUEST, RESPONSE, BATCH, ERROR.
|
||||||
const val MSG_SENSOR_START_RESPONSE = 32769
|
const val MSG_SENSOR_START_REQUEST = 32769
|
||||||
const val MSG_SENSOR_EVENT_INDICATION = 32770
|
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 STREAM_MEDIA = 3
|
||||||
const val CODEC_H264_BP = 3
|
const val CODEC_H264_BP = 3
|
||||||
const val VIDEO_FOCUS_PROJECTED = 1
|
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 SESSION_ID = 1
|
||||||
const val MSG_INPUT_REPORT = 32769
|
const val MSG_INPUT_REPORT = 32769
|
||||||
const val MSG_KEY_BINDING_REQUEST = 32770
|
const val MSG_KEY_BINDING_REQUEST = 32770
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ object ServiceDiscovery {
|
|||||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
"$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)"
|
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 STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||||
|
|
||||||
private val RESOLUTIONS = mapOf(
|
private val RESOLUTIONS = mapOf(
|
||||||
@@ -131,9 +174,23 @@ object ServiceDiscovery {
|
|||||||
val touchWidth: Int,
|
val touchWidth: Int,
|
||||||
val touchHeight: Int,
|
val touchHeight: Int,
|
||||||
val keycodesSupported: List<Int> = emptyList(),
|
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)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
@@ -147,9 +204,8 @@ object ServiceDiscovery {
|
|||||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
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. */
|
/** 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? {
|
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)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||||
@@ -190,15 +255,14 @@ object ServiceDiscovery {
|
|||||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
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):
|
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
|
||||||
* SensorSourceService { repeated SensorType sensors = 1; }
|
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||||
* SensorType { required int32 type = 1; }
|
* Sensor { required SensorType sensor_type = 1; }
|
||||||
*
|
*
|
||||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
* 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
|
* 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)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
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 {
|
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||||
Wire.varint(Wire.fields(it), 1)?.toInt()
|
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +49,10 @@ object SessionReport {
|
|||||||
@Volatile private var lastState = ""
|
@Volatile private var lastState = ""
|
||||||
private var printed = false
|
private var printed = false
|
||||||
|
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||||
appVersion = info?.versionName ?: "?"
|
appVersion = info?.versionName ?: "?"
|
||||||
appCode = info?.longVersionCode ?: 0
|
appCode = info?.longVersionCode ?: 0
|
||||||
@@ -59,6 +63,8 @@ object SessionReport {
|
|||||||
category = Category.NONE
|
category = Category.NONE
|
||||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||||
|
headUnitInfo = null
|
||||||
|
carScreen = ""; carDpi = 0
|
||||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||||
printed = false
|
printed = false
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,14 @@ object SessionReport {
|
|||||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||||
fun auth(status: Int) { authStatus = status }
|
fun auth(status: Int) { authStatus = status }
|
||||||
fun headUnit(info: String) { headUnit = info }
|
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 profile(name: String?) { profile = name ?: "none matched" }
|
||||||
fun video(summary: String) { video = summary }
|
fun video(summary: String) { video = summary }
|
||||||
fun audio(summary: String) { audio = summary }
|
fun audio(summary: String) { audio = summary }
|
||||||
@@ -121,6 +135,18 @@ object SessionReport {
|
|||||||
if (printed || startedAt == 0L) return
|
if (printed || startedAt == 0L) return
|
||||||
printed = true
|
printed = true
|
||||||
render().lines().forEach { GearslipLog.i(it) }
|
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()
|
GearslipLog.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ class VideoSource(
|
|||||||
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
||||||
// and spares the link a big frame every second.
|
// and spares the link a big frame every second.
|
||||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
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_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||||
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||||
if (tuned) {
|
if (tuned) {
|
||||||
@@ -215,6 +228,7 @@ class VideoSource(
|
|||||||
const val MIN_BIT_RATE = 4_000_000
|
const val MIN_BIT_RATE = 4_000_000
|
||||||
const val MAX_BIT_RATE = 12_000_000
|
const val MAX_BIT_RATE = 12_000_000
|
||||||
const val I_FRAME_INTERVAL_S = 5
|
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. */
|
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||||
val BAR_COLOURS = listOf(
|
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.CallUiState
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
||||||
|
import app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -119,15 +120,19 @@ object CarAssistant {
|
|||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
appContext?.let(VoiceFocus::acquire)
|
||||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {}
|
override fun onStart(utteranceId: String?) {}
|
||||||
override fun onDone(utteranceId: String?) {
|
override fun onDone(utteranceId: String?) {
|
||||||
|
VoiceFocus.release()
|
||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||||
}
|
}
|
||||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||||
override fun onError(utteranceId: String?) {
|
override fun onError(utteranceId: String?) {
|
||||||
|
VoiceFocus.release()
|
||||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
_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")
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||||
}
|
}
|
||||||
@@ -138,17 +143,19 @@ object CarAssistant {
|
|||||||
*/
|
*/
|
||||||
fun say(text: String, onDone: () -> Unit) {
|
fun say(text: String, onDone: () -> Unit) {
|
||||||
val engine = tts
|
val engine = tts
|
||||||
if (engine == null || !ttsReady || text.isBlank()) {
|
val context = appContext
|
||||||
|
if (engine == null || context == null || !ttsReady || text.isBlank()) {
|
||||||
mainHandler.post(onDone)
|
mainHandler.post(onDone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
VoiceFocus.acquire(context)
|
||||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {}
|
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")
|
@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.
|
// 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")
|
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Today")
|
||||||
WeatherCard(context)
|
WeatherCard(context)
|
||||||
AgendaCard(context)
|
AgendaCard(context)
|
||||||
NotificationsSection()
|
NotificationsSection()
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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
|
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||||
@@ -72,8 +79,20 @@ fun CarKeyboard(
|
|||||||
onDispose { CarKeyboardVisibility.state.value = false }
|
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) }
|
||||||
|
|
||||||
|
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||||
|
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@@ -135,12 +154,13 @@ fun CarKeyboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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) {
|
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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) {
|
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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
|
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
* 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(
|
private val LETTER_ROWS = listOf(
|
||||||
"qwertyuiop".map { it.toString() },
|
"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.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.background
|
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.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.material.icons.filled.Check
|
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. */
|
/** 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 {
|
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||||
entry.builtIn?.let {
|
entry.builtIn?.let {
|
||||||
@@ -172,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
|||||||
BuiltInApps.Phone -> AppGroup.PHONE
|
BuiltInApps.Phone -> AppGroup.PHONE
|
||||||
BuiltInApps.Web -> AppGroup.WEB
|
BuiltInApps.Web -> AppGroup.WEB
|
||||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||||
|
BuiltInApps.Exit -> AppGroup.EXIT
|
||||||
else -> AppGroup.SETTINGS
|
else -> AppGroup.SETTINGS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,7 +257,7 @@ fun CarLauncher() {
|
|||||||
val id = entry.componentId
|
val id = entry.componentId
|
||||||
return Tile(
|
return Tile(
|
||||||
entry.label, entry.icon, entry.builtIn?.glyph,
|
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))),
|
broken = pkg != null && (KnownApps.isBroken(pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||||
partial = pkg != null && KnownApps.isPartial(pkg),
|
partial = pkg != null && KnownApps.isPartial(pkg),
|
||||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppTile(tile: Tile) {
|
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
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
// 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(
|
GsIconBox(
|
||||||
onClick = tile.onClick,
|
onClick = tile.onClick,
|
||||||
onLongClick = tile.onLongClick,
|
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),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = shape,
|
shape = shape,
|
||||||
) {
|
) {
|
||||||
@@ -369,29 +385,44 @@ private fun AppTile(tile: Tile) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
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))
|
||||||
|
TileLabel(tile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TileIcon(tile: Tile) {
|
||||||
when {
|
when {
|
||||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
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))
|
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
tile.label,
|
tile.label,
|
||||||
|
modifier = modifier,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = maxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -87,8 +87,8 @@ object SensorType {
|
|||||||
18 to "Tire pressure",
|
18 to "Tire pressure",
|
||||||
19 to "Accelerometer",
|
19 to "Accelerometer",
|
||||||
20 to "Gyroscope",
|
20 to "Gyroscope",
|
||||||
21 to "Gravity",
|
21 to "GPS satellites",
|
||||||
22 to "Wheel tick / distance",
|
22 to "Toll card",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
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_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
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_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
private const val KEY_UNITS = "car_units"
|
private const val KEY_UNITS = "car_units"
|
||||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||||
@@ -71,6 +72,7 @@ object CarSettings {
|
|||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
|
private val mediaSearchFlow = MutableStateFlow(false)
|
||||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||||
|
|
||||||
@@ -171,6 +173,13 @@ object CarSettings {
|
|||||||
*/
|
*/
|
||||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
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
|
* 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 -
|
* 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"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "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"
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
unitsFlow.value = runCatching {
|
unitsFlow.value = runCatching {
|
||||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||||
@@ -275,6 +285,11 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
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) {
|
fun setNightMode(mode: NightMode) {
|
||||||
nightFlow.value = mode
|
nightFlow.value = mode
|
||||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fun CarSettingsScreen() {
|
|||||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||||
|
val mediaSearch by CarSettings.mediaSearchEnabled.collectAsState()
|
||||||
val display by CarEnvironment.display.collectAsState()
|
val display by CarEnvironment.display.collectAsState()
|
||||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -74,7 +75,7 @@ fun CarSettingsScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Settings")
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Open on connect",
|
"Open on connect",
|
||||||
@@ -126,14 +127,13 @@ fun CarSettingsScreen() {
|
|||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Media audio",
|
"Media audio",
|
||||||
listOf(false to "Phone output", true to "Through Gearslip"),
|
listOf(false to "Phone audio", true to "Car audio"),
|
||||||
pipeAudio,
|
pipeAudio,
|
||||||
) { CarSettings.setPipeAudio(it) }
|
) { CarSettings.setPipeAudio(it) }
|
||||||
Text(
|
Text(
|
||||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
|
||||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
|
||||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
"normally would, for example over Bluetooth to the car.",
|
||||||
"example over Bluetooth to the car.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -148,7 +148,7 @@ fun CarSettingsScreen() {
|
|||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||||
if (experimentalFeatures) {
|
if (experimentalFeatures) {
|
||||||
ExperimentalFeaturesList(voiceAssistant, carSensors)
|
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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.
|
* so the list reads fine whether there's one entry or several.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean) {
|
private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean, mediaSearch: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
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.",
|
"guess, not a fact, until you've watched one change for yourself.",
|
||||||
checked = carSensors,
|
checked = carSensors,
|
||||||
) { CarSettings.setCarSensorsEnabled(it) }
|
) { 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) }
|
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
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.
|
// The map follows the light outside, whatever the app's own theme is set to.
|
||||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||||
|
|
||||||
@@ -262,13 +266,15 @@ fun CarUi() {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CarSafetyWizard(onDone: () -> Unit) {
|
private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
// The card and its button share one width, capped so neither sprawls across a wide screen.
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(24.dp),
|
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
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.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.NowPlaying
|
||||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
import android.media.session.PlaybackState
|
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
|
* 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 browse by media.browse.collectAsStateWithLifecycle()
|
||||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||||
|
val search by media.search.collectAsStateWithLifecycle()
|
||||||
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
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.
|
// The connection outlives this screen: the home screen's player is the same one.
|
||||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||||
@@ -128,6 +136,29 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
return@Surface
|
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
|
// 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
|
// 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.
|
// 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("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
@@ -192,6 +234,135 @@ private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier =
|
|||||||
|
|
||||||
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
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. */
|
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
@@ -490,13 +661,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 context = LocalContext.current
|
||||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { media.select(entry) },
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.car.theme.GsTone
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.media.CarMedia
|
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. */
|
/** 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:"
|
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(
|
internal class BuiltInApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val glyph: ImageVector,
|
val glyph: ImageVector,
|
||||||
val screen: CarScreen,
|
val screen: CarScreen?,
|
||||||
val open: (CarNavigator) -> Unit,
|
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 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 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) }
|
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
|
else -> false
|
||||||
}
|
}
|
||||||
mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) }
|
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) }
|
(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)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||||
|
ScreenHeader("Screen sharing")
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
if (!TouchRelayService.enabled) {
|
if (!TouchRelayService.enabled) {
|
||||||
TouchRelayWarning()
|
TouchRelayWarning()
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
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.
|
// The tabs share the title's row, so the list and keypad get the height back.
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
ScreenHeader("Phone") {
|
||||||
Text(
|
|
||||||
"Phone",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
* 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
|
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||||
* network or the calendar provider.
|
* network or the calendar provider.
|
||||||
*
|
*
|
||||||
@@ -28,6 +30,7 @@ object Prefetch {
|
|||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
worker.execute {
|
worker.execute {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
|
step("certificate") { CertProvider.warm(app) }
|
||||||
step("launcher apps") { warmLauncherApps(app) }
|
step("launcher apps") { warmLauncherApps(app) }
|
||||||
step("phone apps") { PhoneApps.warm(app) }
|
step("phone apps") { PhoneApps.warm(app) }
|
||||||
step("weather and calendar") { warmDashboard(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.
|
* [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchField(
|
internal fun SearchField(
|
||||||
query: String,
|
query: String,
|
||||||
hint: String,
|
hint: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
|
|||||||
val readings by CarSensors.readings.collectAsState()
|
val readings by CarSensors.readings.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||||
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Vehicle data")
|
||||||
Text(
|
Text(
|
||||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
"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.",
|
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ object VoiceReply {
|
|||||||
LISTENING,
|
LISTENING,
|
||||||
/**
|
/**
|
||||||
* Reading the reply back, then counting down to [State.until] while listening for
|
* 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,
|
CONFIRMING,
|
||||||
SENT,
|
SENT,
|
||||||
@@ -103,7 +103,7 @@ object VoiceReply {
|
|||||||
val until = SystemClock.uptimeMillis() + OFFER_MS
|
val until = SystemClock.uptimeMillis() + OFFER_MS
|
||||||
_state.value = _state.value.copy(until = until, span = 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.
|
// 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(
|
CarAssistant.dictate(
|
||||||
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||||
onSpeaking = {
|
onSpeaking = {
|
||||||
@@ -118,11 +118,11 @@ object VoiceReply {
|
|||||||
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
||||||
} else {
|
} else {
|
||||||
finish()
|
close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// No answer: the card has already offered Reply and the quick replies all along.
|
// No answer counts as no: the message has been heard, so the card goes.
|
||||||
onError = { if (id == run) finish() },
|
onError = { if (id == run) close() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,43 +193,27 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sends in [ms]. */
|
||||||
* 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].
|
|
||||||
*/
|
|
||||||
private fun startCountdown(id: Int, ms: Long) {
|
private fun startCountdown(id: Int, ms: Long) {
|
||||||
val sendAt = SystemClock.uptimeMillis() + ms
|
val sendAt = SystemClock.uptimeMillis() + ms
|
||||||
val held = _state.value.held
|
_state.value = _state.value.copy(until = sendAt, span = ms, held = 0f)
|
||||||
val span = if (held > 0f) (ms / held).toLong() else ms
|
|
||||||
_state.value = _state.value.copy(until = sendAt, span = span, held = 0f)
|
|
||||||
main.removeCallbacksAndMessages(SEND_TOKEN)
|
main.removeCallbacksAndMessages(SEND_TOKEN)
|
||||||
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
main.postAtTime({ if (id == run) send() }, SEND_TOKEN, sendAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listens through the countdown for "cancel", "change it" or "send". The countdown holds from
|
* Listens through the countdown for "cancel", "change it" or "send". The countdown never
|
||||||
* the first word, so a reply can't go out while the driver is still saying "cancel".
|
* pauses: in a noisy car it held for road noise and the radio, and the reply never went.
|
||||||
*/
|
*/
|
||||||
private fun listenForCommand(id: Int) {
|
private fun listenForCommand(id: Int) {
|
||||||
CarAssistant.dictate(
|
CarAssistant.dictate(
|
||||||
onPartial = {},
|
onPartial = {},
|
||||||
cue = false,
|
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 ->
|
onResult = { text ->
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
when (command(text)) {
|
when (command(text)) {
|
||||||
Command.CANCEL -> {
|
Command.CANCEL -> {
|
||||||
finish()
|
close()
|
||||||
CarAssistant.say("Not sent.") {}
|
CarAssistant.say("Not sent.") {}
|
||||||
}
|
}
|
||||||
Command.CHANGE -> {
|
Command.CHANGE -> {
|
||||||
@@ -238,11 +222,17 @@ object VoiceReply {
|
|||||||
ask(id)
|
ask(id)
|
||||||
}
|
}
|
||||||
Command.SEND -> send()
|
Command.SEND -> send()
|
||||||
// Talk that wasn't meant for us, a passenger or the radio: carry on counting.
|
// Talk that wasn't meant for us, a passenger or the radio: keep listening, so a
|
||||||
null -> startCountdown(id, RESUME_MS)
|
// "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() {
|
fun cancel() {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
CarAssistant.stopListening()
|
CarAssistant.stopListening()
|
||||||
CarAssistant.stopSpeaking()
|
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()
|
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. */
|
/** 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_MS = 6_500L
|
||||||
private const val OFFER_GRACE_MS = 1_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 RELISTEN_MS = 400L
|
||||||
private const val RESUME_MS = 3_000L
|
|
||||||
}
|
}
|
||||||
@@ -84,11 +84,13 @@ object Weather {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val data = runCatching { fetch(context, location) }
|
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()
|
.getOrNull()
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
flow.value = WeatherState.Ready(data)
|
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.")
|
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),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
|
ScreenHeader("Weather")
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||||
is WeatherState.Unavailable -> Text(
|
is WeatherState.Unavailable -> Text(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
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(
|
AndroidView(
|
||||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ fun GsIconBox(
|
|||||||
shape: Shape = gsShape(),
|
shape: Shape = gsShape(),
|
||||||
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
/** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */
|
||||||
latched: Boolean = false,
|
latched: Boolean = false,
|
||||||
|
/** Also marks the control with the theme's hold marker, so the hold can be found. */
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -327,6 +328,7 @@ fun GsIconBox(
|
|||||||
.gsSurface(c.container, shape, c.border, pressed, latched),
|
.gsSurface(c.container, shape, c.border, pressed, latched),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
if (onLongClick != null && enabled) GsHoldMarker(gsLook().holdMarker, c.container, shape, source)
|
||||||
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
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,
|
val pressedDim: Float = 0.22f,
|
||||||
/** Scale while pressed, 1 for none. */
|
/** Scale while pressed, 1 for none. */
|
||||||
val pressedScale: Float = 0.95f,
|
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. */
|
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||||
val light: ControlLook? = null,
|
val light: ControlLook? = null,
|
||||||
)
|
)
|
||||||
@@ -67,7 +72,7 @@ object GsThemes {
|
|||||||
val Keycap = GsTheme(
|
val Keycap = GsTheme(
|
||||||
id = "keycap",
|
id = "keycap",
|
||||||
name = "Keycap",
|
name = "Keycap",
|
||||||
control = ControlLook(lip = 4.dp, pressedScale = 1f),
|
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||||
)
|
)
|
||||||
|
|
||||||
val Tactile = GsTheme(
|
val Tactile = GsTheme(
|
||||||
@@ -83,6 +88,7 @@ object GsThemes {
|
|||||||
insetAlpha = 0.30f,
|
insetAlpha = 0.30f,
|
||||||
pressedDim = 0.12f,
|
pressedDim = 0.12f,
|
||||||
pressedScale = 1f,
|
pressedScale = 1f,
|
||||||
|
holdMarker = HoldMarkerStyle.PEEL,
|
||||||
// A wide soft shadow bands once the car's video stream compresses it on a light
|
// 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.
|
// background, so light mode gets a tight one and leans on the bevel instead.
|
||||||
light = ControlLook(
|
light = ControlLook(
|
||||||
@@ -95,6 +101,7 @@ object GsThemes {
|
|||||||
insetAlpha = 0.30f,
|
insetAlpha = 0.30f,
|
||||||
pressedDim = 0.12f,
|
pressedDim = 0.12f,
|
||||||
pressedScale = 1f,
|
pressedScale = 1f,
|
||||||
|
holdMarker = HoldMarkerStyle.PEEL,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -102,7 +109,7 @@ object GsThemes {
|
|||||||
val Flat = GsTheme(
|
val Flat = GsTheme(
|
||||||
id = "flat",
|
id = "flat",
|
||||||
name = "Flat",
|
name = "Flat",
|
||||||
control = ControlLook(),
|
control = ControlLook(holdMarker = HoldMarkerStyle.DOT),
|
||||||
)
|
)
|
||||||
|
|
||||||
val default = Tactile
|
val default = Tactile
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ object KnownApps {
|
|||||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
"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
|
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,9 +65,15 @@ object KnownApps {
|
|||||||
|
|
||||||
fun isBroken(packageName: String): Boolean = packageName in broken
|
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(
|
private val partial = setOf(
|
||||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
"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
|
fun isPartial(packageName: String): Boolean = packageName in partial
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
import android.media.Rating
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
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) }
|
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. */
|
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
||||||
val likeAction
|
val likeAction
|
||||||
get() = custom.firstOrNull { a ->
|
get() = custom.firstOrNull { a ->
|
||||||
@@ -164,6 +168,24 @@ class CarMedia(private val context: Context) {
|
|||||||
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||||
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
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. */
|
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
||||||
class Modes(
|
class Modes(
|
||||||
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID,
|
||||||
@@ -276,6 +298,7 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
private fun onConnected(app: MediaApp) {
|
private fun onConnected(app: MediaApp) {
|
||||||
val mb = browser ?: return
|
val mb = browser ?: return
|
||||||
|
searchComponent = app.component
|
||||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||||
attach(MediaController(context, mb.sessionToken))
|
attach(MediaController(context, mb.sessionToken))
|
||||||
rootId = mb.root
|
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
|
metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
|
||||||
} else null,
|
} else null,
|
||||||
)
|
)
|
||||||
|
checkSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- browsing ------------------------------------------------------------------------
|
// --- browsing ------------------------------------------------------------------------
|
||||||
@@ -401,6 +425,141 @@ class CarMedia(private val context: Context) {
|
|||||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
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. */
|
/** 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() {
|
fun toggleLike() {
|
||||||
val now = _now.value
|
val now = _now.value
|
||||||
@@ -431,6 +590,10 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
connecting = null
|
connecting = null
|
||||||
|
runCatching { searchBrowser?.disconnect() }
|
||||||
|
searchBrowser = null
|
||||||
|
searchComponent = null
|
||||||
|
clearResults()
|
||||||
main.removeCallbacksAndMessages(null)
|
main.removeCallbacksAndMessages(null)
|
||||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||||
compat = null
|
compat = null
|
||||||
@@ -444,6 +607,7 @@ class CarMedia(private val context: Context) {
|
|||||||
_phase.value = Phase.IDLE
|
_phase.value = Phase.IDLE
|
||||||
_now.value = NowPlaying()
|
_now.value = NowPlaying()
|
||||||
_queue.value = emptyList()
|
_queue.value = emptyList()
|
||||||
|
dismissSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -479,5 +643,8 @@ class CarMedia(private val context: Context) {
|
|||||||
const val LAUNCH_WAIT_MS = 1_500L
|
const val LAUNCH_WAIT_MS = 1_500L
|
||||||
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||||
const val SESSION_RETRIES_LAUNCHED = 16
|
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.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import kotlin.math.log10
|
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
|
* 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.
|
* 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
|
* 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.
|
* 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 CALIBRATE_MS = 300L
|
||||||
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
|
private const val PRE_ROLL = RATE * 3 / 10 // keep 300 ms before the first word
|
||||||
private const val CUE_VOLUME = 80
|
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
|
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. */
|
/** 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
|
private const val CUE_TAIL_MS = 250L
|
||||||
@@ -85,13 +90,16 @@ object SpeechEngine {
|
|||||||
worker.execute {
|
worker.execute {
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
|
if (!ensureLoaded(app)) return@execute post { onError(NOT_LOADED) }
|
||||||
|
VoiceFocus.acquire(app)
|
||||||
|
val audio = try {
|
||||||
if (cue) cue()
|
if (cue) cue()
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
val audio = try {
|
|
||||||
record(id) { post(onSpeaking) }
|
record(id) { post(onSpeaking) }
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
GearslipLog.e("speech: recording failed", t)
|
GearslipLog.e("speech: recording failed", t)
|
||||||
return@execute post { onError("The microphone isn't available right now.") }
|
return@execute post { onError("The microphone isn't available right now.") }
|
||||||
|
} finally {
|
||||||
|
VoiceFocus.release()
|
||||||
}
|
}
|
||||||
if (id != run) return@execute
|
if (id != run) return@execute
|
||||||
if (audio == null) return@execute post { onError("Didn't catch that.") }
|
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
|
* 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.
|
* 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()
|
@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 min = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||||
val recorder = AudioRecord(
|
val recorder = AudioRecord(
|
||||||
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
|
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
|
||||||
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
|
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(min, FRAME * 8),
|
||||||
)
|
)
|
||||||
check(recorder.state == AudioRecord.STATE_INITIALIZED) { "AudioRecord didn't initialize" }
|
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())
|
val samples = FloatArray((RATE * MAX_MS / 1000).toInt())
|
||||||
var count = 0
|
var count = 0
|
||||||
val frame = ShortArray(FRAME)
|
val frame = ShortArray(FRAME)
|
||||||
@@ -144,11 +211,11 @@ object SpeechEngine {
|
|||||||
var noiseFrames = 0
|
var noiseFrames = 0
|
||||||
var speechStart = -1
|
var speechStart = -1
|
||||||
var lastSpeech = 0
|
var lastSpeech = 0
|
||||||
recorder.startRecording()
|
|
||||||
try {
|
try {
|
||||||
while (id == run && count + FRAME <= samples.size) {
|
while (id == run && count + FRAME <= samples.size) {
|
||||||
val n = recorder.read(frame, 0, FRAME)
|
val n = mic.read(frame)
|
||||||
if (n <= 0) continue
|
if (n < 0) break
|
||||||
|
if (n == 0) continue
|
||||||
var sum = 0.0
|
var sum = 0.0
|
||||||
for (i in 0 until n) {
|
for (i in 0 until n) {
|
||||||
val v = frame[i] / 32768f
|
val v = frame[i] / 32768f
|
||||||
@@ -175,8 +242,7 @@ object SpeechEngine {
|
|||||||
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
|
if (speechStart >= 0 && (count - lastSpeech) * 1000L / RATE > END_SILENCE_MS) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.stop() }
|
mic.close()
|
||||||
recorder.release()
|
|
||||||
}
|
}
|
||||||
if (speechStart < 0) return null
|
if (speechStart < 0) return null
|
||||||
return samples.copyOfRange(speechStart, count)
|
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.AppSettings
|
||||||
import app.seb3thehacker.gearslip.BuildConfig
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
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. */
|
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -53,11 +54,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
when {
|
when {
|
||||||
needsCertSetup -> Screen.CERT_SETUP
|
needsCertSetup -> Screen.CERT_SETUP
|
||||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
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
|
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.
|
// Home stays upright; every other screen turns with the phone.
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -93,17 +98,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
onOpenSettings = { screen = Screen.SETTINGS },
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||||
onOpenHelp = { screen = Screen.HELP },
|
onOpenHelp = { screen = Screen.HELP },
|
||||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
|
||||||
onDisconnect = onDisconnect,
|
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.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.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.HELP -> HelpScreen(
|
Screen.HELP -> HelpScreen(
|
||||||
onBack = { screen = Screen.HOME },
|
onBack = { screen = Screen.HOME },
|
||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
|
||||||
)
|
)
|
||||||
Screen.CERT_SETUP -> CertSetupScreen(
|
Screen.CERT_SETUP -> CertSetupScreen(
|
||||||
onDone = {
|
onDone = {
|
||||||
@@ -114,8 +123,15 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
onDone = { screen = Screen.HOME },
|
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()
|
CompatWarning()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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. " +
|
"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 " +
|
"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.",
|
"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(
|
Step(
|
||||||
"Optional: keep notifications readable while music plays.",
|
"Optional: keep notifications readable while music plays.",
|
||||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
"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 " +
|
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||||
"music is going to the car, then puts it back.",
|
"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(
|
Step(
|
||||||
"Optional: let the car screen answer and decline calls.",
|
"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. */
|
* here too so it can be reread without repeating the guide itself. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) {
|
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -109,10 +110,7 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayT
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Replay the guided tutorial")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
steps.forEachIndexed { index, step ->
|
steps.forEachIndexed { index, step ->
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
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.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Send
|
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.List
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -49,7 +48,6 @@ fun HomeScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCarPreview: () -> Unit,
|
onOpenCarPreview: () -> Unit,
|
||||||
onOpenHelp: () -> Unit,
|
onOpenHelp: () -> Unit,
|
||||||
onOpenWhatsNew: () -> Unit,
|
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||||
@@ -115,8 +113,6 @@ fun HomeScreen(
|
|||||||
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
|
||||||
RowDivider()
|
|
||||||
ActionRow(Icons.Filled.Star, "What's new", onOpenWhatsNew)
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
RowDivider()
|
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 android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -39,14 +46,14 @@ import androidx.compose.runtime.produceState
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -54,7 +61,12 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenRecommendedApps: () -> Unit,
|
||||||
|
onOpenWhatsNew: () -> Unit,
|
||||||
|
onReplayTutorial: () -> Unit,
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -74,49 +86,77 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(0.dp))
|
Spacer(Modifier.height(0.dp))
|
||||||
GearslipEnabledSection()
|
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||||
|
ConnectionSection()
|
||||||
|
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||||
CertificateSection()
|
CertificateSection()
|
||||||
|
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionTitle(text: String) {
|
private fun Arrow() = Icon(
|
||||||
Text(
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
text,
|
contentDescription = null,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 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. */
|
/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GearslipEnabledSection() {
|
private fun ConnectionSection() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) }
|
||||||
|
SettingsSection("Connection") {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
SettingsRow(
|
||||||
SectionTitle("Gearslip")
|
"Use Gearslip in the car",
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Modifier.toggleable(enabled, role = Role.Switch) {
|
||||||
Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
|
||||||
Switch(checked = enabled, onCheckedChange = {
|
|
||||||
enabled = it
|
enabled = it
|
||||||
AppSettings.setGearslipEnabled(context, it)
|
AppSettings.setGearslipEnabled(context, it)
|
||||||
})
|
},
|
||||||
}
|
subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead",
|
||||||
if (!enabled) {
|
icon = Icons.Filled.Settings,
|
||||||
Text(
|
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||||
"Off - stock Android Auto can take the USB connection instead.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||||
|
SettingsSection("Apps") {
|
||||||
|
SettingsRow(
|
||||||
|
"Recommended apps",
|
||||||
|
Modifier.clickable(onClick = onOpenRecommendedApps),
|
||||||
|
icon = Icons.Filled.Star,
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -131,37 +171,49 @@ private fun CertificateSection() {
|
|||||||
|
|
||||||
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var notice by remember { mutableStateOf<String?>(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 ->
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) pendingUri = uri
|
if (uri != null) pendingUri = uri
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") {
|
||||||
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
|
val s = summary
|
||||||
if (s == null) {
|
val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED
|
||||||
Text("Checking…", style = MaterialTheme.typography.bodyMedium)
|
val hasDetails = s?.subject != null || s?.issuer != null
|
||||||
} else {
|
SettingsRow(
|
||||||
Text(s.headline, style = MaterialTheme.typography.bodyLarge)
|
s?.headline ?: "Checking…",
|
||||||
s.subject?.let { Detail("Subject", it) }
|
if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier,
|
||||||
s.issuer?.let { Detail("Issuer", it) }
|
subtitle = s?.validUntil?.let { "Valid until $it" },
|
||||||
s.validUntil?.let { Detail("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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
if (downloading) "Downloading…" else "Download certificate",
|
||||||
Text("Import certificate (.p12)")
|
Modifier.clickable(enabled = !downloading) {
|
||||||
}
|
downloading = true
|
||||||
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } }
|
||||||
runCatching { CertProvider.downloadAndImport(context) }
|
downloading = false
|
||||||
}
|
|
||||||
result.onSuccess {
|
result.onSuccess {
|
||||||
notice = "Certificate downloaded."
|
notice = "Certificate downloaded."
|
||||||
refresh++
|
refresh++
|
||||||
@@ -170,39 +222,34 @@ private fun CertificateSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
icon = Icons.Filled.Refresh,
|
||||||
) {
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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 ->
|
pendingUri?.let { uri ->
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
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.Manifest
|
||||||
import android.app.AppOpsManager
|
import android.app.AppOpsManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -39,14 +45,16 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
|
||||||
import app.seb3thehacker.gearslip.car.Prefetch
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
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
|
* 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(
|
private class GuidePage(
|
||||||
val title: String,
|
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 permissions: Array<String>? = null,
|
||||||
val command: String? = null,
|
val command: String? = null,
|
||||||
val actionLabel: String? = null,
|
val actionLabel: String? = null,
|
||||||
val onAction: (() -> Unit)? = 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. */
|
/** 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)
|
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 {
|
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
||||||
// --- Safety: always first, and never skipped by an already-granted check ---------
|
// --- Safety: always first, and never skipped by an already-granted check ---------
|
||||||
add(
|
add(
|
||||||
@@ -102,13 +128,14 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
|||||||
)
|
)
|
||||||
|
|
||||||
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
// --- 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(
|
add(
|
||||||
GuidePage(
|
GuidePage(
|
||||||
title = "Microphone",
|
title = "Microphone",
|
||||||
explanation = "Android will not pass a music app's audio to the car without this " +
|
explanation = "Gearslip needs the microphone for two jobs: passing your music " +
|
||||||
"permission. Gearslip does not listen to you: it copies what is already " +
|
"app's sound to the car, and hearing you when you reply to a message or ask " +
|
||||||
"playing, nothing else.",
|
"the assistant. It listens only then, and your speech stays on the phone.",
|
||||||
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
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 " +
|
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 " +
|
"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.",
|
"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 " +
|
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 " +
|
"hides what your notifications say. This command switches that off only while " +
|
||||||
"music is going to the car, then puts it back.",
|
"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.
|
* driver takes on trust, with "Skip" sitting right next to it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onlyUsageNotes: Boolean = false) {
|
||||||
val context = LocalContext.current
|
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) }
|
var index by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// Only ours to handle once there is a previous page to return to; at the first page, the
|
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||||
@@ -307,8 +349,10 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
// A single page has no progress to show.
|
||||||
|
if (pages.size > 1) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (index + 1f) / pages.size },
|
progress = { (index + 1f) / pages.size },
|
||||||
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||||
@@ -320,8 +364,19 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(48.dp))
|
}
|
||||||
|
// 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) {
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -331,15 +386,17 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
page.explanation,
|
page.explanation as? AnnotatedString ?: AnnotatedString(page.explanation.toString()),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
page.command?.let { CopyableCommand(it) }
|
page.command?.let { CopyableCommand(it) }
|
||||||
|
page.content?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
page.permissions != null && alreadyGranted -> {
|
page.permissions != null && alreadyGranted -> {
|
||||||
@@ -372,11 +429,14 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
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.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
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
|
* 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:
|
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
|
||||||
* audio simply never arrives.
|
* 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 {
|
class ServiceDiscoveryAudioTest {
|
||||||
|
|
||||||
@@ -91,4 +96,34 @@ class ServiceDiscoveryAudioTest {
|
|||||||
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
|
||||||
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,3 +22,5 @@ kotlin.code.style=official
|
|||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
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