Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2344169af | ||
|
|
dc287a7fa2 | ||
|
|
2fcd586e67 | ||
|
|
184e4b70c8 | ||
|
|
30b185e86a | ||
|
|
c888d41d51 | ||
|
|
b0ffcf2b54 | ||
|
|
feab4b4e96 | ||
|
|
88e871c65e | ||
|
|
dc7018f394 | ||
|
|
e920093fa8 | ||
|
|
b71905c8c6 | ||
|
|
0a5acc012e | ||
|
|
8d673d855b | ||
|
|
6751951308 | ||
|
|
0f2582f638 | ||
|
|
bb07b95a9f | ||
|
|
9796320b58 | ||
|
|
3cea17d178 | ||
|
|
795963f178 | ||
|
|
ca33b5d01c | ||
|
|
aaa8babd09 | ||
|
|
636e40d88a | ||
|
|
1481bd9c52 | ||
|
|
b57bfb5324 | ||
|
|
e2e0d3f8bb | ||
|
|
d90c39f50a | ||
|
|
8a6c358257 | ||
|
|
de01448f1b | ||
|
|
f414a3c2c8 | ||
|
|
ea45bf994b | ||
|
|
f8aa0a259a | ||
|
|
0cf28b875c | ||
|
|
1c4b472ce0 | ||
|
|
e54fb08fc9 | ||
|
|
0afe857420 | ||
|
|
4b627bffdd | ||
|
|
fed652f9d3 | ||
|
|
ef006dc695 | ||
|
|
55c25fe967 | ||
|
|
46302b7d60 | ||
|
|
2c1fad6f8c | ||
|
|
75d0df19c3 | ||
|
|
456dda897e | ||
|
|
405cecf2e9 | ||
|
|
8fbd1701b1 | ||
|
|
6945f6755e | ||
|
|
a26605c0ab | ||
|
|
068288a0e7 | ||
|
|
dc61607028 | ||
|
|
ac8488200d | ||
|
|
c7077f2a57 | ||
|
|
6efdad5e53 | ||
|
|
c04c35b5cd | ||
|
|
75c11a92d9 | ||
|
|
99ad0d03d7 | ||
|
|
e42cb767c9 | ||
|
|
19d3afb153 | ||
|
|
32bef88a75 | ||
|
|
18571622d8 | ||
|
|
86a1f968ec | ||
|
|
325e115752 | ||
|
|
08ce09ad61 | ||
|
|
d78585fda1 | ||
|
|
4e71689f8e |
No files matched your search
@@ -1,15 +1,20 @@
|
||||
Gearslip 0.3.01 connects wirelessly on more phones and dongles.
|
||||
Gearslip 0.3.12 gives the car screen a fresh look and fixes the wireless loop 0.3.02 caused on some head units.
|
||||
|
||||
## Fixed
|
||||
- Wireless works with dongles that send their Wi-Fi details before their address.
|
||||
- A VPN no longer stops a wireless drive without a word. Gearslip tries the phone's ordinary route around it, and if the VPN still blocks the car, Gearslip says so. To fix it for good, exclude Gearslip in your VPN app.
|
||||
- When Android won't ask for the Nearby devices permission, a button opens Gearslip's settings, where you can turn it on.
|
||||
## New
|
||||
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
|
||||
- The car screen and the phone app take their colors from your phone's Material You palette.
|
||||
- The car's settings sit on the phone's home screen, each group on its own screen.
|
||||
- Put the home screen's player left or right of the map.
|
||||
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
|
||||
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
|
||||
|
||||
## Changed
|
||||
- An app that someone else has re-signed, such as a modified Waze, no longer shows the red X when it accepts Gearslip.
|
||||
- Support Gearslip through Liberapay, Buy Me a Coffee, or Monero, in Settings.
|
||||
- Gearslip now belongs to Twink Software LLC, and its code lives at github.com/twinksoftware/gearslip. Old links still work.
|
||||
- The README lists Gearslip's signing certificate, so you can check your copy with AppVerifier.
|
||||
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
|
||||
- Setup ends with recommended apps, then the button picker, then usage notes.
|
||||
|
||||
## Known issues
|
||||
- Phone calls are unstable right now and may drop or fail to connect.
|
||||
## Fixed
|
||||
- Some head units, such as ZLink adapters, sent their Wi-Fi details twice. Gearslip rejoined the network each time and dropped the connection. It now ignores the repeat.
|
||||
- Gearslip no longer crashes when a wireless connection ends.
|
||||
- Opening a second music app without playing anything keeps the player on the song that's still playing.
|
||||
- Vela's speed sign is full size again.
|
||||
- RF Sentinel and other non-media car apps get their own tile in the launcher.
|
||||
@@ -31,25 +31,27 @@ screen-sharing mode.
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Your car apps in one place. A red X marks the ones that don't work yet. | 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. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Find coffee nearby without leaving the map. |
|
||||
|  |  |
|
||||
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your week, and what you missed. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||
call goes out. When a call comes in, answer or decline it on the car screen.
|
||||
call goes out. When a call comes in, answer or decline it on the car screen. During a
|
||||
call, the nav bar shows who you're talking to, and you mute or hang up from there.
|
||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||
Organic Maps, OsmAnd, and Flitsmeister work today. Vela runs, with rough
|
||||
edges.
|
||||
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
|
||||
the map keeps the right side.
|
||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||
doesn't ship a car interface of its own.
|
||||
- **The web.** A browser with an address bar, back and forward, and its own on-screen
|
||||
keyboard, for anything that doesn't ship a car interface of its own.
|
||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||
the current and coming weather.
|
||||
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
|
||||
@@ -63,30 +65,35 @@ screen-sharing mode.
|
||||
- **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.
|
||||
- **Your phone's colors.** The car screen and the phone app take their colors from your
|
||||
phone's Material You palette, and every card and button matches.
|
||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
|
||||
can set things up before you drive. A change on either screen shows on both.
|
||||
|
||||
## Status
|
||||
|
||||
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
|
||||
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
|
||||
Bluetooth first.
|
||||
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
|
||||
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
|
||||
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
|
||||
then one permission at a time, each with a plain reason and an Allow or Skip. A help
|
||||
screen inside the car answers anything setup didn't cover.
|
||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||
It has been tested against several real navigation and media apps.
|
||||
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
|
||||
tested against several real navigation and media apps.
|
||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||
used in homemade car dashboards.
|
||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||
Gearslip's own player.
|
||||
Gearslip's own player. TIDAL lets only approved apps browse its library.
|
||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||
car to hear calls through its speakers.
|
||||
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
|
||||
default: Android never asks for permission, and your map app's voice reaches the car
|
||||
too. USB sounds a little clearer, with no delay, but Android shows a screen-share
|
||||
consent dialog once per drive. Gearslip never reads the screen; capturing playback is
|
||||
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
|
||||
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
|
||||
the only way an ordinary app can pass another app's audio along.
|
||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||
Android permission or an on-device consent dialog. For USB audio, setup offers two
|
||||
|
||||
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 569 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 116 KiB |
@@ -38,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 10
|
||||
versionCode = 12
|
||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.01-alpha"
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.12-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
@@ -74,8 +74,9 @@ android {
|
||||
applicationIdSuffix = ".dev"
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
||||
// -Pr8 shrinks a debug build like a release one, to test R8 in the debug install.
|
||||
if (project.hasProperty("r8")) {
|
||||
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
|
||||
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||
if (!project.hasProperty("noR8")) {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
@@ -105,9 +106,10 @@ android {
|
||||
|
||||
}
|
||||
|
||||
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
|
||||
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
|
||||
// doesn't break when that API moves again.
|
||||
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
|
||||
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
|
||||
// when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
|
||||
// last time, and with that file gone a build came out holding nothing but classes.dex.
|
||||
tasks.register("renameApk") {
|
||||
doLast {
|
||||
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
||||
@@ -117,7 +119,6 @@ tasks.register("renameApk") {
|
||||
.forEach { apk ->
|
||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||
apk.copyTo(renamed, overwrite = true)
|
||||
apk.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Wireless auto-connect: Android tells Gearslip when a car it manages connects. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
|
||||
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
@@ -159,6 +162,12 @@
|
||||
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||
-->
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.Gearslip.Invisible" />
|
||||
|
||||
<activity-alias
|
||||
android:name=".GearslipUsbAccessory"
|
||||
android:targetActivity=".GearslipActivity"
|
||||
@@ -244,5 +253,15 @@
|
||||
<action android:name="android.telecom.CallScreeningService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Told when a car approved for wireless auto-connect comes up over Bluetooth. -->
|
||||
<service
|
||||
android:name=".wireless.CarPresenceService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.companion.CompanionDeviceService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,190 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Surface
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
|
||||
* frame.
|
||||
*
|
||||
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
|
||||
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
|
||||
* it back. It works on the composited picture, so a map app's own surface is stretched with
|
||||
* everything else - scaling Gearslip's UI alone would leave the map squashed.
|
||||
*/
|
||||
class FrameStretcher(
|
||||
private val output: Surface,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
private val outWidth: Int,
|
||||
private val outHeight: Int,
|
||||
) {
|
||||
|
||||
private val thread = HandlerThread("gearslip-stretch").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
|
||||
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var program = 0
|
||||
private var texture: SurfaceTexture? = null
|
||||
private val transform = FloatArray(16)
|
||||
|
||||
/** Where to draw. Valid once [start] returns true. */
|
||||
lateinit var input: Surface
|
||||
private set
|
||||
|
||||
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
|
||||
fun start(): Boolean {
|
||||
val done = CountDownLatch(1)
|
||||
var ok = false
|
||||
handler.post {
|
||||
ok = runCatching { setUp() }
|
||||
.onFailure { GearslipLog.e("stretch: could not set up", it) }
|
||||
.isSuccess
|
||||
done.countDown()
|
||||
}
|
||||
done.await(2, TimeUnit.SECONDS)
|
||||
if (!ok) release()
|
||||
return ok
|
||||
}
|
||||
|
||||
fun release() {
|
||||
handler.post {
|
||||
runCatching { if (::input.isInitialized) input.release() }
|
||||
runCatching { texture?.release() }
|
||||
texture = null
|
||||
if (display != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
|
||||
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
|
||||
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
|
||||
EGL14.eglTerminate(display)
|
||||
}
|
||||
display = EGL14.EGL_NO_DISPLAY
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUp() {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
val version = IntArray(2)
|
||||
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val count = IntArray(1)
|
||||
val attributes = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL_RECORDABLE_ANDROID, 1,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
|
||||
"no EGL config"
|
||||
}
|
||||
context = EGL14.eglCreateContext(
|
||||
display, configs[0], EGL14.EGL_NO_CONTEXT,
|
||||
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
|
||||
)
|
||||
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
|
||||
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
|
||||
|
||||
program = link(VERTEX, FRAGMENT)
|
||||
val ids = IntArray(1)
|
||||
GLES20.glGenTextures(1, ids, 0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
|
||||
texture = SurfaceTexture(ids[0]).apply {
|
||||
setDefaultBufferSize(width, height)
|
||||
setOnFrameAvailableListener({ draw() }, handler)
|
||||
}
|
||||
input = Surface(texture)
|
||||
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
|
||||
}
|
||||
|
||||
private fun draw() {
|
||||
val tex = texture ?: return
|
||||
runCatching {
|
||||
tex.updateTexImage()
|
||||
tex.getTransformMatrix(transform)
|
||||
GLES20.glViewport(0, 0, outWidth, outHeight)
|
||||
GLES20.glUseProgram(program)
|
||||
val position = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
val coord = GLES20.glGetAttribLocation(program, "aCoord")
|
||||
GLES20.glEnableVertexAttribArray(position)
|
||||
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
|
||||
GLES20.glEnableVertexAttribArray(coord)
|
||||
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
|
||||
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
|
||||
EGL14.eglSwapBuffers(display, eglSurface)
|
||||
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
|
||||
}
|
||||
|
||||
private fun link(vertex: String, fragment: String): Int {
|
||||
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
|
||||
GLES20.glShaderSource(it, source)
|
||||
GLES20.glCompileShader(it)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
|
||||
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
|
||||
}
|
||||
return GLES20.glCreateProgram().also {
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
|
||||
GLES20.glLinkProgram(it)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
|
||||
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
|
||||
const val EGL_RECORDABLE_ANDROID = 0x3142
|
||||
|
||||
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
|
||||
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
|
||||
|
||||
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
|
||||
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
|
||||
|
||||
const val VERTEX = """
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aCoord;
|
||||
uniform mat4 uTransform;
|
||||
varying vec2 vCoord;
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vCoord = (uTransform * aCoord).xy;
|
||||
}
|
||||
"""
|
||||
|
||||
const val FRAGMENT = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vCoord;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(sTexture, vCoord);
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Android Auto frame layout, transcribed from aasdk.
|
||||
*
|
||||
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
|
||||
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
|
||||
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
|
||||
*
|
||||
* byte 0 channel id
|
||||
* byte 1 frameType | encryption | messageType
|
||||
* bytes 2-3 uint16 BE payload length of THIS frame
|
||||
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
|
||||
* payload uint16 BE message id, then the body
|
||||
*/
|
||||
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
|
||||
object Frames {
|
||||
|
||||
const val CHANNEL_CONTROL = 0
|
||||
@@ -36,8 +26,11 @@ object Frames {
|
||||
const val MESSAGE_SPECIFIC = 0
|
||||
const val MESSAGE_CONTROL = 1 shl 2
|
||||
|
||||
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
||||
const val MAX_FRAME_PAYLOAD = 0x4000
|
||||
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
|
||||
private const val FRAGMENT_SIZE = 0x3f00
|
||||
private const val HEADER_SIZE = 4
|
||||
private const val FIRST_HEADER_SIZE = 8
|
||||
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
|
||||
|
||||
class Frame(
|
||||
val channel: Int,
|
||||
@@ -45,6 +38,7 @@ object Frames {
|
||||
val encrypted: Boolean,
|
||||
val messageType: Int,
|
||||
val payload: ByteArray,
|
||||
val totalSize: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -60,6 +54,11 @@ object Frames {
|
||||
frameType: Int = TYPE_BULK,
|
||||
totalSize: Int? = null,
|
||||
): ByteArray {
|
||||
require(channel in 0..255 && payload.size <= 0xffff)
|
||||
require(frameType in TYPE_MIDDLE..TYPE_BULK)
|
||||
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
|
||||
require((frameType == TYPE_FIRST) == (totalSize != null))
|
||||
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
|
||||
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
||||
val header = byteArrayOf(
|
||||
channel.toByte(),
|
||||
@@ -67,7 +66,7 @@ object Frames {
|
||||
((payload.size shr 8) and 0xFF).toByte(),
|
||||
(payload.size and 0xFF).toByte(),
|
||||
)
|
||||
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
|
||||
if (totalSize == null) return header + payload
|
||||
val extended = byteArrayOf(
|
||||
((totalSize shr 24) and 0xFF).toByte(),
|
||||
((totalSize shr 16) and 0xFF).toByte(),
|
||||
@@ -77,6 +76,43 @@ object Frames {
|
||||
return header + extended + payload
|
||||
}
|
||||
|
||||
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
|
||||
fun writeMessage(
|
||||
output: OutputStream,
|
||||
channel: Int,
|
||||
encrypted: Boolean,
|
||||
messageType: Int,
|
||||
message: ByteArray,
|
||||
encrypt: (ByteArray) -> ByteArray,
|
||||
): Int {
|
||||
require(message.size in 2..MAX_MESSAGE_SIZE)
|
||||
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
|
||||
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
|
||||
var offset = 0
|
||||
var frames = 0
|
||||
while (offset < message.size) {
|
||||
val first = fragmented && offset == 0
|
||||
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
|
||||
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
|
||||
val type = when {
|
||||
!fragmented -> TYPE_BULK
|
||||
first -> TYPE_FIRST
|
||||
end == message.size -> TYPE_LAST
|
||||
else -> TYPE_MIDDLE
|
||||
}
|
||||
val chunk = message.copyOfRange(offset, end)
|
||||
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
|
||||
messageType, type, if (type == TYPE_FIRST) message.size else null))
|
||||
offset = end
|
||||
frames++
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
// Local resource limits, not protocol constants. No supported media packet needs more.
|
||||
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Pulls whole frames out of an accumulating buffer.
|
||||
*
|
||||
@@ -87,6 +123,8 @@ object Frames {
|
||||
private var pending = ByteArray(0)
|
||||
|
||||
fun append(data: ByteArray, length: Int) {
|
||||
require(length in 0..data.size)
|
||||
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
|
||||
val grown = ByteArray(pending.size + length)
|
||||
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||
System.arraycopy(data, 0, grown, pending.size, length)
|
||||
@@ -106,30 +144,53 @@ object Frames {
|
||||
val total = headerLength + frameSize
|
||||
if (pending.size < total) return null
|
||||
|
||||
val messageSize = if (frameType == TYPE_FIRST) {
|
||||
var size = 0L
|
||||
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
|
||||
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
|
||||
size.toInt()
|
||||
} else null
|
||||
val payload = pending.copyOfRange(headerLength, total)
|
||||
pending = pending.copyOfRange(total, pending.size)
|
||||
return Frame(
|
||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
|
||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
|
||||
/** Rejoins messages on independent channels, validating their plaintext length and flags. */
|
||||
class Assembler {
|
||||
private val partial = HashMap<Int, ByteArrayOutputStream>()
|
||||
private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
|
||||
private val partial = HashMap<Int, Partial>()
|
||||
private var buffered = 0
|
||||
|
||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
|
||||
TYPE_BULK -> payload
|
||||
TYPE_FIRST -> {
|
||||
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
||||
null
|
||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
|
||||
if (frame.frameType == TYPE_BULK) {
|
||||
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
|
||||
return payload
|
||||
}
|
||||
TYPE_MIDDLE -> {
|
||||
partial[frame.channel]?.write(payload)
|
||||
null
|
||||
}
|
||||
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
||||
else -> null
|
||||
val message = if (frame.frameType == TYPE_FIRST) {
|
||||
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
|
||||
Partial(frame).also { partial[frame.channel] = it }
|
||||
} else partial[frame.channel] ?: fail("Fragment without a first frame")
|
||||
val first = message.first
|
||||
if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
|
||||
val expected = first.totalSize ?: fail("Missing total size")
|
||||
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
|
||||
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
|
||||
message.data.write(payload)
|
||||
buffered += payload.size
|
||||
if (frame.frameType != TYPE_LAST) return null
|
||||
if (message.data.size() != expected) fail("Truncated fragmented message")
|
||||
partial.remove(frame.channel)
|
||||
buffered -= message.data.size()
|
||||
return message.data.toByteArray()
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing {
|
||||
partial.clear()
|
||||
buffered = 0
|
||||
throw IOException(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import android.os.ParcelFileDescriptor
|
||||
import android.provider.Settings
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -117,6 +116,17 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
||||
private var approvingCar: String? = null
|
||||
private val carApproval = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
||||
) { result ->
|
||||
val address = approvingCar ?: return@registerForActivityResult
|
||||
approvingCar = null
|
||||
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
|
||||
else app.seb3thehacker.gearslip.wireless.WirelessAuto.declined(this, address)
|
||||
}
|
||||
|
||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||
@@ -130,7 +140,6 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
@@ -138,6 +147,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
||||
runOnUiThread { startWireless(socket) }
|
||||
}
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
||||
runOnUiThread {
|
||||
approvingCar = address
|
||||
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
|
||||
}
|
||||
}
|
||||
CarSettings.init(this)
|
||||
AppSettings.applyGearslipEnabled(this)
|
||||
// A capture that died without tidying up would leave notifications exposed to every
|
||||
@@ -216,6 +231,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
||||
runCatching { unregisterReceiver(detachReceiver) }
|
||||
PhoneMirror.onStartRequested = null
|
||||
PhoneMirror.onTokenReady = null
|
||||
@@ -304,6 +320,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
||||
return
|
||||
}
|
||||
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
|
||||
// Switched off in Settings: the system shouldn't route the car here at all, but if it
|
||||
// does, leave the accessory alone.
|
||||
@@ -321,6 +341,39 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
start(accessory)
|
||||
}
|
||||
|
||||
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
|
||||
private fun connectToNearbyCar(address: String?) {
|
||||
if (address == null) return
|
||||
if (!AppSettings.gearslipEnabled(this)) {
|
||||
GearslipLog.i("auto-connect: Gearslip is switched off - not connecting")
|
||||
return
|
||||
}
|
||||
if (descriptor != null) {
|
||||
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
|
||||
return
|
||||
}
|
||||
// Only a session that's still running counts. One the car ended (a ByeBye, a dropped
|
||||
// network) leaves its runner behind, since only Gearslip ending a session clears it, and
|
||||
// that used to make every later auto-connect quietly do nothing.
|
||||
if (worker?.isAlive == true || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) {
|
||||
GearslipLog.i("auto-connect: a session is already running")
|
||||
return
|
||||
}
|
||||
if (runner != null) closeAccessory(stopWireless = false)
|
||||
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("auto-connect: needs the Nearby devices permission")
|
||||
return
|
||||
}
|
||||
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
|
||||
val device = adapter?.let { runCatching { it.getRemoteDevice(address) }.getOrNull() }
|
||||
if (device == null) {
|
||||
GearslipLog.w("auto-connect: Bluetooth couldn't hand over the car")
|
||||
return
|
||||
}
|
||||
GearslipLog.i("auto-connect: connecting")
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
|
||||
}
|
||||
|
||||
private fun start(accessory: UsbAccessory) {
|
||||
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
||||
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
||||
|
||||
@@ -52,6 +52,46 @@ object GearslipLog {
|
||||
file = File(dir, "gearslip-$name.log")
|
||||
i("log file: ${file?.absolutePath}")
|
||||
SessionReport.init(context)
|
||||
// One file per launch adds up; the newest are the ones anyone asks for.
|
||||
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
|
||||
}
|
||||
|
||||
private const val KEEP_FILES = 30
|
||||
|
||||
private fun logFiles(dir: File): List<File> =
|
||||
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||
?.sortedByDescending { it.name }.orEmpty()
|
||||
|
||||
/** A log from an earlier launch: when it started, and what a glance at it tells. */
|
||||
class PastLog(val file: File, val started: Date?, val crashed: Boolean, val car: String?)
|
||||
|
||||
/**
|
||||
* Earlier launches' logs, newest first. Each launch starts a fresh file, so after a crash the
|
||||
* one that saw it is here rather than on the live screen.
|
||||
*/
|
||||
fun pastLogs(context: Context): List<PastLog> {
|
||||
val dir = context.getExternalFilesDir(null) ?: return emptyList()
|
||||
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
return logFiles(dir).filter { it != file }.map { f ->
|
||||
val text = runCatching { f.readText() }.getOrDefault("")
|
||||
PastLog(
|
||||
file = f,
|
||||
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
|
||||
crashed = "FATAL uncaught" in text,
|
||||
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An earlier launch's log, copied to the cache dir for sharing like [shareableFile]. */
|
||||
fun shareableFile(context: Context, past: File): android.net.Uri {
|
||||
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
val out = File(dir, "gearslip-log-${past.name.removePrefix("gearslip-").removeSuffix(".log")}.txt")
|
||||
past.copyTo(out, overwrite = true)
|
||||
return androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.logshare", out,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.audio.CarMic
|
||||
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
||||
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||
@@ -12,12 +13,18 @@ import app.seb3thehacker.gearslip.car.DirectionsVoice
|
||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
import app.seb3thehacker.gearslip.car.ScreenFit
|
||||
import app.seb3thehacker.gearslip.car.CarSensors
|
||||
import app.seb3thehacker.gearslip.car.CarServices
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import java.io.IOException
|
||||
import app.seb3thehacker.gearslip.protocol.InputProtocol
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.FutureTask
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
||||
@@ -59,6 +66,7 @@ class GearslipRunner(
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
private var authenticationFailed = false
|
||||
private var drainWriterOnExit = false
|
||||
@Volatile private var stopped = false
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
@@ -71,6 +79,12 @@ class GearslipRunner(
|
||||
private var vehicleProfile: VehicleProfile? = null
|
||||
@Volatile private var videoStarted = false
|
||||
private var videoSource: VideoSource? = null
|
||||
|
||||
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
|
||||
private var stretcher: FrameStretcher? = null
|
||||
|
||||
/** What the car UI draws at when [stretcher] is on; touches are scaled into it. */
|
||||
@Volatile private var drawSize: Pair<Int, Int>? = null
|
||||
@Volatile private var inFlight = 0
|
||||
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
|
||||
private val ackLock = Object()
|
||||
@@ -146,6 +160,8 @@ class GearslipRunner(
|
||||
reportDisconnect()
|
||||
} finally {
|
||||
running = false
|
||||
writer.shutdown()
|
||||
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||
writer.shutdownNow()
|
||||
log.flush()
|
||||
}
|
||||
@@ -160,6 +176,9 @@ class GearslipRunner(
|
||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||
videoSource?.stop()
|
||||
videoSource = null
|
||||
stretcher?.release()
|
||||
stretcher = null
|
||||
CarAudio.openSink = null
|
||||
audioLink?.close()
|
||||
audioLink = null
|
||||
guidanceLink?.close()
|
||||
@@ -181,7 +200,7 @@ class GearslipRunner(
|
||||
|
||||
private fun handleFrame(frame: Frames.Frame) {
|
||||
// A rejected handshake cannot consume later messages as if authentication succeeded.
|
||||
if (authenticationFailed || stopped) return
|
||||
if (authenticationFailed || stopped || !running) return
|
||||
val payload = if (frame.encrypted) {
|
||||
if (!tls.handshakeComplete) {
|
||||
log.w("encrypted frame arrived before the handshake finished - ignoring")
|
||||
@@ -192,7 +211,10 @@ class GearslipRunner(
|
||||
frame.payload
|
||||
}
|
||||
|
||||
val message = assembler.offer(frame, payload) ?: return
|
||||
val message = if (frame.messageType == Frames.MESSAGE_CONTROL) {
|
||||
if (frame.frameType != Frames.TYPE_BULK) throw IOException("Fragmented channel control message")
|
||||
payload
|
||||
} else assembler.offer(frame, payload) ?: return
|
||||
if (message.size < 2) {
|
||||
log.w("runt message on channel ${frame.channel}")
|
||||
return
|
||||
@@ -204,6 +226,7 @@ class GearslipRunner(
|
||||
lastProgress = System.currentTimeMillis()
|
||||
|
||||
if (frame.channel != Frames.CHANNEL_CONTROL) {
|
||||
if (state != State.DONE || !frame.encrypted) return
|
||||
if (frame.channel == videoChannelId) {
|
||||
onVideoMessage(messageId, body)
|
||||
} else if (frame.channel == inputChannelId) {
|
||||
@@ -239,6 +262,9 @@ class GearslipRunner(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (messageId !in setOf(MSG_VERSION_REQUEST, MSG_ENCAPSULATED_SSL, MSG_AUTH_COMPLETE,
|
||||
MSG_BYEBYE_REQUEST, MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR) &&
|
||||
(!frame.encrypted || state != State.DONE && state != State.WAIT_SDR)) return
|
||||
|
||||
when (messageId) {
|
||||
MSG_VERSION_REQUEST -> onVersionRequest(body)
|
||||
@@ -247,11 +273,25 @@ class GearslipRunner(
|
||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||
MSG_PING_REQUEST -> onPingRequest(body)
|
||||
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
||||
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
|
||||
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
|
||||
AudioLink.MSG_FOCUS_RESPONSE -> {
|
||||
// One answer for both sinks: it is the directions' while they wait on one.
|
||||
val state = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
val guidance = guidanceLink
|
||||
if (guidance != null && guidance.expectsFocusAnswer()) guidance.onFocus(state) else audioLink?.onFocus(state)
|
||||
}
|
||||
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onProtocolError(messageId: Int) {
|
||||
val detail = if (messageId == MSG_FRAMING_ERROR) "framing error" else "unexpected message"
|
||||
log.w("head unit terminated the protocol: $detail")
|
||||
SessionReport.fail(SessionReport.Category.UNKNOWN, detail, state.name, "protocol $messageId")
|
||||
SessionStatus.failed("Protocol error", "The head unit reported a $detail.")
|
||||
running = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit asking to end the session. It drops USB a few seconds later whether or not
|
||||
* we answer, so the point here is the reason: an Audi MIB2+ sent this about a second after
|
||||
@@ -265,16 +305,23 @@ class GearslipRunner(
|
||||
3 -> "NOT_SUPPORTED"
|
||||
4 -> "NOT_CURRENTLY_SUPPORTED"
|
||||
5 -> "PROBE_SUPPORTED"
|
||||
6 -> "WIRELESS_PROJECTION_DISABLED"
|
||||
7 -> "POWER_DOWN"
|
||||
8 -> "USER_PROFILE_SWITCH"
|
||||
else -> "unknown"
|
||||
}
|
||||
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
|
||||
log.hex(" byebye raw", body)
|
||||
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
|
||||
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
|
||||
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = state == State.WAIT_SDR || state == State.DONE)
|
||||
log.i("-> ByeByeResponse")
|
||||
drainWriterOnExit = true
|
||||
running = false
|
||||
SessionStatus.disconnected()
|
||||
}
|
||||
|
||||
private fun onVersionRequest(body: ByteArray) {
|
||||
if (state != State.WAIT_VERSION) return
|
||||
if (body.size < 4) {
|
||||
log.w("VersionRequest too short (${body.size} bytes)")
|
||||
return
|
||||
@@ -284,24 +331,16 @@ class GearslipRunner(
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(major, minor)
|
||||
|
||||
// Always 1.3, on purpose. Every car that accepts Gearslip was tested this way, 1.5 units
|
||||
// included. Echoing the car's own version was tried too: a 4.3 unit rejected the
|
||||
// certificate either way, so the version doesn't decide acceptance. The log says so, since
|
||||
// a car offering 1.5 and getting 1.3 back looks like a bug to anyone reading it.
|
||||
val claim = CLAIM_VERSION ?: (major to minor)
|
||||
val response = byteArrayOf(
|
||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||
log.i(
|
||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
||||
if (CLAIM_VERSION != null && claim != (major to minor)) {
|
||||
" - intended: Gearslip always answers ${claim.first}.${claim.second}, which cars " +
|
||||
"offering $major.$minor accept too"
|
||||
} else "",
|
||||
)
|
||||
val response = ProtocolVersion.respond(major, minor)
|
||||
send(MSG_VERSION_RESPONSE, response.bytes, encrypted = false)
|
||||
log.i("-> VersionResponse: ${response.major}.${response.minor}, supported=${response.supported}")
|
||||
if (!response.supported) {
|
||||
authenticationFailed = true
|
||||
drainWriterOnExit = true
|
||||
running = false
|
||||
SessionStatus.failed("Unsupported protocol", "The head unit requested protocol $major.$minor.")
|
||||
return
|
||||
}
|
||||
state = State.TLS_HANDSHAKE
|
||||
SessionStatus.connecting("Securing the link")
|
||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
@@ -318,17 +357,13 @@ class GearslipRunner(
|
||||
if (stopped) return
|
||||
log.e("TLS handshake threw", t)
|
||||
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
|
||||
log.verdict(
|
||||
"NOT VIABLE (TLS rejected)",
|
||||
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
|
||||
"handshake_failure alert here means it validates the phone's certificate.",
|
||||
)
|
||||
SessionStatus.failed(
|
||||
"Head unit rejected the certificate",
|
||||
"The head unit aborted the secure handshake. It validates the phone's certificate.",
|
||||
"Secure connection failed",
|
||||
"The secure handshake could not be completed. Check the session log for details.",
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
running = false
|
||||
SessionReport.print()
|
||||
return
|
||||
}
|
||||
@@ -345,7 +380,7 @@ class GearslipRunner(
|
||||
|
||||
private fun onAuthComplete(body: ByteArray) {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
|
||||
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status != null && status != 0)) return
|
||||
log.i("<- AuthComplete: status=$status")
|
||||
status?.let { SessionReport.auth(it) }
|
||||
|
||||
@@ -356,17 +391,17 @@ class GearslipRunner(
|
||||
SessionStatus.connecting("Certificate accepted - discovering services")
|
||||
sendServiceDiscoveryRequest()
|
||||
}
|
||||
-2 -> {
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
|
||||
log.verdict(
|
||||
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
|
||||
"The head unit explicitly rejected the phone's self-signed certificate. " +
|
||||
"This is a definitive no - it validates the phone against a trust chain " +
|
||||
"we cannot issue from.",
|
||||
)
|
||||
-2, -23, -24 -> {
|
||||
val reason = when (status) {
|
||||
-23 -> "The phone's certificate is not valid yet."
|
||||
-24 -> "The phone's certificate has expired."
|
||||
else -> "The head unit refused the phone's certificate."
|
||||
}
|
||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
||||
log.w("certificate rejected: $reason")
|
||||
SessionStatus.failed(
|
||||
"Head unit rejected the certificate",
|
||||
"The head unit refused the phone's certificate (certificate error).",
|
||||
reason,
|
||||
)
|
||||
state = State.DONE
|
||||
authenticationFailed = true
|
||||
@@ -397,6 +432,7 @@ class GearslipRunner(
|
||||
authenticationFailed = true
|
||||
}
|
||||
}
|
||||
if (authenticationFailed) running = false
|
||||
}
|
||||
|
||||
private fun sendServiceDiscoveryRequest() {
|
||||
@@ -407,6 +443,7 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
if (state != State.WAIT_SDR) return
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
// 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.
|
||||
@@ -527,7 +564,17 @@ class GearslipRunner(
|
||||
// as Android Auto does, instead of letting it play on to no one.
|
||||
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
||||
)
|
||||
openChannel(media.serviceId)
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
openChannel(media.serviceId)
|
||||
} else {
|
||||
// Many cars switch their source to Android Auto the moment this channel opens, which
|
||||
// cut off music the driver chose to play over Bluetooth. Open it only if they switch.
|
||||
log.i("music goes over Bluetooth: the media channel stays closed, so the car keeps its own source")
|
||||
CarAudio.openSink = {
|
||||
CarAudio.openSink = null
|
||||
openChannel(media.serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The car's directions channel, where [DirectionsVoice] reads out each turn. */
|
||||
@@ -545,7 +592,7 @@ class GearslipRunner(
|
||||
tag = "guidance",
|
||||
sessionId = GUIDANCE_SESSION_ID,
|
||||
asksFocus = false,
|
||||
onReady = { DirectionsVoice.attach(it) },
|
||||
onReady = { DirectionsVoice.attach(it) { audioLink?.streaming == true } },
|
||||
onClose = { DirectionsVoice.detach(it) },
|
||||
)
|
||||
openChannel(guidance.serviceId)
|
||||
@@ -619,12 +666,15 @@ class GearslipRunner(
|
||||
|
||||
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
|
||||
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
|
||||
val navigation = ServiceDiscovery.findNavStatusService(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no navigation status channel - the cluster won't show turns")
|
||||
return
|
||||
}
|
||||
val channel = navigation.serviceId
|
||||
log.i("navigation status channel: $channel")
|
||||
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
|
||||
clusterNav = ClusterNav(channel, navigation.minimumIntervalMs.toLong()) { id, body ->
|
||||
send(id, body, encrypted = true, channel = channel)
|
||||
}
|
||||
openChannel(channel)
|
||||
}
|
||||
|
||||
@@ -680,7 +730,7 @@ class GearslipRunner(
|
||||
*/
|
||||
private fun onNightSensor(body: ByteArray) {
|
||||
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
|
||||
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
|
||||
val isNight = (Wire.varint(Wire.fields(night), 1) ?: 0L) != 0L
|
||||
if (isNight != lastSensorNight) {
|
||||
lastSensorNight = isNight
|
||||
log.i("car says it's ${if (isNight) "night" else "day"}")
|
||||
@@ -763,59 +813,60 @@ class GearslipRunner(
|
||||
MSG_KEY_BINDING_RESPONSE ->
|
||||
log.i("<- KeyBindingResponse: status=${Protobuf.readInt32Field(body, 1)}")
|
||||
|
||||
MSG_INPUT_REPORT -> {
|
||||
val report = Wire.fields(body)
|
||||
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||
if (touch == null) {
|
||||
return
|
||||
}
|
||||
val touchFields = Wire.fields(touch)
|
||||
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
|
||||
val pointers = Wire.allBytes(touchFields, 1)
|
||||
if (pointers.isEmpty()) return
|
||||
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
|
||||
val size = config?.pixelSize()
|
||||
// The car crops its margin off the frame and measures taps from the corner of what
|
||||
// it shows, so they come in short by half the margin: the 2024 VW (margin 240) sent
|
||||
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
|
||||
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
|
||||
// and the VW couldn't press past its first screen.
|
||||
val shift = config?.marginInsets() ?: Insets.NONE
|
||||
val points = pointers.mapIndexedNotNull { index, pointer ->
|
||||
val fields = Wire.fields(pointer)
|
||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
||||
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
|
||||
}
|
||||
if (points.isEmpty()) return
|
||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
touchesSeen++
|
||||
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
MSG_INPUT_REPORT -> onInputReport(body)
|
||||
|
||||
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInputReport(body: ByteArray) {
|
||||
val report = Wire.fields(body)
|
||||
if (Wire.varint(report, 1) == null) return
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
// Touchpads (field 7) need a separate gesture pipeline; their coordinates are not
|
||||
// positions on the projected screen.
|
||||
val touch = Wire.bytes(report, 3)
|
||||
if (touch == null) {
|
||||
return
|
||||
}
|
||||
// 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) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
val config = videoConfigs.getOrNull(selectedConfigIndex ?: 0)
|
||||
// The car crops its margin off the frame and measures taps from the corner of what it
|
||||
// shows, so they come in short by half the margin: the 2024 VW (margin 240) sent y=6,
|
||||
// inside the cropped band, and the Uconnect's never passed 351 on a 480-high frame with a
|
||||
// 130 margin. Without this every tap landed 120 px too high.
|
||||
val shift = config?.let(::marginsFor) ?: Insets.NONE
|
||||
val size = config?.pixelSize()
|
||||
// A stretched frame (see [screenShape]): the car's tap is on the frame, the UI is drawn smaller.
|
||||
val drawn = drawSize
|
||||
val stretched = drawn != null && size != null
|
||||
val (action, actionIndex, raw) =
|
||||
(if (stretched) InputProtocol.touch(touch, size) else InputProtocol.touch(touch, size, shift.left, shift.top)) ?: return
|
||||
val points = if (drawn != null && size != null) {
|
||||
raw.map { TouchPoint(it.id, it.x * drawn.first / size.first, it.y * drawn.second / size.second) }
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
if (points.size > 1 && touchesSeen < 3) {
|
||||
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
|
||||
}
|
||||
|
||||
touchesSeen++
|
||||
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the head unit to forward its buttons. Many units send nothing from the steering wheel
|
||||
* until the phone names the keys it wants. What the unit offered is asked for; a unit that
|
||||
@@ -967,6 +1018,37 @@ class GearslipRunner(
|
||||
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels)
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a [frameWidth] x [frameHeight] frame shaped like the screen size set in
|
||||
* Settings, centred and in even pixels; null when none is set or it is the frame's own shape.
|
||||
* Some head units report the wrong size, then crop or squash the frame onto their real screen.
|
||||
*/
|
||||
private fun screenShape(frameWidth: Int, frameHeight: Int): Pair<Int, Int>? {
|
||||
val screen = CarSettings.screenSize.value ?: return null
|
||||
val wider = screen.width.toLong() * frameHeight >= screen.height.toLong() * frameWidth
|
||||
val shape = if (wider) {
|
||||
frameWidth to (frameWidth.toLong() * screen.height / screen.width).toInt()
|
||||
} else {
|
||||
(frameHeight.toLong() * screen.width / screen.height).toInt() to frameHeight
|
||||
}
|
||||
val even = (shape.first / 2 * 2).coerceAtLeast(2) to (shape.second / 2 * 2).coerceAtLeast(2)
|
||||
return even.takeIf { it != (frameWidth to frameHeight) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The strips of the frame the car doesn't show: the head unit's own margins, or, when a screen
|
||||
* size is set to crop, whatever lies outside [screenShape]. The UI keeps out of them and taps
|
||||
* are measured from inside them.
|
||||
*/
|
||||
private fun marginsFor(config: ServiceDiscovery.VideoConfig): Insets {
|
||||
val (width, height) = config.pixelSize() ?: return config.marginInsets()
|
||||
if (CarSettings.screenFit.value != ScreenFit.CROP) return config.marginInsets()
|
||||
val (w, h) = screenShape(width, height) ?: return config.marginInsets()
|
||||
val sideX = width - w
|
||||
val sideY = height - h
|
||||
return Insets(top = sideY / 2, bottom = sideY - sideY / 2, left = sideX / 2, right = sideX - sideX / 2)
|
||||
}
|
||||
|
||||
private fun startVideoSource() {
|
||||
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
||||
val index = selectedConfigIndex ?: 0
|
||||
@@ -987,18 +1069,37 @@ class GearslipRunner(
|
||||
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
||||
// The car crops its margins off the frame, so keep the UI out of them unless a vehicle
|
||||
// profile has its own tuned insets.
|
||||
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
|
||||
val insets = config.marginInsets()
|
||||
stretcher?.release()
|
||||
stretcher = null
|
||||
drawSize = null
|
||||
val shape = screenShape(width, height)
|
||||
val squash = shape != null && CarSettings.screenFit.value == ScreenFit.SQUASH
|
||||
if (shape != null) {
|
||||
log.i(
|
||||
"screen size set to ${CarSettings.screenSize.value} in Settings: " +
|
||||
"${if (squash) "drawing" else "cropping to"} ${shape.first}x${shape.second} of the ${width}x$height frame",
|
||||
)
|
||||
}
|
||||
if (squash) {
|
||||
CarEnvironment.setInsets(Insets.NONE)
|
||||
} else if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
|
||||
val insets = marginsFor(config)
|
||||
CarEnvironment.setInsets(insets)
|
||||
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets")
|
||||
}
|
||||
|
||||
val useProjection = projection != null
|
||||
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
|
||||
videoFrameRate = if (config.frameRate == 1 && !wireless) 60 else 30
|
||||
smoothWindows = 0
|
||||
lastCut = null
|
||||
decoderBound = false
|
||||
adaptiveVideo = CarSettings.experimentalFeaturesEnabled.value && CarSettings.adaptiveVideoEnabled.value
|
||||
if (adaptiveVideo) log.i("video: adaptive bitrate on (experimental)")
|
||||
videoSource = VideoSource(
|
||||
width = width,
|
||||
height = height,
|
||||
// 60fps only over a cable: over Wi-Fi it doubles the data for little the driver sees.
|
||||
frameRate = if (config.frameRate == 1 && !wireless) 60 else 30,
|
||||
frameRate = videoFrameRate,
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
wireless = wireless,
|
||||
onCodecConfig = { csd ->
|
||||
@@ -1015,7 +1116,16 @@ class GearslipRunner(
|
||||
source.start()
|
||||
val surface = source.inputSurface
|
||||
if (projection != null && surface != null) {
|
||||
projection.onSurfaceReady(surface, width, height, config.density)
|
||||
val stretch = shape?.takeIf { squash }?.let { (w, h) ->
|
||||
FrameStretcher(surface, w, h, width, height).takeIf { it.start() }
|
||||
}
|
||||
stretcher = stretch
|
||||
if (stretch != null) {
|
||||
drawSize = stretch.width to stretch.height
|
||||
projection.onSurfaceReady(stretch.input, stretch.width, stretch.height, config.density)
|
||||
} else {
|
||||
projection.onSurfaceReady(surface, width, height, config.density)
|
||||
}
|
||||
SessionStatus.projecting()
|
||||
BluetoothAudioCheck.onProjecting()
|
||||
LocationCheck.onProjecting()
|
||||
@@ -1156,18 +1266,15 @@ class GearslipRunner(
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
value < 0 -> 0f
|
||||
value >= limit -> (limit - 1).toFloat()
|
||||
else -> value.toFloat()
|
||||
}
|
||||
|
||||
private fun onPingRequest(body: ByteArray) {
|
||||
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
|
||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
||||
val fields = Wire.fields(body)
|
||||
val timestamp = Wire.varint(fields, 1) ?: return
|
||||
val payload = Wire.bytes(fields, 3)
|
||||
val response = Protobuf.varintField(1, timestamp) +
|
||||
(payload?.let { Protobuf.bytesField(2, it) } ?: byteArrayOf())
|
||||
send(MSG_PING_RESPONSE, response, encrypted = tls.handshakeComplete)
|
||||
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
||||
}
|
||||
|
||||
@@ -1189,41 +1296,16 @@ class GearslipRunner(
|
||||
try {
|
||||
writer.execute {
|
||||
try {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
val frameCount = Frames.writeMessage(output, channel, encrypted, messageType, payload) { tls.encrypt(it) }
|
||||
if (frameCount > 1) splitMessages++
|
||||
output.flush()
|
||||
} catch (e: java.io.IOException) {
|
||||
} catch (e: IOException) {
|
||||
if (running) log.w("send failed, stopping: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
} catch (e: RejectedExecutionException) {
|
||||
// The session is over; nothing more goes out.
|
||||
}
|
||||
}
|
||||
@@ -1302,8 +1384,10 @@ class GearslipRunner(
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||
log.i(
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages " +
|
||||
"kbps=${(videoSource?.bitRate ?: 0) / 1000}",
|
||||
)
|
||||
adaptBitRate(fps, waited)
|
||||
|
||||
// Self-healing safety net: force a fresh keyframe periodically regardless
|
||||
// of whether we think one is owed. Protects against any future case where
|
||||
@@ -1318,13 +1402,65 @@ class GearslipRunner(
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/** The frame rate the stream was set up for, which adaptive bitrate measures against. */
|
||||
@Volatile private var videoFrameRate = 30
|
||||
|
||||
/** Five-second windows in a row the stream kept up, towards climbing back up. */
|
||||
private var smoothWindows = 0
|
||||
/** The bitrate and frame rate before the last cut, to see whether cutting helped. */
|
||||
private var lastCut: Pair<Int, Double>? = null
|
||||
/** Experimental: read once per stream, so flipping the switch mid-drive changes nothing. */
|
||||
@Volatile private var adaptiveVideo = false
|
||||
/** Set once a cut didn't help: this car's decoder, not the link, sets the pace. */
|
||||
private var decoderBound = false
|
||||
|
||||
/**
|
||||
* Adaptive bitrate. A window where the encoder spent most of its time held back by the
|
||||
* car's acks while the frame rate sagged means the car, or the Wi-Fi to it, can't take
|
||||
* frames this size: a quarter comes off. Two easy windows in a row put a tenth back, up to
|
||||
* where the stream started. A still screen (few frames, no waiting) changes nothing.
|
||||
*/
|
||||
private fun adaptBitRate(fps: Double, waitedMs: Long) {
|
||||
if (!adaptiveVideo) return
|
||||
val source = videoSource ?: return
|
||||
val target = videoFrameRate.toDouble()
|
||||
val behind = waitedMs > ADAPT_WINDOW_MS / 2 && fps < target * 0.8
|
||||
val easy = waitedMs < ADAPT_WINDOW_MS / 10
|
||||
when {
|
||||
behind && !decoderBound -> {
|
||||
smoothWindows = 0
|
||||
val cut = lastCut
|
||||
if (cut != null && fps < cut.second * 1.05) {
|
||||
// The last cut bought no frames: the car's decoder is the limit, not the
|
||||
// link, and smaller frames would only look worse. Undo it and stop here.
|
||||
decoderBound = true
|
||||
source.setBitRate(cut.first)
|
||||
log.i("video: a lower bitrate didn't help the car keep up - back to ${cut.first / 1000} kbps, adapting off")
|
||||
return
|
||||
}
|
||||
val before = source.bitRate
|
||||
source.setBitRate((before * 0.75).toInt())
|
||||
if (source.bitRate != before) {
|
||||
lastCut = before to fps
|
||||
log.i("video: car falling behind - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
|
||||
}
|
||||
}
|
||||
easy && source.bitRate < source.fullBitRate -> {
|
||||
lastCut = null
|
||||
if (++smoothWindows >= 2) {
|
||||
smoothWindows = 0
|
||||
val before = source.bitRate
|
||||
source.setBitRate((before * 1.1).toInt() + 1)
|
||||
log.i("video: keeping up - bitrate ${before / 1000} -> ${source.bitRate / 1000} kbps")
|
||||
}
|
||||
}
|
||||
else -> smoothWindows = 0
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The protocol version Gearslip answers every car with, as (major, minor), or null to echo
|
||||
* the head unit's. Kept at 1.3 because every working car was tested with it; see
|
||||
* onVersionRequest.
|
||||
*/
|
||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
||||
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
|
||||
const val ADAPT_WINDOW_MS = 5_000L
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
@@ -1338,6 +1474,8 @@ class GearslipRunner(
|
||||
const val MSG_BYEBYE_REQUEST = 15
|
||||
const val MSG_BYEBYE_RESPONSE = 16
|
||||
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
||||
const val MSG_UNEXPECTED_MESSAGE = 255
|
||||
const val MSG_FRAMING_ERROR = 65535
|
||||
const val MSG_CHANNEL_OPEN_REQUEST = 7
|
||||
const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
|
||||
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
|
||||
* and it finishes.
|
||||
*/
|
||||
class PermissionActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
|
||||
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
|
||||
.orEmpty()
|
||||
if (wanted.isEmpty() || savedInstanceState != null) {
|
||||
if (wanted.isEmpty()) finish()
|
||||
return
|
||||
}
|
||||
requestPermissions(wanted.toTypedArray(), REQUEST)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_PERMISSIONS = "permissions"
|
||||
private const val REQUEST = 1
|
||||
|
||||
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
|
||||
fun ask(context: Context, permissions: Array<String>) {
|
||||
val intent = Intent(context, PermissionActivity::class.java)
|
||||
.putExtra(EXTRA_PERMISSIONS, permissions)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
|
||||
runCatching { context.startActivity(intent, options.toBundle()) }
|
||||
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyStore
|
||||
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLEngineResult
|
||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
/**
|
||||
* The phone end of the Android Auto TLS session.
|
||||
*
|
||||
* Two things here are the opposite of what aasdk does, because aasdk implements the head
|
||||
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
|
||||
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
|
||||
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
|
||||
* **server** and its certificate goes out as the server certificate - sent unconditionally
|
||||
* on every handshake, which is precisely what we are testing.
|
||||
*
|
||||
* TLS here is not layered over the socket: handshake bytes travel inside
|
||||
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
|
||||
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
|
||||
* the right tool.
|
||||
*/
|
||||
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
|
||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
@@ -36,7 +24,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
||||
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||
|
||||
var handshakeComplete: Boolean = false
|
||||
@Volatile var handshakeComplete: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
@@ -52,6 +40,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
useClientMode = false
|
||||
// want, not need: we ask the head unit for its certificate so the run also tells
|
||||
// us what real units present, but we never make the session depend on getting one.
|
||||
// Homemade head units may have none.
|
||||
wantClientAuth = true
|
||||
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
||||
}
|
||||
@@ -88,7 +77,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
if (netBuffer.hasRemaining()) {
|
||||
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
||||
}
|
||||
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
|
||||
if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
|
||||
}
|
||||
|
||||
HandshakeStatus.NEED_UNWRAP -> {
|
||||
@@ -100,7 +89,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||
continue@loop
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break@loop
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -124,7 +113,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||
fun encrypt(plain: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(plain)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
netBuffer.clear()
|
||||
val result = engine.wrap(source, netBuffer)
|
||||
@@ -132,6 +121,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||
continue
|
||||
}
|
||||
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
|
||||
throw SSLException("TLS could not encrypt application data: ${result.status}")
|
||||
}
|
||||
netBuffer.flip()
|
||||
val chunk = ByteArray(netBuffer.remaining())
|
||||
netBuffer.get(chunk)
|
||||
@@ -140,14 +132,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
||||
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
||||
* self-contained ciphertext.
|
||||
*/
|
||||
/** Each encrypted projection frame contains complete TLS records. */
|
||||
fun decrypt(cipher: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(cipher)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
appBuffer.clear()
|
||||
val result = engine.unwrap(source, appBuffer)
|
||||
@@ -157,10 +145,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
continue
|
||||
}
|
||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
break
|
||||
throw SSLException("Incomplete TLS record in an encrypted frame")
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
|
||||
else -> Unit
|
||||
}
|
||||
appBuffer.flip()
|
||||
|
||||
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
|
||||
*
|
||||
* Only three messages are ever written (AuthResponse is only read):
|
||||
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
|
||||
* PingResponse { required int64 timestamp = 1; }
|
||||
* AuthResponse { required int32 status = 1; }
|
||||
*/
|
||||
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
|
||||
object Protobuf {
|
||||
|
||||
fun varint(value: Long): ByteArray {
|
||||
@@ -41,29 +34,7 @@ object Protobuf {
|
||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||
|
||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||
pos = afterTag
|
||||
val number = (tag ushr 3).toInt()
|
||||
when ((tag and 7L).toInt()) {
|
||||
0 -> {
|
||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||
pos = afterValue
|
||||
if (number == field) return value
|
||||
}
|
||||
2 -> {
|
||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||
pos = afterLen + len.toInt()
|
||||
}
|
||||
5 -> pos += 4
|
||||
1 -> pos += 8
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
||||
|
||||
/**
|
||||
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||
@@ -71,6 +42,7 @@ object Protobuf {
|
||||
* proof that the session is live and we are decrypting correctly.
|
||||
*/
|
||||
fun describe(data: ByteArray, indent: String = " "): String {
|
||||
if (indent.length > 32) return "$indent(nested message)\n"
|
||||
val sb = StringBuilder()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
@@ -89,6 +61,7 @@ object Protobuf {
|
||||
}
|
||||
2 -> {
|
||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||
if (len < 0 || len > data.size - afterLen) break
|
||||
val end = afterLen + len.toInt()
|
||||
if (end > data.size) break
|
||||
val body = data.copyOfRange(afterLen, end)
|
||||
@@ -117,6 +90,7 @@ object Protobuf {
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) return result to pos
|
||||
@@ -135,45 +109,68 @@ object Wire {
|
||||
val out = mutableListOf<Field>()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val tag = readVarint(data, pos) ?: break
|
||||
val tag = readVarint(data, pos) ?: return emptyList()
|
||||
pos = tag.second
|
||||
val number = (tag.first ushr 3).toInt()
|
||||
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
||||
when ((tag.first and 7L).toInt()) {
|
||||
0 -> {
|
||||
val v = readVarint(data, pos) ?: break
|
||||
val v = readVarint(data, pos) ?: return emptyList()
|
||||
pos = v.second
|
||||
out += Field(number, 0, v.first, null)
|
||||
}
|
||||
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
||||
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
|
||||
2 -> {
|
||||
val len = readVarint(data, pos) ?: break
|
||||
val len = readVarint(data, pos) ?: return emptyList()
|
||||
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
|
||||
val end = len.second + len.first.toInt()
|
||||
if (end > data.size || end < len.second) break
|
||||
if (end > data.size || end < len.second) return emptyList()
|
||||
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||
pos = end
|
||||
}
|
||||
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
||||
else -> break
|
||||
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
||||
else -> return emptyList()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun varint(fields: List<Field>, number: Int): Long? =
|
||||
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||
|
||||
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||
fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||
|
||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||
|
||||
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
|
||||
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
|
||||
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
|
||||
|
||||
/** Null means a packed field contained an incomplete or overflowing varint. */
|
||||
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
|
||||
for (field in fields.filter { it.number == number }) {
|
||||
if (field.wireType == 0) add(field.varint)
|
||||
else if (field.wireType == 2) {
|
||||
val bytes = field.bytes ?: continue
|
||||
var position = 0
|
||||
while (position < bytes.size) {
|
||||
val (value, next) = readVarint(bytes, position) ?: return null
|
||||
add(value)
|
||||
position = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) return result to pos
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
/**
|
||||
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
|
||||
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
|
||||
* answer, so only a major version of 0 is refused.
|
||||
*/
|
||||
internal object ProtocolVersion {
|
||||
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
|
||||
val bytes: ByteArray get() = byteArrayOf(
|
||||
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
|
||||
if (supported) 0 else -1, if (supported) 0 else -1,
|
||||
)
|
||||
}
|
||||
|
||||
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class ScreenProjector(private val context: Context) {
|
||||
* preview passes "gearslip-preview".
|
||||
*/
|
||||
fun start(
|
||||
surface: Surface,
|
||||
surface: Surface?,
|
||||
width: Int,
|
||||
height: Int,
|
||||
densityDpi: Int,
|
||||
@@ -219,6 +219,14 @@ class ScreenProjector(private val context: Context) {
|
||||
target.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the display at another surface, or none, without restarting what it shows. The
|
||||
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
|
||||
*/
|
||||
fun setSurface(surface: Surface?) {
|
||||
virtualDisplay?.surface = surface
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runCatching { presentation?.dismiss() }
|
||||
runCatching { owner?.destroy() }
|
||||
|
||||
@@ -76,7 +76,7 @@ object ServiceDiscovery {
|
||||
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
val sinkFields = Wire.fields(sink)
|
||||
val audioConfigs = Wire.allBytes(sinkFields, 3)
|
||||
@@ -116,13 +116,26 @@ object ServiceDiscovery {
|
||||
*/
|
||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||
|
||||
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
|
||||
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
|
||||
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||
|
||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||
fun findNavStatusService(response: ByteArray): NavStatusService? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
|
||||
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
|
||||
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
|
||||
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
|
||||
NavStatusService(id, interval.toInt())
|
||||
}
|
||||
|
||||
private fun serviceId(fields: List<Wire.Field>): Int? =
|
||||
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
|
||||
|
||||
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
|
||||
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +147,7 @@ object ServiceDiscovery {
|
||||
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 id = serviceId(service) ?: continue
|
||||
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||
return AudioService(
|
||||
@@ -179,9 +192,7 @@ object ServiceDiscovery {
|
||||
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
|
||||
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
|
||||
// Packed as declared, though a car may send each method as its own varint.
|
||||
val methods = fields.filter { it.number == 2 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
val methods = Wire.repeatedInts(fields, 2)
|
||||
return BluetoothService(id, address, methods)
|
||||
}
|
||||
return null
|
||||
@@ -215,8 +226,8 @@ object ServiceDiscovery {
|
||||
* repeated TouchScreen touchscreen = 2; ... }
|
||||
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
||||
*
|
||||
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
|
||||
* scaled to the projected video size before injection. [keycodesSupported] is what the
|
||||
* Touch reports use projected video coordinates; these dimensions describe the input
|
||||
* hardware and help select a video configuration. [keycodesSupported] is what the
|
||||
* head unit says it will actually send over the Input channel as key events (steering-wheel
|
||||
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
|
||||
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
|
||||
@@ -245,38 +256,18 @@ object ServiceDiscovery {
|
||||
private fun inputServices(response: ByteArray): List<InputService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val input = Wire.bytes(service, 4) ?: continue
|
||||
val inputFields = Wire.fields(input)
|
||||
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
||||
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
||||
// Declared packed, but some units send each keycode as its own field.
|
||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
val keycodes = Wire.repeatedInts(inputFields, 1)
|
||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||
private fun packedVarints(data: ByteArray): List<Int> = buildList {
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (pos < data.size) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
add(result.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -289,7 +280,7 @@ object ServiceDiscovery {
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
|
||||
val sinkFields = Wire.fields(sink)
|
||||
@@ -316,16 +307,15 @@ object ServiceDiscovery {
|
||||
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||
* Sensor { required SensorType sensor_type = 1; }
|
||||
*
|
||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
|
||||
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
|
||||
* The vehicle data screen keeps their raw contents for diagnostics.
|
||||
*/
|
||||
class SensorService(val serviceId: Int, val types: List<Int>)
|
||||
|
||||
fun findSensorService(response: ByteArray): SensorService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sensorSource = Wire.bytes(service, 2) ?: continue
|
||||
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||
|
||||
@@ -4,33 +4,25 @@ import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLEngineResult
|
||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
/**
|
||||
* Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a
|
||||
* data round trip works.
|
||||
*
|
||||
* This exists because of one specific hazard: a bug in the certificate, the key manager or
|
||||
* the engine wiring produces a dead connection in the car that is indistinguishable from a
|
||||
* head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go
|
||||
* signal. Run this on the bench first; if it passes, a failure in the car is about the head
|
||||
* unit, not about this code.
|
||||
*/
|
||||
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
|
||||
object TlsSelfTest {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
fun run(identity: CertProvider.Identity): Boolean {
|
||||
fun run(identity: CertProvider.Identity, withClientCertificate: Boolean = true): Boolean {
|
||||
log.i("=== TLS self-test starting ===")
|
||||
log.i("cert source: ${identity.source}")
|
||||
log.i(" subject = ${identity.certificate.subjectX500Principal}")
|
||||
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
|
||||
return try {
|
||||
val server = PhoneTls(identity.keyStore, identity.password)
|
||||
val client = Driver(clientEngine())
|
||||
val client = Driver(clientEngine(withClientCertificate))
|
||||
|
||||
var outbound = client.pump(null) // ClientHello
|
||||
var rounds = 0
|
||||
@@ -49,8 +41,7 @@ object TlsSelfTest {
|
||||
return false
|
||||
}
|
||||
|
||||
// Data round trip, exercising the same encrypt path the spike uses for
|
||||
// ServiceDiscoveryRequest.
|
||||
// Exercise the same encrypt path used by ServiceDiscoveryRequest.
|
||||
val plaintext = "gearslip round trip".toByteArray()
|
||||
val decrypted = client.decrypt(server.encrypt(plaintext))
|
||||
if (!decrypted.contentEquals(plaintext)) {
|
||||
@@ -66,9 +57,15 @@ object TlsSelfTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun clientEngine(): SSLEngine {
|
||||
private fun clientEngine(withCertificate: Boolean): SSLEngine {
|
||||
val context = SSLContext.getInstance("TLSv1.2")
|
||||
context.init(null, arrayOf(TrustAll()), SecureRandom())
|
||||
val keyManagers = if (withCertificate) {
|
||||
val client = SelfSignedCert.generate(commonName = "Self-test head unit")
|
||||
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(client.keyStore, SelfSignedCert.PASSWORD)
|
||||
}.keyManagers
|
||||
} else null
|
||||
context.init(keyManagers, arrayOf(TrustAll()), SecureRandom())
|
||||
return context.createSSLEngine().apply {
|
||||
useClientMode = true
|
||||
enabledProtocols = arrayOf("TLSv1.2")
|
||||
|
||||
@@ -42,6 +42,14 @@ class VideoSource(
|
||||
private set
|
||||
|
||||
private var codec: MediaCodec? = null
|
||||
|
||||
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
|
||||
var fullBitRate = 0
|
||||
private set
|
||||
|
||||
/** What the encoder is asked for right now. */
|
||||
@Volatile var bitRate = 0
|
||||
private set
|
||||
private var worker: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private var frameIndex = 0L
|
||||
@@ -51,6 +59,8 @@ class VideoSource(
|
||||
val bitRate = defaultBitRate(width, height, frameRate, level).let {
|
||||
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
|
||||
}
|
||||
fullBitRate = bitRate
|
||||
this.bitRate = bitRate
|
||||
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
@@ -128,9 +138,28 @@ class VideoSource(
|
||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
|
||||
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
|
||||
*/
|
||||
fun setBitRate(bps: Int) {
|
||||
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
|
||||
if (target == bitRate) return
|
||||
runCatching {
|
||||
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
|
||||
bitRate = target
|
||||
}.onFailure { log.w("setBitRate failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
worker?.join(1_000)
|
||||
// Stop is called from threads that are themselves being shut down. An interrupt here
|
||||
// used to escape as an uncaught exception and take the whole app down with the session.
|
||||
try {
|
||||
worker?.join(1_000)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
runCatching { inputSurface?.release() }
|
||||
inputSurface = null
|
||||
runCatching { codec?.stop() }
|
||||
|
||||
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
|
||||
@@ -25,7 +27,10 @@ class AudioLink(
|
||||
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
|
||||
private val tag: String = "audio",
|
||||
private val sessionId: Int = SESSION_ID,
|
||||
/** Media asks the car for focus; directions just start, and the car ducks music under them. */
|
||||
/**
|
||||
* Media asks the car for focus when it starts. Directions ask only around each announcement,
|
||||
* through [askForSpeakers], and only when the music isn't already ours.
|
||||
*/
|
||||
private val asksFocus: Boolean = true,
|
||||
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
|
||||
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
||||
@@ -58,6 +63,47 @@ class AudioLink(
|
||||
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
/** Sending audio now, which for media means the car has handed us the speakers. */
|
||||
val streaming get() = started
|
||||
|
||||
@Volatile private var holdsSpeakers = false
|
||||
@Volatile private var focusAnswer: CountDownLatch? = null
|
||||
@Volatile private var answerDueBy = 0L
|
||||
|
||||
/** True just after [askForSpeakers] or [handBackSpeakers], when the car's next focus answer is ours. */
|
||||
fun expectsFocusAnswer() = System.currentTimeMillis() < answerDueBy
|
||||
|
||||
/**
|
||||
* For directions when nothing of ours is playing, so the car's speakers belong to the radio
|
||||
* or a Bluetooth phone. Android Auto asks for them before each announcement, letting the
|
||||
* other source duck, and hands them back afterwards. Speaking without asking left a 2025
|
||||
* Nissan's Bosch head unit rebooting mid-drive (issue #35).
|
||||
*
|
||||
* Waits briefly for the car's answer and speaks anyway without one, as some cars never answer.
|
||||
*/
|
||||
fun askForSpeakers() {
|
||||
if (holdsSpeakers) return
|
||||
val answer = CountDownLatch(1)
|
||||
focusAnswer = answer
|
||||
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
|
||||
holdsSpeakers = true
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN_TRANSIENT_MAY_DUCK.toLong()))
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(GAIN_TRANSIENT_MAY_DUCK)")
|
||||
if (!answer.await(FOCUS_WAIT_MS, TimeUnit.MILLISECONDS)) {
|
||||
GearslipLog.i("$tag: no focus answer after ${FOCUS_WAIT_MS}ms - speaking anyway")
|
||||
}
|
||||
focusAnswer = null
|
||||
}
|
||||
|
||||
/** Gives back what [askForSpeakers] took, so the other source comes back to full volume. */
|
||||
fun handBackSpeakers() {
|
||||
if (!holdsSpeakers) return
|
||||
holdsSpeakers = false
|
||||
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
|
||||
runCatching { sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) }
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
|
||||
}
|
||||
|
||||
fun opened(status: Int) {
|
||||
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
@@ -161,6 +207,12 @@ class AudioLink(
|
||||
fun onFocus(state: Int) {
|
||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
||||
if (!asksFocus) {
|
||||
// Directions speak whatever the answer: a lost turn is worse than a loud one.
|
||||
answerDueBy = 0L
|
||||
focusAnswer?.countDown()
|
||||
return
|
||||
}
|
||||
val had = focusGranted
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
||||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
||||
@@ -181,7 +233,19 @@ class AudioLink(
|
||||
startStream()
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards starting and stopping. [start] starts the stream at once for head units that never
|
||||
* answer the focus request, and the car's answer arrives on another thread and starts it
|
||||
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
|
||||
* 2014 head unit dropped the connection on the first audio.
|
||||
*/
|
||||
private val startLock = Any()
|
||||
|
||||
private fun startStream() {
|
||||
synchronized(startLock) { startStreamLocked() }
|
||||
}
|
||||
|
||||
private fun startStreamLocked() {
|
||||
if (!ready || started) return
|
||||
sendOnChannel(
|
||||
MSG_START,
|
||||
@@ -200,6 +264,10 @@ class AudioLink(
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
synchronized(startLock) { stopLocked() }
|
||||
}
|
||||
|
||||
private fun stopLocked() {
|
||||
if (!started) return
|
||||
started = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
@@ -286,6 +354,7 @@ class AudioLink(
|
||||
configTimer?.interrupt()
|
||||
configTimer = null
|
||||
stop()
|
||||
handBackSpeakers()
|
||||
ready = false
|
||||
onClose(this)
|
||||
}
|
||||
@@ -303,7 +372,9 @@ class AudioLink(
|
||||
private const val MSG_SETUP_ANSWER = 255
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
||||
private const val FOCUS_RELEASE = 4
|
||||
private const val FOCUS_WAIT_MS = 500L
|
||||
private const val FOCUS_STATE_GAIN = 1
|
||||
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
|
||||
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
|
||||
|
||||
@@ -44,6 +44,9 @@ object CarAudio {
|
||||
|
||||
@Volatile private var link: Link? = null
|
||||
|
||||
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
|
||||
@Volatile var openSink: (() -> Unit)? = null
|
||||
|
||||
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||
val channels get() = link?.channels ?: 2
|
||||
@@ -71,6 +74,7 @@ object CarAudio {
|
||||
when (_capture.value) {
|
||||
Capture.ON, Capture.ASKING, Capture.DENIED -> return
|
||||
Capture.OFF -> if (link == null) {
|
||||
openSink?.invoke()
|
||||
GearslipLog.i("audio: waiting for the car to offer an audio sink")
|
||||
} else {
|
||||
ask()
|
||||
|
||||
@@ -23,19 +23,19 @@ data class Caller(val number: String, val name: String?) {
|
||||
sealed interface CallUiState {
|
||||
data object None : CallUiState
|
||||
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||
data class Active(val caller: Caller?) : CallUiState
|
||||
/** [since] is the uptime the call went off-hook, for the timer. */
|
||||
data class Active(val caller: Caller?, val since: Long) : CallUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the
|
||||
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||
* [GearslipCallScreening]'s response to decline one that is still ringing, and
|
||||
* [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an
|
||||
* ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends.
|
||||
*
|
||||
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||
* only ever carries media playback.
|
||||
* Call audio stays on the phone's normal voice path; nothing here sends it over the USB link,
|
||||
* which only ever carries media playback.
|
||||
*/
|
||||
object CarCalls {
|
||||
|
||||
@@ -47,9 +47,14 @@ object CarCalls {
|
||||
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||
private var ringingCaller: Caller? = null
|
||||
|
||||
private val _muted = MutableStateFlow(false)
|
||||
val muted: StateFlow<Boolean> = _muted.asStateFlow()
|
||||
private var audio: android.media.AudioManager? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (watcher != null) return
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
audio = context.getSystemService(android.media.AudioManager::class.java)
|
||||
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||
@@ -66,6 +71,7 @@ object CarCalls {
|
||||
watcher = null
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
unmute()
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
|
||||
@@ -74,11 +80,15 @@ object CarCalls {
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
// A mute left on would silence the microphone for everything after the call.
|
||||
unmute()
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
pendingRespond = null
|
||||
_state.value = CallUiState.Active(ringingCaller)
|
||||
if (_state.value !is CallUiState.Active) {
|
||||
_state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis())
|
||||
}
|
||||
}
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
// The number itself only ever arrives through the screening service; if this
|
||||
@@ -109,6 +119,39 @@ object CarCalls {
|
||||
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/** A call Gearslip itself is placing, so the call pill can say who it's to. */
|
||||
fun outgoingCall(context: Context, number: String) {
|
||||
ringingCaller = Caller(number, lookUpName(context, number))
|
||||
}
|
||||
|
||||
fun end(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
@Suppress("DEPRECATION")
|
||||
runCatching { telecom.endCall() }
|
||||
.onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") }
|
||||
.onFailure { GearslipLog.w("calls: end failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val manager = audio ?: return
|
||||
val want = !_muted.value
|
||||
runCatching { manager.isMicrophoneMute = want }
|
||||
.onFailure { GearslipLog.w("calls: mute failed: ${it.message}") }
|
||||
// Read back rather than trusted: some phones let the call app overrule it.
|
||||
_muted.value = manager.isMicrophoneMute
|
||||
if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change")
|
||||
}
|
||||
|
||||
private fun unmute() {
|
||||
if (!_muted.value) return
|
||||
runCatching { audio?.isMicrophoneMute = false }
|
||||
_muted.value = false
|
||||
}
|
||||
|
||||
fun answer(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
|
||||
@@ -33,21 +33,17 @@ import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
|
||||
/**
|
||||
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
|
||||
* whole screen, because nothing else matters more right now; an active call is a thin band along
|
||||
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
|
||||
* Takes the whole car screen while a call is ringing, because nothing else matters more right
|
||||
* now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
fun CallOverlay() {
|
||||
val state by CarCalls.state.collectAsState()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
when (val s = state) {
|
||||
// Covers the box regardless of where the caller aligned it, so the alignment below is
|
||||
// only ever felt by the active-call banner, which is the one that isn't full-screen.
|
||||
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||
is CallUiState.Active -> ActiveCallBanner(s, modifier)
|
||||
CallUiState.None -> {}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,25 +106,3 @@ private fun CallButton(icon: ImageVector, label: String, color: Color, enabled:
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.height(0.dp))
|
||||
Text(
|
||||
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,8 +35,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
@@ -59,13 +63,22 @@ fun CarAppStage(
|
||||
) {
|
||||
val template by connection.template.collectAsStateWithLifecycle()
|
||||
|
||||
val cover = remember { ChromeCover() }
|
||||
val navBar = LocalNavBarInset.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||
// the template it described, laid over the top.
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
}
|
||||
TemplateChrome(
|
||||
template,
|
||||
Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR, bottom = navBar),
|
||||
)
|
||||
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
@@ -84,14 +97,25 @@ private fun AppSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var size by remember { mutableStateOf(0 to 0) }
|
||||
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
val cover = LocalChromeCover.current
|
||||
|
||||
// The app lays its map out around whatever the host covers, so a template change moves the
|
||||
// visible area even though the surface itself hasn't changed.
|
||||
LaunchedEffect(template, size) {
|
||||
val (width, height) = size
|
||||
// The app lays its map out around whatever the host covers, so a template change or a card
|
||||
// changing size moves the visible area even though the surface itself hasn't changed.
|
||||
val (width, height) = size
|
||||
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
||||
// The card column moves the map's centre but isn't taken out of the stable area: apps size
|
||||
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = CoverEdge.START) }
|
||||
LaunchedEffect(template, size, measured, measuredStable) {
|
||||
if (width > 0 && height > 0) {
|
||||
val visible = visibleAreaFor(template, width, height)
|
||||
connection.reportAreas(visible = visible, stable = visible)
|
||||
val visible = visibleAreaFor(template, width, height, measured)
|
||||
val stable = if (!isContentTemplate(template)) {
|
||||
visibleAreaFor(template, width, height, measuredStable)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
connection.reportAreas(visible = visible, stable = stable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +125,9 @@ private fun AppSurface(
|
||||
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
||||
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
||||
// and the lyrics pane opening both resize this area.
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
|
||||
) {
|
||||
val epoch by connection.surfaceEpoch.collectAsState()
|
||||
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
||||
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
||||
@@ -189,28 +215,31 @@ private fun ChromeBar(
|
||||
onDisconnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// The app's name and a way out, nothing else: which template is showing, or whether the
|
||||
// app has drawn yet, is for the log, not the driver.
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.55f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val detail = buildList {
|
||||
status.template?.let { add(it) }
|
||||
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
|
||||
}.joinToString(" - ")
|
||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") }
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
"Close ${status.app ?: "the app"}",
|
||||
onDisconnect,
|
||||
size = 36.dp,
|
||||
iconSize = 20.dp,
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,3 +249,6 @@ private val CHROME_BAR = 44.dp
|
||||
|
||||
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||
private const val FLING_MIN_PX_S = 400f
|
||||
|
||||
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
|
||||
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
|
||||
@@ -0,0 +1,51 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Full screen for the screen that asks for it (the browser, or a video in it): no header and no
|
||||
* nav bar. A swipe in from the top or bottom edge brings the nav bar back for a few seconds,
|
||||
* with a button to leave full screen; [revealed] says when.
|
||||
*/
|
||||
object CarFullscreen {
|
||||
|
||||
var active by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var revealed by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var lastTouch = 0L
|
||||
|
||||
fun enter() {
|
||||
active = true
|
||||
revealed = false
|
||||
}
|
||||
|
||||
fun exit() {
|
||||
active = false
|
||||
revealed = false
|
||||
}
|
||||
|
||||
/** A swipe in from an edge: show the nav bar and the way out. */
|
||||
fun reveal() {
|
||||
if (!active) return
|
||||
lastTouch = SystemClock.uptimeMillis()
|
||||
revealed = true
|
||||
}
|
||||
|
||||
/** Any touch keeps a revealed bar up, so it doesn't slide away under a finger reaching for it. */
|
||||
fun touch() {
|
||||
if (revealed) lastTouch = SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
/** Called on a timer; hides the bar again after [REVEAL_MS] without a touch. */
|
||||
fun tick() {
|
||||
if (revealed && SystemClock.uptimeMillis() - lastTouch >= REVEAL_MS) revealed = false
|
||||
}
|
||||
|
||||
private const val REVEAL_MS = 4_000L
|
||||
}
|
||||
@@ -93,13 +93,14 @@ fun CarHome() {
|
||||
if (onHome) seenHome[0] = true
|
||||
}
|
||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
||||
|
||||
// No outer padding on any side, split screen or not - only the gap between the two panes
|
||||
// when the player is sharing the screen with the map.
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// No padding anywhere, split screen or not: the player sits flush against the map.
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||
if (splitScreen && playerLeft) {
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
@@ -107,8 +108,7 @@ fun CarHome() {
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (splitScreen) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight()
|
||||
if (splitScreen && !playerLeft) {
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ object CarKeyboardVisibility {
|
||||
val state = mutableStateOf(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the car UI the phone's own car preview draws. The phone is in hand there, so a
|
||||
* [CarKeyboard] draws nothing and the phone's keyboard does the typing instead (see [PhoneTyping]).
|
||||
*/
|
||||
val LocalCarPreview = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* A keyboard the host draws itself.
|
||||
*
|
||||
@@ -73,10 +79,11 @@ fun CarKeyboard(
|
||||
) {
|
||||
var symbols by remember { mutableStateOf(false) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
val preview = LocalCarPreview.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
CarKeyboardVisibility.state.value = true
|
||||
onDispose { CarKeyboardVisibility.state.value = false }
|
||||
DisposableEffect(preview) {
|
||||
if (!preview) CarKeyboardVisibility.state.value = true
|
||||
onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
|
||||
}
|
||||
|
||||
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||
@@ -88,6 +95,7 @@ fun CarKeyboard(
|
||||
}
|
||||
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||
if (preview) return
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||
|
||||
@@ -119,35 +119,61 @@ internal object LauncherCache {
|
||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||
}.getOrNull()
|
||||
|
||||
val navApps = CarAppCatalog.installed(context)
|
||||
val mediaApps = MediaCatalog.installed(context)
|
||||
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
|
||||
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
|
||||
// way to tell them apart. They're genuinely different screens - browsing versus Now
|
||||
// Playing - so only the label needs disambiguating, not the tile itself.
|
||||
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
|
||||
val carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
|
||||
|
||||
val messagingApps = MessagingCatalog.installed(context)
|
||||
// Google Messages ships its own car app, which won't start under Gearslip. Gearslip's
|
||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||
|
||||
val nav = navApps
|
||||
.filter { it.component.packageName !in messagingPackages }
|
||||
// An app whose template screen is rejected but whose player works through Gearslip
|
||||
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
||||
// that the car screen isn't supported.
|
||||
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
|
||||
.map {
|
||||
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
||||
Entry(label, iconOf(it.component.packageName), template = it)
|
||||
// One decision per app over everything it offers a car - a car screen, a player, car
|
||||
// notifications - rather than a filter per kind that has to know about the others.
|
||||
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||
val icon by lazy { iconOf(pkg) }
|
||||
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg]).map { tile ->
|
||||
when (tile) {
|
||||
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
|
||||
}
|
||||
}
|
||||
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
||||
// A messenger that is also a car media app keeps only its player tile.
|
||||
val messaging = messagingApps
|
||||
.filter { it.packageName !in mediaPackages }
|
||||
.map { Entry(it.label, iconOf(it.packageName), messaging = it) }
|
||||
return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||
}
|
||||
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** What one app gets on the launcher. */
|
||||
private sealed interface AppTile {
|
||||
class Screen(val app: TemplateApp, val label: String) : AppTile
|
||||
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
|
||||
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles for one app, from what it offers.
|
||||
*
|
||||
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
|
||||
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
|
||||
* one tile under its own name.
|
||||
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
|
||||
* "· Browse", since they really are different screens - unless the screen is known to refuse
|
||||
* Gearslip while the player works, when the Browse tile would only report that.
|
||||
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
|
||||
* Messages screen does the job, so it gets only that - and a messenger that is also a media
|
||||
* app keeps only its player.
|
||||
*/
|
||||
private fun tilesFor(
|
||||
car: TemplateApp?,
|
||||
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
||||
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
||||
): List<AppTile> {
|
||||
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
|
||||
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
|
||||
return buildList {
|
||||
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
||||
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
||||
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
|
||||
add(AppTile.Screen(car, if (media != null) "${car.label} · Browse" else car.label))
|
||||
}
|
||||
if (media != null) add(AppTile.Player(media))
|
||||
else if (messaging != null) add(AppTile.Messages(messaging))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
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.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
|
||||
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
|
||||
|
||||
/**
|
||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||
* back at all, since a car touchscreen has no hardware button for it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
val trail = navigator.trail
|
||||
if (trail.size <= 1) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (isLast) Modifier else Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { navigator.jumpTo(index) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
if (!isLast) {
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||
* more room.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val now by rememberNow()
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries = rememberAllEntries()
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val running = rememberRunningApps()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||
// these two from three screens deep in a media app shouldn't mean reading it
|
||||
// first.
|
||||
NavHomeButton(navigator, entries, running)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
|
||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||
// The map app lives on the Home button, so it never gets a second icon here.
|
||||
val runningEntries = running.keys
|
||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
|
||||
// Next turn first, off the map only; the player shares the space when it fits,
|
||||
// and gives way to the turn when it doesn't.
|
||||
// A call takes the player's place until it ends.
|
||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||
if (call != null) {
|
||||
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||
} else if (turn == null || roomForBoth) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||
// Home button above.
|
||||
// The clock is also the notifications button: the dashboard lists them under the
|
||||
// calendar and weather, and a dot here marks anything unread.
|
||||
val unread by CarNotifications.unread.collectAsState()
|
||||
NavChip(
|
||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||
{ navigator.dashboard() },
|
||||
) {
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-2).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
if (battery.percent >= 0) {
|
||||
Text(
|
||||
"${battery.percent}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (battery.charging) {
|
||||
ChargingGlyph(Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
weatherData?.let { w ->
|
||||
Text(
|
||||
" · ${w.temp}°",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||
val selected = navigator.current == CarScreen.Home
|
||||
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||
GsIconBox(
|
||||
{ navigator.home() },
|
||||
colors = navColors(selected),
|
||||
latched = selected,
|
||||
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||
) {
|
||||
val icon = navEntry?.icon
|
||||
if (icon != null) {
|
||||
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||
@Composable
|
||||
private fun NavDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(1.dp)
|
||||
.height(28.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||
* already open; a long press opens its menu (pin, unpin, close).
|
||||
*/
|
||||
@Composable
|
||||
private fun NavEntryIcon(
|
||||
entry: Entry,
|
||||
running: Map<String, RunningSlot>,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
) {
|
||||
val id = entry.componentId ?: return
|
||||
val slot = running[id]
|
||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||
NavAppIcon(
|
||||
entry,
|
||||
open = slot != null,
|
||||
showing = slot != null && slot.isShowing(navigator.current),
|
||||
onLongClick = { navigator.showAppMenu(id) },
|
||||
) { openApp(entry, slot, navigator, frame) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(
|
||||
entry: Entry,
|
||||
open: Boolean,
|
||||
showing: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.size(44.dp),
|
||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||
latched = showing,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||
if (open) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 2.dp)
|
||||
.size(width = 12.dp, height = 3.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||
@Composable
|
||||
private fun navColors(selected: Boolean): GsColors {
|
||||
val s = MaterialTheme.colorScheme
|
||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||
* two buttons beside it.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavAssistantIcon() {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
val active = state.phase != CarAssistant.Phase.IDLE
|
||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||
private val NAV_ITEM_PADDING = 8.dp
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||
@Composable
|
||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
value = System.currentTimeMillis()
|
||||
delay(60_000 - value % 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||
private const val TITLE_SHOWN_MS = 3_000L
|
||||
|
||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||
|
||||
/** The next manoeuvre, as the running nav app last described it. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||
|
||||
/**
|
||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||
* while it's running, whether or not the map is the screen being shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberNextTurn(): NextTurn? {
|
||||
val status by CarServices.nav.status.collectAsState()
|
||||
val template by CarServices.nav.template.collectAsState()
|
||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||
CarSettings.units.collectAsState().value
|
||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||
if (routing.isLoading) return null
|
||||
val step = routing.currentStep ?: return null
|
||||
return NextTurn(
|
||||
icon = step.maneuver?.icon,
|
||||
distance = routing.currentDistance.display(),
|
||||
road = step.road.text().ifEmpty { step.cue.text() },
|
||||
)
|
||||
}
|
||||
|
||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||
@Composable
|
||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||
colors = gsColors(GsTone.Primary),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(NAV_ITEM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||
if (turn.distance.isNotEmpty()) {
|
||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
if (turn.road.isNotEmpty()) {
|
||||
Text(
|
||||
turn.road,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
|
||||
@Composable
|
||||
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val muted by CarCalls.muted.collectAsState()
|
||||
val elapsed by produceState(0L, call.since) {
|
||||
while (true) {
|
||||
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
|
||||
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
|
||||
}
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier = modifier.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = scheme.secondaryContainer,
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
call.caller?.label ?: "On a call",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
"%d:%02d".format(elapsed / 60, elapsed % 60),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
GsIconButton(
|
||||
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
||||
if (muted) "Unmute" else "Mute",
|
||||
CarCalls::toggleMute,
|
||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.CallEnd, "End call", { CarCalls.end(context) },
|
||||
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { "Nothing playing" }
|
||||
|
||||
/**
|
||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||
// The media screen is the full player already; the pill would only repeat its controls.
|
||||
if (navigator.current == CarScreen.Media) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
val position by produceState(now.currentPosition(), now) {
|
||||
while (true) {
|
||||
value = now.currentPosition()
|
||||
if (!now.playing) break
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||
var announcing by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(now.title, now.artist) {
|
||||
announcing = true
|
||||
delay(TITLE_SHOWN_MS)
|
||||
announcing = false
|
||||
}
|
||||
val showTitle = keepTitle || announcing
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||
contentColor = onPill,
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Art (and the title while it shows) opens the full media screen.
|
||||
Row(
|
||||
Modifier
|
||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
if (showTitle) {
|
||||
Text(
|
||||
nowPlayingLabel(now.title, now.artist),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||
size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
if (now.durationMs > 0) {
|
||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||
.height(2.dp)
|
||||
.background(onPill.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||
* bolt, so this is drawn rather than a vector asset.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(w * 0.58f, 0f)
|
||||
lineTo(w * 0.12f, h * 0.58f)
|
||||
lineTo(w * 0.46f, h * 0.58f)
|
||||
lineTo(w * 0.42f, h)
|
||||
lineTo(w * 0.88f, h * 0.40f)
|
||||
lineTo(w * 0.54f, h * 0.40f)
|
||||
close()
|
||||
}
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,7 @@ import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen.
|
||||
*
|
||||
* [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is
|
||||
* willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering
|
||||
* projects (aasdk, openauto). That mapping is carried over from other projects, not verified
|
||||
* against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is
|
||||
* the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct
|
||||
* any type that turns out to be mislabeled.
|
||||
*/
|
||||
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
|
||||
object CarSensors {
|
||||
|
||||
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
|
||||
@@ -30,16 +21,10 @@ object CarSensors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SensorEventIndication. Every reverse-engineered version of this message multiplexes
|
||||
* every sensor type into one shared set of message-level field numbers (field N = "the data
|
||||
* for sensor type N", roughly) rather than tagging events with the type id from
|
||||
* SensorType.type - so field number is read here as a best-effort type id, same as the
|
||||
* other services in this file.
|
||||
*/
|
||||
/** Each SensorBatch field contains repeated readings of one sensor type. */
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val fields = Wire.fields(body)
|
||||
val fields = Wire.fields(body).filter { it.wireType == 2 }
|
||||
val byType = fields.groupBy { it.number }
|
||||
val updated = byType.mapValues { (number, group) ->
|
||||
Reading(
|
||||
@@ -61,10 +46,7 @@ object CarSensors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensor type ids as named across public Android Auto reverse-engineering references. Treated as
|
||||
* a label, not a fact - see [CarSensors] doc.
|
||||
*/
|
||||
/** Sensor type values shared by the discovery service and SensorBatch. */
|
||||
object SensorType {
|
||||
private val NAMES = mapOf(
|
||||
1 to "Location (GPS)",
|
||||
@@ -89,6 +71,10 @@ object SensorType {
|
||||
20 to "Gyroscope",
|
||||
21 to "GPS satellites",
|
||||
22 to "Toll card",
|
||||
23 to "Vehicle energy model",
|
||||
24 to "Trailer",
|
||||
25 to "Raw vehicle energy model",
|
||||
26 to "Raw EV trip settings",
|
||||
)
|
||||
|
||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||
|
||||
@@ -60,6 +60,13 @@ object CarServices {
|
||||
private var navStopped = false
|
||||
private var autoplayPending = false
|
||||
|
||||
/**
|
||||
* The app that was playing when the driver opened another one. Opening an app is only a look
|
||||
* at its library: until something plays there, the music carries on in this one, so leaving
|
||||
* the new app's screen goes back to it, as Android Auto does.
|
||||
*/
|
||||
private var stillPlaying: MediaApp? = null
|
||||
|
||||
fun init(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||
@@ -130,6 +137,8 @@ object CarServices {
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
if (_mediaApp.value?.component == app.component && live) return
|
||||
|
||||
val previous = _mediaApp.value
|
||||
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
|
||||
_mediaApp.value = app
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
@@ -151,12 +160,26 @@ object CarServices {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The media screen closed. If nothing started in the app it showed, go back to the one that
|
||||
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
|
||||
*/
|
||||
fun mediaScreenClosed() {
|
||||
val back = stillPlaying ?: return
|
||||
stillPlaying = null
|
||||
// A track loaded here, even one paused since, means the driver used this app.
|
||||
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
|
||||
openMedia(back)
|
||||
}
|
||||
|
||||
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
||||
fun closeMedia() {
|
||||
if (media.now.value.playing) media.togglePlay()
|
||||
media.disconnect()
|
||||
_mediaApp.value = null
|
||||
autoplayPending = false
|
||||
stillPlaying = null
|
||||
}
|
||||
|
||||
/** Called once the media app is connected: starts playback if that was asked for. */
|
||||
|
||||
@@ -18,6 +18,33 @@ enum class Units { AUTO, IMPERIAL, METRIC }
|
||||
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
|
||||
enum class AppTheme { PHONE, LIGHT, DARK }
|
||||
|
||||
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
|
||||
data class ScreenSize(val width: Int, val height: Int) {
|
||||
override fun toString() = "${width}x$height"
|
||||
|
||||
companion object {
|
||||
fun parse(text: String?): ScreenSize? {
|
||||
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
|
||||
if (parts.size != 2) return null
|
||||
val w = parts[0].trim().toIntOrNull() ?: return null
|
||||
val h = parts[1].trim().toIntOrNull() ?: return null
|
||||
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
|
||||
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
|
||||
*/
|
||||
enum class ScreenFit { CROP, SQUASH }
|
||||
|
||||
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
|
||||
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
|
||||
|
||||
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
|
||||
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
|
||||
|
||||
/**
|
||||
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
|
||||
* button is tapped, and persisted through [AppSettings] so they survive the next connection.
|
||||
@@ -36,6 +63,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
@@ -43,8 +71,15 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
|
||||
private const val KEY_DEBUG_BADGE = "debug_badge"
|
||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||
private const val KEY_UNITS = "car_units"
|
||||
private const val KEY_NAV_BAR = "car_nav_bar"
|
||||
private const val KEY_SCREEN_SIZE = "car_screen_size"
|
||||
private const val KEY_SCREEN_FIT = "car_screen_fit"
|
||||
private const val KEY_PREVIEW_SIZE = "preview_size"
|
||||
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
|
||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
||||
|
||||
@@ -68,6 +103,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||
@@ -79,11 +115,50 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||
private val systemSpeechFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||
private val debugBadgeFlow = MutableStateFlow(true)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
|
||||
val units: StateFlow<Units> = unitsFlow
|
||||
|
||||
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
|
||||
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
|
||||
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
|
||||
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
|
||||
|
||||
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
|
||||
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
|
||||
val screenFit: StateFlow<ScreenFit> = screenFitFlow
|
||||
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
|
||||
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
|
||||
|
||||
/** Takes effect the next time the car connects: the video size is agreed at the start. */
|
||||
fun setScreenSize(size: ScreenSize?) {
|
||||
screenSizeFlow.value = size
|
||||
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
|
||||
}
|
||||
|
||||
fun setScreenFit(fit: ScreenFit) {
|
||||
screenFitFlow.value = fit
|
||||
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
|
||||
}
|
||||
|
||||
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
|
||||
previewSizeFlow.value = size
|
||||
previewCustomFlow.value = custom
|
||||
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
|
||||
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
|
||||
}
|
||||
|
||||
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
|
||||
val navBarMode: StateFlow<NavBarMode> = navBarFlow
|
||||
|
||||
fun setNavBarMode(mode: NavBarMode) {
|
||||
navBarFlow.value = mode
|
||||
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
|
||||
}
|
||||
|
||||
|
||||
private val whatsNewFlow = MutableStateFlow(true)
|
||||
private val whatsNewSeenFlow = MutableStateFlow(0)
|
||||
@@ -146,6 +221,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
*/
|
||||
val playerTitle: StateFlow<Boolean> = playerTitleFlow
|
||||
|
||||
/** The home screen's player sits left of the map instead of right of it. */
|
||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
val openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
@@ -198,6 +276,20 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
*/
|
||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||
|
||||
/**
|
||||
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
|
||||
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
|
||||
*/
|
||||
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
|
||||
|
||||
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
|
||||
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
||||
|
||||
fun setDebugBadge(on: Boolean) {
|
||||
debugBadgeFlow.value = on
|
||||
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 -
|
||||
@@ -223,6 +315,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
@@ -239,10 +332,23 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
|
||||
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
|
||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||
unitsFlow.value = runCatching {
|
||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||
}.getOrDefault(Units.AUTO)
|
||||
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
|
||||
screenFitFlow.value = runCatching {
|
||||
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
|
||||
}.getOrDefault(ScreenFit.CROP)
|
||||
previewSizeFlow.value = runCatching {
|
||||
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
|
||||
}.getOrDefault(PreviewSize.LAST_CAR)
|
||||
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
|
||||
navBarFlow.value = runCatching {
|
||||
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
|
||||
}.getOrDefault(NavBarMode.SHOW)
|
||||
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
|
||||
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
|
||||
}
|
||||
@@ -282,6 +388,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||
}
|
||||
|
||||
fun setPlayerLeft(on: Boolean) {
|
||||
playerLeftFlow.value = on
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
@@ -325,6 +436,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
|
||||
}
|
||||
|
||||
fun setAdaptiveVideoEnabled(on: Boolean) {
|
||||
adaptiveVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setCarSensorsEnabled(on: Boolean) {
|
||||
carSensorsFlow.value = on
|
||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||
|
||||
@@ -5,12 +5,15 @@ import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -51,6 +54,9 @@ fun CarSettingsScreen() {
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val night by CarSettings.nightMode.collectAsState()
|
||||
val units by CarSettings.units.collectAsState()
|
||||
val navBar by CarSettings.navBarMode.collectAsState()
|
||||
val screenSize by CarSettings.screenSize.collectAsState()
|
||||
val screenFit by CarSettings.screenFit.collectAsState()
|
||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||
@@ -58,10 +64,12 @@ fun CarSettingsScreen() {
|
||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
@@ -70,140 +78,233 @@ fun CarSettingsScreen() {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Settings")
|
||||
item {
|
||||
ScreenHeader("Settings")
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
item { Group("Screen") }
|
||||
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Units",
|
||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Units",
|
||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Screen size (next connection)",
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: "From the car") },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Car audio",
|
||||
listOf(false to "Bluetooth", true to "USB"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||
"Android never asks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!pipeAudio) {
|
||||
if (screenSize != null) item {
|
||||
ChoiceRow(
|
||||
"Fit to the screen",
|
||||
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
item { Group("Nav bar and player") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Nav bar",
|
||||
listOf(
|
||||
NavBarMode.SHOW to "Always show",
|
||||
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||
NavBarMode.HIDE to "Hide until tapped",
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Player beside the map",
|
||||
listOf(false to "Right", true to "Left"),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
item { Group("Sound and voice") }
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Car audio",
|
||||
listOf(false to "Bluetooth", true to "USB"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||
"Android never asks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item { Group("Warnings") }
|
||||
|
||||
if (!pipeAudio) item {
|
||||
ChoiceRow(
|
||||
"Warn when Bluetooth isn't connected",
|
||||
listOf(false to "Off", true to "On"),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
ChoiceRow(
|
||||
"Warn when location is off",
|
||||
listOf(false to "Off", true to "On"),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Spoken directions",
|
||||
listOf(false to "Off", true to "On"),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
Text(
|
||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
||||
"speaks. Turn it off if your map app's own voice already plays in the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
ExperimentalFeatureRow(
|
||||
title = "Use the phone's speech recognition",
|
||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
||||
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
|
||||
"none, so Gearslip keeps using its own.",
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
|
||||
WebPageSetting()
|
||||
|
||||
ScreenFit(vehicle)
|
||||
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
if (experimentalFeatures) {
|
||||
ExperimentalFeaturesList(carSensors)
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Warn when location is off",
|
||||
listOf(false to "Off", true to "On"),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Spoken directions",
|
||||
listOf(false to "Off", true to "On"),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
item {
|
||||
Text(
|
||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
||||
"speaks. Turn it off if your map app's own voice already plays in the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ExperimentalFeatureRow(
|
||||
"What's new after updates",
|
||||
"Show the list of changes once, the first time the car connects after an update.",
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
||||
title = "Use the phone's speech recognition",
|
||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
||||
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
|
||||
"none, so Gearslip keeps using its own.",
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
item { Group("More") }
|
||||
|
||||
item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo)
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
ExperimentalFeatureRow(
|
||||
"What's new after updates",
|
||||
"Show the list of changes once, the first time the car connects after an update.",
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,8 +312,37 @@ fun CarSettingsScreen() {
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth >= 720.dp && options.size <= 4) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
|
||||
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
ChoiceButtons(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A group of settings: a heading, so a long list reads as a few short ones. */
|
||||
@Composable
|
||||
private fun Group(title: String) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
run {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
options.forEach { (value, label) ->
|
||||
val on = value == selected
|
||||
@@ -239,7 +369,7 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
||||
* so the list reads fine whether there's one entry or several.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean) {
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -252,6 +382,12 @@ private fun ExperimentalFeaturesList(carSensors: Boolean) {
|
||||
"guess, not a fact, until you've watched one change for yourself.",
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = "Adaptive video quality",
|
||||
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
|
||||
"up, and raises it again once it can. Takes effect on the next connection.",
|
||||
checked = adaptiveVideo,
|
||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,3 +542,8 @@ private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Common car screen sizes; Settings on the phone takes any other. */
|
||||
private val SCREEN_PRESETS = listOf(
|
||||
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
|
||||
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
|
||||
)
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
@@ -72,6 +75,7 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
@@ -114,7 +118,7 @@ fun CarUi() {
|
||||
AppTheme.LIGHT -> false
|
||||
AppTheme.DARK -> true
|
||||
}
|
||||
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)
|
||||
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark)
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
@@ -123,6 +127,7 @@ fun CarUi() {
|
||||
while (true) {
|
||||
delay(250)
|
||||
controlsWake.tick()
|
||||
CarFullscreen.tick()
|
||||
}
|
||||
}
|
||||
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
|
||||
@@ -193,10 +198,25 @@ fun CarUi() {
|
||||
.fillMaxSize()
|
||||
// Watches every touch without taking any: first in line, consumes nothing.
|
||||
.pointerInput(controlsWake) {
|
||||
// In full screen, a swipe that starts at the top or bottom edge brings the
|
||||
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
|
||||
val edge = 32.dp.toPx()
|
||||
val slop = 24.dp.toPx()
|
||||
var startY = -1f
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial)
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
controlsWake.touch()
|
||||
CarFullscreen.touch()
|
||||
val change = event.changes.firstOrNull() ?: continue
|
||||
if (!CarFullscreen.active) continue
|
||||
if (change.pressed && !change.previousPressed) {
|
||||
val y = change.position.y
|
||||
startY = if (y < edge || y > size.height - edge) y else -1f
|
||||
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
|
||||
CarFullscreen.reveal()
|
||||
startY = -1f
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,6 +231,26 @@ fun CarUi() {
|
||||
}
|
||||
val screen = navigator.current
|
||||
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||
val keyboardVisible by CarKeyboardVisibility.state
|
||||
// Off "Always show", the bar floats over the map rather than taking a row of its
|
||||
// own: hiding it then leaves the map's surface the size it was. Resizing it would
|
||||
// hand the map app a new surface each time, and some go black on that.
|
||||
val navBarMode by CarSettings.navBarMode.collectAsState()
|
||||
val appTemplate by CarServices.nav.template.collectAsState()
|
||||
val floatingBar = navBarMode != NavBarMode.SHOW
|
||||
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
||||
NavBarMode.SHOW -> false
|
||||
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
|
||||
NavBarMode.HIDE -> true
|
||||
}
|
||||
// A full wait from when hiding becomes possible, not from whenever the last touch was.
|
||||
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
|
||||
// A call keeps the bar up whatever the setting: End and Mute live on it.
|
||||
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
|
||||
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
|
||||
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Only the screen that asked: going Home or anywhere else ends it.
|
||||
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
||||
Box(Modifier.fillMaxSize().padding(pad)) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (!safetyWarningSeen) {
|
||||
@@ -223,12 +263,29 @@ fun CarUi() {
|
||||
// Home never leaves the composition; other screens cover it. Dropping it
|
||||
// would tear down the map's surface, and coming back would then show black
|
||||
// while the nav app redrew on a new one.
|
||||
CarHome()
|
||||
CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
|
||||
}
|
||||
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||
// show both.
|
||||
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
|
||||
}
|
||||
// Every other screen, and everything that pops up, in a layer over Home rather
|
||||
// than inside its area: full screen grows this layer over the nav bar's row while
|
||||
// Home, and the map's surface in it, keeps its size.
|
||||
if (safetyWarningSeen) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
|
||||
) {
|
||||
if (screen != CarScreen.Home) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The floating bar never hides here, so keep the screen clear of it.
|
||||
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
|
||||
// Keeps touches that miss this screen's controls off the map.
|
||||
.pointerInput(Unit) { detectTapGestures { } },
|
||||
) {
|
||||
@@ -247,7 +304,7 @@ fun CarUi() {
|
||||
}
|
||||
}
|
||||
}
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
CallPromptOverlay()
|
||||
BluetoothPairOverlay()
|
||||
PopupTimer()
|
||||
@@ -258,21 +315,39 @@ fun CarUi() {
|
||||
)
|
||||
// Above the popup: a call is more urgent than any notification.
|
||||
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
||||
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||
AssistantOverlay(Modifier.align(Alignment.BottomCenter))
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||
CallOverlay()
|
||||
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
val badge by CarSettings.debugBadge.collectAsState()
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
|
||||
// Bottom corner, not top: a screen's own header almost always puts
|
||||
// something in the top-right (settings, search, a Liked Songs
|
||||
// shortcut) and this badge used to sit right on top of it, hiding
|
||||
// whatever the screen drew there.
|
||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd))
|
||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||
}
|
||||
if (floatingBar || fullscreen) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
enter = androidx.compose.animation.slideInVertically { it },
|
||||
exit = androidx.compose.animation.slideOutVertically { it },
|
||||
) { CarNavBar(navigator) }
|
||||
}
|
||||
if (fullscreen) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
CarFullscreen.revealed,
|
||||
Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
enter = androidx.compose.animation.slideInVertically { -it },
|
||||
exit = androidx.compose.animation.slideOutVertically { -it },
|
||||
) {
|
||||
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
||||
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Exit full screen", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||
// show both.
|
||||
val keyboardVisible by CarKeyboardVisibility.state
|
||||
if (!keyboardVisible) CarNavBar(navigator)
|
||||
}
|
||||
// Over everything, nav bar included: a long press on an app, anywhere.
|
||||
AppMenu(navigator, rememberRunningApps())
|
||||
@@ -284,6 +359,14 @@ fun CarUi() {
|
||||
}
|
||||
}
|
||||
|
||||
internal val NAV_BAR_HEIGHT = 56.dp
|
||||
|
||||
/**
|
||||
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
|
||||
* zero while it's hidden or when it has a row of its own.
|
||||
*/
|
||||
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
|
||||
|
||||
/**
|
||||
* A single blocking screen, shown once per install before anything else the car screen can show -
|
||||
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
|
||||
@@ -398,512 +481,6 @@ private fun DebugBuildBadge(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||
* back at all, since a car touchscreen has no hardware button for it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
val trail = navigator.trail
|
||||
if (trail.size <= 1) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (isLast) Modifier else Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { navigator.jumpTo(index) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
if (!isLast) {
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||
* more room.
|
||||
*/
|
||||
@Composable
|
||||
private fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val now by rememberNow()
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries = rememberAllEntries()
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val running = rememberRunningApps()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||
// these two from three screens deep in a media app shouldn't mean reading it
|
||||
// first.
|
||||
NavHomeButton(navigator, entries, running)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
|
||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||
// The map app lives on the Home button, so it never gets a second icon here.
|
||||
val runningEntries = running.keys
|
||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
|
||||
// Next turn first, off the map only; the player shares the space when it fits,
|
||||
// and gives way to the turn when it doesn't.
|
||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||
if (turn == null || roomForBoth) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||
// Home button above.
|
||||
// The clock is also the notifications button: the dashboard lists them under the
|
||||
// calendar and weather, and a dot here marks anything unread.
|
||||
val unread by CarNotifications.unread.collectAsState()
|
||||
NavChip(
|
||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||
{ navigator.dashboard() },
|
||||
) {
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-2).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
if (battery.percent >= 0) {
|
||||
Text(
|
||||
"${battery.percent}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (battery.charging) {
|
||||
ChargingGlyph(Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
weatherData?.let { w ->
|
||||
Text(
|
||||
" · ${w.temp}°",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||
val selected = navigator.current == CarScreen.Home
|
||||
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||
GsIconBox(
|
||||
{ navigator.home() },
|
||||
colors = navColors(selected),
|
||||
latched = selected,
|
||||
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||
) {
|
||||
val icon = navEntry?.icon
|
||||
if (icon != null) {
|
||||
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = "Home",
|
||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||
@Composable
|
||||
private fun NavDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(1.dp)
|
||||
.height(28.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||
* already open; a long press opens its menu (pin, unpin, close).
|
||||
*/
|
||||
@Composable
|
||||
private fun NavEntryIcon(
|
||||
entry: Entry,
|
||||
running: Map<String, RunningSlot>,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
) {
|
||||
val id = entry.componentId ?: return
|
||||
val slot = running[id]
|
||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||
NavAppIcon(
|
||||
entry,
|
||||
open = slot != null,
|
||||
showing = slot != null && slot.isShowing(navigator.current),
|
||||
onLongClick = { navigator.showAppMenu(id) },
|
||||
) { openApp(entry, slot, navigator, frame) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(
|
||||
entry: Entry,
|
||||
open: Boolean,
|
||||
showing: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.size(44.dp),
|
||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||
latched = showing,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||
if (open) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 2.dp)
|
||||
.size(width = 12.dp, height = 3.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||
@Composable
|
||||
private fun navColors(selected: Boolean): GsColors {
|
||||
val s = MaterialTheme.colorScheme
|
||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||
* two buttons beside it.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavAssistantIcon() {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
val active = state.phase != CarAssistant.Phase.IDLE
|
||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||
private val NAV_ITEM_PADDING = 8.dp
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||
@Composable
|
||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
value = System.currentTimeMillis()
|
||||
delay(60_000 - value % 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||
private const val TITLE_SHOWN_MS = 3_000L
|
||||
|
||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||
|
||||
/** The next manoeuvre, as the running nav app last described it. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||
|
||||
/**
|
||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||
* while it's running, whether or not the map is the screen being shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberNextTurn(): NextTurn? {
|
||||
val status by CarServices.nav.status.collectAsState()
|
||||
val template by CarServices.nav.template.collectAsState()
|
||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||
CarSettings.units.collectAsState().value
|
||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||
if (routing.isLoading) return null
|
||||
val step = routing.currentStep ?: return null
|
||||
return NextTurn(
|
||||
icon = step.maneuver?.icon,
|
||||
distance = routing.currentDistance.display(),
|
||||
road = step.road.text().ifEmpty { step.cue.text() },
|
||||
)
|
||||
}
|
||||
|
||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||
@Composable
|
||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||
colors = gsColors(GsTone.Primary),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(NAV_ITEM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||
if (turn.distance.isNotEmpty()) {
|
||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
if (turn.road.isNotEmpty()) {
|
||||
Text(
|
||||
turn.road,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { "Nothing playing" }
|
||||
|
||||
/**
|
||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||
// The media screen is the full player already; the pill would only repeat its controls.
|
||||
if (navigator.current == CarScreen.Media) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
val position by produceState(now.currentPosition(), now) {
|
||||
while (true) {
|
||||
value = now.currentPosition()
|
||||
if (!now.playing) break
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||
var announcing by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(now.title, now.artist) {
|
||||
announcing = true
|
||||
delay(TITLE_SHOWN_MS)
|
||||
announcing = false
|
||||
}
|
||||
val showTitle = keepTitle || announcing
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||
contentColor = onPill,
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Art (and the title while it shows) opens the full media screen.
|
||||
Row(
|
||||
Modifier
|
||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
if (showTitle) {
|
||||
Text(
|
||||
nowPlayingLabel(now.title, now.artist),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||
size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
if (now.durationMs > 0) {
|
||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||
.height(2.dp)
|
||||
.background(onPill.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||
* bolt, so this is drawn rather than a vector asset.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(w * 0.58f, 0f)
|
||||
lineTo(w * 0.12f, h * 0.58f)
|
||||
lineTo(w * 0.46f, h * 0.58f)
|
||||
lineTo(w * 0.42f, h)
|
||||
lineTo(w * 0.88f, h * 0.40f)
|
||||
lineTo(w * 0.54f, h * 0.40f)
|
||||
close()
|
||||
}
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
||||
private object NoHaptics : HapticFeedback {
|
||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
||||
|
||||
@@ -24,23 +24,36 @@ import app.seb3thehacker.gearslip.Protobuf
|
||||
*/
|
||||
class ClusterNav(
|
||||
val channelId: Int,
|
||||
minimumIntervalMs: Long = 0,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
|
||||
@Volatile private var running = false
|
||||
private var worker: Thread? = null
|
||||
private var updatesRequested = true
|
||||
private var sentStatus = -1
|
||||
private var sentTurn: String? = null
|
||||
private var sentDistance: String? = null
|
||||
|
||||
@Synchronized
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
|
||||
MSG_START -> GearslipLog.i("cluster: <- start")
|
||||
MSG_STOP -> GearslipLog.i("cluster: <- stop")
|
||||
MSG_START -> {
|
||||
updatesRequested = true
|
||||
sentStatus = -1
|
||||
sentTurn = null
|
||||
sentDistance = null
|
||||
GearslipLog.i("cluster: <- start")
|
||||
}
|
||||
MSG_STOP -> {
|
||||
updatesRequested = false
|
||||
GearslipLog.i("cluster: <- stop")
|
||||
}
|
||||
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
@@ -48,19 +61,28 @@ class ClusterNav(
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread({
|
||||
worker = Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
|
||||
Thread.sleep(TICK_MS)
|
||||
try {
|
||||
Thread.sleep(updateIntervalMs)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}, "gearslip-cluster").start()
|
||||
}, "gearslip-cluster").also { it.start() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
worker?.interrupt()
|
||||
worker = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun tick() {
|
||||
if (!running || !updatesRequested) return
|
||||
val turn = NavTurn.current()
|
||||
val status = when {
|
||||
turn == null -> STATUS_INACTIVE
|
||||
@@ -82,7 +104,7 @@ class ClusterNav(
|
||||
val maneuver = step.maneuver
|
||||
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
|
||||
val road = step.road.text().ifBlank { step.cue.text() }
|
||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
|
||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
|
||||
if (key == sentTurn) return
|
||||
sentTurn = key
|
||||
var body = Protobuf.stringField(1, road) +
|
||||
|
||||
@@ -27,7 +27,8 @@ import kotlin.math.roundToInt
|
||||
* same turn the instrument cluster gets ([NavTurn]) and says it itself: once when the turn comes
|
||||
* up, again a kilometre or so out on a long stretch, and once more just before it. The words are
|
||||
* rendered with the phone's text-to-speech to a file, never played on the phone, and streamed to
|
||||
* the car, which ducks the music under them the way it does for real Android Auto.
|
||||
* the car, which ducks the music under them the way it does for real Android Auto. When the music
|
||||
* isn't Gearslip's, each announcement first asks the car for its speakers, as Android Auto does.
|
||||
*
|
||||
* Nothing spoken is logged: road names are where the driver is going.
|
||||
*/
|
||||
@@ -42,7 +43,10 @@ object DirectionsVoice {
|
||||
|
||||
@Volatile private var link: AudioLink? = null
|
||||
@Volatile private var running = false
|
||||
private val speaker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
|
||||
@Volatile private var musicIsOurs: () -> Boolean = { false }
|
||||
private val speaker = Executors.newSingleThreadScheduledExecutor { Thread(it, "gearslip-directions").apply { isDaemon = true } }
|
||||
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
|
||||
private var spoken = 0
|
||||
@Volatile private var latest = 0
|
||||
|
||||
private var turnKey: String? = null
|
||||
@@ -62,8 +66,9 @@ object DirectionsVoice {
|
||||
}
|
||||
|
||||
/** The car opened its directions sink: start following the map app's turns. */
|
||||
fun attach(link: AudioLink) {
|
||||
fun attach(link: AudioLink, musicIsOurs: () -> Boolean) {
|
||||
this.link = link
|
||||
this.musicIsOurs = musicIsOurs
|
||||
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
||||
if (running) return
|
||||
running = true
|
||||
@@ -82,16 +87,26 @@ object DirectionsVoice {
|
||||
running = false
|
||||
}
|
||||
|
||||
/** Why the last turn wasn't spoken, so the log says it once rather than every tick. */
|
||||
private var silentBecause: String? = null
|
||||
|
||||
private fun quiet(reason: String) {
|
||||
if (silentBecause == reason) return
|
||||
silentBecause = reason
|
||||
GearslipLog.i("guidance: not speaking - $reason")
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
if (!CarSettings.speakDirections.value) return
|
||||
val turn = NavTurn.current()
|
||||
if (turn == null) {
|
||||
turnKey = null
|
||||
return
|
||||
}
|
||||
if (turn.rerouting) return
|
||||
if (!CarSettings.speakDirections.value) return quiet("Spoken directions is off in the car's Settings")
|
||||
if (turn.rerouting) return quiet("the map app is rerouting")
|
||||
val step = turn.step
|
||||
val instruction = instruction(step) ?: return
|
||||
val instruction = instruction(step) ?: return quiet("the map app gave this turn no words")
|
||||
silentBecause = null
|
||||
val meters = turn.distance?.let(NavTurn::toMeters)
|
||||
val close = (turn.seconds != null && turn.seconds <= NEAR_SECONDS) || (meters != null && meters <= NEAR_METERS)
|
||||
|
||||
@@ -223,7 +238,16 @@ object DirectionsVoice {
|
||||
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
|
||||
return
|
||||
}
|
||||
stream(sink, pcm)
|
||||
// Logged before speaking too, so a car that dies mid-announcement shows what it was doing.
|
||||
GearslipLog.i("guidance: speaking a turn")
|
||||
// Held through back-to-back prompts rather than asked for and given back each time.
|
||||
if (!musicIsOurs()) sink.askForSpeakers()
|
||||
val turn = ++spoken
|
||||
try {
|
||||
stream(sink, pcm)
|
||||
} finally {
|
||||
speaker.schedule({ if (turn == spoken) sink.handBackSpeakers() }, HOLD_MS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
GearslipLog.i("guidance: spoke a turn (${pcm.size / (2 * sink.channels) * 1000 / sink.sampleRate} ms)")
|
||||
}
|
||||
|
||||
@@ -321,6 +345,7 @@ object DirectionsVoice {
|
||||
private const val PREPARE_FROM_METERS = 2_500
|
||||
private const val PREPARE_AT_METERS = 1_000
|
||||
private const val LEAD_MS = 100L
|
||||
private const val HOLD_MS = 2_000L
|
||||
private const val SYNTH_TIMEOUT_S = 10L
|
||||
private val ORDINALS = listOf("first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth")
|
||||
}
|
||||
@@ -11,27 +11,35 @@ import android.os.SystemClock
|
||||
*
|
||||
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
|
||||
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
|
||||
* writes state when the buttons were actually hidden.
|
||||
* writes state when the buttons were actually hidden. The nav bar, when set to hide, goes the
|
||||
* same way but waits longer ([NAV_BAR_IDLE_MS]).
|
||||
*/
|
||||
class MapControlsWake {
|
||||
|
||||
var awake by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var navBarAwake by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var lastTouch = SystemClock.uptimeMillis()
|
||||
|
||||
fun touch() {
|
||||
lastTouch = SystemClock.uptimeMillis()
|
||||
if (!awake) awake = true
|
||||
if (!navBarAwake) navBarAwake = true
|
||||
}
|
||||
|
||||
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
|
||||
fun tick() {
|
||||
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
|
||||
val idle = SystemClock.uptimeMillis() - lastTouch
|
||||
if (awake && idle >= IDLE_MS) awake = false
|
||||
if (navBarAwake && idle >= NAV_BAR_IDLE_MS) navBarAwake = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val IDLE_MS = 4_000L
|
||||
const val NAV_BAR_IDLE_MS = 8_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import android.provider.Settings
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import app.seb3thehacker.gearslip.media.LyricsResult
|
||||
import app.seb3thehacker.gearslip.media.Lyrics
|
||||
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||
import android.media.session.PlaybackState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
||||
// The media screen's browse list, up-next queue, rows and icons. Split out of MediaScreen.kt.
|
||||
|
||||
@Composable
|
||||
internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
browse.unavailable -> Notice(
|
||||
"$appLabel keeps its library to itself.",
|
||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||
)
|
||||
browse.failed -> Notice("Could not load this list.", null)
|
||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- up next -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||
*/
|
||||
@Composable
|
||||
internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val upNext = remember(queue, now.activeQueueItemId) {
|
||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||
if (current >= 0) queue.drop(current + 1) else queue
|
||||
}
|
||||
Column(modifier) {
|
||||
if (upNext.isEmpty()) {
|
||||
Notice(
|
||||
"Nothing queued.",
|
||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||
"not every app tells Gearslip what comes next.",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.playQueueItem(track) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (track.subtitle.isNotEmpty()) {
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (entry.browsable && entry.playable) {
|
||||
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||
}
|
||||
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's artwork, or a folder or note in its place when the app sends none. */
|
||||
@Composable
|
||||
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
||||
Box(
|
||||
Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else Icon(fallback, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's transport icons, from their published path data (they live in the extended set). */
|
||||
internal object MediaIcons {
|
||||
val MusicNote = icon("MusicNote", "M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z")
|
||||
val Folder = icon("Folder", "M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z")
|
||||
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
|
||||
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
|
||||
/** The 3x3 app grid; not in material-icons-core. */
|
||||
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
|
||||
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
|
||||
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
|
||||
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
|
||||
|
||||
val CallEnd = icon("CallEnd", "M12,9c-1.6,0 -3.15,0.25 -4.6,0.72v3.1c0,0.39 -0.23,0.74 -0.56,0.9 -0.98,0.49 -1.87,1.12 -2.66,1.85 -0.18,0.18 -0.43,0.28 -0.7,0.28 -0.28,0 -0.53,-0.11 -0.71,-0.29L0.29,13.08c-0.18,-0.17 -0.29,-0.42 -0.29,-0.7 0,-0.28 0.11,-0.53 0.29,-0.71C3.34,8.78 7.46,7 12,7s8.66,1.78 11.71,4.67c0.18,0.18 0.29,0.43 0.29,0.71 0,0.28 -0.11,0.53 -0.29,0.71l-2.48,2.48c-0.18,0.18 -0.43,0.29 -0.71,0.29 -0.27,0 -0.52,-0.11 -0.7,-0.28 -0.79,-0.74 -1.69,-1.36 -2.67,-1.85 -0.33,-0.16 -0.56,-0.5 -0.56,-0.9v-3.1C15.15,9.25 13.6,9 12,9z")
|
||||
val Mic = icon("Mic", "M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z")
|
||||
val MicOff = icon("MicOff", "M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z")
|
||||
|
||||
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
||||
name, 24.dp, 24.dp, 24f, 24f,
|
||||
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
||||
}
|
||||
@@ -93,6 +93,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { CarServices.mediaScreenClosed() } }
|
||||
|
||||
val art = rememberArt(now)
|
||||
|
||||
@@ -444,7 +445,7 @@ private sealed interface LyricsState {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||
internal fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -491,7 +492,14 @@ private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||
) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
if (art != null) {
|
||||
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
// No cover (or nothing playing): a note, so the square reads as a cover, not a hole.
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(MediaIcons.MusicNote, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize(0.35f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,6 +526,12 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
if (now.title.isEmpty() && now.error == null) {
|
||||
Text(
|
||||
"Press play to pick up where you left off, or find something in Browse.",
|
||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth())
|
||||
@@ -555,141 +569,3 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier)
|
||||
|
||||
// --- browsing --------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
browse.unavailable -> Notice(
|
||||
"$appLabel keeps its library to itself.",
|
||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
||||
)
|
||||
browse.failed -> Notice("Could not load this list.", null)
|
||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- up next -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||
*/
|
||||
@Composable
|
||||
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val upNext = remember(queue, now.activeQueueItemId) {
|
||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||
if (current >= 0) queue.drop(current + 1) else queue
|
||||
}
|
||||
Column(modifier) {
|
||||
if (upNext.isEmpty()) {
|
||||
Notice(
|
||||
"Nothing queued.",
|
||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||
"not every app tells Gearslip what comes next.",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.playQueueItem(track) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (track.subtitle.isNotEmpty()) {
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (entry.subtitle.isNotEmpty()) {
|
||||
Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (entry.browsable && entry.playable) {
|
||||
GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) })
|
||||
}
|
||||
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's transport icons, from their published path data (they live in the extended set). */
|
||||
internal object MediaIcons {
|
||||
val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z")
|
||||
val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z")
|
||||
/** The 3x3 app grid; not in material-icons-core. */
|
||||
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
|
||||
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
|
||||
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
|
||||
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
||||
name, 24.dp, 24.dp, 24f, 24f,
|
||||
).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Settings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -80,14 +81,24 @@ internal fun loadContacts(context: Context): List<Contact> {
|
||||
return out.distinctBy { it.number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the call through Telecom directly. Starting ACTION_CALL from here launched the system's
|
||||
* call activity onto the car's display, which Android refuses for another app's activity, and
|
||||
* from the background, which it blocks too - so the call never went out.
|
||||
*/
|
||||
internal fun placeCall(context: Context, number: String) {
|
||||
if (number.isBlank()) return
|
||||
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: can't place a call without CALL_PHONE")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(android.telecom.TelecomManager::class.java)
|
||||
runCatching { telecom!!.placeCall(Uri.fromParts("tel", number, null), null) }
|
||||
.onSuccess {
|
||||
GearslipLog.i("calls: placing a call")
|
||||
app.seb3thehacker.gearslip.call.CarCalls.outgoingCall(context, number)
|
||||
}
|
||||
.onFailure { GearslipLog.w("calls: placing a call failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
|
||||
@@ -122,7 +133,7 @@ private const val TAB_KEYPAD = 1
|
||||
@Composable
|
||||
fun PhoneDialerScreen() {
|
||||
val context = LocalContext.current
|
||||
val hasPermissions = hasCallPermissions(context)
|
||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
@@ -137,7 +148,7 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
if (!hasPermissions) {
|
||||
MissingPermission(context)
|
||||
MissingPermission(context) { hasPermissions = true }
|
||||
return@Column
|
||||
}
|
||||
|
||||
@@ -148,19 +159,23 @@ fun PhoneDialerScreen() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingPermission(context: Context) {
|
||||
private fun MissingPermission(context: Context, onGranted: () -> Unit) {
|
||||
// Android's dialog can't show on the car screen, so it opens on the phone; checking again
|
||||
// every second picks the answer up without the driver having to come back in.
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
while (!hasCallPermissions(context)) kotlinx.coroutines.delay(1_000)
|
||||
onGranted()
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
||||
"from the app's settings.",
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
|
||||
"the phone when you're parked.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text("Open app settings") }
|
||||
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
||||
}) { Text("Ask on the phone") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.geometry.Rect as ComposeRect
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -10,6 +17,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -86,87 +94,203 @@ fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) {
|
||||
* How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of
|
||||
* it. Expressed as the visible rectangle within a [width] x [height] surface.
|
||||
*
|
||||
* Navigation's cards are measured where they land ([measured], from [ChromeCover]); an app
|
||||
* centres its position marker in this rectangle, so a guess puts the marker under the card.
|
||||
* A template that isn't map-backed covers the surface completely, and reporting an empty
|
||||
* rectangle is how the app is told to stop bothering.
|
||||
*/
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) {
|
||||
is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt())
|
||||
fun visibleAreaFor(template: Template?, width: Int, height: Int, measured: Rect? = null): Rect = when (template) {
|
||||
is NavigationTemplate -> measured ?: Rect(0, 0, width, height)
|
||||
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
|
||||
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height)
|
||||
-> measured ?: Rect((width * CARD_COLUMN_FRACTION).toInt(), 0, width, height)
|
||||
null -> Rect(0, 0, width, height)
|
||||
else -> Rect(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
/** The side of the map a piece of chrome sits against, and so the side it shrinks the visible area from. */
|
||||
internal enum class CoverEdge { TOP, BOTTOM, START, END }
|
||||
|
||||
/**
|
||||
* Where the chrome over the map actually landed, in window pixels. The map buttons count even
|
||||
* while faded out, or whatever an app draws in a corner (Waze's speedometer) is under them the
|
||||
* moment they come back.
|
||||
*/
|
||||
internal class ChromeCover {
|
||||
private val pieces = mutableStateMapOf<String, Pair<CoverEdge, ComposeRect>>()
|
||||
|
||||
fun put(key: String, edge: CoverEdge, bounds: ComposeRect) {
|
||||
val piece = edge to bounds
|
||||
if (pieces[key] != piece) pieces[key] = piece
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
pieces.remove(key)
|
||||
}
|
||||
|
||||
/** What is left of a [width] x [height] surface at [surface] (window pixels), in surface pixels. */
|
||||
fun visibleWithin(surface: ComposeRect, width: Int, height: Int, skip: CoverEdge? = null): Rect {
|
||||
var left = 0f
|
||||
var top = 0f
|
||||
var right = width.toFloat()
|
||||
var bottom = height.toFloat()
|
||||
pieces.values.forEach { (edge, b) ->
|
||||
if (edge == skip) return@forEach
|
||||
when (edge) {
|
||||
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
|
||||
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
|
||||
CoverEdge.START -> left = maxOf(left, b.right - surface.left)
|
||||
CoverEdge.END -> right = minOf(right, b.left - surface.left)
|
||||
}
|
||||
}
|
||||
// Never hand an app nothing to draw in, however the cards end up.
|
||||
if (right - left < width / 4f) { left = 0f; right = width.toFloat() }
|
||||
if (bottom - top < height / 4f) { top = 0f; bottom = height.toFloat() }
|
||||
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
||||
|
||||
/**
|
||||
* The floating nav bar, as far as the app's map is concerned. Its full [height] at once rather
|
||||
* than following the slide, so the app is told once instead of every frame.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BottomCover(height: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxWidth().height(height).covers("nav-bar", CoverEdge.BOTTOM))
|
||||
}
|
||||
|
||||
/** Records where this lands, padding included, until it leaves the screen. */
|
||||
@Composable
|
||||
internal fun Modifier.covers(key: String, edge: CoverEdge): Modifier {
|
||||
val cover = LocalChromeCover.current ?: return this
|
||||
DisposableEffect(cover, key) { onDispose { cover.remove(key) } }
|
||||
return onGloballyPositioned { cover.put(key, edge, it.boundsInWindow()) }
|
||||
}
|
||||
|
||||
// --- navigation ------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
|
||||
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
|
||||
}
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
if (!pan) template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
||||
val connection = LocalCarAppConnection.current
|
||||
val pan by connection.panMode.collectAsState()
|
||||
// Pan mode clears the map for looking around: only the buttons stay.
|
||||
val routing = (template.navigationInfo as? RoutingInfo)?.takeUnless { pan }
|
||||
val estimate = template.destinationTravelEstimate?.takeUnless { pan }
|
||||
NavigationBars(template, routing, estimate, modifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
|
||||
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
||||
* the cards and gives the map the whole width.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavigationBars(
|
||||
template: NavigationTemplate,
|
||||
routing: RoutingInfo?,
|
||||
estimate: TravelEstimate?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val pan by connection.panMode.collectAsState()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val column = cardColumnWidth(maxWidth)
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
if (routing != null || estimate != null) {
|
||||
Column(
|
||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.padding(12.dp),
|
||||
mapActionsModifier = Modifier.covers("nav-buttons", CoverEdge.END),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The manoeuvre card: what to do next, how far away it is, and onto what road. */
|
||||
/**
|
||||
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
|
||||
* and onto which road, the lanes to be in, and the turn after it along the bottom.
|
||||
*/
|
||||
@Composable
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
||||
Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val step = info.currentStep ?: return
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
ChromeSurface(modifier) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(distance, style = ChromeType.headline)
|
||||
Column {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
val cue = step.cue.text()
|
||||
if (cue.isNotEmpty()) {
|
||||
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val road = step.road.text()
|
||||
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
||||
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
||||
Text(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||
info.junctionImage?.let { JunctionImage(it) }
|
||||
}
|
||||
}
|
||||
val road = step.road.text()
|
||||
if (road.isNotEmpty()) {
|
||||
Text(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
JunctionImage(junction)
|
||||
}
|
||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
||||
info.nextStep?.let { next ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
|
||||
val nextText = next.cue.text().ifEmpty { next.road.text() }
|
||||
if (nextText.isNotEmpty()) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,63 +367,59 @@ private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
||||
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
||||
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
||||
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
||||
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
|
||||
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
|
||||
* navigation the way going Home already does rather than guess at the app's own controls.
|
||||
*/
|
||||
@Composable
|
||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||
val distance = estimate.remainingDistance.display()
|
||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||
val trip = estimate.tripText.text()
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
trip.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
|
||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||
ChromeSurface(modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
Row(
|
||||
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
||||
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
||||
// Ending navigation is its own call, separate from a back press - a back press only
|
||||
// pops the on-screen card, leaving the app still believing it is navigating (so a
|
||||
// freshly picked destination silently does nothing); this is the call that actually
|
||||
// clears that flag on the app's side.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
"Stop navigating",
|
||||
{ CarServices.nav.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 40.dp,
|
||||
iconSize = 20.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Column(Modifier.weight(1f)) {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
||||
// Ending navigation is its own call, separate from a back press - a back press only
|
||||
// pops the on-screen card, leaving the app still believing it is navigating.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
"Stop navigating",
|
||||
{ connection.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 52.dp,
|
||||
iconSize = 26.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,16 +441,18 @@ private fun MapPaneLayout(
|
||||
fullHeight: Boolean = false,
|
||||
pane: @Composable () -> Unit,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val pan by connection.panMode.collectAsState()
|
||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
if (!pan) {
|
||||
ChromeSurface(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.covers("map-pane", CoverEdge.START)
|
||||
.padding(12.dp)
|
||||
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
|
||||
.width(cardColumnWidth(maxWidth) - 24.dp)
|
||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
|
||||
shadowElevation = 8.dp,
|
||||
@@ -451,11 +573,16 @@ private fun ListPane(
|
||||
}
|
||||
}
|
||||
|
||||
/** Fraction of the surface the content pane covers beside a map. */
|
||||
private const val CONTENT_PANE_FRACTION = 0.42f
|
||||
|
||||
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
|
||||
private const val ESTIMATE_FRACTION = 0.14f
|
||||
|
||||
/**
|
||||
* The width of the cards beside the map, the same for a place card and for navigation so the
|
||||
* map doesn't jump sideways when a route starts.
|
||||
*/
|
||||
internal fun cardColumnWidth(maxWidth: androidx.compose.ui.unit.Dp): androidx.compose.ui.unit.Dp =
|
||||
(maxWidth * CARD_COLUMN_FRACTION).coerceIn(280.dp, 440.dp).coerceAtMost(maxWidth)
|
||||
|
||||
private const val CARD_COLUMN_FRACTION = 0.4f
|
||||
|
||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||
private fun Template?.needsFullHeight() =
|
||||
|
||||
@@ -517,7 +517,8 @@ private val sectionCache = android.util.LruCache<String, List<Item>>(64)
|
||||
*/
|
||||
@Composable
|
||||
private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") {
|
||||
val appPackage = CarServices.nav.appPackage.orEmpty()
|
||||
val connection = LocalCarAppConnection.current
|
||||
val appPackage = connection.appPackage.orEmpty()
|
||||
ContentSurface(modifier) {
|
||||
// The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from
|
||||
// template.actions, not the header - but a bottom-of-content row is wrong for a driver
|
||||
|
||||
@@ -106,7 +106,8 @@ internal fun ChromeSurface(
|
||||
) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
// The nav bar's own container colour, so cards over the map match the rest of the car UI.
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = shape,
|
||||
modifier = modifier,
|
||||
@@ -217,28 +218,34 @@ internal fun headerTitle(header: Header?, fallback: String): String =
|
||||
* actions in a row along the top, its map controls stacked under them. Keeping them in one
|
||||
* column is what stops them colliding on a screen only 480px tall.
|
||||
*/
|
||||
/** How visible the map buttons stay once they fade: enough to know they're there. */
|
||||
private const val RESTING_ALPHA = 0.35f
|
||||
|
||||
@Composable
|
||||
internal fun RightEdgeControls(
|
||||
actionStrip: ActionStrip?,
|
||||
mapActionStrip: ActionStrip?,
|
||||
modifier: Modifier = Modifier,
|
||||
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||
mapActionsModifier: Modifier = Modifier,
|
||||
) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
if (actions.isEmpty() && mapActions.isEmpty()) return
|
||||
// A template that stops offering Pan must not leave the screen stuck in pan mode.
|
||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
|
||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
|
||||
|
||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
||||
// the first touch on one wakes them rather than pressing it.
|
||||
val awake = LocalMapControlsWake.current.awake
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
val pan by connection.panMode.collectAsState()
|
||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
||||
var holdsFocus by remember { mutableStateOf(false) }
|
||||
val visible = awake || pan || holdsFocus
|
||||
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
targetValue = if (visible) 1f else RESTING_ALPHA,
|
||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||
label = "mapControls",
|
||||
)
|
||||
@@ -263,7 +270,15 @@ internal fun RightEdgeControls(
|
||||
actions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
mapActions.forEach { ActionButton(it) }
|
||||
if (mapActions.isNotEmpty()) {
|
||||
Column(
|
||||
mapActionsModifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
mapActions.forEach { ActionButton(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +293,7 @@ internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, lar
|
||||
}
|
||||
|
||||
/** A row with nothing to show: no words, no picture, nothing to tap. */
|
||||
private fun CarRow.isBlank(): Boolean =
|
||||
internal fun CarRow.isBlank(): Boolean =
|
||||
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
|
||||
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
|
||||
|
||||
@@ -288,13 +303,14 @@ private fun CarRow.isBlank(): Boolean =
|
||||
*/
|
||||
@Composable
|
||||
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val connection = LocalCarAppConnection.current
|
||||
if (action.type == Action.TYPE_APP_ICON) {
|
||||
AppIconBadge()
|
||||
return
|
||||
}
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val title = action.title.text()
|
||||
val panOn by CarServices.nav.panMode.collectAsState()
|
||||
val panOn by connection.panMode.collectAsState()
|
||||
val active = action.type == Action.TYPE_PAN && panOn
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
@@ -309,8 +325,8 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
|
||||
GsIconBox(
|
||||
onClick = {
|
||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
||||
if (action.type == Action.TYPE_BACK) connection.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) connection.setPanMode(!panOn)
|
||||
else action.onClickDelegate?.click("action")
|
||||
},
|
||||
modifier = modifier,
|
||||
@@ -421,8 +437,9 @@ private const val AUTO_START_MS = 5_000L
|
||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||
@Composable
|
||||
private fun AppIconBadge() {
|
||||
val connection = LocalCarAppConnection.current
|
||||
val context = LocalContext.current
|
||||
val pkg = CarServices.nav.appPackage
|
||||
val pkg = connection.appPackage
|
||||
val icon = remember(pkg) {
|
||||
pkg?.let {
|
||||
runCatching {
|
||||
@@ -497,315 +514,6 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
||||
|
||||
// --- lists, grids and panes ---------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
internal fun PaneTitle(title: String) {
|
||||
if (title.isEmpty()) return
|
||||
Text(
|
||||
title,
|
||||
style = ChromeType.title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItemListColumn(
|
||||
single: ItemList?,
|
||||
sectioned: List<ItemList>? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rows = buildList {
|
||||
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
||||
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
val message = single?.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||
val onSelected = single?.onSelectedDelegate
|
||||
val chosen = single?.selectedIndex ?: -1
|
||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
itemsIndexed(rows) { index, row ->
|
||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
||||
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
||||
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
||||
@Composable
|
||||
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||
if (items.isEmpty()) return
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(84.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||
* instead of wrapping into a grid and wants its full-size cover art.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||
Column(
|
||||
modifier
|
||||
.clickable { item.onClickDelegate.click("grid item") }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CarGlyph(item.image, Modifier.size(iconSize))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = ChromeType.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = item.text.text()
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val card = PlaceCard.of(rows)
|
||||
Column(modifier) {
|
||||
card.heading?.let { PlaceHeading(it) }
|
||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
if (card.heading == null && card.buttons.isEmpty()) {
|
||||
card.details.forEach { RowItem(it, large = true) }
|
||||
} else {
|
||||
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
||||
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
||||
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
||||
* heading and no buttons, and draws exactly as a plain list of rows.
|
||||
*/
|
||||
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
||||
companion object {
|
||||
fun of(rows: List<CarRow>): PlaceCard {
|
||||
// A short tappable row with an icon and nothing under it is a button in all but name:
|
||||
// MAPS.ME's "Let's go" and "Save".
|
||||
val buttons = rows.filter {
|
||||
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
||||
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
||||
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
||||
// A first row that can't be tapped and has no icon is the place's name and kind.
|
||||
val heading = rows.firstOrNull()?.takeIf {
|
||||
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
||||
}
|
||||
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceHeading(row: CarRow) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
||||
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
row.texts.orEmpty().take(2).forEach {
|
||||
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
||||
@Composable
|
||||
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rows.forEachIndexed { i, row ->
|
||||
GsIconBox(
|
||||
onClick = { row.onClickDelegate.click("row") },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||
enabled = row.isEnabled,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CarGlyph(row.image, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
||||
* value when the row has no label. A phone number gets a Call button that asks first.
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailRow(row: CarRow, placeName: String) {
|
||||
val context = LocalContext.current
|
||||
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
||||
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
||||
val values = texts.ifEmpty { listOf(row.title.text()) }
|
||||
val number = values.firstOrNull { it.isPhoneNumber() }
|
||||
val canCall = number != null &&
|
||||
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
label?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
values.take(3).forEach {
|
||||
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (canCall) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GsIconBox(
|
||||
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Call", style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
||||
private fun String.isPhoneNumber(): Boolean {
|
||||
val t = trim()
|
||||
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
||||
return t.count { it.isDigit() } in 7..15
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||
val toggle = row.toggle
|
||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (!clickable) it else it.clickable {
|
||||
when {
|
||||
toggle != null -> {
|
||||
checked = !checked
|
||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||
}
|
||||
selection != null -> selection.onPick()
|
||||
else -> row.onClickDelegate.click("row")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selection != null) {
|
||||
RadioButton(selected = selection.selected, onClick = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||
// list's tenth row read as the same kind of text.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title.text(),
|
||||
style = ChromeType.title,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||
Text(
|
||||
line.text(),
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
if (toggle != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||
|
||||
/**
|
||||
* Whether this colour stands out against the background that [content] was chosen for: at least
|
||||
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
||||
*/
|
||||
private fun Color.readableOn(content: Color): Boolean {
|
||||
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
||||
val a = luminance() + 0.05f
|
||||
val b = background.luminance() + 0.05f
|
||||
return maxOf(a, b) / minOf(a, b) >= 3f
|
||||
}
|
||||
|
||||
internal fun Color.luminanceIsDark(): Boolean =
|
||||
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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 app.seb3thehacker.gearslip.host.selected
|
||||
import app.seb3thehacker.gearslip.host.checkedChanged
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.ActionStrip
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.GridItem
|
||||
import androidx.car.app.model.Header
|
||||
import androidx.car.app.model.ItemList
|
||||
import androidx.car.app.model.Pane
|
||||
import androidx.car.app.model.Row as CarRow
|
||||
import androidx.car.app.model.Template
|
||||
import app.seb3thehacker.gearslip.host.click
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.image
|
||||
import app.seb3thehacker.gearslip.host.isDrawable
|
||||
import app.seb3thehacker.gearslip.host.standardType
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
|
||||
// Lists, grids, panes and rows of templated apps. Split out of TemplateParts.kt.
|
||||
|
||||
@Composable
|
||||
internal fun PaneTitle(title: String) {
|
||||
if (title.isEmpty()) return
|
||||
Text(
|
||||
title,
|
||||
style = ChromeType.title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItemListColumn(
|
||||
single: ItemList?,
|
||||
sectioned: List<ItemList>? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rows = buildList {
|
||||
single?.items?.filterIsInstance<CarRow>()?.let(::addAll)
|
||||
sectioned?.forEach { list -> addAll(list.items.filterIsInstance<CarRow>()) }
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
val message = single?.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||
val onSelected = single?.onSelectedDelegate
|
||||
val chosen = single?.selectedIndex ?: -1
|
||||
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
itemsIndexed(rows) { index, row ->
|
||||
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as
|
||||
* often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays
|
||||
* modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */
|
||||
@Composable
|
||||
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||
if (items.isEmpty()) return
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(84.dp),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||
* instead of wrapping into a grid and wants its full-size cover art.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||
Column(
|
||||
modifier
|
||||
.clickable { item.onClickDelegate.click("grid item") }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CarGlyph(item.image, Modifier.size(iconSize))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = ChromeType.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = item.text.text()
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val card = PlaceCard.of(rows)
|
||||
Column(modifier) {
|
||||
card.heading?.let { PlaceHeading(it) }
|
||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
if (card.heading == null && card.buttons.isEmpty()) {
|
||||
card.details.forEach { RowItem(it, large = true) }
|
||||
} else {
|
||||
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
|
||||
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
|
||||
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
|
||||
* heading and no buttons, and draws exactly as a plain list of rows.
|
||||
*/
|
||||
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
|
||||
companion object {
|
||||
fun of(rows: List<CarRow>): PlaceCard {
|
||||
// A short tappable row with an icon and nothing under it is a button in all but name:
|
||||
// MAPS.ME's "Let's go" and "Save".
|
||||
val buttons = rows.filter {
|
||||
it.onClickDelegate != null && it.toggle == null && it.image != null &&
|
||||
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
|
||||
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
|
||||
// A first row that can't be tapped and has no icon is the place's name and kind.
|
||||
val heading = rows.firstOrNull()?.takeIf {
|
||||
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
|
||||
}
|
||||
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceHeading(row: CarRow) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
|
||||
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
row.texts.orEmpty().take(2).forEach {
|
||||
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
|
||||
@Composable
|
||||
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rows.forEachIndexed { i, row ->
|
||||
GsIconBox(
|
||||
onClick = { row.onClickDelegate.click("row") },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||
enabled = row.isEnabled,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CarGlyph(row.image, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One fact about a place, tighter than a list row: a small label over its value, or just the
|
||||
* value when the row has no label. A phone number gets a Call button that asks first.
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailRow(row: CarRow, placeName: String) {
|
||||
val context = LocalContext.current
|
||||
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
|
||||
val label = row.title.text().takeIf { texts.isNotEmpty() }
|
||||
val values = texts.ifEmpty { listOf(row.title.text()) }
|
||||
val number = values.firstOrNull { it.isPhoneNumber() }
|
||||
val canCall = number != null &&
|
||||
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(24.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
label?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
values.take(3).forEach {
|
||||
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (canCall) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GsIconBox(
|
||||
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Call", style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
|
||||
private fun String.isPhoneNumber(): Boolean {
|
||||
val t = trim()
|
||||
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
|
||||
return t.count { it.isDigit() } in 7..15
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
|
||||
val toggle = row.toggle
|
||||
// Shown at once and corrected when the app answers with its own template, so the switch
|
||||
// never lags a tap; keyed on what the app last said so its answer wins.
|
||||
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
|
||||
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (!clickable) it else it.clickable {
|
||||
when {
|
||||
toggle != null -> {
|
||||
checked = !checked
|
||||
toggle.onCheckedChangeDelegate.checkedChanged(checked)
|
||||
}
|
||||
selection != null -> selection.onPick()
|
||||
else -> row.onClickDelegate.click("row")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selection != null) {
|
||||
RadioButton(selected = selection.selected, onClick = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
row.image?.let {
|
||||
CarGlyph(it, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||
// list's tenth row read as the same kind of text.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title.text(),
|
||||
style = ChromeType.title,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||
Text(
|
||||
line.text(),
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
ActionRow(row.actions.orEmpty())
|
||||
if (toggle != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
|
||||
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
|
||||
|
||||
/**
|
||||
* Whether this colour stands out against the background that [content] was chosen for: at least
|
||||
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
|
||||
*/
|
||||
internal fun Color.readableOn(content: Color): Boolean {
|
||||
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
|
||||
val a = luminance() + 0.05f
|
||||
val b = background.luminance() + 0.05f
|
||||
return maxOf(a, b) / minOf(a, b) >= 3f
|
||||
}
|
||||
@@ -2,7 +2,14 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
@@ -24,6 +31,10 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
|
||||
/**
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||
@@ -43,11 +54,35 @@ fun WebApp(content: String) {
|
||||
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
|
||||
var web by remember { mutableStateOf<WebView?>(null) }
|
||||
var fieldText by remember { mutableStateOf<String?>(null) }
|
||||
// A page's own full screen, such as a video's: the view to show, and how to hand it back.
|
||||
var pageFullscreen by remember { mutableStateOf<Pair<View, WebChromeClient.CustomViewCallback>?>(null) }
|
||||
// Whether the video asked for full screen, rather than the driver from the header: ending
|
||||
// the video's then leaves full screen too, instead of stranding them in it.
|
||||
var videoMadeFullscreen by remember { mutableStateOf(false) }
|
||||
val fullscreen = CarFullscreen.active
|
||||
// What the header shows and what its buttons can do, refreshed as pages load.
|
||||
var pageTitle by remember { mutableStateOf("") }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// The address being typed, or null while the header shows the page title.
|
||||
var address by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Leaving the browser ends full screen, whoever started it.
|
||||
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
|
||||
// The way out of full screen also ends the page's.
|
||||
LaunchedEffect(fullscreen) {
|
||||
if (!fullscreen) pageFullscreen?.let { (_, callback) ->
|
||||
pageFullscreen = null
|
||||
callback.onCustomViewHidden()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(web) {
|
||||
navigator.backInterceptor = {
|
||||
val view = web
|
||||
if (view != null && view.canGoBack()) {
|
||||
if (CarFullscreen.active) {
|
||||
CarFullscreen.exit()
|
||||
true
|
||||
} else if (view != null && view.canGoBack()) {
|
||||
view.goBack()
|
||||
true
|
||||
} else {
|
||||
@@ -59,25 +94,57 @@ fun WebApp(content: String) {
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
if (!fullscreen) {
|
||||
ScreenHeader(pageTitle.ifEmpty { "Browser" }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, "Search or type an address", {
|
||||
fieldText = null
|
||||
address = ""
|
||||
})
|
||||
if (canGoForward) {
|
||||
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, "Forward", { web?.goForward() })
|
||||
}
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, "Reload", { web?.reload() })
|
||||
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, "Start page", {
|
||||
web?.let { view -> loadContent(view, content) }
|
||||
})
|
||||
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
|
||||
}
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient(ring)
|
||||
webViewClient = LoggingClient(ring) { view ->
|
||||
canGoForward = view.canGoForward()
|
||||
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onReceivedTitle(view: WebView?, title: String?) {
|
||||
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
|
||||
}
|
||||
|
||||
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
|
||||
videoMadeFullscreen = !CarFullscreen.active
|
||||
pageFullscreen = view to callback
|
||||
CarFullscreen.enter()
|
||||
}
|
||||
|
||||
override fun onHideCustomView() {
|
||||
if (pageFullscreen == null) return
|
||||
pageFullscreen = null
|
||||
if (videoMadeFullscreen) CarFullscreen.exit()
|
||||
}
|
||||
}
|
||||
addJavascriptInterface(
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
}
|
||||
loadContent(this, content)
|
||||
web = this
|
||||
}
|
||||
},
|
||||
@@ -85,6 +152,33 @@ fun WebApp(content: String) {
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
pageFullscreen?.let { (view, _) ->
|
||||
androidx.compose.runtime.key(view) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
FrameLayout(context).apply {
|
||||
setBackgroundColor(android.graphics.Color.BLACK)
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRelease = { it.removeAllViews() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
address?.let { typed ->
|
||||
CarKeyboard(
|
||||
text = typed,
|
||||
onTextChange = { address = it },
|
||||
onSubmit = {
|
||||
web?.loadUrl(addressToUrl(typed))
|
||||
address = null
|
||||
},
|
||||
onDismiss = { address = null },
|
||||
)
|
||||
}
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
@@ -102,9 +196,29 @@ fun WebApp(content: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient(private val ring: String) : WebViewClient() {
|
||||
/** The start page: a web address, or a page of HTML Gearslip made itself. */
|
||||
private fun loadContent(view: WebView, content: String) {
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
view.loadUrl(content)
|
||||
} else {
|
||||
view.loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||
private fun addressToUrl(typed: String): String {
|
||||
val text = typed.trim()
|
||||
return when {
|
||||
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||
' ' !in text && '.' in text -> "https://$text"
|
||||
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.let(onLoaded)
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
view?.evaluateJavascript(focusRingJs(ring), null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package app.seb3thehacker.gearslip.car.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
|
||||
/**
|
||||
* The phone's Material You colours, with its accent washed over the surfaces. Material You's own
|
||||
* surfaces are nearly neutral, which reads as flat grey on a car screen; tinting them with the
|
||||
* phone's primary colour (purple on a purple phone) gives the whole UI the phone's hue, the way
|
||||
* Material 3's surface tint does, and it changes with the wallpaper like everything else.
|
||||
* Higher containers take more of the tint, so cards still stand off the background.
|
||||
*/
|
||||
fun ColorScheme.withTintedSurfaces(dark: Boolean): ColorScheme {
|
||||
val base = if (dark) 0.03f else 0.02f
|
||||
fun tint(own: Color, step: Int) = primary.copy(alpha = base + step * (if (dark) 0.015f else 0.01f)).compositeOver(own)
|
||||
return copy(
|
||||
background = tint(background, 0),
|
||||
surface = tint(surface, 0),
|
||||
surfaceDim = tint(surfaceDim, 0),
|
||||
surfaceContainerLowest = tint(surfaceContainerLowest, 0),
|
||||
surfaceContainerLow = tint(surfaceContainerLow, 1),
|
||||
surfaceContainer = tint(surfaceContainer, 2),
|
||||
surfaceContainerHigh = tint(surfaceContainerHigh, 3),
|
||||
surfaceContainerHighest = tint(surfaceContainerHighest, 4),
|
||||
surfaceBright = tint(surfaceBright, 4),
|
||||
surfaceVariant = tint(surfaceVariant, 3),
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ class TemplateApp(
|
||||
) {
|
||||
val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories
|
||||
|
||||
/** A media car screen, or one that names no category (older media apps). */
|
||||
val isMediaScreen: Boolean get() = kind == "media" || kind == "app"
|
||||
|
||||
/** "navigation", "media", ... - what the app told the system it is. */
|
||||
val kind: String
|
||||
get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) }
|
||||
|
||||
@@ -91,6 +91,9 @@ class CarAppConnection(private val context: Context) {
|
||||
private var navigationManager: INavigationManager? = null
|
||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||
private var surfaceCallback: ISurfaceCallback? = null
|
||||
|
||||
/** Last visible and stable areas the UI reported, kept for the next surface. */
|
||||
private var areas: Pair<Rect, Rect>? = null
|
||||
private var lent: Lent? = null
|
||||
|
||||
private var binding: ServiceConnection? = null
|
||||
@@ -223,6 +226,7 @@ class CarAppConnection(private val context: Context) {
|
||||
fun disconnect() = disconnect(keepLocation = false)
|
||||
|
||||
private fun disconnect(keepLocation: Boolean) {
|
||||
main.removeCallbacks(handshakeTimeout)
|
||||
clearToasts()
|
||||
lent?.let { sendSurfaceDestroyed(it) }
|
||||
lent = null
|
||||
@@ -254,11 +258,24 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
// --- handshake ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* An app that won't have Gearslip as its host never answers the first question. Car App
|
||||
* Library 1.9 and later throw inside the app when a host that isn't Google's asks, and since
|
||||
* the call is one-way the error never comes back, so without this the car would show
|
||||
* "Starting..." for ever.
|
||||
*/
|
||||
private val handshakeTimeout = Runnable {
|
||||
if (_status.value.phase == Phase.HANDSHAKE) {
|
||||
fail(Phase.REJECTED, "It didn't answer Gearslip. Apps built for newer versions of Android Auto only run on Google's.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBound(service: IBinder) {
|
||||
val app = ICarApp.Stub.asInterface(service)
|
||||
carApp = app
|
||||
update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") }
|
||||
GearslipLog.i("host: bound; requesting app info")
|
||||
main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS)
|
||||
|
||||
call("getAppInfo", onValue = { value ->
|
||||
val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}")
|
||||
@@ -271,6 +288,7 @@ class CarAppConnection(private val context: Context) {
|
||||
* offers cannot be rendered correctly, so it is refused here rather than half-drawn.
|
||||
*/
|
||||
private fun onAppInfo(info: AppInfo) {
|
||||
main.removeCallbacks(handshakeTimeout)
|
||||
val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel)
|
||||
GearslipLog.i(
|
||||
"host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " +
|
||||
@@ -385,6 +403,8 @@ class CarAppConnection(private val context: Context) {
|
||||
// failure worth surfacing.
|
||||
runCatching {
|
||||
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||
// Car App Library 1.8 versions its host interfaces; each answers with the version it was built from.
|
||||
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
navigationManager = when (value) {
|
||||
@@ -552,9 +572,11 @@ class CarAppConnection(private val context: Context) {
|
||||
// The app draws for the whole surface; the visible area is what isn't covered by host
|
||||
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
||||
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
||||
// has measured. Until then the whole surface is fair game.
|
||||
// has measured. A fresh surface the same size keeps the last ones, since the UI only
|
||||
// reports again when they change; until then the whole surface is fair game.
|
||||
val whole = Rect(0, 0, surface.width, surface.height)
|
||||
reportAreas(whole, whole)
|
||||
val last = areas?.takeIf { (visible, stable) -> whole.contains(visible) && whole.contains(stable) }
|
||||
reportAreas(last?.first ?: whole, last?.second ?: whole)
|
||||
|
||||
GearslipLog.i(
|
||||
"host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface"
|
||||
@@ -575,6 +597,7 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
/** Tells the app which part of its surface the driver can actually see. */
|
||||
fun reportAreas(visible: Rect, stable: Rect) {
|
||||
areas = visible to stable
|
||||
val callback = surfaceCallback ?: return
|
||||
runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) }
|
||||
runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) }
|
||||
@@ -605,6 +628,7 @@ class CarAppConnection(private val context: Context) {
|
||||
// --- host interfaces the app calls back into -----------------------------------------
|
||||
|
||||
private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IAppHost.VERSION
|
||||
override fun invalidate() {
|
||||
GearslipLog.i("host: app invalidated its template")
|
||||
main.post { requestTemplate() }
|
||||
@@ -643,6 +667,7 @@ class CarAppConnection(private val context: Context) {
|
||||
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
|
||||
|
||||
private val navigationHost = object : INavigationHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = INavigationHost.VERSION
|
||||
override fun navigationStarted() {
|
||||
GearslipLog.i("host: navigation started")
|
||||
navigating = true
|
||||
@@ -670,6 +695,7 @@ class CarAppConnection(private val context: Context) {
|
||||
* IBinder must be a real IOnDoneCallback - the app dispatches the reply through it.
|
||||
*/
|
||||
private val hardwareHost = object : ICarHardwareHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ICarHardwareHost.VERSION
|
||||
override fun getCarHardwareResult(
|
||||
type: Int,
|
||||
params: Bundleable?,
|
||||
@@ -700,6 +726,7 @@ class CarAppConnection(private val context: Context) {
|
||||
* Library documents for a projected head unit; they are about the screen, not about us.
|
||||
*/
|
||||
private val constraintHost = object : IConstraintHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IConstraintHost.VERSION
|
||||
override fun getContentLimit(type: Int): Int = when (type) {
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
|
||||
@@ -718,10 +745,12 @@ class CarAppConnection(private val context: Context) {
|
||||
* that pushes suggestions shouldn't fail because its host doesn't surface them.
|
||||
*/
|
||||
private val suggestionHost = object : ISuggestionHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ISuggestionHost.VERSION
|
||||
override fun updateSuggestions(suggestions: Bundleable?) = Unit
|
||||
}
|
||||
|
||||
private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
|
||||
override fun getInterfaceVersion(): Int = ICarHost.VERSION
|
||||
private val appHost = createAppHost(owner)
|
||||
|
||||
override fun getHost(type: String?): IBinder? = when (type) {
|
||||
@@ -754,6 +783,7 @@ class CarAppConnection(private val context: Context) {
|
||||
*/
|
||||
private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) {
|
||||
val callback = object : IOnDoneCallback.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
main.post { onValue(value) }
|
||||
@@ -769,6 +799,7 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun noop(name: String) = object : IOnDoneCallback.Stub() {
|
||||
override fun getInterfaceVersion(): Int = IOnDoneCallback.VERSION
|
||||
override fun onSuccess(response: Bundleable?) = Unit
|
||||
override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed")
|
||||
}
|
||||
@@ -833,6 +864,9 @@ class CarAppConnection(private val context: Context) {
|
||||
/** How long a new destination waits for the old route to end; see [navigateTo]. */
|
||||
const val REROUTE_WAIT_MS = 1_500L
|
||||
|
||||
/** How long an app gets to answer the first question before it counts as refusing. */
|
||||
const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||
|
||||
/** Context.BIND_FOREGROUND_SERVICE; the constant itself is hidden from the SDK. */
|
||||
const val BIND_FOREGROUND_SERVICE = 0x04000000
|
||||
}
|
||||
|
||||
@@ -31,7 +31,41 @@ import kotlin.math.roundToInt
|
||||
*/
|
||||
|
||||
/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */
|
||||
fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty()
|
||||
fun CarText?.text(): String {
|
||||
val chars = this?.takeIf { !it.isEmpty }?.toCharSequence() ?: return ""
|
||||
return chars.withSpansFilledIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps write a route's time and length as spans over placeholder characters ("\u0000 · \u0000"
|
||||
* with a DurationSpan and a DistanceSpan), leaving the host to format them. Plain toString()
|
||||
* keeps only the placeholders, which is how a route option ended up reading "·".
|
||||
*/
|
||||
private fun CharSequence.withSpansFilledIn(): String {
|
||||
if (this !is android.text.Spanned) return toString()
|
||||
val spans = getSpans(0, length, androidx.car.app.model.CarSpan::class.java)
|
||||
.filter { it is androidx.car.app.model.DurationSpan || it is androidx.car.app.model.DistanceSpan }
|
||||
.sortedBy { getSpanStart(it) }
|
||||
if (spans.isEmpty()) return toString()
|
||||
val out = StringBuilder()
|
||||
var at = 0
|
||||
spans.forEach { span ->
|
||||
val start = getSpanStart(span)
|
||||
val end = getSpanEnd(span)
|
||||
if (start < at || end > length) return@forEach
|
||||
out.append(this, at, start)
|
||||
out.append(
|
||||
when (span) {
|
||||
is androidx.car.app.model.DurationSpan -> remainingTime(span.durationSeconds)
|
||||
is androidx.car.app.model.DistanceSpan -> span.distance.display()
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
at = end
|
||||
}
|
||||
out.append(this, at, length)
|
||||
return out.toString().replace("\u0000", "").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch
|
||||
@@ -53,7 +87,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
|
||||
fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
val icon = this?.icon ?: return null
|
||||
val key = icon.cacheKey(context)
|
||||
iconCache.get(key)?.let { return it }
|
||||
key?.let { k -> iconCache.get(k)?.let { return it } }
|
||||
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
||||
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||
.getOrNull()
|
||||
@@ -73,7 +107,7 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() }
|
||||
.onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||
.getOrNull() ?: return null
|
||||
iconCache.put(key, bitmap)
|
||||
key?.let { iconCache.put(it, bitmap) }
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@@ -83,7 +117,15 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
|
||||
* new Saved button came up as its search glyph).
|
||||
*/
|
||||
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
|
||||
/**
|
||||
* Null for an icon that carries its own pixels: two different bitmaps describe the same, so a
|
||||
* key would hand one app's turn arrow the picture of whatever bitmap was cached first.
|
||||
*/
|
||||
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String? {
|
||||
if (type == androidx.core.graphics.drawable.IconCompat.TYPE_BITMAP ||
|
||||
type == androidx.core.graphics.drawable.IconCompat.TYPE_ADAPTIVE_BITMAP ||
|
||||
type == androidx.core.graphics.drawable.IconCompat.TYPE_DATA
|
||||
) return null
|
||||
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
|
||||
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
||||
return "${describe()}@$installed"
|
||||
|
||||
@@ -24,6 +24,7 @@ object KnownApps {
|
||||
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||
"com.mapquest.android.ace", // MapQuest: map, routing, navigation
|
||||
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
|
||||
"app.vela", // Vela Maps: map, search, route options, turn-by-turn with lanes
|
||||
)
|
||||
|
||||
fun works(packageName: String): Boolean = packageName in working
|
||||
@@ -99,7 +100,6 @@ object KnownApps {
|
||||
* call working. Wins over [works] and [playerWorks] for the badge.
|
||||
*/
|
||||
private val partial = setOf(
|
||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
||||
"com.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
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ class CarMediaStatus(
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.seb3thehacker.gearslip.protocol
|
||||
|
||||
import app.seb3thehacker.gearslip.TouchPoint
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
|
||||
/** Decodes the touch coordinates and pointer index carried by an InputReport. */
|
||||
internal object InputProtocol {
|
||||
data class Touch(val action: Int, val actionIndex: Int, val points: List<TouchPoint>)
|
||||
|
||||
/** [shiftX] and [shiftY] move each point by the margin the car crops off before measuring. */
|
||||
fun touch(body: ByteArray, size: Pair<Int, Int>?, shiftX: Int = 0, shiftY: Int = 0): Touch? {
|
||||
val fields = Wire.fields(body)
|
||||
val action = Wire.varint(fields, 3)?.toInt() ?: 0
|
||||
if (action !in setOf(0, 1, 2, 5, 6)) return null
|
||||
val pointers = Wire.allBytes(fields, 1)
|
||||
if (pointers.isEmpty() || pointers.size > 32) return null
|
||||
val actionIndex = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||
if (actionIndex !in pointers.indices) return null
|
||||
val points = pointers.map { pointer ->
|
||||
val values = Wire.fields(pointer)
|
||||
val x = Wire.varint(values, 1)?.toInt() ?: return null
|
||||
val y = Wire.varint(values, 2)?.toInt() ?: return null
|
||||
val id = Wire.varint(values, 3) ?: return null
|
||||
if (id !in 0L..31L) return null
|
||||
TouchPoint(id.toInt(), clamp(x + shiftX, size?.first), clamp(y + shiftY, size?.second))
|
||||
}
|
||||
if (points.map { it.id }.toSet().size != points.size) return null
|
||||
return Touch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
limit <= 0 -> 0f
|
||||
else -> value.coerceIn(0, limit - 1).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,17 @@ object UsageStats {
|
||||
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
private var signerCache: String? = null
|
||||
|
||||
/** The first 16 hex digits of the SHA-256 of this APK's signing certificate. */
|
||||
private fun signer(context: Context): String = signerCache ?: runCatching {
|
||||
val cert = context.packageManager
|
||||
.getPackageInfo(context.packageName, android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray() ?: return@runCatching ""
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(cert)
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
}.getOrDefault("").also { signerCache = it }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -263,6 +274,9 @@ object UsageStats {
|
||||
.put("version", BuildConfig.VERSION_NAME)
|
||||
.put("code", BuildConfig.VERSION_CODE)
|
||||
.put("build", if (BuildConfig.DEBUG) "debug" else "release")
|
||||
// Who signed this copy, so a fork's builds can be told from Gearslip's own. Every copy
|
||||
// from the same publisher sends the same value, so it says nothing about the phone.
|
||||
.put("signer", signer(context))
|
||||
// Which boxes are ticked, so the server knows what this phone shares and can drop
|
||||
// anything it kept from a box since unticked.
|
||||
.put("shares", JSONArray().apply {
|
||||
|
||||
@@ -45,6 +45,9 @@ internal object GearslipLinks {
|
||||
const val SOURCE = "https://github.com/twinksoftware/gearslip"
|
||||
const val CARS = "$SOURCE/blob/main/CARS.md"
|
||||
|
||||
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
|
||||
val RELEASE_NOTES = "$SOURCE/releases/tag/v${app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME}"
|
||||
|
||||
fun open(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
|
||||
@@ -4,15 +4,8 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.HardwareBuffer
|
||||
import android.media.ImageReader
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -39,9 +32,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -59,7 +50,7 @@ import app.seb3thehacker.gearslip.car.CarUi
|
||||
* Runs the real car UI on the bench and shows it on the phone.
|
||||
*
|
||||
* This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by
|
||||
* the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the
|
||||
* the same [ScreenProjector] and fed the same touches - with a SurfaceView standing in for the
|
||||
* H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the
|
||||
* SurfaceView a templated app draws its map into: if it appears here, it will reach the car.
|
||||
*
|
||||
@@ -69,49 +60,26 @@ import app.seb3thehacker.gearslip.car.CarUi
|
||||
fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val width = frame.width
|
||||
val height = frame.height
|
||||
val density = frame.densityDpi
|
||||
val previewSize by app.seb3thehacker.gearslip.car.CarSettings.previewSize.collectAsStateWithLifecycle()
|
||||
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
||||
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
||||
|
||||
val projector = remember { ScreenProjector(context) }
|
||||
|
||||
DisposableEffect(width, height, density) {
|
||||
CarEnvironment.start(context)
|
||||
|
||||
// COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader;
|
||||
// CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame
|
||||
// gets copied into a Bitmap. Without both, frames arrive but every copy fails.
|
||||
val reader = ImageReader.newInstance(
|
||||
width, height, PixelFormat.RGBA_8888, 3,
|
||||
HardwareBuffer.USAGE_COMPOSER_OVERLAY or
|
||||
HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
|
||||
HardwareBuffer.USAGE_CPU_READ_OFTEN,
|
||||
)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
reader.setOnImageAvailableListener({ source ->
|
||||
val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener
|
||||
try {
|
||||
bitmap = image.toBitmap(width, height)
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("preview: could not copy a frame", t)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}, handler)
|
||||
|
||||
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
|
||||
|
||||
onDispose {
|
||||
projector.stop()
|
||||
reader.close()
|
||||
bitmap = null
|
||||
// No surface yet: the SurfaceView below hands its own over once it exists, and the
|
||||
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
||||
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
||||
projector.start(null, width, height, density, name = "gearslip-preview") {
|
||||
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
||||
}
|
||||
onDispose { projector.stop() }
|
||||
}
|
||||
|
||||
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
||||
@@ -140,19 +108,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
bitmap?.let {
|
||||
Image(
|
||||
it.asImageBitmap(),
|
||||
contentDescription = "Car screen",
|
||||
androidx.compose.runtime.key(width, height) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
factory = { viewContext ->
|
||||
android.view.SurfaceView(viewContext).apply {
|
||||
// The car's own size; the compositor scales it to the view.
|
||||
holder.setFixedSize(width, height)
|
||||
holder.addCallback(object : android.view.SurfaceHolder.Callback {
|
||||
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
|
||||
projector.setSurface(holder.surface)
|
||||
|
||||
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
|
||||
projector.setSurface(null)
|
||||
})
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} ?: Text("Waiting for a frame…", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullscreen) {
|
||||
Fullscreen(onBack) {
|
||||
Fullscreen(width.toFloat() / height, onBack) {
|
||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||
frameContent(Modifier)
|
||||
@@ -192,6 +173,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
||||
*/
|
||||
@Composable
|
||||
private fun Fullscreen(
|
||||
aspect: Float,
|
||||
onBack: () -> Unit,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
@@ -213,19 +195,31 @@ private fun Fullscreen(
|
||||
// is on and turning it sideways/back isn't an option.
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Box(
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
|
||||
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
||||
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
||||
FilledTonalIconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||
// a button of its own. The button goes in the black beside the frame, never on it: over
|
||||
// the frame it covered the car's own buttons. A frame the screen's own shape leaves no
|
||||
// room, and then the back gesture is the way out.
|
||||
val frameWidth = minOf(maxWidth, maxHeight * aspect)
|
||||
val frameHeight = minOf(maxHeight, maxWidth / aspect)
|
||||
val room = 56.dp
|
||||
val corner = when {
|
||||
maxWidth - frameWidth >= room * 2 -> Alignment.BottomStart
|
||||
maxHeight - frameHeight >= room * 2 -> Alignment.TopStart
|
||||
else -> null
|
||||
}
|
||||
if (corner != null) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.align(corner).padding(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,14 +244,26 @@ private fun Int.toCarAction(): Int = when (this) {
|
||||
* Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly
|
||||
* stride, so the copy is made at the padded width and then cropped back.
|
||||
*/
|
||||
private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
|
||||
val plane = planes[0]
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowPadding = plane.rowStride - pixelStride * width
|
||||
val padded = Bitmap.createBitmap(
|
||||
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
padded.copyPixelsFromBuffer(plane.buffer)
|
||||
return if (padded.width == width) padded
|
||||
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
|
||||
/**
|
||||
* The size the preview draws at. The last car's frame by default; this device's whole screen,
|
||||
* for a head unit running Gearslip on its own (issue #12); or a size typed into Settings. The
|
||||
* two new ones get a car-like density: 160dpi for every 480 pixels of height.
|
||||
*/
|
||||
private fun previewFrame(
|
||||
context: android.content.Context,
|
||||
lastCar: CarEnvironment.Frame,
|
||||
size: app.seb3thehacker.gearslip.car.PreviewSize,
|
||||
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
|
||||
): Triple<Int, Int, Int> {
|
||||
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
|
||||
return when (size) {
|
||||
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
|
||||
custom?.let { carLike(it.width, it.height) } ?: Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||
app.seb3thehacker.gearslip.car.PreviewSize.FILL -> {
|
||||
val bounds = context.getSystemService(android.view.WindowManager::class.java).maximumWindowMetrics.bounds
|
||||
// Landscape, whichever way the device is held right now.
|
||||
carLike(maxOf(bounds.width(), bounds.height()) / 2 * 2, minOf(bounds.width(), bounds.height()) / 2 * 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.PreviewSize
|
||||
import app.seb3thehacker.gearslip.car.ScreenFit
|
||||
import app.seb3thehacker.gearslip.car.ScreenSize
|
||||
|
||||
/**
|
||||
* The car screen's real size, for a head unit that reports the wrong one and stretches the
|
||||
* picture, and the size the phone's car preview draws at. The same screen size and fit are in
|
||||
* the car's own Settings; typing in any size is only here.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CarScreenSection() {
|
||||
val size by CarSettings.screenSize.collectAsState()
|
||||
val fit by CarSettings.screenFit.collectAsState()
|
||||
val preview by CarSettings.previewSize.collectAsState()
|
||||
val previewCustom by CarSettings.previewCustom.collectAsState()
|
||||
var editingCar by remember { mutableStateOf(false) }
|
||||
var editingPreview by remember { mutableStateOf(false) }
|
||||
|
||||
if (editingCar) {
|
||||
SizeDialog(
|
||||
title = "Car screen size",
|
||||
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
|
||||
"such as 1280x480. It takes effect the next time you connect.",
|
||||
initial = size,
|
||||
clearLabel = "Use the car's",
|
||||
onDismiss = { editingCar = false },
|
||||
onSave = { CarSettings.setScreenSize(it); editingCar = false },
|
||||
)
|
||||
}
|
||||
if (editingPreview) {
|
||||
SizeDialog(
|
||||
title = "Preview size",
|
||||
note = "The size the car preview draws at, such as 1920x720.",
|
||||
initial = previewCustom,
|
||||
clearLabel = null,
|
||||
onDismiss = { editingPreview = false },
|
||||
onSave = { custom ->
|
||||
CarSettings.setPreviewSize(if (custom == null) PreviewSize.LAST_CAR else PreviewSize.CUSTOM, custom)
|
||||
editingPreview = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
"Car screen",
|
||||
footer = "Gearslip uses the size the car reports unless you set one.",
|
||||
) {
|
||||
SettingsRow(
|
||||
"Screen size",
|
||||
Modifier.clickable { editingCar = true },
|
||||
subtitle = size?.let { "$it, set by you" } ?: "From the car",
|
||||
)
|
||||
if (size != null) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Crop the edges",
|
||||
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
|
||||
subtitle = "For a car that cuts off the top and bottom, or the sides",
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Squash to fit",
|
||||
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
|
||||
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
|
||||
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection("Car preview size") {
|
||||
SettingsRow(
|
||||
"Last car",
|
||||
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
|
||||
},
|
||||
subtitle = "The size of the last car you connected to",
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"This screen",
|
||||
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
|
||||
CarSettings.setPreviewSize(PreviewSize.FILL)
|
||||
},
|
||||
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Custom",
|
||||
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
|
||||
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
|
||||
},
|
||||
subtitle = previewCustom?.toString() ?: "Enter a size",
|
||||
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
|
||||
)
|
||||
if (preview == PreviewSize.CUSTOM) {
|
||||
SettingsDivider()
|
||||
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a width and height; [clearLabel], when given, offers going back to no size at all. */
|
||||
@Composable
|
||||
private fun SizeDialog(
|
||||
title: String,
|
||||
note: String,
|
||||
initial: ScreenSize?,
|
||||
clearLabel: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (ScreenSize?) -> Unit,
|
||||
) {
|
||||
var width by remember { mutableStateOf(initial?.width?.toString().orEmpty()) }
|
||||
var height by remember { mutableStateOf(initial?.height?.toString().orEmpty()) }
|
||||
val parsed = ScreenSize.parse("${width}x$height")
|
||||
val number = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(note)
|
||||
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
|
||||
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
|
||||
dismissButton = {
|
||||
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
|
||||
else TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
private enum class Screen { INTRO, HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS }
|
||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, USAGE_NOTES, HELP, WHATS_NEW, RECOMMENDED_APPS, CAR_GROUP }
|
||||
|
||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||
@Composable
|
||||
@@ -47,8 +47,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||
}
|
||||
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||
// Where launch goes once the intro (if any) is done.
|
||||
val afterIntro = remember {
|
||||
// Where launch goes: setup until it's done, then Home.
|
||||
val start = remember {
|
||||
when {
|
||||
needsCertSetup -> Screen.CERT_SETUP
|
||||
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||
@@ -57,14 +57,14 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
else -> Screen.HOME
|
||||
}
|
||||
}
|
||||
var screen by rememberSaveable { mutableStateOf(if (Intro.due(context)) Screen.INTRO else afterIntro) }
|
||||
// A replay from About goes back to Settings; the launch one carries on into setup.
|
||||
var introReturn by rememberSaveable { mutableStateOf(afterIntro) }
|
||||
var screen by rememberSaveable { mutableStateOf(start) }
|
||||
// Home's "it worked" card opens Settings at usage notes rather than at the top.
|
||||
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
|
||||
// What's new and Connection help open from Home and from Settings; back returns to whichever.
|
||||
var backTo by rememberSaveable { mutableStateOf(Screen.SETTINGS) }
|
||||
BackHandler(enabled = screen != Screen.HOME && screen != Screen.INTRO) {
|
||||
// Which of the car's setting groups Home opened.
|
||||
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
|
||||
BackHandler(enabled = screen != Screen.HOME) {
|
||||
screen = when (screen) {
|
||||
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
|
||||
Screen.HELP, Screen.WHATS_NEW -> backTo
|
||||
@@ -101,33 +101,30 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.consumeWindowInsets(WindowInsets.statusBars),
|
||||
) {
|
||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW && screen != Screen.INTRO) DebugBuildBanner()
|
||||
if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DebugBuildBanner()
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
when (screen) {
|
||||
Screen.INTRO -> IntroScreen(onDone = { screen = introReturn })
|
||||
Screen.HOME -> HomeScreen(
|
||||
onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
|
||||
onOpenWhatsNew = { backTo = Screen.HOME; screen = Screen.WHATS_NEW },
|
||||
onOpenHelp = { backTo = Screen.HOME; screen = Screen.HELP },
|
||||
onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS },
|
||||
onOpenCarPreview = { backTo = Screen.HOME; screen = Screen.CAR_PREVIEW },
|
||||
onOpenLogs = { backTo = Screen.HOME; screen = Screen.LOGS },
|
||||
onDisconnect = onDisconnect,
|
||||
onOpenCarGroup = { carGroup = it; screen = Screen.CAR_GROUP },
|
||||
)
|
||||
Screen.CAR_GROUP -> CarGroupScreen(carGroup, onBack = { screen = Screen.HOME })
|
||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = backTo })
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = backTo })
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
onBack = { screen = Screen.HOME },
|
||||
onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
|
||||
onOpenWhatsNew = { settingsAtUsageNotes = false; backTo = Screen.SETTINGS; screen = Screen.WHATS_NEW },
|
||||
onOpenHelp = { settingsAtUsageNotes = false; backTo = Screen.SETTINGS; screen = Screen.HELP },
|
||||
onOpenCarPreview = { settingsAtUsageNotes = false; screen = Screen.CAR_PREVIEW },
|
||||
onOpenLogs = { settingsAtUsageNotes = false; screen = Screen.LOGS },
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
onReplayIntro = { Intro.replay(context); introReturn = Screen.SETTINGS; screen = Screen.INTRO },
|
||||
atUsageNotes = settingsAtUsageNotes,
|
||||
)
|
||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = backTo })
|
||||
Screen.HELP -> HelpScreen(
|
||||
onBack = { screen = backTo },
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
@@ -148,8 +145,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
// A car keyboard is open: offer the phone's own, for a passenger.
|
||||
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
|
||||
// A car keyboard is open: offer the phone's own, for a passenger. In the car preview it is
|
||||
// the only keyboard, since the preview draws none of its own.
|
||||
PhoneTypingBar()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
|
||||
/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */
|
||||
/**
|
||||
* Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists),
|
||||
* with the same accent tint over the surfaces as the car screen, so the two look like one app.
|
||||
*/
|
||||
@Composable
|
||||
fun GearslipTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val dark = isSystemInDarkTheme()
|
||||
val scheme = (if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)).withTintedSurfaces(dark)
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.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.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The setup guide's tones, taken from the phone's own Material You colours like the rest of the app.
|
||||
* Every size is taken from the screen's width, so the guide scales from small phones to tablets.
|
||||
*/
|
||||
internal class IntroColors(
|
||||
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
|
||||
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
|
||||
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun introColors(): IntroColors {
|
||||
val m = MaterialTheme.colorScheme
|
||||
return IntroColors(
|
||||
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
|
||||
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
|
||||
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
|
||||
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
|
||||
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
||||
|
||||
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
|
||||
@Composable
|
||||
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
|
||||
val a = remember(key) { Animatable(0f) }
|
||||
LaunchedEffect(key) {
|
||||
delay(delayMs.toLong())
|
||||
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
|
||||
}
|
||||
return a.value
|
||||
}
|
||||
|
||||
/** Slides up and fades in, like the mockup's `up`. */
|
||||
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
|
||||
val e = Rise.transform(p)
|
||||
alpha = e
|
||||
translationY = (1 - e) * distance.toPx()
|
||||
}
|
||||
|
||||
/** Grows in with a bounce, like the mockup's `bounce`. */
|
||||
internal fun Modifier.pop(p: Float) = graphicsLayer {
|
||||
val e = Pop.transform(p)
|
||||
alpha = p.coerceAtMost(1f)
|
||||
scaleX = 0.5f + 0.5f * e
|
||||
scaleY = 0.5f + 0.5f * e
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's button styles side by side, each drawn in its own style, and a row of real car
|
||||
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
|
||||
val chosenId by CarSettings.uiTheme.collectAsState()
|
||||
val chosen = GsThemes.byId(chosenId)
|
||||
// Flat first: it's the default.
|
||||
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
|
||||
Column(
|
||||
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
|
||||
verticalArrangement = Arrangement.spacedBy(cq(6f)),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||
themes.forEach { theme ->
|
||||
CompositionLocalProvider(LocalGsTheme provides theme) {
|
||||
GsButton(
|
||||
onClick = { CarSettings.setUiTheme(theme.id) },
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
|
||||
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
|
||||
) {
|
||||
Text(
|
||||
if (theme == chosen) "✓ ${theme.name}" else theme.name,
|
||||
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What the car shows: the player's controls and a full-width action.
|
||||
CompositionLocalProvider(LocalGsTheme provides chosen) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
|
||||
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
|
||||
GsButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(cq(15f)),
|
||||
tone = GsTone.Tonal,
|
||||
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Three keys of a keypad, pressing one after another. */
|
||||
@Composable
|
||||
internal fun ButtonsIcon(color: Color) {
|
||||
val t by rememberInfiniteTransition(label = "keys").animateFloat(
|
||||
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
|
||||
// Each key dips for a moment while its turn comes round.
|
||||
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
|
||||
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
|
||||
androidx.compose.ui.geometry.CornerRadius(3f))
|
||||
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
|
||||
@Composable
|
||||
internal fun Cookie(color: Color, modifier: Modifier) {
|
||||
Canvas(modifier) {
|
||||
val unit = size.minDimension / 100f
|
||||
val path = Path()
|
||||
var deg = 0
|
||||
while (deg <= 360) {
|
||||
val a = deg * PI / 180
|
||||
val r = (44 + 4.5 * cos(9 * a)) * unit
|
||||
val x = center.x + (r * cos(a)).toFloat()
|
||||
val y = center.y + (r * sin(a)).toFloat()
|
||||
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
deg += 3
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws in the mockup's 48-unit icon box. */
|
||||
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
|
||||
val s = size.minDimension / 48f
|
||||
scale(s, s, Offset.Zero) { block() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WifiIcon(color: Color) {
|
||||
val t = rememberInfiniteTransition(label = "wifi")
|
||||
val waves = (0..2).map { i ->
|
||||
t.animateFloat(
|
||||
0.35f, 1f,
|
||||
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
|
||||
label = "wave$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val stroke = Stroke(5f, cap = StrokeCap.Round)
|
||||
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
|
||||
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
|
||||
val cy = y + sqrt(r * r - half * half)
|
||||
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
|
||||
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
|
||||
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
|
||||
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
|
||||
}
|
||||
drawCircle(color, 3.6f, Offset(24f, 41f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MicIcon(color: Color) {
|
||||
val t = rememberInfiniteTransition(label = "mic")
|
||||
val waves = (0..1).map { i ->
|
||||
t.animateFloat(
|
||||
0.25f, 1f,
|
||||
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
|
||||
label = "mic$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
|
||||
val stroke = Stroke(4f, cap = StrokeCap.Round)
|
||||
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
|
||||
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
|
||||
// Sound waves either side, pulsing as if someone's talking.
|
||||
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
|
||||
val r = if (i == 0) 15f else 21f
|
||||
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
|
||||
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StarIcon(color: Color) {
|
||||
val turn by rememberInfiniteTransition(label = "star").animateFloat(
|
||||
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val star = Path()
|
||||
for (i in 0 until 10) {
|
||||
val r = if (i % 2 == 0) 20f else 8.5f
|
||||
val a = (-90 + i * 36) * PI / 180
|
||||
val x = 24f + (r * cos(a)).toFloat()
|
||||
val y = 25f + (r * sin(a)).toFloat()
|
||||
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
|
||||
}
|
||||
star.close()
|
||||
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HeartIcon(color: Color) {
|
||||
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
|
||||
1f, 1f,
|
||||
infiniteRepeatable(keyframes {
|
||||
durationMillis = 1400
|
||||
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
|
||||
}),
|
||||
label = "beat",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
scale(beat, beat, Offset(24f, 24f)) {
|
||||
val heart = Path().apply {
|
||||
moveTo(24f, 41f)
|
||||
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
|
||||
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
|
||||
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
|
||||
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
|
||||
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
|
||||
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
|
||||
close()
|
||||
}
|
||||
drawPath(heart, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
||||
repeat(count) { i ->
|
||||
val on = i == current
|
||||
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
|
||||
Box(
|
||||
Modifier
|
||||
.size(width, cq(2f))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.AppTheme
|
||||
import app.seb3thehacker.gearslip.car.CarApps
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.NavBarMode
|
||||
import app.seb3thehacker.gearslip.car.NightMode
|
||||
import app.seb3thehacker.gearslip.car.Units
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
|
||||
/** The car's setting groups, each opening its own screen from the phone's home screen. */
|
||||
enum class CarGroup(val title: String) {
|
||||
SCREEN("Screen"),
|
||||
NAV_BAR("Nav bar and player"),
|
||||
SOUND("Sound and voice"),
|
||||
WARNINGS("Warnings"),
|
||||
}
|
||||
|
||||
private val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large")
|
||||
private val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark")
|
||||
private val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night")
|
||||
private val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric")
|
||||
private val navBarChoices = listOf(
|
||||
NavBarMode.SHOW to "Always show",
|
||||
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
||||
NavBarMode.HIDE to "Hide until tapped",
|
||||
)
|
||||
private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }
|
||||
|
||||
/**
|
||||
* The car's settings on the phone's home screen: one card per group with a summary of where it
|
||||
* stands, each opening a screen of its own. They are the same settings as the car's own Settings
|
||||
* app, so a change in either shows in both; the phone is just the easier place when parked.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Modifier) {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val navBar by CarSettings.navBarMode.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
|
||||
fun summary(group: CarGroup) = when (group) {
|
||||
CarGroup.SCREEN -> listOf(openChoices().label(open), sizeChoices.label(scale), themeChoices.label(theme))
|
||||
CarGroup.NAV_BAR -> listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}")
|
||||
CarGroup.SOUND -> listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}")
|
||||
CarGroup.WARNINGS -> listOf(
|
||||
"Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}",
|
||||
"Location ${if (locationWarning) "on" else "off"}",
|
||||
)
|
||||
}.filter { it.isNotEmpty() }.joinToString(" · ")
|
||||
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Car",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
CarGroup.entries.forEachIndexed { i, group ->
|
||||
if (i > 0) SettingsDivider()
|
||||
SettingsRow(
|
||||
group.title,
|
||||
Modifier.clickable { onOpen(group) },
|
||||
subtitle = summary(group),
|
||||
trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One group of the car's settings, on a screen of its own. */
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
||||
androidx.compose.material3.Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
androidx.compose.material3.TopAppBar(
|
||||
title = { Text(group.title) },
|
||||
navigationIcon = {
|
||||
androidx.compose.material3.IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).padding(horizontal = 24.dp)
|
||||
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard { GroupRows(group) }
|
||||
Text(
|
||||
"These are the same settings as Settings in the car. A change here shows there too.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupRows(group: CarGroup) {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val night by CarSettings.nightMode.collectAsState()
|
||||
val units by CarSettings.units.collectAsState()
|
||||
val navBar by CarSettings.navBarMode.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
|
||||
when (group) {
|
||||
CarGroup.SCREEN -> {
|
||||
Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect)
|
||||
SettingsDivider()
|
||||
Choice("Size", sizeChoices, scale, CarSettings::setScale)
|
||||
SettingsDivider()
|
||||
Choice("App theme", themeChoices, theme, CarSettings::setAppTheme)
|
||||
SettingsDivider()
|
||||
Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme)
|
||||
SettingsDivider()
|
||||
Choice("Map day/night", nightChoices, night, CarSettings::setNightMode)
|
||||
SettingsDivider()
|
||||
Choice("Units", unitChoices, units, CarSettings::setUnits)
|
||||
}
|
||||
CarGroup.NAV_BAR -> {
|
||||
Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode)
|
||||
SettingsDivider()
|
||||
Choice("Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), playerTitle, CarSettings::setPlayerTitle)
|
||||
SettingsDivider()
|
||||
Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft)
|
||||
SettingsDivider()
|
||||
Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay)
|
||||
}
|
||||
CarGroup.SOUND -> {
|
||||
Choice("Car audio", listOf(false to "Bluetooth", true to "USB"), pipeAudio, CarSettings::setPipeAudio)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"Spoken directions",
|
||||
speakDirections,
|
||||
CarSettings::setSpeakDirections,
|
||||
note = "Turn it off if your map app's own voice already plays in the car.",
|
||||
)
|
||||
SettingsDivider()
|
||||
Toggle(
|
||||
"Use the phone's speech recognition",
|
||||
systemSpeech,
|
||||
CarSettings::setSystemSpeech,
|
||||
note = "On most phones that's Google's, which may send your voice to Google.",
|
||||
)
|
||||
}
|
||||
CarGroup.WARNINGS -> {
|
||||
if (!pipeAudio) {
|
||||
Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning)
|
||||
SettingsDivider()
|
||||
}
|
||||
Toggle("Warn when location is off", locationWarning, CarSettings::setLocationWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
|
||||
|
||||
/** A setting with a few named choices: its current one shown, the rest a tap away. */
|
||||
@Composable
|
||||
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
SettingsRow(
|
||||
title,
|
||||
Modifier.clickable { menu = true },
|
||||
trailing = {
|
||||
// The menu hangs from the value it changes, at the row's end.
|
||||
Box {
|
||||
Text(choices.label(selected), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||
DropdownMenu(menu, onDismissRequest = { menu = false }) {
|
||||
choices.forEach { (value, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontWeight = if (value == selected) FontWeight.Bold else FontWeight.Normal) },
|
||||
onClick = {
|
||||
onSelect(value)
|
||||
menu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toggle(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String? = null) {
|
||||
SettingsRow(
|
||||
title,
|
||||
Modifier.clickable { onChange(!on) },
|
||||
subtitle = note,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -27,6 +28,8 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material.icons.filled.List
|
||||
@@ -60,10 +63,12 @@ import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onOpenUsageNotes: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenLogs: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onOpenCarGroup: (CarGroup) -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
@@ -121,25 +126,18 @@ fun HomeScreen(
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
Spacer(Modifier.height(24.dp))
|
||||
HomeCarSettings(onOpenCarGroup)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
Column(Modifier.padding(vertical = 16.dp)) {
|
||||
SettingsCard {
|
||||
SettingsRow(
|
||||
"What's new in ${Intro.release}",
|
||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Which cars work",
|
||||
Modifier.clickable(onClick = onOpenHelp),
|
||||
subtitle = "Most do. See which ones, and what to set up first",
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeShortcut(Icons.Filled.Info, "Help", onOpenHelp, Modifier.weight(1f))
|
||||
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
||||
if (!live) HomeShortcut(Icons.Filled.PlayArrow, "Preview", onOpenCarPreview, Modifier.weight(1f))
|
||||
HomeShortcut(Icons.AutoMirrored.Filled.List, "Logs", onOpenLogs, Modifier.weight(1f))
|
||||
}
|
||||
if (live) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -157,6 +155,27 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** One of Home's shortcuts at the bottom: an icon over a one-word label, small enough that all fit in a row. */
|
||||
@Composable
|
||||
private fun HomeShortcut(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 64.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bluetooth audio is chosen, but the phone isn't connected to the car. See [BluetoothAudioCheck]. */
|
||||
@Composable
|
||||
private fun BluetoothMissingCard() {
|
||||
|
||||
@@ -1,877 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import app.seb3thehacker.gearslip.speech.SpeechModels
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The release intro: a few screens shown once after updating to a new release, before setup. New
|
||||
* installs skip it. Its look follows the 0.3 mockup: Material shapes in the phone's own colours,
|
||||
* with every size taken from the screen's width so it scales from small phones to tablets.
|
||||
*/
|
||||
object Intro {
|
||||
private const val PREFS = "intro"
|
||||
private const val KEY_SEEN = "seen_release"
|
||||
private const val KEY_STEP = "step"
|
||||
|
||||
/** "0.3" from "0.3.00-alpha": the intro belongs to a release, not to each patch. */
|
||||
val release: String = BuildConfig.VERSION_NAME.split('.').take(2).joinToString(".")
|
||||
|
||||
/**
|
||||
* True once per release for people who used Gearslip before. A new install, or one with its
|
||||
* data cleared, gets the setup guide instead: there's nothing "new" yet, so this release
|
||||
* counts as seen and the next one shows. Install dates can't tell those apart, since
|
||||
* clearing data keeps them, so this goes by what setup left behind; 0.2 saved the same.
|
||||
*/
|
||||
fun due(context: Context): Boolean {
|
||||
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_SEEN, null) == release) return false
|
||||
val usedBefore = AppSettings.hasSeenPermissionsSetup(context) ||
|
||||
AppSettings.hasSkippedCertSetup(context) || CertProvider.hasAnyCert(context)
|
||||
if (!usedBefore) {
|
||||
markSeen(context)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun markSeen(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_SEEN, release)
|
||||
.remove(KEY_STEP)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** A replay from Settings starts at the first page. */
|
||||
fun replay(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(KEY_STEP).apply()
|
||||
}
|
||||
|
||||
/** The page the intro was on, so an app restart part-way through resumes there. */
|
||||
internal fun savedStep(context: Context): String? =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_STEP, null)
|
||||
|
||||
internal fun saveStep(context: Context, step: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_STEP, step).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** The intro's tones, taken from the phone's own Material You colours like the rest of the app. */
|
||||
internal class IntroColors(
|
||||
val surface: Color, val onSurface: Color, val onVariant: Color, val outline: Color,
|
||||
val primary: Color, val onPrimary: Color, val primaryC: Color, val onPrimaryC: Color,
|
||||
val tertiaryC: Color, val onTertiaryC: Color, val secondaryC: Color, val onSecondaryC: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun introColors(): IntroColors {
|
||||
val m = MaterialTheme.colorScheme
|
||||
return IntroColors(
|
||||
surface = m.surface, onSurface = m.onSurface, onVariant = m.onSurfaceVariant, outline = m.outline,
|
||||
primary = m.primary, onPrimary = m.onPrimary, primaryC = m.primaryContainer, onPrimaryC = m.onPrimaryContainer,
|
||||
tertiaryC = m.tertiaryContainer, onTertiaryC = m.onTertiaryContainer,
|
||||
secondaryC = m.secondaryContainer, onSecondaryC = m.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
/** In the order shown: what's new, what to pick, housekeeping, then the people behind it. */
|
||||
private enum class IntroStep { WELCOME, WIRELESS, KNOBS, VOICE, THEME, APPS, GRAPHENE, CHAT, THANKS }
|
||||
|
||||
private val Pop = CubicBezierEasing(0.3f, 1.6f, 0.5f, 1f)
|
||||
private val Rise = CubicBezierEasing(0.2f, 0.8f, 0.2f, 1f)
|
||||
|
||||
@Composable
|
||||
fun IntroScreen(onDone: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val c = introColors()
|
||||
// The GrapheneOS screen is only news to people running it.
|
||||
val steps = remember {
|
||||
IntroStep.entries.filter { it != IntroStep.GRAPHENE || UsageStats.osName(context) == "GrapheneOS" }
|
||||
}
|
||||
// Turning exploit protection back on restarts the app, so the intro picks up where it was.
|
||||
var index by remember {
|
||||
mutableIntStateOf(steps.indexOfFirst { it.name == Intro.savedStep(context) }.coerceAtLeast(0))
|
||||
}
|
||||
val step = steps[index]
|
||||
LaunchedEffect(step) { Intro.saveStep(context, step.name) }
|
||||
val finish = {
|
||||
Intro.markSeen(context)
|
||||
onDone()
|
||||
}
|
||||
BackHandler(enabled = index > 0) { index-- }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(c.surface)) {
|
||||
val w = maxWidth
|
||||
val h = maxHeight
|
||||
// The mockup's cqw: one percent of the screen's width.
|
||||
fun cq(n: Float): Dp = w * (n / 100f)
|
||||
val density = LocalDensity.current
|
||||
fun cqSp(n: Float): TextUnit = with(density) { cq(n).toSp() }
|
||||
|
||||
// The step on top, Back and Next under it, the same as the setup guide.
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (step) {
|
||||
IntroStep.WELCOME -> Welcome(c, ::cq, ::cqSp, h)
|
||||
IntroStep.WIRELESS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Wireless.",
|
||||
"If your car does wireless Android Auto, pair once and leave the cable at home.", step) { WifiIcon(c.onSecondaryC) }
|
||||
IntroStep.KNOBS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Knobs work.",
|
||||
"Turn, push, done. No touchscreen? Gearslip doesn't need one anymore.", step) { DialIcon(c.onTertiaryC) }
|
||||
IntroStep.VOICE -> VoiceStep(c, ::cq, ::cqSp, step)
|
||||
IntroStep.GRAPHENE -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Protections back on.",
|
||||
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode. If you " +
|
||||
"turned it on for Gearslip, turn it off to get full protection back.", step,
|
||||
action = "Open app settings" to {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}
|
||||
},
|
||||
outlined = true,
|
||||
) { ShieldIcon(c.onSecondaryC, c.secondaryC) }
|
||||
IntroStep.THANKS -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Thank you.",
|
||||
"Thanks to your usage notes, Gearslip is confirmed to work in cars newer than 2020. " +
|
||||
"Each note shows which cars work and which still fail, so I know what to fix next.", step,
|
||||
action = "See which cars work" to { GearslipLinks.open(context, GearslipLinks.CARS) },
|
||||
outlined = true,
|
||||
secondAction = "Support on Liberapay" to { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
|
||||
) { HeartIcon(c.onTertiaryC) }
|
||||
IntroStep.CHAT -> BadgeStep(c, ::cq, ::cqSp, c.primaryC, "Come say hi.",
|
||||
"Gearslip has a chat room on Matrix. Talk with other drivers about cars, apps, and " +
|
||||
"where Gearslip is headed.", step,
|
||||
action = "Join on Matrix" to { GearslipLinks.open(context, GearslipLinks.MATRIX) },
|
||||
outlined = true,
|
||||
) { ChatIcon(c.onPrimaryC, c.primaryC) }
|
||||
IntroStep.APPS -> BadgeStep(c, ::cq, ::cqSp, c.secondaryC, "Good company.",
|
||||
"Free, open-source apps that work well in the car. The first in each row works best. Tap one to see it on GitHub.", step,
|
||||
extra = { AppPicks(c, ::cq, ::cqSp, step) }) { StarIcon(c.onSecondaryC) }
|
||||
IntroStep.THEME -> BadgeStep(c, ::cq, ::cqSp, c.tertiaryC, "Your buttons.",
|
||||
"Pick how the car's buttons look. Change it any time in the car's settings.", step,
|
||||
extra = { ThemePicker(c, ::cq, ::cqSp, step) }) { ButtonsIcon(c.onTertiaryC) }
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = cq(8f))) {
|
||||
Dots(c, steps.size, index, ::cq, Modifier.align(Alignment.CenterHorizontally))
|
||||
Spacer(Modifier.height(cq(5f)))
|
||||
PillRow(
|
||||
c, ::cq, ::cqSp,
|
||||
onBack = if (index > 0) ({ index-- }) else null,
|
||||
label = if (index == steps.lastIndex) "Start" else "Next",
|
||||
) { if (index < steps.lastIndex) index++ else finish() }
|
||||
Spacer(Modifier.height(cq(6f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 0 to 1 once each time [key] changes, after [delayMs]: drives the entrance of one element. */
|
||||
@Composable
|
||||
internal fun entrance(key: Any, delayMs: Int, durationMs: Int = 650): Float {
|
||||
val a = remember(key) { Animatable(0f) }
|
||||
LaunchedEffect(key) {
|
||||
delay(delayMs.toLong())
|
||||
a.animateTo(1f, tween(durationMs, easing = LinearEasing))
|
||||
}
|
||||
return a.value
|
||||
}
|
||||
|
||||
/** Slides up and fades in, like the mockup's `up`. */
|
||||
internal fun Modifier.up(p: Float, distance: Dp) = graphicsLayer {
|
||||
val e = Rise.transform(p)
|
||||
alpha = e
|
||||
translationY = (1 - e) * distance.toPx()
|
||||
}
|
||||
|
||||
/** Grows in with a bounce, like the mockup's `bounce`. */
|
||||
internal fun Modifier.pop(p: Float) = graphicsLayer {
|
||||
val e = Pop.transform(p)
|
||||
alpha = p.coerceAtMost(1f)
|
||||
scaleX = 0.5f + 0.5f * e
|
||||
scaleY = 0.5f + 0.5f * e
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Welcome(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, height: Dp) {
|
||||
val key = IntroStep.WELCOME
|
||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Cookie(c.primaryC, Modifier.size(cq(120f)).offset(x = cq(28f), y = -cq(22f)).align(Alignment.TopEnd).graphicsLayer { rotationZ = spin; alpha = 0.9f })
|
||||
Ticker(c.secondaryC, c.onSecondaryC, "OPEN SOURCE ✦ NO ACCOUNT ✦ YOUR CAR, YOUR PHONE ✦ ", cq, cqSp, reverse = true, tilt = 3f,
|
||||
key = key, modifier = Modifier.align(Alignment.TopCenter).padding(top = height * 0.11f))
|
||||
|
||||
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = cq(8f))) {
|
||||
Text("Welcome to", color = c.onVariant, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.up(entrance(key, 100), cq(7f)))
|
||||
Text(
|
||||
Intro.release, color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(44f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-2.4f), lineHeight = cqSp(38f)),
|
||||
modifier = Modifier.offset(x = -cq(1.5f)).pop(entrance(key, 250, 900)),
|
||||
)
|
||||
Text(
|
||||
"alpha · the big one", color = c.onTertiaryC,
|
||||
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = cq(4f)).up(entrance(key, 700), cq(7f))
|
||||
.clip(RoundedCornerShape(50)).background(c.tertiaryC).padding(horizontal = cq(4.4f), vertical = cq(2.2f)),
|
||||
)
|
||||
}
|
||||
|
||||
Confetti(c, key, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
/** A tilted band of words scrolling past, slid in from the side when the step opens. */
|
||||
@Composable
|
||||
private fun Ticker(
|
||||
bg: Color, fg: Color, words: String, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit,
|
||||
reverse: Boolean, tilt: Float, key: Any, modifier: Modifier,
|
||||
) {
|
||||
val enter = entrance(key, 400, 800)
|
||||
val scroll by rememberInfiniteTransition(label = "ticker").animateFloat(
|
||||
0f, 1f, infiniteRepeatable(tween(if (reverse) 20_000 else 16_000, easing = LinearEasing)), label = "ticker",
|
||||
)
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
rotationZ = tilt
|
||||
scaleX = 1.2f
|
||||
val e = Rise.transform(enter)
|
||||
alpha = e
|
||||
translationX = (1 - e) * -cq(30f).toPx()
|
||||
}
|
||||
.background(bg)
|
||||
.padding(vertical = cq(2.6f)),
|
||||
) {
|
||||
Text(
|
||||
words.repeat(4),
|
||||
color = fg, maxLines = 1, softWrap = false,
|
||||
style = TextStyle(fontSize = cqSp(5f), fontWeight = FontWeight.ExtraBold),
|
||||
modifier = Modifier
|
||||
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||
.graphicsLayer {
|
||||
// The text holds the words four times; sliding by half loops cleanly.
|
||||
val shift = size.width / 2f * (if (reverse) 1 - scroll else scroll)
|
||||
translationX = -shift
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Material shapes thrown once from behind the version number. */
|
||||
@Composable
|
||||
private fun Confetti(c: IntroColors, key: Any, modifier: Modifier) {
|
||||
val p = entrance(key, 450, 1300)
|
||||
val bits = remember {
|
||||
val r = Random(7)
|
||||
List(22) { i ->
|
||||
val a = i / 22f * 2 * PI.toFloat() + r.nextFloat() * 0.4f
|
||||
val d = 30f + r.nextFloat() * 45f
|
||||
Bit(cos(a) * d, sin(a) * d * 1.2f, r.nextInt(-270, 270).toFloat(), i % 4, i % 3)
|
||||
}
|
||||
}
|
||||
if (p <= 0f || p >= 1f) return
|
||||
val tones = listOf(c.primary, c.primaryC, c.tertiaryC, c.secondaryC)
|
||||
Canvas(modifier) {
|
||||
val unit = size.width / 100f
|
||||
val e = CubicBezierEasing(0.15f, 0.7f, 0.3f, 1f).transform(p)
|
||||
val alpha = if (p < 0.7f) 1f else 1f - (p - 0.7f) / 0.3f
|
||||
val origin = Offset(size.width * 0.3f, size.height * 0.42f)
|
||||
bits.forEach { b ->
|
||||
val at = origin + Offset(b.dx * unit * e, b.dy * unit * e)
|
||||
val s = 3.6f * unit * (0.3f + 0.7f * e)
|
||||
rotate(b.spin * e, at) {
|
||||
val color = tones[b.tone].copy(alpha = alpha)
|
||||
when (b.shape) {
|
||||
0 -> drawCircle(color, s / 2, at)
|
||||
1 -> drawRoundRect(color, at - Offset(s / 2, s / 2), Size(s, s), androidx.compose.ui.geometry.CornerRadius(unit))
|
||||
else -> drawRoundRect(color, at - Offset(s / 2, s / 4), Size(s, s / 2), androidx.compose.ui.geometry.CornerRadius(s / 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Bit(val dx: Float, val dy: Float, val spin: Float, val tone: Int, val shape: Int)
|
||||
|
||||
/** One idea per screen: a spinning cookie with an icon, a headline, a line of text, maybe a button. */
|
||||
@Composable
|
||||
private fun BadgeStep(
|
||||
c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, badge: Color,
|
||||
title: String, body: String, key: Any,
|
||||
action: Pair<String, () -> Unit>? = null, outlined: Boolean = false,
|
||||
secondAction: Pair<String, () -> Unit>? = null,
|
||||
extra: (@Composable () -> Unit)? = null,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
val spin by rememberInfiniteTransition(label = "spin").animateFloat(
|
||||
0f, 360f, infiniteRepeatable(tween(18_000, easing = LinearEasing)), label = "spin",
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = cq(8f)),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(cq(34f)).pop(entrance(key, 50, 800)), contentAlignment = Alignment.Center) {
|
||||
Cookie(badge, Modifier.fillMaxSize().graphicsLayer { rotationZ = spin })
|
||||
Box(Modifier.size(cq(15f))) { icon() }
|
||||
}
|
||||
Spacer(Modifier.height(cq(8f)))
|
||||
Text(
|
||||
title, color = c.onSurface,
|
||||
style = TextStyle(fontSize = cqSp(13.5f), fontWeight = FontWeight.Black, letterSpacing = cqSp(-0.6f), lineHeight = cqSp(12.5f)),
|
||||
modifier = Modifier.up(entrance(key, 200, 700), cq(7f)),
|
||||
)
|
||||
Spacer(Modifier.height(cq(4f)))
|
||||
Text(
|
||||
body, color = c.onVariant,
|
||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||
modifier = Modifier.up(entrance(key, 350, 700), cq(7f)),
|
||||
)
|
||||
extra?.invoke()
|
||||
listOfNotNull(action, secondAction).forEachIndexed { i, (label, onClick) ->
|
||||
Spacer(Modifier.height(if (i > 0) cq(3f) else if (outlined) cq(7f) else cq(9f)))
|
||||
Text(
|
||||
label,
|
||||
color = if (outlined) c.primary else c.onPrimary,
|
||||
style = TextStyle(fontSize = cqSp(if (outlined) 4.4f else 4.6f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier
|
||||
.up(entrance(key, 500 + i * 150, 700), cq(7f))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.then(
|
||||
if (outlined) Modifier.border(BorderStroke(2.dp, c.outline), RoundedCornerShape(50))
|
||||
else Modifier.background(c.primary),
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = if (outlined) cq(7f) else cq(9f), vertical = if (outlined) cq(3.6f) else cq(4.2f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice: a few things to say, drifting past, and a button that fetches both speech models so the
|
||||
* assistant works the first time someone tries it in the car.
|
||||
*/
|
||||
@Composable
|
||||
private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
||||
val context = LocalContext.current
|
||||
remember { SpeechModels.init(context) }
|
||||
val needed = SpeechModels.RECOMMENDED
|
||||
val states = needed.map { SpeechModels.state(it).collectAsState().value }
|
||||
val ready = states.all { it == SpeechModels.State.Ready }
|
||||
val label = when {
|
||||
states.any { it is SpeechModels.State.Downloading } ->
|
||||
"Downloading, ${needed.zip(states).sumOf { (m, st) -> m.downloadMb * percentOf(st) } / needed.sumOf { it.downloadMb }}%"
|
||||
states.any { it is SpeechModels.State.Failed } -> "Download failed. Try again"
|
||||
else -> "Download voice, ${needed.sumOf { it.downloadMb }} MB"
|
||||
}
|
||||
BadgeStep(
|
||||
c, cq, cqSp, c.primaryC, "Just say it.",
|
||||
"Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " +
|
||||
"into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.",
|
||||
key,
|
||||
action = if (ready) null else label to { SpeechModels.downloadRecommended(context) },
|
||||
outlined = true,
|
||||
extra = { CommandDrift(c, cq, cqSp, key) },
|
||||
) { MicIcon(c.onPrimaryC) }
|
||||
}
|
||||
|
||||
private fun percentOf(state: SpeechModels.State) = when (state) {
|
||||
SpeechModels.State.Ready -> 100
|
||||
is SpeechModels.State.Downloading -> state.percent
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private val SAMPLE_COMMANDS = listOf(
|
||||
"Play Daft Punk", "What's playing?", "Next song",
|
||||
"Call Mom", "Open Organic Maps", "Pause", "Help",
|
||||
)
|
||||
|
||||
/** Example commands rising slowly through a window that fades at both edges. */
|
||||
@Composable
|
||||
private fun CommandDrift(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: Any) {
|
||||
val scroll by rememberInfiniteTransition(label = "drift").animateFloat(
|
||||
0f, 1f, infiniteRepeatable(tween(SAMPLE_COMMANDS.size * 2_200, easing = LinearEasing)), label = "drift",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = cq(6f))
|
||||
.fillMaxWidth()
|
||||
.height(cq(34f))
|
||||
.up(entrance(key, 500, 700), cq(7f))
|
||||
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
Brush.verticalGradient(0f to Color.Transparent, 0.25f to Color.Black, 0.75f to Color.Black, 1f to Color.Transparent),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.wrapContentHeight(Alignment.Top, unbounded = true)
|
||||
// The list is there twice; rising by half loops cleanly.
|
||||
.graphicsLayer { translationY = -size.height / 2f * scroll },
|
||||
verticalArrangement = Arrangement.spacedBy(cq(2.4f)),
|
||||
) {
|
||||
repeat(2) {
|
||||
SAMPLE_COMMANDS.forEach { say ->
|
||||
Text(
|
||||
"\u201c$say\u201d", color = c.onSecondaryC,
|
||||
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(c.secondaryC)
|
||||
.padding(horizontal = cq(4.4f), vertical = cq(2f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Each kind of app with its picks, the first recommended most. */
|
||||
private val APP_PICKS = listOf(
|
||||
"Maps" to listOf(
|
||||
"Organic Maps" to "https://github.com/organicmaps/organicmaps",
|
||||
"Vela" to "https://github.com/PimpinPumpkin/Vela",
|
||||
),
|
||||
"Music" to listOf(
|
||||
"ViVi Music" to "https://github.com/vivizzz007/vivi-music",
|
||||
"Metrolist" to "https://github.com/MetrolistGroup/Metrolist",
|
||||
),
|
||||
"Podcasts" to listOf(
|
||||
"AntennaPod" to "https://github.com/AntennaPod/AntennaPod",
|
||||
),
|
||||
)
|
||||
|
||||
/** One row per kind, with a chip per app opening its GitHub page; the rows arrive one after another. */
|
||||
@Composable
|
||||
private fun AppPicks(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
||||
val context = LocalContext.current
|
||||
Column(Modifier.padding(top = cq(6f)), verticalArrangement = Arrangement.spacedBy(cq(2.6f))) {
|
||||
APP_PICKS.forEachIndexed { i, (kind, apps) ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.up(entrance(key, 500 + i * 120, 700), cq(7f))
|
||||
.clip(RoundedCornerShape(cq(5f)))
|
||||
.background(c.secondaryC)
|
||||
.padding(horizontal = cq(4f), vertical = cq(3f)),
|
||||
) {
|
||||
Text(kind.uppercase(), color = c.onSecondaryC.copy(alpha = 0.7f),
|
||||
style = TextStyle(fontSize = cqSp(3.2f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.3f)),
|
||||
modifier = Modifier.padding(start = cq(1f), bottom = cq(1.6f)))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
||||
apps.forEach { (name, url) ->
|
||||
Text(
|
||||
"$name ↗", color = c.onSecondaryC,
|
||||
style = TextStyle(fontSize = cqSp(4.4f), fontWeight = FontWeight.ExtraBold),
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.border(BorderStroke(1.5.dp, c.onSecondaryC.copy(alpha = 0.35f)), RoundedCornerShape(50))
|
||||
.clickable {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
.padding(horizontal = cq(3.6f), vertical = cq(2f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's button styles side by side, each drawn in its own style, and a row of real car
|
||||
* controls below that redraws in whichever is picked. Picking saves it, so the car shows it too.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemePicker(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) {
|
||||
val chosenId by CarSettings.uiTheme.collectAsState()
|
||||
val chosen = GsThemes.byId(chosenId)
|
||||
// Flat first: it's the default.
|
||||
val themes = listOf(GsThemes.Flat) + GsThemes.builtIn.filter { it != GsThemes.Flat }
|
||||
Column(
|
||||
Modifier.padding(top = cq(7f)).fillMaxWidth().up(entrance(key, 500, 700), cq(7f)),
|
||||
verticalArrangement = Arrangement.spacedBy(cq(6f)),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) {
|
||||
themes.forEach { theme ->
|
||||
CompositionLocalProvider(LocalGsTheme provides theme) {
|
||||
GsButton(
|
||||
onClick = { CarSettings.setUiTheme(theme.id) },
|
||||
modifier = Modifier.weight(1f),
|
||||
tone = if (theme == chosen) GsTone.Primary else GsTone.Neutral,
|
||||
contentPadding = PaddingValues(horizontal = cq(1f), vertical = cq(3f)),
|
||||
) {
|
||||
Text(
|
||||
if (theme == chosen) "✓ ${theme.name}" else theme.name,
|
||||
style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What the car shows: the player's controls and a full-width action.
|
||||
CompositionLocalProvider(LocalGsTheme provides chosen) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(cq(3f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.Filled.Search, "Search", onClick = {}, size = cq(15f), iconSize = cq(7f))
|
||||
GsIconButton(Icons.Filled.PlayArrow, "Play", onClick = {}, tone = GsTone.Primary, size = cq(15f), iconSize = cq(8f))
|
||||
GsButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.weight(1f).height(cq(15f)),
|
||||
tone = GsTone.Tonal,
|
||||
) { Text("Navigate", style = TextStyle(fontSize = cqSp(4.6f), fontWeight = FontWeight.Bold)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Three keys of a keypad, pressing one after another. */
|
||||
@Composable
|
||||
private fun ButtonsIcon(color: Color) {
|
||||
val t by rememberInfiniteTransition(label = "keys").animateFloat(
|
||||
0f, 3f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "keys",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
listOf(6f, 19f, 32f).forEachIndexed { i, x ->
|
||||
// Each key dips for a moment while its turn comes round.
|
||||
val dip = (1f - kotlin.math.abs(t - i - 0.5f) * 2f).coerceIn(0f, 1f) * 4f
|
||||
drawRoundRect(color.copy(alpha = 0.45f), Offset(x, 16f + 4f), Size(10f, 16f),
|
||||
androidx.compose.ui.geometry.CornerRadius(3f))
|
||||
drawRoundRect(color, Offset(x, 16f + dip), Size(10f, 16f), androidx.compose.ui.geometry.CornerRadius(3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material's scalloped "cookie": a circle whose radius waves around its edge. */
|
||||
@Composable
|
||||
internal fun Cookie(color: Color, modifier: Modifier) {
|
||||
Canvas(modifier) {
|
||||
val unit = size.minDimension / 100f
|
||||
val path = Path()
|
||||
var deg = 0
|
||||
while (deg <= 360) {
|
||||
val a = deg * PI / 180
|
||||
val r = (44 + 4.5 * cos(9 * a)) * unit
|
||||
val x = center.x + (r * cos(a)).toFloat()
|
||||
val y = center.y + (r * sin(a)).toFloat()
|
||||
if (deg == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
deg += 3
|
||||
}
|
||||
path.close()
|
||||
drawPath(path, color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws in the mockup's 48-unit icon box. */
|
||||
private inline fun DrawScope.inIconBox(block: DrawScope.() -> Unit) {
|
||||
val s = size.minDimension / 48f
|
||||
scale(s, s, Offset.Zero) { block() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WifiIcon(color: Color) {
|
||||
val t = rememberInfiniteTransition(label = "wifi")
|
||||
val waves = (0..2).map { i ->
|
||||
t.animateFloat(
|
||||
0.35f, 1f,
|
||||
infiniteRepeatable(tween(800, delayMillis = 0, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 200)),
|
||||
label = "wave$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val stroke = Stroke(5f, cap = StrokeCap.Round)
|
||||
// The mockup's three arcs, inner to outer: radius r through (24 ± half, y).
|
||||
listOf(Triple(8f, 6f, 34f), Triple(18f, 13f, 26f), Triple(28f, 20f, 18f)).forEachIndexed { i, (r, half, y) ->
|
||||
val cy = y + sqrt(r * r - half * half)
|
||||
val start = Math.toDegrees(atan2((y - cy).toDouble(), (-half).toDouble())).toFloat()
|
||||
val end = Math.toDegrees(atan2((y - cy).toDouble(), half.toDouble())).toFloat()
|
||||
drawArc(color.copy(alpha = waves[i].value), start, (end - start + 360f) % 360f, false,
|
||||
Offset(24f - r, cy - r), Size(2 * r, 2 * r), style = stroke)
|
||||
}
|
||||
drawCircle(color, 3.6f, Offset(24f, 41f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialIcon(color: Color) {
|
||||
val angle by rememberInfiniteTransition(label = "dial").animateFloat(
|
||||
-70f, -70f,
|
||||
infiniteRepeatable(keyframes {
|
||||
durationMillis = 2600
|
||||
-70f at 0 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
||||
70f at 1040
|
||||
70f at 1430 using CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
|
||||
-70f at 2470
|
||||
}),
|
||||
label = "dial",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
drawCircle(color, 19f, Offset(24f, 24f), style = Stroke(5f))
|
||||
rotate(angle, Offset(24f, 24f)) {
|
||||
drawRoundRect(color, Offset(21.5f, 7f), Size(5f, 13f), androidx.compose.ui.geometry.CornerRadius(2.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MicIcon(color: Color) {
|
||||
val t = rememberInfiniteTransition(label = "mic")
|
||||
val waves = (0..1).map { i ->
|
||||
t.animateFloat(
|
||||
0.25f, 1f,
|
||||
infiniteRepeatable(tween(700, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 250)),
|
||||
label = "mic$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
drawRoundRect(color, Offset(18f, 5f), Size(12f, 22f), androidx.compose.ui.geometry.CornerRadius(6f))
|
||||
val stroke = Stroke(4f, cap = StrokeCap.Round)
|
||||
drawArc(color, 0f, 180f, false, Offset(12f, 14f), Size(24f, 22f), style = stroke)
|
||||
drawLine(color, Offset(24f, 36f), Offset(24f, 42f), 4f, StrokeCap.Round)
|
||||
// Sound waves either side, pulsing as if someone's talking.
|
||||
listOf(1f to 0, -1f to 0, 1f to 1, -1f to 1).forEach { (side, i) ->
|
||||
val r = if (i == 0) 15f else 21f
|
||||
drawArc(color.copy(alpha = waves[i].value), if (side > 0) -35f else 145f, 70f, false,
|
||||
Offset(24f - r, 16f - r), Size(2 * r, 2 * r), style = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StarIcon(color: Color) {
|
||||
val turn by rememberInfiniteTransition(label = "star").animateFloat(
|
||||
-8f, 8f, infiniteRepeatable(tween(1600, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "star",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val star = Path()
|
||||
for (i in 0 until 10) {
|
||||
val r = if (i % 2 == 0) 20f else 8.5f
|
||||
val a = (-90 + i * 36) * PI / 180
|
||||
val x = 24f + (r * cos(a)).toFloat()
|
||||
val y = 25f + (r * sin(a)).toFloat()
|
||||
if (i == 0) star.moveTo(x, y) else star.lineTo(x, y)
|
||||
}
|
||||
star.close()
|
||||
rotate(turn, Offset(24f, 25f)) { drawPath(star, color) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ShieldIcon(color: Color, tick: Color) {
|
||||
val p = entrance(IntroStep.GRAPHENE, 800, 500)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
val shield = Path().apply {
|
||||
moveTo(24f, 4f); lineTo(40f, 10f); lineTo(40f, 22f)
|
||||
cubicTo(40f, 32f, 33f, 40f, 24f, 44f)
|
||||
cubicTo(15f, 40f, 8f, 32f, 8f, 22f)
|
||||
lineTo(8f, 10f); close()
|
||||
}
|
||||
drawPath(shield, color)
|
||||
drawPartialStroke(listOf(Offset(16f, 24f), Offset(22f, 30f), Offset(33f, 18f)), p, tick, 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A speech bubble with three dots that pulse, like someone typing. */
|
||||
@Composable
|
||||
private fun ChatIcon(color: Color, dots: Color) {
|
||||
val t = rememberInfiniteTransition(label = "typing")
|
||||
val pulse = (0..2).map { i ->
|
||||
t.animateFloat(
|
||||
0.35f, 1f,
|
||||
infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse,
|
||||
initialStartOffset = androidx.compose.animation.core.StartOffset(i * 170)),
|
||||
label = "dot$i",
|
||||
)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
drawRoundRect(color, Offset(4f, 7f), Size(40f, 28f), CornerRadius(10f, 10f))
|
||||
drawPath(Path().apply { moveTo(12f, 33f); lineTo(10f, 43f); lineTo(22f, 33f); close() }, color)
|
||||
pulse.forEachIndexed { i, a -> drawCircle(dots.copy(alpha = a.value), 3.2f, Offset(15f + i * 9f, 21f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HeartIcon(color: Color) {
|
||||
val beat by rememberInfiniteTransition(label = "beat").animateFloat(
|
||||
1f, 1f,
|
||||
infiniteRepeatable(keyframes {
|
||||
durationMillis = 1400
|
||||
1f at 0; 1.16f at 210; 1f at 420; 1.1f at 630; 1f at 840
|
||||
}),
|
||||
label = "beat",
|
||||
)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
inIconBox {
|
||||
scale(beat, beat, Offset(24f, 24f)) {
|
||||
val heart = Path().apply {
|
||||
moveTo(24f, 41f)
|
||||
cubicTo(10f, 31f, 5f, 23f, 5f, 16f)
|
||||
cubicTo(5f, 10f, 10f, 6f, 15f, 6f)
|
||||
cubicTo(19f, 6f, 22f, 8f, 24f, 12f)
|
||||
cubicTo(26f, 8f, 29f, 6f, 33f, 6f)
|
||||
cubicTo(38f, 6f, 43f, 10f, 43f, 16f)
|
||||
cubicTo(43f, 23f, 38f, 31f, 24f, 41f)
|
||||
close()
|
||||
}
|
||||
drawPath(heart, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A polyline drawn up to [progress] of its length, so a tick can sweep in. */
|
||||
private fun DrawScope.drawPartialStroke(points: List<Offset>, progress: Float, color: Color, width: Float) {
|
||||
if (progress <= 0f) return
|
||||
val lengths = points.zipWithNext { a, b -> (b - a).getDistance() }
|
||||
var left = lengths.sum() * FastOutSlowInEasing.transform(progress.coerceAtMost(1f))
|
||||
val path = Path().apply { moveTo(points[0].x, points[0].y) }
|
||||
for (i in lengths.indices) {
|
||||
if (left <= 0f) break
|
||||
val f = (left / lengths[i]).coerceAtMost(1f)
|
||||
val to = points[i] + (points[i + 1] - points[i]) * f
|
||||
path.lineTo(to.x, to.y)
|
||||
left -= lengths[i]
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Dots(c: IntroColors, count: Int, current: Int, cq: (Float) -> Dp, modifier: Modifier) {
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(cq(2f))) {
|
||||
repeat(count) { i ->
|
||||
val on = i == current
|
||||
val width by animateDpAsState(if (on) cq(7f) else cq(2f), tween(300), label = "dot")
|
||||
Box(
|
||||
Modifier
|
||||
.size(width, cq(2f))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (on) c.primary else c.outline.copy(alpha = 0.45f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -55,6 +56,9 @@ fun LogsScreen(onBack: () -> Unit) {
|
||||
val version by GearslipLog.version.collectAsStateWithLifecycle()
|
||||
val lines = remember(version) { GearslipLog.snapshot() }
|
||||
|
||||
var pastOpen by remember { mutableStateOf(false) }
|
||||
if (pastOpen) PastLogsDialog(onDismiss = { pastOpen = false })
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val atEnd by remember {
|
||||
derivedStateOf {
|
||||
@@ -96,17 +100,16 @@ fun LogsScreen(onBack: () -> Unit) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(Intent.createChooser(send, "Share log"))
|
||||
}
|
||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
|
||||
shareLog(context, uri)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Share") }
|
||||
OutlinedButton(
|
||||
onClick = { pastOpen = true },
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) { Text("Earlier") }
|
||||
OutlinedButton(
|
||||
onClick = { GearslipLog.clearLive() },
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
@@ -153,6 +156,68 @@ fun LogsScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareLog(context: android.content.Context, uri: android.net.Uri) {
|
||||
val send = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
|
||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(Intent.createChooser(send, "Share log"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs from earlier launches. Opening the app after a crash starts a new log, so the one that
|
||||
* saw the crash is only reachable here.
|
||||
*/
|
||||
@Composable
|
||||
private fun PastLogsDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val logs by androidx.compose.runtime.produceState<List<GearslipLog.PastLog>?>(null) {
|
||||
value = withContext(Dispatchers.IO) { GearslipLog.pastLogs(context) }
|
||||
}
|
||||
val format = remember { java.text.SimpleDateFormat("EEE d MMM, h:mm a", java.util.Locale.getDefault()) }
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Earlier logs") },
|
||||
text = {
|
||||
val list = logs
|
||||
when {
|
||||
list == null -> Text("Loading…")
|
||||
list.isEmpty() -> Text("No earlier logs yet. Each time Gearslip starts, it begins a new one.")
|
||||
else -> LazyColumn {
|
||||
itemsIndexed(list) { _, log ->
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context, log.file) }
|
||||
shareLog(context, uri)
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
) {
|
||||
Text(log.started?.let(format::format) ?: log.file.name, style = MaterialTheme.typography.titleMedium)
|
||||
val detail = listOfNotNull(
|
||||
"Crashed".takeIf { log.crashed },
|
||||
log.car,
|
||||
"${(log.file.length() + 1023) / 1024} KB",
|
||||
).joinToString(" · ")
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (log.crashed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Close") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */
|
||||
@Composable
|
||||
private fun colorFor(line: String): Color = when (line.getOrNull(13)) {
|
||||
|
||||
@@ -57,6 +57,9 @@ private val recommended = listOf(
|
||||
"Podcasts" to listOf(
|
||||
RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."),
|
||||
),
|
||||
"Other cool apps" to listOf(
|
||||
RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", "Spots trackers and cameras nearby."),
|
||||
),
|
||||
)
|
||||
|
||||
/** Opened from Settings: the same list the setup guide ends with. */
|
||||
@@ -135,11 +138,13 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
|
||||
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)
|
||||
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||
Text(app.name, style = MaterialTheme.typography.titleMedium)
|
||||
// Colours set outright: the setup guide draws this on its own background, with no
|
||||
// Surface to hand down a content colour, and unset text came out dark on dark.
|
||||
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (icon != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
|
||||
@@ -79,12 +79,7 @@ import kotlinx.coroutines.withContext
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenRecommendedApps: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenLogs: () -> Unit,
|
||||
onReplayTutorial: () -> Unit,
|
||||
onReplayIntro: () -> Unit,
|
||||
atUsageNotes: Boolean = false,
|
||||
) {
|
||||
val scroll = rememberScrollState()
|
||||
@@ -119,13 +114,23 @@ fun SettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(0.dp))
|
||||
SettingsSection {
|
||||
val context = LocalContext.current
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.RELEASE_NOTES) },
|
||||
subtitle = "Read the ${BuildConfig.VERSION_NAME} release notes on GitHub",
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
SettingsSection(
|
||||
"Support Gearslip",
|
||||
footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.",
|
||||
) { DonateRows() }
|
||||
ConnectionSection()
|
||||
HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs)
|
||||
CarAudioSection()
|
||||
CarScreenSection()
|
||||
VoiceModelSection()
|
||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||
SettingsSection(
|
||||
@@ -135,7 +140,8 @@ fun SettingsScreen(
|
||||
) { UsageStatsRows() }
|
||||
SettingsSection("Updates") { UpdateRow() }
|
||||
CertificateSection()
|
||||
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
|
||||
AboutSection(onReplayTutorial)
|
||||
if (BuildConfig.DEBUG) DebugSection()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -148,26 +154,10 @@ private fun Arrow() = Icon(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
/** What's new, the replays, and where Gearslip lives outside the app. */
|
||||
/** The setup guide replay, and where Gearslip lives outside the app. */
|
||||
@Composable
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
|
||||
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||
SettingsSection("About") {
|
||||
SettingsRow(
|
||||
"What's new",
|
||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
||||
subtitle = "Version ${BuildConfig.VERSION_NAME}",
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the ${Intro.release} intro",
|
||||
Modifier.clickable(onClick = onReplayIntro),
|
||||
subtitle = "What's in this release, in a few taps",
|
||||
icon = Icons.Filled.Star,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
Modifier.clickable(onClick = onReplayTutorial),
|
||||
@@ -199,48 +189,25 @@ private fun ConnectionSection() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Connection help, and the tools for checking what Gearslip does. */
|
||||
/** Tools for testing a debug build; release builds never show this section. */
|
||||
@Composable
|
||||
private fun HelpSection(onOpenHelp: () -> Unit, onOpenCarPreview: () -> Unit, onOpenLogs: () -> Unit) {
|
||||
private fun DebugSection() {
|
||||
val context = LocalContext.current
|
||||
val status by SessionStatus.state.collectAsState()
|
||||
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
||||
SettingsSection("Help and tools") {
|
||||
SettingsSection("Debug build") {
|
||||
SettingsRow(
|
||||
"Connection help",
|
||||
Modifier.clickable(onClick = onOpenHelp),
|
||||
subtitle = "Which cars work, and what to set up first",
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = { Arrow() },
|
||||
"Send a test message",
|
||||
Modifier.clickable { TestMessage.post(context) },
|
||||
subtitle = "Posts a message notification to read on the car",
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
)
|
||||
// The preview draws the car screen on the phone, so it waits until no car is using it.
|
||||
if (!live) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Car preview",
|
||||
Modifier.clickable(onClick = onOpenCarPreview),
|
||||
subtitle = "See the car screen on your phone",
|
||||
icon = Icons.Filled.PlayArrow,
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
}
|
||||
SettingsDivider()
|
||||
val badge by CarSettings.debugBadge.collectAsState()
|
||||
SettingsRow(
|
||||
"Live logs",
|
||||
Modifier.clickable(onClick = onOpenLogs),
|
||||
subtitle = "What Gearslip and the car are saying to each other",
|
||||
icon = Icons.AutoMirrored.Filled.List,
|
||||
trailing = { Arrow() },
|
||||
"Debug badge on the car screen",
|
||||
Modifier.clickable { CarSettings.setDebugBadge(!badge) },
|
||||
subtitle = "Turn it off for screenshots",
|
||||
trailing = { Switch(checked = badge, onCheckedChange = null) },
|
||||
)
|
||||
if (BuildConfig.DEBUG) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Send a test message",
|
||||
Modifier.clickable { TestMessage.post(context) },
|
||||
subtitle = "Debug builds only. Posts a message notification to read on the car",
|
||||
icon = Icons.AutoMirrored.Filled.Send,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +229,7 @@ private fun CarAudioSection() {
|
||||
SettingsRow(
|
||||
"USB",
|
||||
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||
subtitle = "Clearer sound with no delay. Android asks to share audio once each drive",
|
||||
subtitle = "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound",
|
||||
trailing = { RadioButton(selected = usb, onClick = null) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.layout.offset
|
||||
|
||||
/**
|
||||
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
||||
@@ -100,6 +103,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
||||
* can be verified as done, only offered.
|
||||
*/
|
||||
/** The guide's colours and its one-percent-of-the-width scale, for content that sizes itself the same way. */
|
||||
internal class GuideScale(val c: IntroColors, val cq: (Float) -> Dp, val cqSp: (Float) -> TextUnit)
|
||||
|
||||
private class GuidePage(
|
||||
val title: String,
|
||||
/** Plain text, or an [AnnotatedString] when part of it needs to stand out. */
|
||||
@@ -112,8 +118,8 @@ private class GuidePage(
|
||||
val command: String? = null,
|
||||
val actionLabel: String? = null,
|
||||
val onAction: (() -> Unit)? = null,
|
||||
/** Drawn under [explanation]. */
|
||||
val content: (@Composable () -> Unit)? = null,
|
||||
/** Drawn under [explanation], sized off the same screen-width scale as the page. */
|
||||
val content: (@Composable (GuideScale) -> Unit)? = null,
|
||||
/** Only shown when the driver picked USB audio on the car audio page. */
|
||||
val usbOnly: Boolean = false,
|
||||
)
|
||||
@@ -365,20 +371,24 @@ 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 = "Good company.",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- The very end, every time: usage notes. On a replay, the switch shows what was
|
||||
// chosen before.
|
||||
// --- At the end, every time: apps worth installing, how the car's buttons look, and last
|
||||
// the usage notes. On a replay, the switch shows what was chosen before.
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Good company.",
|
||||
explanation = RECOMMENDED_INTRO,
|
||||
icon = { StarIcon(it) },
|
||||
content = { RecommendedAppsList() },
|
||||
),
|
||||
)
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Your buttons.",
|
||||
explanation = "Pick how the car's buttons look. Change it any time in the car's settings.",
|
||||
icon = { ButtonsIcon(it) },
|
||||
content = { ThemePicker(it.c, it.cq, it.cqSp, key = "guide-buttons") },
|
||||
),
|
||||
)
|
||||
add(usageNotesPage(context))
|
||||
}
|
||||
|
||||
@@ -417,7 +427,7 @@ private fun AudioChoice() {
|
||||
) { CarSettings.setPipeAudio(false) }
|
||||
AudioCard(
|
||||
"USB", null,
|
||||
"Clearer sound with no delay. Android asks to share audio once each drive.",
|
||||
"Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound.",
|
||||
selected = usb,
|
||||
) { CarSettings.setPipeAudio(true) }
|
||||
}
|
||||
@@ -509,10 +519,11 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
// pushes them off the screen. Short pages sit in the middle.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
key(index) {
|
||||
val scroll = rememberScrollState()
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scroll)
|
||||
.heightIn(min = maxHeight)
|
||||
.padding(vertical = cq(6f)),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -550,7 +561,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
style = TextStyle(fontSize = cqSp(4.5f), lineHeight = cqSp(6.5f)),
|
||||
)
|
||||
page.command?.let { CopyableCommand(it) }
|
||||
page.content?.invoke()
|
||||
page.content?.invoke(GuideScale(c, ::cq, ::cqSp))
|
||||
if (page.permissions != null && alreadyGranted) {
|
||||
Text(
|
||||
"Already allowed.", color = c.primary,
|
||||
@@ -559,6 +570,9 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
}
|
||||
}
|
||||
}
|
||||
// A long page (the app list, the usage notes) says so: a fade over the
|
||||
// last lines and a nudge to keep going, until the bottom is in view.
|
||||
if (scroll.canScrollForward) ScrollHint(c, ::cq, ::cqSp, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,6 +599,28 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl
|
||||
}
|
||||
}
|
||||
|
||||
/** A fade into the page's background with "Scroll for more" over it, at the bottom of a page that goes on. */
|
||||
@Composable
|
||||
private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, modifier: Modifier = Modifier) {
|
||||
val bob by rememberInfiniteTransition(label = "hint").animateFloat(
|
||||
0f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "bob",
|
||||
)
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(cq(22f))
|
||||
.background(androidx.compose.ui.graphics.Brush.verticalGradient(listOf(c.surface.copy(alpha = 0f), c.surface))),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Text(
|
||||
"Scroll for more ↓",
|
||||
color = c.primary,
|
||||
style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class PillStyle { FILLED, OUTLINED, TEXT }
|
||||
|
||||
/** The intro's rounded buttons: filled for the step's main action, outlined beside it, bare text to skip. */
|
||||
|
||||
@@ -109,6 +109,8 @@ internal fun WirelessButton() {
|
||||
.clickable {
|
||||
devices = null
|
||||
WirelessLink.start(context, device)
|
||||
// Next time this car connects, Gearslip does this by itself.
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.connectedByHand(context, device)
|
||||
}
|
||||
.padding(vertical = 14.dp),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package app.seb3thehacker.gearslip.wireless
|
||||
|
||||
import android.companion.CompanionDeviceService
|
||||
|
||||
/** Android calls this when a car approved in [WirelessAuto] connects over Bluetooth. */
|
||||
class CarPresenceService : CompanionDeviceService() {
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onDeviceAppeared(address: String) {
|
||||
WirelessAuto.carAppeared(applicationContext, address)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onDeviceDisappeared(address: String) {}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.seb3thehacker.gearslip.wireless
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.companion.AssociationRequest
|
||||
import android.companion.BluetoothDeviceFilter
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.GearslipActivity
|
||||
|
||||
/**
|
||||
* Starts wireless Android Auto by itself when a car Gearslip has connected to before comes up
|
||||
* over Bluetooth.
|
||||
*
|
||||
* Android only lets an app wake up and open its screen from the background for a device it
|
||||
* manages as a companion, so the first wireless connection to a car also asks Android to make
|
||||
* that car Gearslip's companion. Android shows its own one-time "Allow" for it; after that it
|
||||
* tells [CarPresenceService] whenever the car connects, and Gearslip connects to it.
|
||||
*/
|
||||
object WirelessAuto {
|
||||
|
||||
/** Action on the activity's intent when the car came up on its own. */
|
||||
const val ACTION_CAR_NEARBY = "app.seb3thehacker.gearslip.CAR_NEARBY"
|
||||
const val EXTRA_ADDRESS = "address"
|
||||
|
||||
private const val PREFS = "gearslip_wireless_auto"
|
||||
|
||||
private val log = GearslipLog.tagged("AAW")
|
||||
|
||||
/** Set by the activity: shows Android's companion dialog, which only an activity can do. */
|
||||
@Volatile var showApproval: ((IntentSender, String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Called whenever the driver connects to [device] by hand. Asks Android once per car; a car
|
||||
* already approved only has its presence watched again, in case Android forgot. A car only
|
||||
* counts as asked once the driver has answered the dialog: if it never showed, the next
|
||||
* connection by hand asks again.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connectedByHand(context: Context, device: BluetoothDevice) {
|
||||
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
|
||||
val address = device.address
|
||||
if (isCompanion(manager, address)) {
|
||||
watch(context, address)
|
||||
return
|
||||
}
|
||||
if (context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(address, false)) {
|
||||
log.i("auto-connect: already asked about this car and it was declined; connect by hand")
|
||||
return
|
||||
}
|
||||
|
||||
val request = AssociationRequest.Builder()
|
||||
.addDeviceFilter(BluetoothDeviceFilter.Builder().setAddress(address).build())
|
||||
.setSingleDevice(true)
|
||||
.build()
|
||||
log.i("auto-connect: asking Android to let Gearslip watch for ${WirelessLink.name(device)}")
|
||||
@Suppress("DEPRECATION")
|
||||
manager.associate(request, object : CompanionDeviceManager.Callback() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onDeviceFound(chooser: IntentSender) {
|
||||
showApproval?.invoke(chooser, address) ?: log.w("auto-connect: no screen to ask on")
|
||||
}
|
||||
|
||||
override fun onFailure(error: CharSequence?) {
|
||||
log.w("auto-connect: Android wouldn't ask ($error)")
|
||||
}
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
}
|
||||
|
||||
/** The driver allowed it in Android's dialog. */
|
||||
fun approved(context: Context, address: String) {
|
||||
log.i("auto-connect: allowed")
|
||||
answered(context, address)
|
||||
watch(context, address)
|
||||
}
|
||||
|
||||
/** The driver said no in Android's dialog: this car isn't asked about again. */
|
||||
fun declined(context: Context, address: String) {
|
||||
log.w("auto-connect: declined in Android's dialog")
|
||||
answered(context, address)
|
||||
}
|
||||
|
||||
private fun answered(context: Context, address: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(address, true).apply()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun watch(context: Context, address: String) {
|
||||
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return
|
||||
@Suppress("DEPRECATION")
|
||||
runCatching { manager.startObservingDevicePresence(address) }
|
||||
.onFailure { log.w("auto-connect: couldn't watch for the car (${it.message})") }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isCompanion(manager: CompanionDeviceManager, address: String): Boolean =
|
||||
runCatching { manager.associations.any { it.equals(address, ignoreCase = true) } }.getOrDefault(false)
|
||||
|
||||
/** From [CarPresenceService]: the car is here, so bring Gearslip up to connect to it. */
|
||||
fun carAppeared(context: Context, address: String) {
|
||||
log.i("auto-connect: a car Gearslip knows connected over Bluetooth")
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(context, GearslipActivity::class.java)
|
||||
.setAction(ACTION_CAR_NEARBY)
|
||||
.putExtra(EXTRA_ADDRESS, address)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}.onFailure { log.w("auto-connect: couldn't open Gearslip (${it.message})") }
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,13 @@ object WirelessLink {
|
||||
@Volatile private var tcp: Socket? = null
|
||||
@Volatile private var network: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile private var worker: Thread? = null
|
||||
|
||||
/** A start-up or a session's Bluetooth channel is under way. */
|
||||
val busy: Boolean get() = worker?.isAlive == true
|
||||
/** Set when Android drops the car's network mid-try, so the rest don't bind to a dead one. */
|
||||
@Volatile private var lostNetwork: Network? = null
|
||||
/** The car network asked for or joined, until Android says it's gone or never came. */
|
||||
@Volatile private var activeSsid: String? = null
|
||||
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
||||
@Volatile private var tcpRun = 0
|
||||
private val writeLock = Any()
|
||||
@@ -128,6 +133,7 @@ object WirelessLink {
|
||||
runCatching { context.getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(cb) }
|
||||
}
|
||||
network = null
|
||||
activeSsid = null
|
||||
// Stopped before the car's session began, so nothing else resets Home's card.
|
||||
if (starting) {
|
||||
log.i("wireless start-up stopped")
|
||||
@@ -220,6 +226,14 @@ object WirelessLink {
|
||||
val bssid = text(3)
|
||||
val security = Wire.varint(f, 4) ?: -1
|
||||
log.i("<- InfoResponse: ssid=\"$ssid\" bssid=$bssid security=$security password=${if (password.isEmpty()) "none" else "(${password.length} chars)"}")
|
||||
// ZLink head units send the Wi-Fi details again once the stream is up.
|
||||
// Asking Android for the network again releases the one carrying the
|
||||
// stream, which aborts its socket; a phone running Android Auto just
|
||||
// carries on, so the repeat is ignored while that network is in use.
|
||||
if (ssid == activeSsid) {
|
||||
log.i("InfoResponse again for \"$ssid\", which the phone is already on - ignoring it")
|
||||
continue
|
||||
}
|
||||
val join = { ip: String, port: Int -> joinWifi(context, output, name, ssid, password, bssid, ip, port) }
|
||||
val known = target
|
||||
if (known != null) {
|
||||
@@ -329,6 +343,7 @@ object WirelessLink {
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
if (activeSsid == ssid) activeSsid = null
|
||||
log.w("Wi-Fi: couldn't join \"$ssid\" (declined, out of range, or wrong password)")
|
||||
runCatching { send(output, MSG_CONNECTION_STATUS, Protobuf.varintField(1, -1)) }
|
||||
SessionStatus.failed(
|
||||
@@ -353,6 +368,7 @@ object WirelessLink {
|
||||
|
||||
override fun onLost(net: Network) {
|
||||
lostNetwork = net
|
||||
if (activeSsid == ssid) activeSsid = null
|
||||
log.w(
|
||||
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
|
||||
"in the Wi-Fi list does this; the remaining tries go over the phone's own Wi-Fi",
|
||||
@@ -361,6 +377,7 @@ object WirelessLink {
|
||||
}
|
||||
network?.let { runCatching { manager.unregisterNetworkCallback(it) } }
|
||||
network = callback
|
||||
activeSsid = ssid
|
||||
manager.requestNetwork(request, callback, WIFI_WAIT_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,12 @@
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
</style>
|
||||
|
||||
<!-- For PermissionActivity: only Android's own permission dialog shows, over whatever was there. -->
|
||||
<style name="Theme.Gearslip.Invisible" parent="Theme.Gearslip">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:windowAnimationStyle">@null</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.view.Surface
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
@@ -7,17 +8,20 @@ import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.FutureTask
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLContext
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionLifecycleTest {
|
||||
private val version = message(1, byteArrayOf(0, 1, 0, 3))
|
||||
|
||||
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
|
||||
Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body,
|
||||
Frames.CHANNEL_CONTROL, false, byteArrayOf((id ushr 8).toByte(), id.toByte()) + body,
|
||||
)
|
||||
|
||||
private fun hello(): ByteArray {
|
||||
@@ -41,13 +45,13 @@ class ConnectionLifecycleTest {
|
||||
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
|
||||
|
||||
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
|
||||
for (status in listOf(-2, -3)) {
|
||||
for (status in listOf(-2, -3, -23, -24)) {
|
||||
runner(ByteArrayInputStream(
|
||||
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
|
||||
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
|
||||
)).run()
|
||||
assertEquals(
|
||||
if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
|
||||
if (status == -3) SessionReport.Category.AUTH else SessionReport.Category.CERTIFICATE,
|
||||
SessionReport.category,
|
||||
)
|
||||
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
|
||||
@@ -56,14 +60,70 @@ class ConnectionLifecycleTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `protocol error terminates setup and ignores later messages`() {
|
||||
for (error in listOf(255, 65535)) {
|
||||
val runner = runner(ByteArrayInputStream(version + message(error) + version + message(6)))
|
||||
runner.run()
|
||||
assertFalse(getField(runner, "running") as Boolean)
|
||||
assertEquals(SessionReport.Category.UNKNOWN, SessionReport.category)
|
||||
assertEquals("Protocol error", SessionStatus.state.value.headline)
|
||||
assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `head unit can end the session before authentication`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(ByteArrayInputStream(version + message(15, Protobuf.varintField(1, 7))), output, ::identity)
|
||||
runner.run()
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
assertEquals(2, requireNotNull(parser.next()).payload[1].toInt())
|
||||
val reply = requireNotNull(parser.next())
|
||||
assertFalse(reply.encrypted)
|
||||
assertArrayEquals(byteArrayOf(0, 16), reply.payload)
|
||||
assertEquals(SessionReport.Category.BYEBYE, SessionReport.category)
|
||||
assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
|
||||
}
|
||||
|
||||
@Test fun `TLS failure ignores later protocol messages`() {
|
||||
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
|
||||
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
|
||||
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
|
||||
}
|
||||
|
||||
@Test fun `local TLS failure ends the session without waiting for peer EOF`() {
|
||||
val input = CountingInput(version + hello())
|
||||
val runner = GearslipRunner(input, ByteArrayOutputStream(), { error("identity unavailable") })
|
||||
runner.run()
|
||||
assertEquals(1, input.reads)
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
assertEquals("Secure connection failed", SessionStatus.state.value.headline)
|
||||
assertTrue((getField(runner, "writer") as ExecutorService).isShutdown)
|
||||
}
|
||||
|
||||
@Test fun `unsupported version sends rejection and ends without waiting for peer EOF`() {
|
||||
val input = CountingInput(message(1, byteArrayOf(0, 0, 0, 9)))
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(input, output, ::identity)
|
||||
runner.run()
|
||||
assertEquals(1, input.reads)
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val response = requireNotNull(parser.next())
|
||||
assertArrayEquals(byteArrayOf(0, 2) + ProtocolVersion.respond(0, 9).bytes, response.payload)
|
||||
assertEquals("Unsupported protocol", SessionStatus.state.value.headline)
|
||||
}
|
||||
|
||||
private class CountingInput(bytes: ByteArray) : ByteArrayInputStream(bytes) {
|
||||
var reads = 0
|
||||
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
|
||||
reads++
|
||||
return super.read(bytes, offset, length)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `authentication success before TLS completes is ignored`() {
|
||||
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
|
||||
assertEquals(SessionReport.Category.TLS, SessionReport.category)
|
||||
@@ -133,4 +193,64 @@ class ConnectionLifecycleTest {
|
||||
assertTrue((field.get(runner) as ExecutorService).isShutdown)
|
||||
}
|
||||
}
|
||||
@Test fun `ping echoes the full timestamp and optional payload`() {
|
||||
for (payload in listOf(null, byteArrayOf(), byteArrayOf(0, -1, 42))) {
|
||||
val output = ByteArrayOutputStream()
|
||||
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), output, ::identity)
|
||||
val identity = identity()
|
||||
val tls = FutureTask { PhoneTls(identity.keyStore, identity.password) }.apply { run() }
|
||||
setField(runner, "identityTask", tls)
|
||||
val timestamp = 0x1234_5678_9abcL
|
||||
val request = Protobuf.varintField(1, timestamp) + Protobuf.varintField(2, 1) +
|
||||
(payload?.let { Protobuf.bytesField(3, it) } ?: byteArrayOf())
|
||||
val ping = GearslipRunner::class.java.getDeclaredMethod("onPingRequest", ByteArray::class.java)
|
||||
.apply { isAccessible = true }
|
||||
try {
|
||||
ping.invoke(runner, request)
|
||||
val writer = getField(runner, "writer") as ExecutorService
|
||||
writer.shutdown()
|
||||
assertTrue(writer.awaitTermination(2, TimeUnit.SECONDS))
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val response = requireNotNull(parser.next()).payload
|
||||
assertEquals(12, response[1].toInt())
|
||||
val fields = Wire.fields(response.copyOfRange(2, response.size))
|
||||
assertEquals(timestamp, Wire.varint(fields, 1))
|
||||
assertArrayEquals(payload, Wire.bytes(fields, 2))
|
||||
} finally {
|
||||
runner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getField(runner: GearslipRunner, name: String): Any? =
|
||||
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.get(runner)
|
||||
|
||||
private fun setField(runner: GearslipRunner, name: String, value: Any) {
|
||||
GearslipRunner::class.java.getDeclaredField(name).apply { isAccessible = true }.set(runner, value)
|
||||
}
|
||||
|
||||
|
||||
@Test fun `touchpad reports are not injected as touchscreen taps`() {
|
||||
var touches = 0
|
||||
val projection = object : Projection {
|
||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) = Unit
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) { touches++ }
|
||||
override fun onProjectionStopped() = Unit
|
||||
}
|
||||
val runner = GearslipRunner(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream(), ::identity, projection)
|
||||
val input = GearslipRunner::class.java.getDeclaredMethod("onInputReport", ByteArray::class.java)
|
||||
.apply { isAccessible = true }
|
||||
val touch = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||
Protobuf.varintField(2, 200) + Protobuf.varintField(3, 0))
|
||||
try {
|
||||
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(7, touch))
|
||||
assertEquals(0, touches)
|
||||
input.invoke(runner, Protobuf.varintField(1, 1000) + Protobuf.bytesField(3, touch))
|
||||
assertEquals(1, touches)
|
||||
} finally {
|
||||
runner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -42,6 +43,14 @@ class CryptoTest {
|
||||
assertTrue(TlsSelfTest.run(identity))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TLS still connects a head unit that presents no certificate, as homemade ones may`() {
|
||||
val generated = SelfSignedCert.generate()
|
||||
val identity = CertProvider.Identity(generated.keyStore, generated.certificate,
|
||||
SelfSignedCert.PASSWORD, "test")
|
||||
assertTrue(TlsSelfTest.run(identity, withClientCertificate = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frames round-trip through the parser`() {
|
||||
val payload = ByteArray(300) { it.toByte() }
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class FramingTest {
|
||||
@Test fun `fragmentation survives every byte arriving separately`() {
|
||||
val message = ByteArray(40_000) { it.toByte() }
|
||||
val output = ByteArrayOutputStream()
|
||||
assertEquals(3, Frames.writeMessage(output, 5, false, Frames.MESSAGE_SPECIFIC, message) { error("plaintext") })
|
||||
val parser = Frames.Parser()
|
||||
val assembler = Frames.Assembler()
|
||||
var received: ByteArray? = null
|
||||
for (byte in output.toByteArray()) {
|
||||
parser.append(byteArrayOf(byte), 1)
|
||||
parser.next()?.let { received = assembler.offer(it, it.payload) }
|
||||
}
|
||||
assertArrayEquals(message, received)
|
||||
}
|
||||
|
||||
@Test fun `encrypted fragments count plaintext length and encrypt each chunk`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val message = ByteArray(20_000) { 42 }
|
||||
Frames.writeMessage(output, 1, true, Frames.MESSAGE_SPECIFIC, message) { byteArrayOf(99) + it }
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val first = parser.next()!!
|
||||
val last = parser.next()!!
|
||||
assertEquals(20_000, first.totalSize)
|
||||
assertEquals(16_121, first.payload.size)
|
||||
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||
val assembler = Frames.Assembler()
|
||||
assertNull(assembler.offer(first, first.payload.drop(1).toByteArray()))
|
||||
assertArrayEquals(message, assembler.offer(last, last.payload.drop(1).toByteArray()))
|
||||
}
|
||||
|
||||
@Test fun `fragment budget includes the short or extended header`() {
|
||||
for (size in listOf(16_124, 16_125, 32_245)) {
|
||||
val output = ByteArrayOutputStream()
|
||||
Frames.writeMessage(output, 1, false, Frames.MESSAGE_SPECIFIC, ByteArray(size)) { error("plaintext") }
|
||||
val parser = Frames.Parser()
|
||||
parser.append(output.toByteArray(), output.size())
|
||||
val first = parser.next()!!
|
||||
if (size == 16_124) {
|
||||
assertEquals(Frames.TYPE_BULK, first.frameType)
|
||||
assertEquals(16_124, first.payload.size)
|
||||
} else {
|
||||
assertEquals(Frames.TYPE_FIRST, first.frameType)
|
||||
assertEquals(size, first.totalSize)
|
||||
assertEquals(16_120, first.payload.size)
|
||||
val next = parser.next()!!
|
||||
if (size == 32_245) {
|
||||
assertEquals(Frames.TYPE_MIDDLE, next.frameType)
|
||||
assertEquals(16_124, next.payload.size)
|
||||
val last = parser.next()!!
|
||||
assertEquals(Frames.TYPE_LAST, last.frameType)
|
||||
assertEquals(1, last.payload.size)
|
||||
} else {
|
||||
assertEquals(Frames.TYPE_LAST, next.frameType)
|
||||
assertEquals(5, next.payload.size)
|
||||
}
|
||||
}
|
||||
assertNull(parser.next())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `channel control messages cannot be fragmented`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
Frames.writeMessage(ByteArrayOutputStream(), 1, false, Frames.MESSAGE_CONTROL, ByteArray(16_125)) { it }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `malformed and mismatched fragment runs are rejected`() {
|
||||
val first = Frames.Frame(1, Frames.TYPE_FIRST, true, 0, byteArrayOf(), 4)
|
||||
val last = Frames.Frame(1, Frames.TYPE_LAST, true, 0, byteArrayOf())
|
||||
assertThrows(IOException::class.java) { Frames.Assembler().offer(last, byteArrayOf(1)) }
|
||||
val assembler = Frames.Assembler()
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3)) }
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(last, byteArrayOf(3, 4, 5)) }
|
||||
assembler.offer(first, byteArrayOf(1, 2))
|
||||
assertThrows(IOException::class.java) { assembler.offer(first, byteArrayOf(1, 2)) }
|
||||
}
|
||||
|
||||
@Test fun `oversized declared message is rejected before allocation`() {
|
||||
val parser = Frames.Parser()
|
||||
val frame = byteArrayOf(1, 1, 0, 1, 0x7f, -1, -1, -1, 0)
|
||||
parser.append(frame, frame.size)
|
||||
assertThrows(IOException::class.java) { parser.next() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ProtobufTest {
|
||||
@Test fun `malformed suffix invalidates the whole message`() {
|
||||
for (suffix in listOf(byteArrayOf(0), byteArrayOf(18, -1), byteArrayOf(13, 1), byteArrayOf(17, 1))) {
|
||||
assertTrue(Wire.fields(byteArrayOf(8, 2) + suffix).isEmpty())
|
||||
assertNull(Protobuf.readInt32Field(byteArrayOf(8, 2) + suffix, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `lengths cannot wrap when narrowed to int`() {
|
||||
val huge = byteArrayOf(18) + Protobuf.varint(0x1_0000_0001) + byteArrayOf(42)
|
||||
assertTrue(Wire.fields(huge).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `last singular field wins and negative int32 keeps its sign`() {
|
||||
assertEquals(2, Protobuf.readInt32Field(byteArrayOf(8, 1, 8, 2), 1))
|
||||
assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3), 1))
|
||||
assertEquals(8_803_621_255L, Protobuf.readInt64Field(Protobuf.varintField(1, 8_803_621_255L), 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ProtocolVersionTest {
|
||||
@Test fun `advertises supported version to older and newer 1x peers`() {
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 3).bytes)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 7).bytes)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(1, 9).bytes)
|
||||
}
|
||||
|
||||
@Test fun `newer head units get the 1x answer they accept`() {
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(4, 2).bytes)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, 0, 0), ProtocolVersion.respond(6, 1).bytes)
|
||||
}
|
||||
|
||||
@Test fun `major version 0 is refused`() {
|
||||
val response = ProtocolVersion.respond(0, 9)
|
||||
assertFalse(response.supported)
|
||||
assertArrayEquals(byteArrayOf(0, 1, 0, 7, -1, -1), response.bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ServiceDiscoveryChannelTest {
|
||||
private fun service(id: Long, field: Int, payload: ByteArray = byteArrayOf()) =
|
||||
Protobuf.bytesField(1, Protobuf.varintField(1, id) + Protobuf.bytesField(field, payload))
|
||||
|
||||
@Test fun `invalid channel IDs cannot alias the control or another service channel`() {
|
||||
val config = Protobuf.varintField(1, 16000) + Protobuf.varintField(2, 16) + Protobuf.varintField(3, 1)
|
||||
val audio = Protobuf.bytesField(3, config)
|
||||
val microphone = Protobuf.bytesField(2, config)
|
||||
val sensor = Protobuf.bytesField(1, Protobuf.varintField(1, 1))
|
||||
for (id in listOf(-1L, 0L, 256L, 0x1_0000_0001L)) {
|
||||
assertNull(ServiceDiscovery.findMediaStatusChannel(service(id, 9)))
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(id, 8, Protobuf.varintField(1, 1000))))
|
||||
assertNull(ServiceDiscovery.findVideoService(service(id, 3, Protobuf.bytesField(4, byteArrayOf()))))
|
||||
assertNull(ServiceDiscovery.findInputService(service(id, 4)))
|
||||
assertNull(ServiceDiscovery.findMicService(service(id, 5, microphone)))
|
||||
assertNull(ServiceDiscovery.findSensorService(service(id, 2, sensor)))
|
||||
assertTrue(ServiceDiscovery.findAudioServices(service(id, 3, audio)).isEmpty())
|
||||
}
|
||||
assertEquals(255, ServiceDiscovery.findMediaStatusChannel(service(255, 9)))
|
||||
}
|
||||
|
||||
@Test fun `navigation discovery preserves the requested update interval`() {
|
||||
val navigation = requireNotNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, 1500))))
|
||||
assertEquals(6, navigation.serviceId)
|
||||
assertEquals(1500, navigation.minimumIntervalMs)
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8)))
|
||||
assertNull(ServiceDiscovery.findNavStatusService(service(6, 8, Protobuf.varintField(1, -1))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ClusterNavTest {
|
||||
@Test fun `head unit minimum interval limits repeated navigation updates`() {
|
||||
val first = CountDownLatch(1)
|
||||
val next = CountDownLatch(1)
|
||||
val count = AtomicInteger()
|
||||
val nav = ClusterNav(6, 1000) { _, _ ->
|
||||
if (count.incrementAndGet() == 1) first.countDown() else next.countDown()
|
||||
}
|
||||
try {
|
||||
nav.onMessage(8, Protobuf.varintField(1, 0))
|
||||
assertTrue(first.await(2, TimeUnit.SECONDS))
|
||||
nav.onMessage(32769, byteArrayOf())
|
||||
assertFalse("the fixed 500ms timer must not override the head unit", next.await(700, TimeUnit.MILLISECONDS))
|
||||
assertTrue(next.await(2, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
nav.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `head unit stop pauses updates and start resends current status`() {
|
||||
val messages = mutableListOf<Int>()
|
||||
val nav = ClusterNav(6) { id, _ -> messages += id }
|
||||
// Drive polling synchronously so the test does not depend on a background timer.
|
||||
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||
val tick = ClusterNav::class.java.getDeclaredMethod("tick").apply { isAccessible = true }
|
||||
running.set(nav, true)
|
||||
try {
|
||||
tick.invoke(nav)
|
||||
assertEquals(listOf(32771), messages)
|
||||
nav.onMessage(32770, byteArrayOf())
|
||||
tick.invoke(nav)
|
||||
assertEquals(1, messages.size)
|
||||
nav.onMessage(32769, byteArrayOf())
|
||||
tick.invoke(nav)
|
||||
assertEquals(listOf(32771, 32771), messages)
|
||||
nav.stop()
|
||||
tick.invoke(nav)
|
||||
assertEquals(2, messages.size)
|
||||
} finally {
|
||||
nav.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `missing channel open status does not start publishing`() {
|
||||
val nav = ClusterNav(6) { _, _ -> error("channel was not accepted") }
|
||||
nav.onMessage(8, byteArrayOf())
|
||||
val running = ClusterNav::class.java.getDeclaredField("running").apply { isAccessible = true }
|
||||
assertFalse(running.getBoolean(nav))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.seb3thehacker.gearslip.protocol
|
||||
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class InputProtocolTest {
|
||||
private fun pointer(id: Int) = Protobuf.bytesField(1,
|
||||
Protobuf.varintField(1, 100) + Protobuf.varintField(2, 200) + Protobuf.varintField(3, id.toLong()))
|
||||
|
||||
@Test fun `multitouch preserves pointer IDs and action index`() {
|
||||
val body = pointer(7) + pointer(3) + Protobuf.varintField(2, 1) + Protobuf.varintField(3, 5)
|
||||
val touch = requireNotNull(InputProtocol.touch(body, 800 to 480))
|
||||
assertEquals(5, touch.action)
|
||||
assertEquals(1, touch.actionIndex)
|
||||
assertEquals(listOf(7, 3), touch.points.map { it.id })
|
||||
assertEquals(100f, touch.points[1].x)
|
||||
}
|
||||
|
||||
@Test fun `invalid pointer index is rejected instead of targeting a different finger`() {
|
||||
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, 1), null))
|
||||
assertNull(InputProtocol.touch(pointer(0) + Protobuf.varintField(2, -1), null))
|
||||
}
|
||||
|
||||
@Test fun `missing required pointer fields and duplicate IDs are rejected`() {
|
||||
assertNull(InputProtocol.touch(Protobuf.bytesField(1, Protobuf.varintField(1, 20)), null))
|
||||
assertNull(InputProtocol.touch(pointer(1) + pointer(1), null))
|
||||
}
|
||||
|
||||
@Test fun `pointer IDs must fit the Android motion event range`() {
|
||||
for (id in listOf(-1L, 32L, 0x1_0000_0000L)) {
|
||||
val body = Protobuf.bytesField(1, Protobuf.varintField(1, 100) +
|
||||
Protobuf.varintField(2, 200) + Protobuf.varintField(3, id))
|
||||
assertNull(InputProtocol.touch(body, null))
|
||||
}
|
||||
assertEquals(31, requireNotNull(InputProtocol.touch(pointer(31), null)).points.single().id)
|
||||
}
|
||||
|
||||
@Test fun `oversized pointer sets are rejected before constructing motion events`() {
|
||||
val body = (0..32).fold(byteArrayOf()) { bytes, id -> bytes + pointer(id) }
|
||||
assertNull(InputProtocol.touch(body, null))
|
||||
}
|
||||
|
||||
@Test fun `optional touch fields use down and first pointer defaults`() {
|
||||
val touch = requireNotNull(InputProtocol.touch(pointer(0), 50 to 60))
|
||||
assertEquals(0, touch.action)
|
||||
assertEquals(0, touch.actionIndex)
|
||||
assertEquals(49f, touch.points.single().x)
|
||||
assertEquals(59f, touch.points.single().y)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ espressoCore = "3.7.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
carApp = "1.7.0"
|
||||
carApp = "1.8.0-rc01"
|
||||
appcompat = "1.7.1"
|
||||
material = "1.14.0"
|
||||
constraintlayout = "2.2.1"
|
||||
|
||||