mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
back buttons on every screen, exit to the car's own UI, smoother link
Every screen reached from Home has a themed back button in its header. A new Exit tile asks the car to show its own interface, as Android Auto's does. All writes to the car now go through one thread, so reading never waits on a write; LIVI no longer stalls for two seconds at a time. Touches go to the front of the main thread's queue.
This commit is contained in:
1 parent
1adeb6f7e1
commit
0eeb486d88
13 files changed
+246
-55
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||||
|
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||||
|
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||||
|
*/
|
||||||
|
object CarFocus {
|
||||||
|
|
||||||
|
private val projectedFlow = MutableStateFlow(true)
|
||||||
|
|
||||||
|
/** False while the car shows its own interface. */
|
||||||
|
val projected: StateFlow<Boolean> = projectedFlow
|
||||||
|
|
||||||
|
@Volatile internal var request: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||||
|
fun exitToCar() {
|
||||||
|
request?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun set(projected: Boolean) {
|
||||||
|
projectedFlow.value = projected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ import java.io.FileOutputStream
|
|||||||
* rotation or theme change from recreating it and dropping the connection.
|
* rotation or theme change from recreating it and dropping the connection.
|
||||||
*/
|
*/
|
||||||
class GearslipActivity : ComponentActivity(), Projection {
|
class GearslipActivity : ComponentActivity(), Projection {
|
||||||
|
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
|
||||||
|
|
||||||
private var descriptor: ParcelFileDescriptor? = null
|
private var descriptor: ParcelFileDescriptor? = null
|
||||||
private var runner: GearslipRunner? = null
|
private var runner: GearslipRunner? = null
|
||||||
@@ -323,7 +325,9 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||||
runOnUiThread {
|
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||||
|
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||||
|
mainHandler.postAtFrontOfQueue {
|
||||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class GearslipRunner(
|
|||||||
private val assembler = Frames.Assembler()
|
private val assembler = Frames.Assembler()
|
||||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||||
|
|
||||||
|
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||||
|
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
|
||||||
|
|
||||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||||
|
|
||||||
@@ -127,6 +130,9 @@ class GearslipRunner(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
running = false
|
running = false
|
||||||
|
writer.shutdownNow()
|
||||||
|
CarFocus.request = null
|
||||||
|
CarFocus.set(true)
|
||||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||||
videoSource?.stop()
|
videoSource?.stop()
|
||||||
videoSource = null
|
videoSource = null
|
||||||
@@ -589,6 +595,8 @@ class GearslipRunner(
|
|||||||
val at = points[actionIndex]
|
val at = points[actionIndex]
|
||||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||||
}
|
}
|
||||||
|
// Time the next frame against this tap: see [reportTouchLatency].
|
||||||
|
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||||
projection?.onTouch(action, actionIndex, points)
|
projection?.onTouch(action, actionIndex, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,14 +699,17 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||||
|
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||||
// The head unit repeats this while it waits for frames; only act once.
|
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||||
if (!videoStarted) {
|
onVideoFocus(mode)
|
||||||
videoStarted = true
|
}
|
||||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
|
||||||
startVideoSource()
|
// Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
|
||||||
}
|
MSG_VIDEO_FOCUS_REQUEST -> {
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
MSG_MEDIA_ACK -> {
|
MSG_MEDIA_ACK -> {
|
||||||
@@ -772,6 +783,7 @@ class GearslipRunner(
|
|||||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
frameRate = if (config.frameRate == 1) 60 else 30,
|
||||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||||
onCodecConfig = { csd ->
|
onCodecConfig = { csd ->
|
||||||
|
lastCodecConfig = csd
|
||||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||||
log.hex(" csd", csd, limit = 64)
|
log.hex(" csd", csd, limit = 64)
|
||||||
@@ -800,6 +812,8 @@ class GearslipRunner(
|
|||||||
* makes it produce fewer frames, each one whole.
|
* makes it produce fewer frames, each one whole.
|
||||||
*/
|
*/
|
||||||
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||||
|
// The car is showing its own screen and would throw these away.
|
||||||
|
if (!videoProjected) return
|
||||||
synchronized(ackLock) {
|
synchronized(ackLock) {
|
||||||
val waitStart = System.currentTimeMillis()
|
val waitStart = System.currentTimeMillis()
|
||||||
while (running && inFlight >= maxUnacked) {
|
while (running && inFlight >= maxUnacked) {
|
||||||
@@ -809,6 +823,7 @@ class GearslipRunner(
|
|||||||
if (since > ACK_STALL_MS) {
|
if (since > ACK_STALL_MS) {
|
||||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||||
inFlight = 0
|
inFlight = 0
|
||||||
|
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
ackLock.wait(ACK_STALL_MS - since + 1)
|
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||||
@@ -828,15 +843,11 @@ class GearslipRunner(
|
|||||||
resyncAttempts = 0
|
resyncAttempts = 0
|
||||||
lastKeyframeSentAt = System.currentTimeMillis()
|
lastKeyframeSentAt = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
try {
|
// Counted before it's queued: the writer may send it and the ack come back before this
|
||||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
// thread runs again, and an ack counted ahead of its frame would leave the window full.
|
||||||
} catch (e: java.io.IOException) {
|
synchronized(ackLock) { inFlight++ }
|
||||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||||
running = false
|
reportTouchLatency(presentationTimeUs)
|
||||||
videoSource?.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inFlight++
|
|
||||||
framesSent++
|
framesSent++
|
||||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||||
log.i(
|
log.i(
|
||||||
@@ -846,6 +857,74 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the car is showing Gearslip; false while it shows its own interface. */
|
||||||
|
@Volatile private var videoProjected = true
|
||||||
|
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
|
||||||
|
@Volatile private var lastCodecConfig: ByteArray? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car says who has its screen. The first grant starts the video. After that the car may
|
||||||
|
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
|
||||||
|
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
|
||||||
|
* keyframe, since the car's decoder may have been reset in between.
|
||||||
|
*/
|
||||||
|
private fun onVideoFocus(mode: Int) {
|
||||||
|
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||||
|
if (!videoStarted) {
|
||||||
|
// The head unit repeats this while it waits for frames; only start once.
|
||||||
|
if (!projected) return
|
||||||
|
videoStarted = true
|
||||||
|
CarFocus.request = ::requestNativeFocus
|
||||||
|
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||||
|
startVideoSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (projected == videoProjected) return
|
||||||
|
videoProjected = projected
|
||||||
|
CarFocus.set(projected)
|
||||||
|
if (projected) {
|
||||||
|
log.i("the car handed its screen back - restarting the stream")
|
||||||
|
// Frames sent just before the car took over may never be acked.
|
||||||
|
synchronized(ackLock) {
|
||||||
|
inFlight = 0
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
|
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||||
|
videoSource?.requestSyncFrame()
|
||||||
|
} else {
|
||||||
|
log.i("the car is showing its own screen - video paused, audio carries on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||||
|
private fun requestNativeFocus() {
|
||||||
|
if (!videoStarted) return
|
||||||
|
// VideoFocusRequestNotification { mode = 2; reason = 3 }
|
||||||
|
val request = Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
|
||||||
|
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
|
||||||
|
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
|
||||||
|
log.i("-> VideoFocusRequest(mode=NATIVE, reason=LAUNCH_NATIVE)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
|
||||||
|
@Volatile private var touchAtUs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
|
||||||
|
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
|
||||||
|
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
|
||||||
|
* and display come on top and can't be seen from here.
|
||||||
|
*/
|
||||||
|
private fun reportTouchLatency(presentationTimeUs: Long) {
|
||||||
|
val touchAt = touchAtUs
|
||||||
|
if (touchAt == 0L || presentationTimeUs <= touchAt) return
|
||||||
|
touchAtUs = 0L
|
||||||
|
val nowUs = System.nanoTime() / 1000
|
||||||
|
val draw = (presentationTimeUs - touchAt) / 1000
|
||||||
|
val encode = (nowUs - presentationTimeUs) / 1000
|
||||||
|
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||||
|
}
|
||||||
|
|
||||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||||
limit == null -> value.toFloat()
|
limit == null -> value.toFloat()
|
||||||
value < 0 -> 0f
|
value < 0 -> 0f
|
||||||
@@ -872,35 +951,49 @@ class GearslipRunner(
|
|||||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||||
) + body
|
) + body
|
||||||
|
|
||||||
synchronized(output) {
|
// Every write goes through one thread, in order. The reading thread must never wait on
|
||||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
// a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
|
||||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
// behind a big video frame (to answer a ping) and a head unit stuck writing that ack
|
||||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
// held each other up until something timed out, about two seconds a time.
|
||||||
} else {
|
try {
|
||||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
writer.execute {
|
||||||
// separately, and carry the total plaintext length in the FIRST frame.
|
try {
|
||||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||||
var offset = 0
|
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||||
while (offset < payload.size) {
|
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
} else {
|
||||||
val chunk = payload.copyOfRange(offset, offset + size)
|
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||||
val frameType = when {
|
// separately, and carry the total plaintext length in the FIRST frame.
|
||||||
offset == 0 -> Frames.TYPE_FIRST
|
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
var offset = 0
|
||||||
else -> Frames.TYPE_MIDDLE
|
while (offset < payload.size) {
|
||||||
|
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||||
|
val chunk = payload.copyOfRange(offset, offset + size)
|
||||||
|
val frameType = when {
|
||||||
|
offset == 0 -> Frames.TYPE_FIRST
|
||||||
|
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||||
|
else -> Frames.TYPE_MIDDLE
|
||||||
|
}
|
||||||
|
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||||
|
output.write(
|
||||||
|
Frames.build(
|
||||||
|
channel, encrypted, framePayload, messageType, frameType,
|
||||||
|
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
splitMessages++
|
||||||
}
|
}
|
||||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
output.flush()
|
||||||
output.write(
|
} catch (e: java.io.IOException) {
|
||||||
Frames.build(
|
if (running) log.w("send failed, stopping: ${e.message}")
|
||||||
channel, encrypted, framePayload, messageType, frameType,
|
running = false
|
||||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
videoSource?.stop()
|
||||||
),
|
|
||||||
)
|
|
||||||
offset += size
|
|
||||||
}
|
}
|
||||||
splitMessages++
|
|
||||||
}
|
}
|
||||||
output.flush()
|
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||||
|
// The session is over; nothing more goes out.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1132,9 @@ class GearslipRunner(
|
|||||||
const val STREAM_MEDIA = 3
|
const val STREAM_MEDIA = 3
|
||||||
const val CODEC_H264_BP = 3
|
const val CODEC_H264_BP = 3
|
||||||
const val VIDEO_FOCUS_PROJECTED = 1
|
const val VIDEO_FOCUS_PROJECTED = 1
|
||||||
|
const val VIDEO_FOCUS_NATIVE = 2
|
||||||
|
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
|
||||||
|
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
|
||||||
const val SESSION_ID = 1
|
const val SESSION_ID = 1
|
||||||
const val MSG_INPUT_REPORT = 32769
|
const val MSG_INPUT_REPORT = 32769
|
||||||
const val MSG_KEY_BINDING_REQUEST = 32770
|
const val MSG_KEY_BINDING_REQUEST = 32770
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ fun CarDashboardScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Today")
|
||||||
WeatherCard(context)
|
WeatherCard(context)
|
||||||
AgendaCard(context)
|
AgendaCard(context)
|
||||||
NotificationsSection()
|
NotificationsSection()
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ internal fun rememberAllEntries(): List<Entry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The launcher's groups, in the order they're laid out. */
|
/** The launcher's groups, in the order they're laid out. */
|
||||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
|
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
|
||||||
|
|
||||||
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||||
entry.builtIn?.let {
|
entry.builtIn?.let {
|
||||||
@@ -175,6 +175,7 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
|||||||
BuiltInApps.Phone -> AppGroup.PHONE
|
BuiltInApps.Phone -> AppGroup.PHONE
|
||||||
BuiltInApps.Web -> AppGroup.WEB
|
BuiltInApps.Web -> AppGroup.WEB
|
||||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||||
|
BuiltInApps.Exit -> AppGroup.EXIT
|
||||||
else -> AppGroup.SETTINGS
|
else -> AppGroup.SETTINGS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fun CarSettingsScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Settings")
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Open on connect",
|
"Open on connect",
|
||||||
|
|||||||
@@ -40,11 +40,26 @@ import app.seb3thehacker.gearslip.car.theme.GsButton
|
|||||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.media.CarMedia
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
|
import app.seb3thehacker.gearslip.CarFocus
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.PathParser
|
||||||
|
|
||||||
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||||
internal const val BUILT_IN_PREFIX = "gearslip:"
|
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||||
|
|
||||||
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */
|
||||||
|
/** Material's "directions car", drawn here because only the core icon set is bundled. */
|
||||||
|
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
|
||||||
|
PathParser().parsePathString(
|
||||||
|
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
|
||||||
|
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
|
||||||
|
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
|
||||||
|
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
|
||||||
|
"M5,11l1.5,-4.5h11L19,11L5,11z",
|
||||||
|
).toNodes(),
|
||||||
|
fill = SolidColor(Color.Black),
|
||||||
|
).build()
|
||||||
|
|
||||||
internal class BuiltInApp(
|
internal class BuiltInApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
@@ -60,7 +75,10 @@ internal object BuiltInApps {
|
|||||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
||||||
|
|
||||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings)
|
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||||
|
val Exit = BuiltInApp("exit", "Exit", CarGlyph, CarScreen.Home) { CarFocus.exitToCar() }
|
||||||
|
|
||||||
|
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||||
|
ScreenHeader("Screen sharing")
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
if (!TouchRelayService.enabled) {
|
if (!TouchRelayService.enabled) {
|
||||||
TouchRelayWarning()
|
TouchRelayWarning()
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -101,13 +101,7 @@ fun PhoneDialerScreen() {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
// The tabs share the title's row, so the list and keypad get the height back.
|
// The tabs share the title's row, so the list and keypad get the height back.
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
ScreenHeader("Phone") {
|
||||||
Text(
|
|
||||||
"Phone",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||||
|
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||||
|
* so this is the way out of a screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
|
Row(
|
||||||
|
modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ fun VehicleDataScreen() {
|
|||||||
val readings by CarSensors.readings.collectAsState()
|
val readings by CarSensors.readings.collectAsState()
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||||
Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
ScreenHeader("Vehicle data")
|
||||||
Text(
|
Text(
|
||||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||||
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
"watch a row change while you do the thing (brake, drive, headlights) to confirm it.",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fun WeatherScreen() {
|
|||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
|
ScreenHeader("Weather")
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||||
is WeatherState.Unavailable -> Text(
|
is WeatherState.Unavailable -> Text(
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -53,6 +55,8 @@ fun WebApp(content: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||||
|
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
|||||||
Reference in new issue
Block a user