mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+22
-10
@@ -1,15 +1,27 @@
|
||||
Gearslip 0.3.01 connects wirelessly on more phones and dongles.
|
||||
Gearslip 0.3.02 connects wirelessly by itself, and phone calls work much better.
|
||||
|
||||
## 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
|
||||
- Wireless connects by itself. After your first wireless drive, Android asks once whether Gearslip may watch for that car. From then on, Gearslip connects whenever the car's Bluetooth does. A USB cable still wins.
|
||||
- Phone calls work much better. Calls you place from the car's dialer go through, and you can end or mute a call from the nav bar, which shows who you're talking to and for how long.
|
||||
- The browser goes full screen, for the whole page or a video in it.
|
||||
- The nav bar can hide while you navigate, or until you tap the screen.
|
||||
- Directions sit in short bars, so map apps keep their maps clear of them.
|
||||
- Share a log from an earlier launch, not just the current one.
|
||||
- In the car preview, you type with the phone's keyboard.
|
||||
|
||||
## 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.
|
||||
- Help, Preview, and Logs sit in a row at the bottom of the phone's home screen.
|
||||
- What's new, at the top of Settings, opens the release notes here on GitHub.
|
||||
|
||||
## Fixed
|
||||
- Before each spoken turn, Gearslip asks the car for its speakers, as Android Auto does. Some head units rebooted without this.
|
||||
- An app that won't run in Gearslip, such as Yandex Maps or 2GIS, now says so after 10 seconds instead of showing "Starting" forever.
|
||||
- Head units that take only AAC music get one start message, not two. A 2014 aftermarket unit dropped the connection over the second.
|
||||
- With music on Bluetooth, the car keeps its Bluetooth source.
|
||||
- Head units offering Android Auto 4.x and 6.x get a version they accept.
|
||||
- Homemade head units without a certificate keep working, and the wheel buttons and knob work as they do in the main build.
|
||||
- A car that reports the wrong screen size gets the right one.
|
||||
- iscle fixed how Gearslip frames, secures, and negotiates the connection, and how it checks what the car sends.
|
||||
|
||||
## Known issues
|
||||
- Phone calls are unstable right now and may drop or fail to connect.
|
||||
- Call audio plays through the car's Bluetooth phone connection, as it does with Android Auto. If a call plays on the phone instead, open the car in the phone's Bluetooth settings and turn on Phone calls.
|
||||
@@ -38,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 10
|
||||
versionCode = 11
|
||||
// -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.02-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")
|
||||
|
||||
@@ -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"
|
||||
@@ -244,5 +247,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,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")
|
||||
@@ -138,6 +149,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 +233,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 +322,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 +343,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,8 +1069,21 @@ 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")
|
||||
}
|
||||
@@ -1015,7 +1110,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 +1260,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 +1290,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.
|
||||
}
|
||||
}
|
||||
@@ -1319,13 +1395,6 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
@@ -1338,6 +1407,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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ 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.ui.viewinterop.AndroidView
|
||||
@@ -59,13 +61,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) {
|
||||
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,13 +95,16 @@ 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) }
|
||||
LaunchedEffect(template, size, measured) {
|
||||
if (width > 0 && height > 0) {
|
||||
val visible = visibleAreaFor(template, width, height)
|
||||
val visible = visibleAreaFor(template, width, height, measured)
|
||||
connection.reportAreas(visible = visible, stable = visible)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +115,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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -94,12 +94,8 @@ fun CarHome() {
|
||||
}
|
||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||
|
||||
// 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()) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
@@ -108,7 +104,7 @@ fun CarHome() {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (splitScreen) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight()
|
||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -45,6 +72,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||
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"
|
||||
|
||||
@@ -84,6 +116,43 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
|
||||
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)
|
||||
@@ -243,6 +312,17 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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
|
||||
@@ -51,6 +53,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()
|
||||
@@ -70,140 +75,215 @@ 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 {
|
||||
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(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(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(
|
||||
"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(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(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(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
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) }
|
||||
}
|
||||
|
||||
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) {
|
||||
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(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Warn when location is off",
|
||||
listOf(false to "Off", true to "On"),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
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) }
|
||||
item {
|
||||
ChoiceRow(
|
||||
"Spoken directions",
|
||||
listOf(false to "Off", true to "On"),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
WebPageSetting()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
ScreenFit(vehicle)
|
||||
item {
|
||||
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) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
if (experimentalFeatures) {
|
||||
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)
|
||||
}
|
||||
|
||||
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("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…")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,3 +486,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
|
||||
@@ -123,6 +126,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 +197,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 +230,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 +262,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 +303,7 @@ fun CarUi() {
|
||||
}
|
||||
}
|
||||
}
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
CallPromptOverlay()
|
||||
BluetoothPairOverlay()
|
||||
PopupTimer()
|
||||
@@ -258,21 +314,38 @@ 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))
|
||||
CallOverlay()
|
||||
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) {
|
||||
// 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 +357,14 @@ fun CarUi() {
|
||||
}
|
||||
}
|
||||
|
||||
private 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
|
||||
@@ -462,7 +543,7 @@ private fun CarNavBar(navigator: CarNavigator) {
|
||||
val running = rememberRunningApps()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
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
|
||||
@@ -488,12 +569,16 @@ private fun CarNavBar(navigator: CarNavigator) {
|
||||
|
||||
// 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)) {
|
||||
turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||
if (turn == null || roomForBoth) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -760,6 +845,58 @@ private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(" - ")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -688,6 +688,12 @@ internal object MediaIcons {
|
||||
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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,89 +94,183 @@ 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)
|
||||
-> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, measured?.bottom ?: 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, 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): Rect {
|
||||
var left = 0f
|
||||
var top = 0f
|
||||
var right = width.toFloat()
|
||||
var bottom = height.toFloat()
|
||||
pieces.values.forEach { (edge, b) ->
|
||||
when (edge) {
|
||||
CoverEdge.TOP -> top = maxOf(top, b.bottom - surface.top)
|
||||
CoverEdge.BOTTOM -> bottom = minOf(bottom, b.top - surface.top)
|
||||
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))
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn in one short bar across the top, the arrival time in another along the bottom, so the
|
||||
* map keeps its full width. Neither grows past [NAV_BAR_MAX_WIDTH]. The bottom one is short and
|
||||
* left out of the visible area on purpose, so a speedometer in the app's bottom corner sits level
|
||||
* with it rather than stacked above it. The map buttons sit between them on the right.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavigationBars(
|
||||
template: NavigationTemplate,
|
||||
routing: RoutingInfo?,
|
||||
estimate: TravelEstimate?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val pan by CarServices.nav.panMode.collectAsState()
|
||||
Column(modifier.fillMaxSize()) {
|
||||
routing?.let {
|
||||
RoutingBar(
|
||||
it,
|
||||
Modifier.covers("nav-top", CoverEdge.TOP).padding(start = 12.dp, top = 12.dp, end = 12.dp)
|
||||
.widthIn(max = NAV_BAR_MAX_WIDTH).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
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))
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
estimate?.let {
|
||||
TravelEstimateBar(
|
||||
it,
|
||||
Modifier.align(Alignment.BottomStart).padding(12.dp)
|
||||
.widthIn(max = NAV_BAR_MAX_WIDTH),
|
||||
)
|
||||
}
|
||||
}
|
||||
RightEdgeControls(
|
||||
if (pan) null else template.actionStrip,
|
||||
template.mapActionStrip,
|
||||
Modifier.padding(12.dp),
|
||||
// Only the column: the app's own buttons are a wide row up top, over the map's
|
||||
// edge rather than where a speedometer or the like would go.
|
||||
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 in one row: arrow, distance, what to do, onto what road, and lanes or the next turn. */
|
||||
@Composable
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
private fun RoutingBar(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 = 16.dp, vertical = 12.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val step = info.currentStep ?: return
|
||||
ChromeSurface(modifier) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(44.dp))
|
||||
CarGlyph(step.maneuver?.icon, Modifier.size(40.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(distance, style = ChromeType.headline)
|
||||
}
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(distance, style = ChromeType.headline, maxLines = 1)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
}
|
||||
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) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val road = step.road.text()
|
||||
if (road.isNotEmpty()) {
|
||||
Text(
|
||||
road,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val next = info.nextStep
|
||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
||||
LaneGuidance(step, Modifier.padding(start = 12.dp).widthIn(max = 200.dp).heightIn(max = 48.dp))
|
||||
} else if (next != null) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
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)
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
// Rare, and only where an arrow can't explain the junction: worth the bar growing for.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
JunctionImage(junction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,9 +346,8 @@ 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
|
||||
* where a thumb reaches - the app's own way to stop navigating. One short line along the bottom,
|
||||
* stopping short of the map buttons. 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.
|
||||
@@ -265,7 +366,7 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
||||
trip.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
|
||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||
// The same solid card the manoeuvre bar 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) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
@@ -288,17 +389,23 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
|
||||
shape = CircleShape,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (detail.isNotEmpty()) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,8 +561,9 @@ 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
|
||||
|
||||
/** Past this the bars only spread the same words further apart, across a map left with no music beside it. */
|
||||
private val NAV_BAR_MAX_WIDTH = 460.dp
|
||||
|
||||
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
|
||||
private fun Template?.needsFullHeight() =
|
||||
|
||||
@@ -222,6 +222,8 @@ 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 actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||
@@ -263,7 +265,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -43,11 +50,30 @@ 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
|
||||
|
||||
// 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,9 +85,14 @@ 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("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
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
|
||||
@@ -69,6 +100,19 @@ fun WebApp(content: String) {
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient(ring)
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
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",
|
||||
@@ -85,6 +129,22 @@ 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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
|
||||
@@ -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} " +
|
||||
@@ -552,9 +570,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 +595,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")) }
|
||||
@@ -833,6 +854,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,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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -108,26 +108,22 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
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,
|
||||
)
|
||||
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 +144,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +63,10 @@ import app.seb3thehacker.gearslip.stats.WorkedPrompt
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenWhatsNew: () -> Unit,
|
||||
onOpenHelp: () -> Unit,
|
||||
onOpenUsageNotes: () -> Unit,
|
||||
onOpenCarPreview: () -> Unit,
|
||||
onOpenLogs: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
@@ -125,21 +129,11 @@ fun HomeScreen(
|
||||
|
||||
// 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 +151,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() {
|
||||
|
||||
@@ -135,11 +135,6 @@ object Intro {
|
||||
.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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,17 @@ 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()
|
||||
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() },
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +82,9 @@ 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
|
||||
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user