{e(d['title']).replace(chr(10),'
')}
{e(d['intro'])}
{e(d['promise'])}
{e(d['requires'])}
{e(d['title']).replace(chr(10),'
')}
{e(d['intro'])}
{e(d['promise'])}
{e(d['requires'])}
{e(d['gallery'])}
{e(d['setup'])}
- {''.join('
- '+e(x)+' ' for x in d['steps'])}
{e(d['bssid'])}
{e(d['adb'])} ↗{e(d['whats'])}
- {''.join('
- '+e(x)+' ' for x in d['features'])}
{e(d['compat'])}
{e(d['compatText'])}
.png` (48 files, 38x41 px) are taken from BYDMate
-(https://github.com/AndyShaman/BYDMate, `app/src/main/assets/navi/`). The file name is the Gaode maneuver code
-(see `hud/BydManeuverCodes.kt`). The BYDMate project itself describes them as donor assets.
+BYD HUD maneuver icons were imported from AndyShaman/BYDMate, app/src/main/assets/navi/.
+Required Notice: Copyright AndyShaman (https://github.com/AndyShaman/BYDMate)
+PolyForm Noncommercial License 1.0.0: https://polyformproject.org/licenses/noncommercial/1.0.0/
+See LICENSE-BYDMate.txt. These assets are not relicensed under the application's code license.
+Upstream describes these as donor assets; original provenance is not independently established.
diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml
index 9426b5a..9eaadf2 100644
--- a/shared/src/main/AndroidManifest.xml
+++ b/shared/src/main/AndroidManifest.xml
@@ -24,6 +24,7 @@
android:required="false" />
+
diff --git a/shared/src/main/assets/byd-hud-icons/LICENSE-BYDMate.txt b/shared/src/main/assets/byd-hud-icons/LICENSE-BYDMate.txt
new file mode 100644
index 0000000..d264e3f
--- /dev/null
+++ b/shared/src/main/assets/byd-hud-icons/LICENSE-BYDMate.txt
@@ -0,0 +1,81 @@
+BYDMate - Trip and Charge Tracker for BYD DiLink 5.0
+Copyright (C) 2026 AndyShaman (https://github.com/AndyShaman)
+
+Licensed under the PolyForm Noncommercial License 1.0.0.
+Required Notice: Copyright AndyShaman (https://github.com/AndyShaman/BYDMate)
+
+---
+
+# Polyform Noncommercial License 1.0.0
+
+
+
+## Acceptance
+
+In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
+
+## Copyright License
+
+The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
+
+## Distribution License
+
+The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
+
+## Notices
+
+You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
+
+> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
+
+## Changes and New Works License
+
+The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
+
+## Patent License
+
+The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
+
+## Noncommercial Purposes
+
+Any noncommercial purpose is a permitted purpose.
+
+## Personal Uses
+
+Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
+
+## Noncommercial Organizations
+
+Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
+
+## Fair Use
+
+You may have "fair use" rights for the software under the law. These terms do not limit them.
+
+## No Other Rights
+
+These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
+
+## Patent Defense
+
+If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
+
+## Violations
+
+The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
+
+## No Liability
+
+***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
+
+## Definitions
+
+The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
+
+**You** refers to the individual or entity agreeing to these terms.
+
+**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
+
+**Your licenses** are all the licenses granted to you for the software under these terms.
+
+**Use** means anything you do with the software requiring one of your licenses.
diff --git a/shared/src/main/assets/byd-hud-icons/NOTICE.txt b/shared/src/main/assets/byd-hud-icons/NOTICE.txt
new file mode 100644
index 0000000..c448a16
--- /dev/null
+++ b/shared/src/main/assets/byd-hud-icons/NOTICE.txt
@@ -0,0 +1,5 @@
+BYD HUD maneuver icons were imported from AndyShaman/BYDMate, app/src/main/assets/navi/.
+Required Notice: Copyright AndyShaman (https://github.com/AndyShaman/BYDMate)
+PolyForm Noncommercial License 1.0.0: https://polyformproject.org/licenses/noncommercial/1.0.0/
+See LICENSE-BYDMate.txt. These assets are not relicensed under the application's code license.
+Upstream describes these as donor assets; original provenance is not independently established.
diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt
index a81b3f2..47ec0fb 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt
@@ -29,6 +29,7 @@ data class AudioFormat(
class AudioStream(
private val key: ByteArray,
private val streamType: Int = -1,
+ private val onDiagnostic: (String) -> Unit = {},
) : Closeable {
interface Listener {
fun onStarted(firstSample: Int) {}
@@ -76,77 +77,86 @@ class AudioStream(
}
private fun runData(socket: DatagramSocket, listener: Listener) {
+ val stats = StreamReceiveStats("audio type=$streamType", onDiagnostic)
val buffer = ByteArray(DATAGRAM_BYTES)
- while (!closed.get()) {
- val packet = DatagramPacket(buffer, buffer.size)
- try {
- socket.receive(packet)
- } catch (_: Exception) {
- if (closed.get()) return else continue
- }
- val wire = packet.data.copyOf(packet.length)
- val packetNumber = receivedPackets.incrementAndGet()
- if (wire.size < RTP_HEADER_LEN + TAIL_LEN) {
- if (packetNumber == 1) {
- android.util.Log.w(
+ try {
+ while (!closed.get()) {
+ val packet = DatagramPacket(buffer, buffer.size)
+ try {
+ stats.reading()
+ socket.receive(packet)
+ } catch (_: Exception) {
+ if (closed.get()) return else continue
+ }
+ stats.received(packet.length, if (packet.length >= 12)
+ ((buffer[2].toInt() and 0xff) shl 8) or (buffer[3].toInt() and 0xff) else null)
+ val wire = packet.data.copyOf(packet.length)
+ val packetNumber = receivedPackets.incrementAndGet()
+ if (wire.size < RTP_HEADER_LEN + TAIL_LEN) {
+ if (packetNumber == 1) {
+ android.util.Log.w(
+ TAG,
+ "audio stream type=$streamType short packet bytes=${wire.size}",
+ )
+ }
+ listener.onPacket(
+ wire,
+ null,
+ null,
+ IOException("audio packet shorter than RTP header plus tail"),
+ )
+ stats.processed()
+ continue
+ }
+
+ val aad = wire.copyOfRange(4, RTP_HEADER_LEN)
+ val sealedEnd = wire.size - NONCE_LEN
+ val sealed = wire.copyOfRange(RTP_HEADER_LEN, sealedEnd)
+ val shortNonce = wire.copyOfRange(sealedEnd, wire.size)
+ val nonce = ByteArray(12).also { shortNonce.copyInto(it, 4) }
+ val sample = readU32Be(wire, 4)
+
+ val payload = try {
+ AirPlayCrypto.chachaOpen(key, nonce, sealed, aad)
+ } catch (error: Exception) {
+ val failureNumber = authenticationFailures.incrementAndGet()
+ if (failureNumber == 1) {
+ android.util.Log.w(
+ TAG,
+ "audio stream type=$streamType first decrypt failure " +
+ "wire=${wire.toHexString()}",
+ error,
+ )
+ }
+ listener.onPacket(wire, null, sample, error)
+ stats.processed()
+ continue
+ }
+ val rtp = wire.copyOf(RTP_HEADER_LEN) + payload
+ val decryptedNumber = decryptedPackets.incrementAndGet()
+ if (decryptedNumber <= FIRST_PACKET_LOG_COUNT) {
+ android.util.Log.i(
TAG,
- "audio stream type=$streamType short packet bytes=${wire.size}",
+ "audio stream type=$streamType packet=$decryptedNumber sample=$sample " +
+ "wireBytes=${wire.size} payloadBytes=${payload.size} " +
+ "payloadHead=${payload.copyOf(minOf(payload.size, 16)).toHexString()}",
+ )
+ } else if (decryptedNumber % PACKET_LOG_INTERVAL == 0) {
+ android.util.Log.i(
+ TAG,
+ "audio stream type=$streamType decrypted=$decryptedNumber " +
+ "authFailures=${authenticationFailures.get()}",
)
}
- listener.onPacket(
- wire,
- null,
- null,
- IOException("audio packet shorter than RTP header plus tail"),
- )
- continue
- }
-
- val aad = wire.copyOfRange(4, RTP_HEADER_LEN)
- val sealedEnd = wire.size - NONCE_LEN
- val sealed = wire.copyOfRange(RTP_HEADER_LEN, sealedEnd)
- val shortNonce = wire.copyOfRange(sealedEnd, wire.size)
- val nonce = ByteArray(12).also { shortNonce.copyInto(it, 4) }
- val sample = readU32Be(wire, 4)
-
- val payload = try {
- AirPlayCrypto.chachaOpen(key, nonce, sealed, aad)
- } catch (error: Exception) {
- val failureNumber = authenticationFailures.incrementAndGet()
- if (failureNumber == 1) {
- android.util.Log.w(
- TAG,
- "audio stream type=$streamType first decrypt failure " +
- "wire=${wire.toHexString()}",
- error,
- )
+ listener.onPacket(wire, rtp, sample, null)
+ if (!started) {
+ started = true
+ listener.onStarted(sample)
}
- listener.onPacket(wire, null, sample, error)
- continue
+ listener.onRtp(rtp, sample)
+ stats.processed()
}
- val rtp = wire.copyOf(RTP_HEADER_LEN) + payload
- val decryptedNumber = decryptedPackets.incrementAndGet()
- if (decryptedNumber <= FIRST_PACKET_LOG_COUNT) {
- android.util.Log.i(
- TAG,
- "audio stream type=$streamType packet=$decryptedNumber sample=$sample " +
- "wireBytes=${wire.size} payloadBytes=${payload.size} " +
- "payloadHead=${payload.copyOf(minOf(payload.size, 16)).toHexString()}",
- )
- } else if (decryptedNumber % PACKET_LOG_INTERVAL == 0) {
- android.util.Log.i(
- TAG,
- "audio stream type=$streamType decrypted=$decryptedNumber " +
- "authFailures=${authenticationFailures.get()}",
- )
- }
- listener.onPacket(wire, rtp, sample, null)
- if (!started) {
- started = true
- listener.onStarted(sample)
- }
- listener.onRtp(rtp, sample)
- }
+ } finally { stats.flush(ended = true) }
}
private fun runControl(socket: DatagramSocket) {
diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt
index a68d38f..fd686b2 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt
@@ -70,7 +70,7 @@ class CarPlayMediaEngine(
val key = outputKey(session, stream) ?: return null
val streamKey = StreamKey(session, type)
Log.i(TAG, "airplay screen key connectionID=${unsignedPlistDecimal(stream["streamConnectionID"])}")
- val screen = ScreenStream(key)
+ val screen = ScreenStream(key, session::logDebug)
sink.setVideoDiagnosticHandler(type) {
if (it == "first frame rendered") session.videoFrameRendered()
session.logDebug("Video: $it")
@@ -132,7 +132,7 @@ class CarPlayMediaEngine(
val capture = audioCaptureDirectory?.let { AudioPacketCapture(it, type) }
if (capture != null) audioCaptures[type] = capture
- val audio = AudioStream(key, type)
+ val audio = AudioStream(key, type, session::logDebug)
val (dataPort, controlPort) = audio.listen(
object : AudioStream.Listener {
override fun onStarted(firstSample: Int) {
diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt
index 1c2ae68..3a7e49c 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt
@@ -19,7 +19,7 @@ enum class VideoCodec { H264, H265 }
* (avcC/hvcC) or a ChaCha20-Poly1305 sealed VideoFrame. The key is the DataStream output key
* and the per-frame nonce is an 8-byte little-endian counter.
*/
-class ScreenStream(private val key: ByteArray) : Closeable {
+class ScreenStream(private val key: ByteArray, private val onDiagnostic: (String) -> Unit = {}) : Closeable {
interface Listener {
fun onCodec(codec: VideoCodec) {}
fun onConfig(codecData: ByteArray) {}
@@ -64,18 +64,23 @@ class ScreenStream(private val key: ByteArray) : Closeable {
private fun run(sock: Socket) {
var failure: Throwable? = null
+ val stats = StreamReceiveStats("video", onDiagnostic)
try {
val input = sock.getInputStream()
while (!closed.get()) {
+ stats.reading()
val header = readFully(input, HEADER_LEN) ?: break
val bodySize = readU32Le(header, 0)
if (bodySize > MAX_BODY) break
val body = readFully(input, bodySize) ?: break
+ stats.received(HEADER_LEN + bodySize)
onMessage(header, body)
+ stats.processed()
}
} catch (error: Exception) {
failure = error
} finally {
+ stats.flush(ended = true)
if (socket === sock) socket = null
safeClose(sock)
if (!closed.get()) listener.onClosed(failure)
diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/StreamReceiveStats.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/StreamReceiveStats.kt
new file mode 100644
index 0000000..9beb055
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/StreamReceiveStats.kt
@@ -0,0 +1,56 @@
+package com.shilapi.xcertplay.airplay
+
+/** Receive-thread timing only: no payloads, endpoint addresses, or route data. */
+internal class StreamReceiveStats(
+ private val label: String,
+ private val report: (String) -> Unit,
+ private val nowNs: () -> Long = System::nanoTime,
+) {
+ private var windowStart = nowNs()
+ private var readStart = windowStart
+ private var processingStart = windowStart
+ private var packets = 0
+ private var bytes = 0L
+ private var maxReadNs = 0L
+ private var maxProcessNs = 0L
+ private var nextSequence: Int? = null
+ private var forwardGapPackets = 0
+ private var lateOrDuplicate = 0
+
+ fun reading() { readStart = nowNs() }
+
+ fun received(size: Int, sequence: Int? = null) {
+ processingStart = nowNs()
+ maxReadNs = maxOf(maxReadNs, processingStart - readStart)
+ packets++
+ bytes += size
+ if (sequence != null) {
+ val expected = nextSequence
+ val delta = if (expected == null) 0 else (sequence - expected) and 0xffff
+ if (delta < 0x8000) {
+ forwardGapPackets += delta
+ nextSequence = (sequence + 1) and 0xffff
+ } else lateOrDuplicate++
+ }
+ }
+
+ fun processed() {
+ maxProcessNs = maxOf(maxProcessNs, nowNs() - processingStart)
+ flush()
+ }
+
+ fun flush(ended: Boolean = false) {
+ val now = nowNs()
+ if (!ended && now - windowStart < 5_000_000_000L) return
+ runCatching { report("Receive: $label packets=$packets bytes=$bytes readMaxMs=${maxReadNs / 1_000_000} " +
+ "processMaxUs=${maxProcessNs / 1000} seqForwardGaps=$forwardGapPackets " +
+ "lateOrDuplicate=$lateOrDuplicate ended=$ended") }
+ windowStart = now
+ packets = 0
+ bytes = 0
+ maxReadNs = 0
+ maxProcessNs = 0
+ forwardGapPackets = 0
+ lateOrDuplicate = 0
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterBridge.kt
index c189b17..a06316d 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterBridge.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterBridge.kt
@@ -20,14 +20,13 @@ internal object BydClusterBridge {
private const val KEY_STATE = 10019
private const val STATE_ENDED = 9
private const val KEEPALIVE_TICKS = 5
- private const val MARKER_PREFS = "diplay_byd_cluster_state"
- private const val KEY_GUIDANCE_ACTIVE = "guidance_active"
private const val FLAG_RECEIVER_INCLUDE_BACKGROUND = 0x01000000 // hidden Intent flag, as sent by stock clients
private val lock = Any()
private val route = BydHudRouteState()
private var context: Context? = null
private var available = false
+ private var factory: BydFactoryNavigationOutput? = null
private var senderStarted = false
private var lastSent: BydClusterFrame? = null
private var ticksSinceSend = 0
@@ -42,13 +41,11 @@ internal object BydClusterBridge {
} catch (_: PackageManager.NameNotFoundException) {
false
}
- Log.i(TAG, "cluster adapter available=$available")
- // A swipe from recents force-stops DiPlay before it can end guidance; the adapter would keep
- // showing the last arrow indefinitely, so end what a previous process left behind.
- if (available && guidanceMarker(appContext)) {
- Log.i(TAG, "ending guidance left by a previous DiPlay process")
- sendEndLocked()
+ if (!available && appContext.packageName.endsWith(".hudtest")) {
+ factory = BydFactoryNavigationOutput(appContext.applicationContext)
+ available = true
}
+ Log.i(TAG, "cluster adapter available=$available factoryTest=${factory != null}")
if (available && !senderStarted) {
senderStarted = true
Executors.newSingleThreadScheduledExecutor { runnable ->
@@ -66,14 +63,9 @@ internal object BydClusterBridge {
}
}
- /** Ends cluster guidance immediately; called when DiPlay is about to be killed. */
- fun endNow() = synchronized(lock) {
- if (available && lastSent != null) sendEndLocked()
- }
-
fun clear() = synchronized(lock) {
- // Only end guidance we started, so a stock navigation session is never cut off.
- if (available && route.clear() && lastSent != null) sendEndLocked()
+ route.clear() // Prevent the keepalive from restoring guidance after cleanup.
+ if (available && lastSent != null) sendEndLocked()
}
private fun tick() = synchronized(lock) {
@@ -93,6 +85,12 @@ internal object BydClusterBridge {
return
}
if (!force && frame == lastSent) return
+ factory?.let {
+ it.update(frame.icon, frame.roundaboutExit, frame.distanceMeters)
+ lastSent = frame
+ ticksSinceSend = 0
+ return
+ }
val intent = baseIntent(KEY_GUIDANCE).apply {
putExtra("TYPE", 0)
putExtra("EXTRA_STATE", 0)
@@ -105,7 +103,6 @@ internal object BydClusterBridge {
putExtra("ROUTE_REMAIN_TIME", frame.routeRemainingSeconds)
}
if (broadcastLocked(intent)) {
- if (lastSent == null) context?.let { setGuidanceMarker(it, true) }
lastSent = frame
ticksSinceSend = 0
if (!guidanceLogged) {
@@ -116,6 +113,12 @@ internal object BydClusterBridge {
}
private fun sendEndLocked() {
+ factory?.let {
+ it.clear()
+ lastSent = null
+ guidanceLogged = false
+ return
+ }
val intent = baseIntent(KEY_STATE).apply {
putExtra("EXTRA_STATE", STATE_ENDED)
putExtra("EXTRA_IS_FOREGROUND", 1)
@@ -125,20 +128,12 @@ internal object BydClusterBridge {
putExtra("ROUTE_REMAIN_DIS", -1)
putExtra("ROUTE_REMAIN_TIME", -1)
}
- broadcastLocked(intent)
- context?.let { setGuidanceMarker(it, false) }
+ if (!broadcastLocked(intent)) return
lastSent = null
guidanceLogged = false
Log.i(TAG, "cluster guidance ended")
}
- private fun guidanceMarker(context: Context): Boolean =
- context.getSharedPreferences(MARKER_PREFS, Context.MODE_PRIVATE).getBoolean(KEY_GUIDANCE_ACTIVE, false)
-
- private fun setGuidanceMarker(context: Context, active: Boolean) =
- context.getSharedPreferences(MARKER_PREFS, Context.MODE_PRIVATE).edit()
- .putBoolean(KEY_GUIDANCE_ACTIVE, active).apply()
-
// IS_BYD_MAP=true is required: the adapter drops foreign frames while it believes the stock map navigates.
private fun baseIntent(keyType: Int) = Intent(AMAP_ACTION).apply {
setPackage(AMAP_PACKAGE)
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterFrame.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterFrame.kt
index 300b131..76af981 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterFrame.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydClusterFrame.kt
@@ -48,7 +48,8 @@ internal data class BydClusterFrame(
6 -> if (leftHandTraffic) ROUNDABOUT_ENTER_CLOCKWISE else ROUNDABOUT_ENTER
7 -> if (leftHandTraffic) ROUNDABOUT_EXIT_CLOCKWISE else ROUNDABOUT_EXIT
10, 12, 24, 25, 27 -> DESTINATION
- else -> STRAIGHT
+ 3, 5, 8, 9, 11, 51 -> STRAIGHT
+ else -> 0 // Unknown/no maneuver must not become a false straight arrow.
}
return BydClusterFrame(icon, 0, distance, road, remainingMeters, remainingSeconds)
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryNavigationOutput.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryNavigationOutput.kt
new file mode 100644
index 0000000..2ca37cc
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryNavigationOutput.kt
@@ -0,0 +1,65 @@
+package com.shilapi.xcertplay.hud
+
+import android.content.Context
+import android.util.Log
+import java.lang.reflect.InvocationTargetException
+
+/** Temporary HUD-test output using the installed factory SDK and the app's own identity. */
+internal class BydFactoryNavigationOutput(private val context: Context) {
+ private var device: Any? = null
+ private var disabled = false
+ private var showing = false
+ private var logged = false
+
+ fun update(icon: Int, exit: Int, distance: Int) {
+ if (disabled || !context.packageName.endsWith(".bydhudtest") && !context.packageName.endsWith(".hudtest")) return
+ val turn = BydFactoryTurnCode.map(icon, exit) ?: run { clear(); return }
+ if (distance !in 0..16777214) { clear(); return }
+ try {
+ val sdk = device ?: Class.forName("android.hardware.bydauto.instrument.BYDAutoInstrumentDevice")
+ .getMethod("getInstance", Context::class.java).invoke(null, context).also { device = it }
+ if (!showing) {
+ checkResult("start", sdk.javaClass.getMethod("sendAutoNaviStatus", Int::class.javaPrimitiveType).invoke(sdk, 2))
+ showing = true
+ }
+ val idsClass = Class.forName("android.hardware.bydauto.BYDAutoFeatureIds\$Instrument")
+ val ids = arrayOf("INSTRUMENT_FRONT_CROSSING_DISTANCE_SET", "INSTRUMENT_GUIDE_INFO_SIMPLE_SET", "INSTRUMENT_GUIDE_INFO_AND_ROAD_AHEAD_DISTANCE_SET")
+ .map { idsClass.getField(it).getInt(null) }.toIntArray()
+ val valueClass = Class.forName("android.hardware.bydauto.BYDAutoEventValue")
+ val value = valueClass.getConstructor().newInstance()
+ valueClass.getField("intArrayValue").set(value, intArrayOf(distance, turn, turn))
+ checkResult("guidance", sdk.javaClass.getMethod("set", IntArray::class.java, valueClass).invoke(sdk, ids, value))
+ if (!logged) {
+ Log.i(TAG, "Factory navigation accepted turn=$turn distance=$distance; visible HUD output unconfirmed")
+ logged = true
+ }
+ } catch (error: Exception) {
+ // If start succeeded but guidance failed, end our own navigation state once.
+ clear()
+ disabled = true
+ val cause = (error as? InvocationTargetException)?.targetException ?: error
+ Log.w(TAG, "Factory navigation disabled: ${cause.javaClass.simpleName}: ${cause.message}")
+ }
+ }
+
+ fun clear() {
+ if (!showing) return
+ showing = false
+ logged = false
+ try {
+ val sdk = device ?: return
+ checkResult("end", sdk.javaClass.getMethod("sendAutoNaviStatus", Int::class.javaPrimitiveType).invoke(sdk, 1))
+ Log.i(TAG, "Factory navigation ended")
+ } catch (error: Exception) {
+ disabled = true
+ val cause = (error as? InvocationTargetException)?.targetException ?: error
+ Log.w(TAG, "Factory navigation cleanup failed: ${cause.message}")
+ }
+ }
+
+ private fun checkResult(operation: String, result: Any?) {
+ check(result == 0) { "$operation returned $result" }
+ }
+
+ companion object { private const val TAG = "BYD-Factory-Navi" }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryTurnCode.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryTurnCode.kt
new file mode 100644
index 0000000..c9ef9f7
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydFactoryTurnCode.kt
@@ -0,0 +1,14 @@
+package com.shilapi.xcertplay.hud
+
+/** Factory navigation's NEW_ICON -> instrument code mapping, verified against installed firmware. */
+internal object BydFactoryTurnCode {
+ private val codes = intArrayOf(0, 0, 1, 2, 3, 5, 7, 8, 9, 11, 45, 13, 24, 46, 47, 48, 49, 14, 23, 10, 12, 15, 18, 20, 22, 16, 17, 19, 21)
+ fun map(icon: Int, exit: Int): Int? {
+ if (icon !in 2..28) return null
+ if (exit in 1..10) {
+ if (icon == 11 || icon == 12) return exit + 24
+ if (icon == 17 || icon == 18) return exit + 34
+ }
+ return codes[icon]
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudBridge.kt
index 849f64e..05df27a 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudBridge.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudBridge.kt
@@ -20,23 +20,26 @@ internal object BydHudBridge {
private const val SOMEIP_ACTION = "com.ts.car.someip.SomeIpServerService"
private const val SOMEIP_TOKEN = "ts.car.someip.sdk.ISomeIpServerInterface"
private const val CALLBACK_TOKEN = "ts.car.someip.sdk.ISomeIpCallback"
- private const val SERVICE_ID = 0x000B010A00010000L
- private const val HUD_TOPIC = 0x0004010A00018001L
+ private const val SERVICE_ID = BydHudProtocol.SERVICE_TOPIC
+ private const val HUD_TOPIC = BydHudProtocol.NAVIGATION_TOPIC
private const val TX_REGISTER_CALLBACK = 1
private const val TX_START_SERVICE = 4
private const val TX_FIRE_EVENT = 6
private const val ICON_ASSET_DIR = "byd-hud-icons"
+ private val callbacks = Executors.newSingleThreadExecutor { task ->
+ Thread(task, "diplay-hud-callback").apply { isDaemon = true }
+ }
private val lock = Any()
private val route = BydHudRouteState()
private var context: Context? = null
private var binder: IBinder? = null
private var binding = false
private var started = false
- private var sequence = 0
private var senderStarted = false
private var guidanceSentLogged = false
private var showing = false
+ private var lastSendResult: Int? = null
private var icons: Map? = null
// The gateway pings registered callbacks and drops registrations that do not answer like an AIDL stub.
@@ -49,6 +52,20 @@ internal object BydHudBridge {
reply?.writeInt(0)
true
}
+ FIRST_CALL_TRANSACTION + 1 -> {
+ data.enforceInterface(CALLBACK_TOKEN)
+ val ready = data.readInt() != 0
+ Log.i(TAG, "SOME/IP HAL ready=$ready")
+ reply?.writeNoException()
+ true
+ }
+ FIRST_CALL_TRANSACTION + 2 -> {
+ data.enforceInterface(CALLBACK_TOKEN)
+ reply?.writeNoException()
+ reply?.writeInt(0) // No response to an unsupported request.
+ reply?.writeInt(0) // No modified input parcel.
+ true
+ }
else -> super.onTransact(code, data, reply, flags)
}
}
@@ -60,7 +77,7 @@ internal object BydHudBridge {
senderStarted = true
Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "diplay-byd-hud").apply { isDaemon = true }
- }.scheduleAtFixedRate(::tick, 1, 1, TimeUnit.SECONDS)
+ }.scheduleWithFixedDelay(::tick, BydHudProtocol.REPEAT_MILLIS, BydHudProtocol.REPEAT_MILLIS, TimeUnit.MILLISECONDS)
}
}
@@ -79,10 +96,11 @@ internal object BydHudBridge {
}
/** Clears the HUD immediately; called when DiPlay is about to be killed. */
- fun clearNow() = synchronized(lock) { clearHudLocked() }
+ fun clearNow() = clear()
fun clear() = synchronized(lock) {
- if (route.clear()) clearHudLocked()
+ route.clear()
+ clearHudLocked()
}
private fun tick() = synchronized(lock) {
@@ -106,20 +124,20 @@ internal object BydHudBridge {
val payload = BydHudPayload.guidance(
distanceMeters = guidance.distanceMeters,
maneuver = guidance.maneuver,
- sequence = sequence++ and 0xff,
icon = iconFor(guidance.gaode),
road = guidance.road,
)
- if (sendLocked(payload) && !guidanceSentLogged) {
+ val accepted = sendLocked(payload)
+ if (accepted && !guidanceSentLogged) {
guidanceSentLogged = true
- Log.i(TAG, "HUD guidance accepted $guidance")
+ Log.i(TAG, "HUD profile (300ms repeat, fixed field2=2): gateway accepted guidance; rendering unconfirmed")
}
- showing = true
+ if (accepted) showing = true
}
private fun clearHudLocked() {
if (!showing) return
- sendLocked(BydHudPayload.clear())
+ if (!sendLocked(BydHudPayload.clear())) return
showing = false
guidanceSentLogged = false
}
@@ -158,38 +176,47 @@ internal object BydHudBridge {
}
}
+ private fun onGatewayCallback(action: () -> Unit) {
+ callbacks.execute {
+ synchronized(lock) {
+ runCatching(action).onFailure { Log.w(TAG, "gateway callback failed", it) }
+ }
+ }
+ }
+
private val connection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName, service: IBinder) = synchronized(lock) {
+ override fun onServiceConnected(name: ComponentName, service: IBinder) = onGatewayCallback {
binder = service
binding = true
- // Also runs after a gateway restart: its registrations and started services are gone.
- val registered = transactLocked(TX_REGISTER_CALLBACK) { it.writeStrongBinder(callback) }
- started = transactLocked(TX_START_SERVICE) { it.writeLong(SERVICE_ID) } >= 0
- Log.i(TAG, "SOME/IP connected, callback=$registered started=$started")
+ val registered = transactLocked(TX_REGISTER_CALLBACK, returnsValue = false) { it.writeStrongBinder(callback) }
+ val startResult = if (registered == 0) transactLocked(TX_START_SERVICE) { it.writeLong(SERVICE_ID) } else -1
+ // Firmware explicitly treats 13 as an already-started service.
+ started = BydHudProtocol.serviceStarted(startResult)
+ Log.i(TAG, "SOME/IP connected, callback=$registered startResult=$startResult started=$started")
showing = false
sendCurrentLocked()
}
- // The binding stays registered and BIND_AUTO_CREATE reconnects; binding again would leak a connection.
- override fun onServiceDisconnected(name: ComponentName) = synchronized(lock) {
+ // BIND_AUTO_CREATE reconnects after a gateway restart without a second binding.
+ override fun onServiceDisconnected(name: ComponentName) = onGatewayCallback {
binder = null
started = false
}
- override fun onBindingDied(name: ComponentName) = synchronized(lock) { resetBindingLocked() }
- override fun onNullBinding(name: ComponentName) = synchronized(lock) { resetBindingLocked() }
+ override fun onBindingDied(name: ComponentName) = onGatewayCallback { resetBindingLocked() }
+ override fun onNullBinding(name: ComponentName) = onGatewayCallback { resetBindingLocked() }
}
- private inline fun transactLocked(code: Int, write: (Parcel) -> Unit): Int {
+ private inline fun transactLocked(code: Int, returnsValue: Boolean = true, write: (Parcel) -> Unit): Int {
val target = binder ?: return -1
val data = Parcel.obtain()
val reply = Parcel.obtain()
return try {
data.writeInterfaceToken(SOMEIP_TOKEN)
write(data)
- target.transact(code, data, reply, 0)
+ check(target.transact(code, data, reply, 0)) { "Unsupported transaction $code" }
reply.readException()
- reply.readInt()
+ if (returnsValue) reply.readInt() else 0
} catch (error: Throwable) {
Log.w(TAG, "SOME/IP transaction $code failed", error)
-1
@@ -208,11 +235,14 @@ internal object BydHudBridge {
it.writeInt(payload.size)
it.writeByteArray(payload)
}
- if (result < 0) {
- Log.w(TAG, "HUD send result=$result")
+ if (lastSendResult != result) {
+ Log.i(TAG, "HUD fireEvent result=$result bytes=${payload.size}")
+ lastSendResult = result
+ }
+ if (result != 0) {
if (binder?.isBinderAlive != true) resetBindingLocked()
}
- return result >= 0
+ return BydHudProtocol.eventAccepted(result)
}
private fun resetBindingLocked() {
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudPayload.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudPayload.kt
index 1cf4d6a..dcbd3d3 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudPayload.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudPayload.kt
@@ -10,13 +10,13 @@ internal object BydHudPayload {
fun guidance(
distanceMeters: Int,
maneuver: Int,
- sequence: Int,
icon: ByteArray? = null,
road: String = "",
eta: String = "",
): ByteArray =
ByteArrayOutputStream(64 + (icon?.size ?: 0)).apply {
- field(2, (sequence and 0xff).toLong())
+ // HUD experiment A: match the fixed guidance value observed by BYDMate.
+ field(2, 2)
field(6, 1)
bytes(7, ByteArray(0))
bytes(8, icon ?: ByteArray(0))
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudProtocol.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudProtocol.kt
new file mode 100644
index 0000000..be4f0a4
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudProtocol.kt
@@ -0,0 +1,10 @@
+package com.shilapi.xcertplay.hud
+
+/** Shared wire contract; keep DiPlay and DiAuto copies identical apart from their package. */
+internal object BydHudProtocol {
+ const val SERVICE_TOPIC = 0x000B010A00010000L
+ const val NAVIGATION_TOPIC = 0x0004010A00018001L
+ const val REPEAT_MILLIS = 300L
+ fun serviceStarted(code: Int): Boolean = code == 0 || code == 13
+ fun eventAccepted(code: Int): Boolean = code == 0
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudRouteState.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudRouteState.kt
index f89da4a..6c709ce 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudRouteState.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydHudRouteState.kt
@@ -39,6 +39,7 @@ internal class BydHudRouteState(private val nanoTime: () -> Long = System::nanoT
private var remainingSeconds: Long? = null
private var remainingMeters: Long? = null
private var emptyListSinceNs: Long? = null
+ private var lastRouteUpdateNs: Long? = null
fun accept(messageId: Int, payload: ByteArray): BydHudRouteChange {
if (!validTlvs(payload)) return BydHudRouteChange.NONE
@@ -80,12 +81,15 @@ internal class BydHudRouteState(private val nanoTime: () -> Long = System::nanoT
remainingSeconds = null
remainingMeters = null
emptyListSinceNs = null
+ lastRouteUpdateNs = null
maneuvers.clear()
return wasActive
}
private fun activeManeuver(): Maneuver? {
if (!routeActive || activeIndex < 0) return null
+ val updated = lastRouteUpdateNs ?: return null
+ if (nanoTime() - updated >= STALE_ROUTE_NS) return null
val emptySince = emptyListSinceNs
if (emptySince != null && nanoTime() - emptySince >= EMPTY_LIST_HIDE_NS) return null
return maneuvers[activeIndex]
@@ -95,6 +99,7 @@ internal class BydHudRouteState(private val nanoTime: () -> Long = System::nanoT
private fun roadFor(maneuver: Maneuver): String = maneuver.afterRoad.ifEmpty { currentRoad }
private fun parseRouteUpdate(data: ByteArray): BydHudRouteChange {
+ lastRouteUpdateNs = nanoTime()
var state: Int? = null
var distance: Int? = null
var firstManeuver: Int? = null
@@ -196,6 +201,7 @@ internal class BydHudRouteState(private val nanoTime: () -> Long = System::nanoT
const val ROUTE_GUIDANCE_UPDATE = 0x5201
const val ROUTE_GUIDANCE_MANEUVER_UPDATE = 0x5202
private const val TLV_HEADER_BYTES = 4
+ private const val STALE_ROUTE_NS = 30_000_000_000L
private const val EMPTY_LIST_HIDE_NS = 3_000_000_000L
}
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydNavigationOutputs.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydNavigationOutputs.kt
index 6a78477..e71f1de 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydNavigationOutputs.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydNavigationOutputs.kt
@@ -1,15 +1,39 @@
package com.shilapi.xcertplay.hud
-/** App-facing entry point for the BYD navigation outputs. */
+import android.content.Context
+import com.shilapi.xcertplay.iap2.wire.Iap2Frame
+
+/** Nonblocking boundary between phone control messages and vendor services. */
object BydNavigationOutputs {
- /** Safe to call on every app start: ends guidance a force-stopped previous process left on the cluster. */
- fun initialize(context: android.content.Context) {
- runCatching { BydClusterBridge.initialize(context) }
+ /** Recover a journaled interrupted output when the app opens, even before a phone reconnects. */
+ fun onAppOpened(context: Context) { if (BydStandaloneHudOutput.available(context)) start(context) }
+ fun setDiagnosticHold(hold: Boolean) { BydStandaloneHudOutput.syntheticHold = hold }
+ @Volatile private var useStandalone = false
+ private val standalone = NavigationOutputWorker("diplay-standalone-output", BydStandaloneNavigationBridge::clear)
+ private val hud = NavigationOutputWorker("diplay-hud-output", BydHudBridge::clear)
+ private val cluster = NavigationOutputWorker("diplay-cluster-output", BydClusterBridge::clear)
+
+ fun start(context: Context) {
+ val app = context.applicationContext
+ useStandalone = BydStandaloneHudOutput.available(app)
+ if (useStandalone) standalone.start { BydStandaloneNavigationBridge.initialize(app) }
+ else {
+ hud.start { BydHudBridge.initialize(app) }
+ cluster.start { BydClusterBridge.initialize(app) }
+ }
}
- /** Ends guidance on the cluster (which the HUD mirrors) and the HUD before the process may die. */
- fun endNow() {
- runCatching { BydClusterBridge.endNow() }
- runCatching { BydHudBridge.clearNow() }
+ internal fun onFrame(frame: Iap2Frame) {
+ if (frame.messageId != BydHudRouteState.ROUTE_GUIDANCE_UPDATE &&
+ frame.messageId != BydHudRouteState.ROUTE_GUIDANCE_MANEUVER_UPDATE) return
+ val owned = frame // Iap2Frame is immutable and defensively copies its payload.
+ if (useStandalone) standalone.submit { BydStandaloneNavigationBridge.onFrame(owned) }
+ else {
+ hud.submit { BydHudBridge.onFrame(owned) }
+ cluster.submit { BydClusterBridge.onFrame(owned) }
+ }
}
+
+ /** Best effort while alive; Android does not guarantee callbacks before force-stop. */
+ fun endNow() { standalone.clear(); hud.clear(); cluster.clear() }
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydOutputSettings.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydOutputSettings.kt
index 290c86d..b6d0c05 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/hud/BydOutputSettings.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydOutputSettings.kt
@@ -16,7 +16,7 @@ object BydOutputSettings {
/** Whether the head unit has a BYD navigation receiver, so settings can hide a switch that cannot work. */
fun available(context: Context): Boolean =
- installed(context, "com.byd.amapservice") || installed(context, "com.ts.car.someip.service")
+ BydStandaloneHudOutput.available(context) || installed(context, "com.byd.amapservice") || installed(context, "com.ts.car.someip.service")
private fun installed(context: Context, pkg: String): Boolean =
runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneHudOutput.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneHudOutput.kt
new file mode 100644
index 0000000..38efe04
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneHudOutput.kt
@@ -0,0 +1,67 @@
+package com.shilapi.xcertplay.hud
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Process
+import android.util.Log
+import java.security.MessageDigest
+
+/** Ordinary-app IPC to the real stock receiver. No shell, local socket or permission grant. */
+internal class BydStandaloneHudOutput private constructor(context: Context) {
+ private val app = context.applicationContext
+ private val prefs = app.getSharedPreferences("byd_standalone_hud", Context.MODE_PRIVATE)
+ private val session = BydStandaloneSession(
+ send = { packet ->
+ app.sendBroadcast(Intent("byd.hud.NAVIGATION").setComponent(TARGET)
+ .putExtra("normal", packet).addFlags(Intent.FLAG_RECEIVER_FOREGROUND))
+ Log.d(TAG, "dispatch uid=${Process.myUid()} bytes=${packet.split(',').size}")
+ },
+ rememberPendingClear = { pending ->
+ check(prefs.edit().putBoolean("pending_clear", pending).commit()) { "Cannot persist HUD cleanup" }
+ },
+ needsRecovery = prefs.getBoolean("pending_clear", false),
+ )
+
+ init {
+ Log.i(TAG, "Standalone navigation ready uid=${Process.myUid()} helper=none")
+ // Retain the journal if dispatch fails; the next scheduled tick retries.
+ runCatching { session.clear() }.onFailure { Log.w(TAG, "Startup clear will retry", it) }
+ }
+
+ fun update(icon: Int, exit: Int, distanceMeters: Int, road: String) =
+ session.update(icon, exit, distanceMeters, road)
+ fun clear() = session.clear()
+
+ companion object {
+ private const val TAG = "BYD-Standalone-Live"
+ private val TARGET = ComponentName("com.byd.clusterdebug", "com.byd.clusterdebug.BroadcastReceiverCAN")
+ @Volatile var syntheticHold = false
+
+ fun create(context: Context): BydStandaloneHudOutput? =
+ if (available(context)) BydStandaloneHudOutput(context) else null
+
+ /** Enable production and diagnostic packages only on the physically tested firmware. */
+ fun available(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT < 28 || context.packageName !in setOf(
+ "com.andrerinas.headunitrevived", "com.shihab.diplay",
+ "com.andrerinas.headunitrevived.bydhudtest", "com.shihab.diplay.hudtest")) return false
+ if (Build.FINGERPRINT != "BYD-AUTO/IVI/IVI:13/TP1A.220624.014/eng.build20260722.221155:user/release-keys") return false
+ return runCatching {
+ val manager = context.packageManager
+ val info = manager.getPackageInfo(TARGET.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ val receiver = manager.getReceiverInfo(TARGET, 0)
+ val signers = info.signingInfo?.apkContentsSigners ?: return false
+ info.longVersionCode == 10601004L &&
+ info.applicationInfo!!.flags and ApplicationInfo.FLAG_SYSTEM != 0 &&
+ receiver.enabled && receiver.exported && receiver.permission.isNullOrEmpty() &&
+ signers.size == 1 && MessageDigest.getInstance("SHA-256").digest(signers[0].toByteArray())
+ .joinToString("") { "%02x".format(it.toInt() and 255) } ==
+ "efe3ca8ada0d10c655c3df9910ad2ebc121a47d9a6358434eb24074309933efc"
+ }.getOrDefault(false)
+ }
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneNavigationBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneNavigationBridge.kt
new file mode 100644
index 0000000..e3e5aa9
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneNavigationBridge.kt
@@ -0,0 +1,49 @@
+package com.shilapi.xcertplay.hud
+
+import android.content.Context
+import android.util.Log
+import com.shilapi.xcertplay.iap2.wire.Iap2Frame
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+internal object BydStandaloneNavigationBridge {
+ private val lock = Any()
+ private val route = BydHudRouteState()
+ private var context: Context? = null
+ private var output: BydStandaloneHudOutput? = null
+ private var started = false
+
+ fun initialize(appContext: Context) = synchronized(lock) {
+ context = appContext.applicationContext
+ if (output == null) output = BydStandaloneHudOutput.create(appContext)
+ if (output != null && !started) {
+ started = true
+ Executors.newSingleThreadScheduledExecutor { task ->
+ Thread(task, "diplay-standalone-navi").apply { isDaemon = true }
+ }.scheduleWithFixedDelay(::tick, 0, 500, TimeUnit.MILLISECONDS)
+ }
+ }
+
+ fun onFrame(frame: Iap2Frame) = synchronized(lock) {
+ route.accept(frame.messageId, frame.payload)
+ Unit
+ }
+
+ fun clear() = synchronized(lock) {
+ route.clear() // A keepalive must never resurrect ended guidance.
+ output?.clear()
+ Unit
+ }
+
+ private fun tick() = synchronized(lock) {
+ if (BydStandaloneHudOutput.syntheticHold) return@synchronized
+ try {
+ val frame = if (context?.let(BydOutputSettings::enabled) == true)
+ route.currentApple()?.let(BydClusterFrame::from) else null
+ if (frame == null) output?.clear()
+ else output?.update(frame.icon, frame.roundaboutExit, frame.distanceMeters, frame.road)
+ } catch (error: Exception) {
+ Log.w("DiPlay-Standalone", "HUD update/cleanup will retry", error)
+ }
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandalonePackets.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandalonePackets.kt
new file mode 100644
index 0000000..21b4396
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandalonePackets.kt
@@ -0,0 +1,64 @@
+package com.shilapi.xcertplay.hud
+
+/** Navigation records traced from the installed IVI HAL; no arbitrary feature input. */
+object BydStandalonePackets {
+ const val MAX_DISTANCE_METERS = 16777214
+ fun start(): String = "43,E0,00,3A,01,02"
+ fun clear(): String = "43,E0,00,3A,01,01"
+ fun guidance(icon: Int, exit: Int, distanceMeters: Int, road: String? = null): String? {
+ val turn = BydFactoryTurnCode.map(icon, exit) ?: return null
+ if (distanceMeters !in 0..MAX_DISTANCE_METERS) return null
+ return (record(0x43F01018, distanceMeters) + record(0x43F01010, turn) +
+ record(0x43F01030, turn)).toHex() + (road?.let { "," + streetName(it) } ?: "")
+ }
+ /** Exact non-RCS encodeReqSplitString format from the inspected IVI HAL.
+ * The HAL caps this feature at 96 bytes. Each record carries seven content bytes,
+ * a sequence byte, and the original feature incremented by 0x1000.
+ */
+ fun streetName(road: String): String {
+ val text = buildString {
+ var index = 0
+ while (index < road.length && length < 48) {
+ val char = road[index++]
+ when {
+ char.isHighSurrogate() -> {
+ if (index < road.length && road[index].isLowSurrogate()) {
+ if (length > 46) break
+ append(char).append(road[index++])
+ }
+ }
+ char.isLowSurrogate() -> Unit
+ char.isISOControl() || char.isWhitespace() -> append(' ')
+ else -> append(char)
+ }
+ }
+ }.trim().ifEmpty { " " } // setString rejects zero bytes; a space replaces stale text.
+ val bytes = text.toByteArray(Charsets.UTF_16LE)
+ val contentSize = bytes.size + 2
+ val content = ByteArray(((contentSize + 6) / 7) * 7) { 0xFF.toByte() }
+ content[0] = bytes.size.toByte()
+ bytes.copyInto(content, 1)
+ content[bytes.size + 1] = bytes.sumOf { it.toInt() and 255 }.toByte()
+ val packet = ByteArray(content.size / 7 * 13)
+ for (index in 0 until content.size / 7) {
+ val feature = 0x43FA1008 + (index shl 12)
+ val offset = index * 13
+ packet[offset] = (feature ushr 24).toByte()
+ packet[offset + 1] = (feature ushr 16).toByte()
+ packet[offset + 2] = (feature ushr 8).toByte()
+ packet[offset + 3] = feature.toByte()
+ packet[offset + 4] = 8
+ packet[offset + 5] = (feature ushr 12).toByte()
+ content.copyInto(packet, offset + 6, index * 7, (index + 1) * 7)
+ }
+ return packet.toHex()
+ }
+ private fun ByteArray.toHex(): String = joinToString(",") { "%02X".format(it.toInt() and 255) }
+
+ private fun record(feature: Int, value: Int): ByteArray = byteArrayOf(
+ (feature ushr 24).toByte(), (feature ushr 16).toByte(),
+ (feature ushr 8).toByte(), feature.toByte(), 4,
+ (value ushr 24).toByte(), (value ushr 16).toByte(),
+ (value ushr 8).toByte(), value.toByte(),
+ )
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneSession.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneSession.kt
new file mode 100644
index 0000000..992a61e
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStandaloneSession.kt
@@ -0,0 +1,51 @@
+package com.shilapi.xcertplay.hud
+
+/** Single-worker lifecycle; all vehicle writes and persistence stay off the phone thread. */
+internal class BydStandaloneSession(
+ private val send: (String) -> Unit,
+ private val rememberPendingClear: (Boolean) -> Unit,
+ needsRecovery: Boolean = false,
+ private val nanoTime: () -> Long = System::nanoTime,
+) {
+ private var showing = needsRecovery
+ private var recovering = needsRecovery
+ private var lastPacket: String? = null
+ private var lastSendNs = 0L
+
+ fun update(icon: Int, exit: Int, distanceMeters: Int, road: String? = null) {
+ if (recovering) clear()
+ val packet = BydStandalonePackets.guidance(icon, exit, distanceMeters, road)
+ if (packet == null) { clear(); return }
+ try {
+ if (!showing) {
+ // Commit before publishing a start, so the next app launch can recover a crash.
+ rememberPendingClear(true)
+ showing = true
+ send(BydStandalonePackets.start())
+ }
+ val now = nanoTime()
+ if (lastPacket != packet || now - lastSendNs >= 1_000_000_000L) {
+ send(packet)
+ lastPacket = packet
+ lastSendNs = now
+ }
+ } catch (error: Exception) {
+ // A failed update may follow a successful start; clear before trying again.
+ recovering = showing
+ runCatching { clear() }
+ throw error
+ }
+ }
+
+ fun clear() {
+ if (!showing) return
+ recovering = true
+ send(BydStandalonePackets.clear())
+ // sendBroadcast returning is dispatch success, not hardware acknowledgement.
+ rememberPendingClear(false)
+ showing = false
+ recovering = false
+ lastPacket = null
+ lastSendNs = 0L
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/BydStarterBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStarterBridge.kt
new file mode 100644
index 0000000..be610d4
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/BydStarterBridge.kt
@@ -0,0 +1,120 @@
+package com.shilapi.xcertplay.hud
+
+import android.content.Context
+import java.net.Socket
+import java.net.InetSocketAddress
+import android.os.SystemClock
+import android.util.Log
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/** Test-only client of the user's explicitly ADB-started, navigation-only local bridge. */
+object BydStarterBridge {
+ private const val TAG = "BYD-Starter-Bridge"
+ private const val MAGIC = 0x42594431
+ private data class Frame(val turn: Int, val distance: Int, val receivedMs: Long)
+ @Volatile private var latest: Frame? = null
+ @Volatile private var demoStartMs: Long? = null
+ @Volatile private var token: ByteArray? = null
+ private var started = false
+ private var socket: Socket? = null
+ private var output: DataOutputStream? = null
+ private var input: DataInputStream? = null
+ private var retryAfterMs = 0L
+ private var lastNotice: String? = null
+
+ @Synchronized fun initialize(context: Context) {
+ if (context.packageName !in setOf("com.andrerinas.headunitrevived.bydhudtest", "com.shihab.diplay.hudtest") || started) return
+ val saved = context.getSharedPreferences("hud_starter", Context.MODE_PRIVATE).getString("token", null)
+ token = decodeToken(saved)
+ started = true
+ Executors.newSingleThreadScheduledExecutor { task ->
+ Thread(task, "byd-starter-client").apply { isDaemon = true }
+ }.scheduleWithFixedDelay(::tick, 0, 500, TimeUnit.MILLISECONDS)
+ }
+
+ @Synchronized fun configure(context: Context, secret: String) {
+ check(context.packageName in setOf("com.andrerinas.headunitrevived.bydhudtest", "com.shihab.diplay.hudtest"))
+ val parsed = requireNotNull(decodeToken(secret)) { "Invalid starter token" }
+ context.getSharedPreferences("hud_starter", Context.MODE_PRIVATE).edit().putString("token", secret).apply()
+ initialize(context)
+ token = parsed
+ Log.i(TAG, "Temporary starter credentials received")
+ }
+
+ private fun decodeToken(secret: String?): ByteArray? = secret?.takeIf { it.matches(Regex("[0-9a-f]{64}")) }
+ ?.let { value -> ByteArray(32) { value.substring(it * 2, it * 2 + 2).toInt(16).toByte() } }
+
+ fun update(icon: Int, exit: Int, distance: Int) {
+ val turn = BydFactoryTurnCode.map(icon, exit)
+ latest = if (turn != null && distance in 0..16777214) Frame(turn, distance, SystemClock.elapsedRealtime()) else null
+ }
+
+ fun clear(cancelDemo: Boolean = true) { latest = null; if (cancelDemo) demoStartMs = null }
+
+ /** Called only by the DUMP-protected debug receiver after the parked-demo request. */
+ fun demonstrate(context: Context) {
+ initialize(context)
+ demoStartMs = SystemClock.elapsedRealtime()
+ Log.i(TAG, "Parked demo requested: left 500m, right 800m, then clear")
+ }
+
+ private fun tick() {
+ try {
+ val now = SystemClock.elapsedRealtime()
+ val demo = demoStartMs
+ val frame = if (demo != null) {
+ when {
+ now - demo < 8_000 -> Frame(1, 500, now)
+ now - demo < 16_000 -> Frame(2, 800, now)
+ now - demo < 20_000 -> null // Explicitly show cleanup before resuming phone guidance.
+ else -> { demoStartMs = null; null }
+ }
+ } else latest?.takeIf { now - it.receivedMs < 6_000 }
+ if (frame == null) {
+ if (socket != null) {
+ send(0, 0, 0)
+ notice("Guidance cleared")
+ close()
+ }
+ return
+ }
+ if (socket == null) {
+ if (now < retryAfterMs) return
+ val secret = token ?: return
+ val next = Socket()
+ try {
+ next.connect(InetSocketAddress("127.0.0.1", 42671), 1_000)
+ next.soTimeout = 1_500
+ output = DataOutputStream(next.outputStream).also { it.write(secret); it.flush() }
+ input = DataInputStream(next.inputStream)
+ socket = next
+ } catch (error: Exception) { next.close(); throw error }
+ }
+ send(1, frame.turn, frame.distance)
+ notice("Connected to ADB starter; navigation writes acknowledged")
+ } catch (error: Exception) {
+ close() // EOF makes the helper clear any guidance it owns.
+ retryAfterMs = SystemClock.elapsedRealtime() + 3_000
+ notice("Waiting for ADB starter (${error.javaClass.simpleName}: ${error.message})")
+ }
+ }
+
+ private fun send(operation: Int, turn: Int, distance: Int) {
+ val out = checkNotNull(output)
+ out.writeInt(MAGIC); out.writeInt(operation); out.writeInt(turn); out.writeInt(distance); out.flush()
+ val result = checkNotNull(input).readInt()
+ check(result == 0) { "Starter rejected command: $result" }
+ }
+
+ private fun close() {
+ try { socket?.close() } catch (_: Exception) { }
+ socket = null; input = null; output = null
+ }
+
+ private fun notice(message: String) {
+ if (message != lastNotice) { Log.i(TAG, message); lastNotice = message }
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/hud/NavigationOutputWorker.kt b/shared/src/main/java/com/shilapi/xcertplay/hud/NavigationOutputWorker.kt
new file mode 100644
index 0000000..917b8a0
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/hud/NavigationOutputWorker.kt
@@ -0,0 +1,36 @@
+package com.shilapi.xcertplay.hud
+
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+
+/** Each output owns a worker: a blocked vendor service cannot block the phone or the other output. */
+internal class NavigationOutputWorker(name: String, private val clearOutput: () -> Unit) {
+ private val executor = ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
+ ArrayBlockingQueue(128), { task -> Thread(task, name).apply { isDaemon = true } })
+ private var accepting = false
+
+ @Synchronized fun start(initialize: () -> Unit) {
+ if (accepting) return
+ accepting = true
+ executor.queue.clear()
+ executor.execute { runCatching { clearOutput(); initialize() } }
+ }
+
+ @Synchronized fun submit(action: () -> Unit) {
+ if (!accepting) return
+ try {
+ executor.execute { runCatching(action) }
+ } catch (_: java.util.concurrent.RejectedExecutionException) {
+ // Incremental protocol frames cannot safely be dropped individually. Stop this output
+ // until the next session, and clear after any in-flight vendor call returns.
+ clear()
+ }
+ }
+
+ @Synchronized fun clear() {
+ accepting = false
+ executor.queue.clear()
+ executor.execute { runCatching(clearOutput) }
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt
index 5498871..bd8dba2 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt
@@ -21,6 +21,9 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.TimeUnit
/**
* Android rendering backend for the CarPlay media engine. Video frames are
@@ -35,6 +38,7 @@ class AndroidMediaSink(
private val advancedAudioChannelMapping: Boolean = false,
onScreenStreamActiveChanged: ((Int, Boolean) -> Unit)? = null,
private val mediaBufferMillis: Int = MediaAudioBuffer.DEFAULT_MILLIS,
+ private val onAudioDiagnostic: (String) -> Unit = {},
) : MediaSink {
private val screenStateLock = Any()
private val activeScreenTypes = mutableSetOf()
@@ -168,7 +172,7 @@ class AndroidMediaSink(
val existing = audioRenderers[type]
if (existing?.format == format) return existing
existing?.close()
- return AudioRenderer(format, advancedAudioChannelMapping, mediaBufferMillis).also { audioRenderers[type] = it }
+ return AudioRenderer(format, advancedAudioChannelMapping, mediaBufferMillis, onAudioDiagnostic).also { audioRenderers[type] = it }
}
}
@@ -230,7 +234,7 @@ private class VideoDecoder(
null -> Unit
}
decoder?.let(::drainOutput)
- stats.logIfDue()
+ stats.logIfDue()?.let(report)
if (referenceChain.needsKeyFrame && lastConfig != null && outputSurface != null) requestKeyFrameIfDue()
} catch (error: Exception) {
if (running) Log.e(TAG, "video decoder job failed: ${job?.javaClass?.simpleName}", error)
@@ -392,9 +396,8 @@ private class VideoDecoder(
Log.w(TAG, "Video recovery: $reason; waiting for keyframe")
stats.onRecovery()
report("recovery: $reason; waiting for keyframe")
- // Flushing keeps the configured codec; recreating it at 2K+ adds a visible stall on every Wi-Fi burst.
- val codec = decoder
- if (codec == null || runCatching { codec.flush() }.isFailure) releaseDecoder()
+ // Recreate with codec-specific data: flush can discard CSD before the first output.
+ releaseDecoder()
referenceChain.reset()
requestKeyFrameIfDue()
}
@@ -492,6 +495,7 @@ private class AudioRenderer(
val format: AudioFormat,
private val advancedAudioChannelMapping: Boolean,
private val mediaBufferMillis: Int,
+ private val report: (String) -> Unit,
) : Closeable {
private data class AudioPacket(val rtp: ByteArray, val sample: Int)
@@ -513,11 +517,18 @@ private class AudioRenderer(
private var inputDropped = 0
private var outputBuffers = 0
private var firstPcmLogged = false
- @Volatile private var packetsReceived = 0
- @Volatile private var packetsDropped = 0
+ private val packetsReceived = AtomicInteger()
+ private val packetsDropped = AtomicInteger()
+ private val lastArrivalNs = AtomicLong()
+ private val maxArrivalGapMs = AtomicLong()
+ private var maxWriteMs = 0L
private var statsWindowStartNs = 0L
private var statsLastUnderruns = 0
private var bytesPerSecond = 0
+ private val bufferProgress = AudioBufferProgress(if (format.channels >= 2) 4 else 2)
+ private var underrunsAtPlaybackStart = 0
+ private var lastPcmWriteNs = 0L
+ private var rebufferCount = 0
private val thread = Thread(::run, "carplay-audio").apply { isDaemon = true }
fun start() {
@@ -527,12 +538,18 @@ private class AudioRenderer(
}
fun submit(rtp: ByteArray, sample: Int) {
- if (started) packetsReceived++
+ if (started) {
+ packetsReceived.incrementAndGet()
+ val now = System.nanoTime()
+ val previous = lastArrivalNs.getAndSet(now)
+ if (previous != 0L) maxArrivalGapMs.accumulateAndGet((now - previous) / 1_000_000L, ::maxOf)
+ }
if (!started || !queue.offer(AudioPacket(rtp, sample))) {
- if (started) packetsDropped++
+ if (started) packetsDropped.incrementAndGet()
if (started && !droppedPacketsLogged) {
droppedPacketsLogged = true
Log.w(TAG, "audio queue full; dropping newest packets to bound latency")
+ report("Audio: queue full audioType=${format.audioType}")
}
}
}
@@ -551,14 +568,22 @@ private class AudioRenderer(
}
createTrack()
while (running) {
- handle(queue.take())
+ queue.poll(AUDIO_POLL_MILLIS, TimeUnit.MILLISECONDS)?.let(::handle)
+ // Output becomes ready asynchronously, including after the last packet of a burst.
+ // Waiting for the next UDP packet can strand decoded sound for hundreds of ms.
+ codec?.let(::drainCodec)
+ maintainPlaybackBuffer()
logStatsIfDue()
}
} catch (_: InterruptedException) {
// Worker shut down.
} catch (error: Exception) {
- if (running) Log.e(TAG, "audio renderer worker failed", error)
+ if (running) {
+ Log.e(TAG, "audio renderer worker failed", error)
+ report("Audio: renderer failed audioType=${format.audioType} error=${error.javaClass.simpleName}")
+ }
} finally {
+ runCatching { logStatsIfDue(force = true) }
release()
}
}
@@ -624,6 +649,9 @@ private class AudioRenderer(
track = built
val capacityBytes = built.bufferSizeInFrames * frameBytes
startThresholdBytes = MediaAudioBuffer.startBytesFor(plan.startBytes, capacityBytes, PREBUFFER_WRITE_CHUNK_BYTES)
+ report("Audio: ready audioType=${format.audioType} codec=${format.codec} " +
+ "rate=${format.sampleRate} channels=${format.channels} " +
+ "bufferMs=${capacityBytes * 1000L / bytesPerSecond} startMs=${startThresholdBytes * 1000L / bytesPerSecond}")
Log.i(
TAG,
"audio track prepared type=${format.payloadType} audioType=${format.audioType} " +
@@ -845,36 +873,63 @@ private class AudioRenderer(
} else {
minOf(length - written, PREBUFFER_WRITE_CHUNK_BYTES)
}
+ val writeStarted = System.nanoTime()
val count = track.write(data, offset + written, writeLength, AudioTrack.WRITE_BLOCKING)
+ maxWriteMs = maxOf(maxWriteMs, (System.nanoTime() - writeStarted) / 1_000_000L)
if (count <= 0) break
written += count
+ bufferProgress.written(count)
+ lastPcmWriteNs = System.nanoTime()
if (!playbackStarted) {
prebufferBytes += count
if (prebufferBytes >= startThresholdBytes) {
- track.play()
- playbackStarted = true
+ startPlayback(track)
Log.i(TAG, "audio playback started type=${format.payloadType}")
}
}
}
}
- // One line per 5 s while audio flows, so a drive log shows whether music gaps come from the network.
- private fun logStatsIfDue() {
+ private fun startPlayback(track: AudioTrack) {
+ underrunsAtPlaybackStart = track.underrunCount
+ track.play()
+ playbackStarted = true
+ }
+
+ private fun maintainPlaybackBuffer() {
+ val track = track ?: return
+ if (bufferProgress.shouldRebuffer(format.audioType, playbackStarted,
+ track.underrunCount > underrunsAtPlaybackStart, queue.isEmpty(), track.playbackHeadPosition)) {
+ // The hardware buffer has actually drained. Pause without flushing or discarding PCM,
+ // then use the configured start threshold again when music resumes.
+ track.pause()
+ playbackStarted = false
+ prebufferBytes = 0
+ rebufferCount++
+ }
+ // A short final burst may never reach the start threshold. Play it after a bounded wait.
+ if (!playbackStarted && prebufferBytes > 0 && queue.isEmpty() &&
+ System.nanoTime() - lastPcmWriteNs >= BUFFER_TAIL_WAIT_NS) {
+ startPlayback(track)
+ }
+ }
+
+ // Persist counters even during packet starvation, and flush before disconnect releases the track.
+ private fun logStatsIfDue(force: Boolean = false) {
val now = System.nanoTime()
if (statsWindowStartNs == 0L) statsWindowStartNs = now
- if (now - statsWindowStartNs < STATS_WINDOW_NS) return
- val track = track
+ if (!force && now - statsWindowStartNs < STATS_WINDOW_NS) return
val underruns = track?.underrunCount ?: 0
- Log.i(
- STATS_TAG,
- "audio stats audioType=${format.audioType} codec=${format.codec} rx=$packetsReceived " +
- "dropped=$packetsDropped underruns=+${underruns - statsLastUnderruns} queue=${queue.size} " +
- "playing=$playbackStarted",
- )
+ val lastRx = lastArrivalNs.get()
+ val line = "audio stats audioType=${format.audioType} codec=${format.codec} rx=${packetsReceived.getAndSet(0)} " +
+ "dropped=${packetsDropped.getAndSet(0)} underruns=+${underruns - statsLastUnderruns} queue=${queue.size} " +
+ "playing=$playbackStarted maxGapMs=${maxArrivalGapMs.getAndSet(0)} " +
+ "sinceRxMs=${if (lastRx == 0L) -1 else (now - lastRx) / 1_000_000L} maxWriteMs=$maxWriteMs " +
+ "decoderDroppedTotal=$inputDropped outputBuffersTotal=$outputBuffers rebuffers=$rebufferCount ended=$force"
+ Log.i(STATS_TAG, line)
+ report(line)
statsLastUnderruns = underruns
- packetsReceived = 0
- packetsDropped = 0
+ maxWriteMs = 0L
statsWindowStartNs = now
}
@@ -943,6 +998,8 @@ private class AudioRenderer(
const val OPUS_CODEC_DELAY_NANOS = 6_500_000L
const val OPUS_SEEK_PRE_ROLL_NANOS = 80_000_000L
const val INPUT_TIMEOUT_US = 10_000L
+ const val AUDIO_POLL_MILLIS = 10L
+ const val BUFFER_TAIL_WAIT_NS = 500_000_000L
// Holds a burst after a Wi-Fi gap (~4 s of AAC) instead of dropping it.
const val MAX_QUEUED_PACKETS = 192
const val PREBUFFER_WRITE_CHUNK_BYTES = 2 * 1024
diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AudioBufferProgress.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AudioBufferProgress.kt
new file mode 100644
index 0000000..1757002
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/media/AudioBufferProgress.kt
@@ -0,0 +1,21 @@
+package com.shilapi.xcertplay.media
+
+/** Tracks the unsigned AudioTrack head across wrap without flushing already queued sound. */
+internal class AudioBufferProgress(private val frameBytes: Int) {
+ private var writtenBytes = 0L
+ private var playedFrames = 0L
+ private var lastHead = 0L
+
+ fun written(bytes: Int) { writtenBytes += bytes }
+
+ fun queuedBytes(rawHead: Int): Long {
+ val head = rawHead.toLong() and 0xffff_ffffL
+ playedFrames += (head - lastHead) and 0xffff_ffffL
+ lastHead = head
+ return (writtenBytes - playedFrames * frameBytes).coerceAtLeast(0)
+ }
+
+ fun shouldRebuffer(audioType: String, playing: Boolean, underrunSinceStart: Boolean,
+ compressedQueueEmpty: Boolean, rawHead: Int): Boolean =
+ audioType == "media" && playing && underrunSinceStart && compressedQueueEmpty && queuedBytes(rawHead) == 0L
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt
index 1831b2a..1c32c5e 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt
@@ -37,6 +37,9 @@ data class CarPlayBonjourEndpoint(
)
sealed interface CarPlayBonjourEvent {
+ data class Discovery(val stage: Stage, val ipv4Count: Int = 0, val ipv6Count: Int = 0) : CarPlayBonjourEvent {
+ enum class Stage { ADDED, NO_MATCHING_ADDRESS, INVALID_PORT }
+ }
data class Resolved(val endpoint: CarPlayBonjourEndpoint) : CarPlayBonjourEvent
data class Probed(
@@ -47,6 +50,17 @@ sealed interface CarPlayBonjourEvent {
) : CarPlayBonjourEvent
}
+/** Saved reports need discovery outcomes without phone names, addresses, or pairing identifiers. */
+fun CarPlayBonjourEvent.diagnosticSummary(): String = when (this) {
+ is CarPlayBonjourEvent.Discovery -> "control discovery stage=$stage ipv4=$ipv4Count ipv6=$ipv6Count"
+ is CarPlayBonjourEvent.Resolved ->
+ "control resolved family=${if (':' in endpoint.host) "IPv6" else "IPv4"} port=${endpoint.port}"
+ is CarPlayBonjourEvent.Probed -> {
+ val status = statusLine?.let { Regex("^HTTP/\\d(?:\\.\\d)? (\\d{3})(?: |$)").find(it)?.groupValues?.get(1) }
+ "control probe attempts=$attempts status=${status ?: "none"} error=${error?.javaClass?.simpleName ?: "none"}"
+ }
+}
+
/** Pure protocol values shared by the Android runtime and JVM tests. */
object CarPlayBonjourProtocol {
fun airPlayTxtRecords(
@@ -116,6 +130,7 @@ class CarPlayBonjour(
.getSystemService(Context.NSD_SERVICE) as NsdManager
private val services = LinkedBlockingQueue()
private val interfaceServices = LinkedBlockingQueue>()
+ private val discoveryEvents = LinkedBlockingQueue(32)
private val seenServices = ConcurrentHashMap.newKeySet()
private val lifecycleLock = Any()
private val localAdvertisedAddress = advertisedHostAddress()
@@ -136,7 +151,10 @@ class CarPlayBonjour(
private val interfaceListener = object : ServiceListener {
override fun serviceAdded(event: ServiceEvent) {
- if (!closed) event.dns.requestServiceInfo(event.type, event.name, true)
+ if (!closed) {
+ discoveryEvents.offer(CarPlayBonjourEvent.Discovery(CarPlayBonjourEvent.Discovery.Stage.ADDED))
+ event.dns.requestServiceInfo(event.type, event.name, true)
+ }
}
override fun serviceRemoved(event: ServiceEvent) {
@@ -149,8 +167,16 @@ class CarPlayBonjour(
// Keep the HTTP probe in the same address family as its bound source.
val address = info.inetAddresses.firstOrNull {
(it is Inet4Address) == (localAdvertisedAddress is Inet4Address)
- }?.let(::applyLocalScope) ?: return
- if (info.port !in 1..65535 || !seenServices.add(event.name)) return
+ }?.let(::applyLocalScope)
+ if (address == null || info.port !in 1..65535) {
+ discoveryEvents.offer(CarPlayBonjourEvent.Discovery(
+ if (address == null) CarPlayBonjourEvent.Discovery.Stage.NO_MATCHING_ADDRESS
+ else CarPlayBonjourEvent.Discovery.Stage.INVALID_PORT,
+ info.inetAddresses.count { it is Inet4Address }, info.inetAddresses.count { it is Inet6Address },
+ ))
+ return
+ }
+ if (!seenServices.add(event.name)) return
val endpoint = CarPlayBonjourEndpoint(
event.name, address.hostAddress ?: return, info.port,
info.getPropertyString("id"),
@@ -273,6 +299,7 @@ class CarPlayBonjour(
activeSocket = null
services.clear()
interfaceServices.clear()
+ discoveryEvents.clear()
dnsToClose = interfaceMdns
interfaceMdns = null
workerToJoin = worker
@@ -322,6 +349,7 @@ class CarPlayBonjour(
while (!closed) {
if (useInterfaceMdns) {
try {
+ while (true) emit(discoveryEvents.poll() ?: break)
val (endpoint, address) = interfaceServices.poll(
WORKER_POLL_MILLIS, TimeUnit.MILLISECONDS,
) ?: continue
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt
index cc108fc..63b87f7 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt
@@ -18,7 +18,6 @@ import java.net.Inet6Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.SocketException
-import java.net.UnknownHostException
import java.util.Collections
import java.util.concurrent.TimeUnit
@@ -37,6 +36,7 @@ class ManualHotspotManager(
band: ManualHotspotBand,
channel: Int,
security: ManualHotspotSecurity,
+ private val onDiagnostic: (String) -> Unit = {},
) : WirelessHotspotManager {
private val appContext = context.applicationContext
private val connectivityManager =
@@ -104,6 +104,10 @@ class ManualHotspotManager(
else -> null
}
val security = apConfiguration?.security ?: expectedSecurity
+ onDiagnostic("Manual hotspot configReadable=${apConfiguration != null} " +
+ "security=$security channelKnown=${channel > 0} " +
+ "hardwareAddressKnown=${localInterface.hardwareAddress != null} iface=${localInterface.name} " +
+ "family=${if (localInterface.hostAddress is Inet6Address) "IPv6" else "IPv4"}")
if (security != Iap2WirelessSecurity.NONE && passphrase.isEmpty()) {
throw IOException("Manual hotspot is secured but no passphrase was provided")
}
@@ -248,30 +252,8 @@ class ManualHotspotManager(
return score
}
- private fun NetworkInterface.hotspotAddress(): InetAddress? {
- var ipv6: Inet6Address? = null
- for (address in Collections.list(inetAddresses)) {
- if (address is Inet4Address && !address.isLoopbackAddress &&
- !address.isLinkLocalAddress
- ) {
- return address
- }
- if (address is Inet6Address && address.isLinkLocalAddress) {
- if (ipv6 == null) {
- ipv6 = if (address.scopeId == index) {
- address
- } else {
- try {
- Inet6Address.getByAddress(null, address.address, this)
- } catch (_: UnknownHostException) {
- null
- }
- }
- }
- }
- }
- return ipv6
- }
+ private fun NetworkInterface.hotspotAddress(): InetAddress? =
+ wirelessHostAddress(Collections.list(inetAddresses), index)
private fun frequencyFromConnectionInfo(): Int? {
val connectionInfo = try {
@@ -353,7 +335,7 @@ class ManualHotspotManager(
0
}
val band = try {
- WifiConfiguration::class.java.getField("apBand").getInt(configuration)
+ legacyHotspotBandToSoftApBand(WifiConfiguration::class.java.getField("apBand").getInt(configuration))
} catch (_: ReflectiveOperationException) {
null
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHostAddress.kt b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHostAddress.kt
new file mode 100644
index 0000000..262b652
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHostAddress.kt
@@ -0,0 +1,18 @@
+package com.shilapi.xcertplay.network
+
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+
+/** Use the same scoped, link-local path for manual APs as for Wi-Fi Direct. */
+internal fun wirelessHostAddress(addresses: List, interfaceIndex: Int): InetAddress? {
+ if (interfaceIndex > 0) {
+ addresses.filterIsInstance().firstOrNull { it.isLinkLocalAddress }?.let {
+ return Inet6Address.getByAddress(null, it.address, interfaceIndex)
+ }
+ }
+ return addresses.firstOrNull {
+ it is Inet4Address && !it.isLoopbackAddress && !it.isLinkLocalAddress &&
+ !it.isAnyLocalAddress && !it.isMulticastAddress
+ }
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt
index 1a75aea..c29d3fc 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt
@@ -65,7 +65,7 @@ internal fun wifiChannelToFrequencyMhz(channel: Int, band: Int? = null): Int? =
band == 1 && channel in 1..13 -> 2407 + channel * 5
band == 1 && channel == 14 -> 2484
band == 2 && channel in 32..177 -> 5000 + channel * 5
- band == 3 && channel in 1..233 -> 5950 + channel * 5
+ band == 4 && channel in 1..233 -> 5950 + channel * 5
else -> null
}
@@ -73,7 +73,16 @@ internal fun wifiChannelToFrequencyMhz(channel: Int, band: Int? = null): Int? =
internal fun wifiBandLabel(band: Int?): String? = when (band) {
1 -> "2.4 GHz"
2 -> "5 GHz"
- 3 -> "6 GHz"
+ 3 -> "2.4 / 5 GHz (auto)"
+ 4 -> "6 GHz"
+ else -> null
+}
+
+/** WifiConfiguration uses 0/1/-1; SoftApConfiguration uses a 1/2/4 bit mask. */
+internal fun legacyHotspotBandToSoftApBand(band: Int?): Int? = when (band) {
+ 0 -> 1
+ 1 -> 2
+ -1 -> 3
else -> null
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
index 4e475c0..b31d798 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
@@ -27,14 +27,14 @@ import com.shilapi.xcertplay.airplay.AirPlayMediaHandler
import com.shilapi.xcertplay.airplay.AirPlaySession
import com.shilapi.xcertplay.airplay.AirPlaySessionListener
import com.shilapi.xcertplay.airplay.PairingStore
-import com.shilapi.xcertplay.hud.BydClusterBridge
-import com.shilapi.xcertplay.hud.BydHudBridge
+import com.shilapi.xcertplay.hud.BydNavigationOutputs
import com.shilapi.xcertplay.iap2.session.Iap2Session
import com.shilapi.xcertplay.mfi.Iap2MfiAuthenticationClient
import com.shilapi.xcertplay.mfi.MfiAuthenticationClient
import com.shilapi.xcertplay.mfi.RemoteMfiAuthenticationClient
import com.shilapi.xcertplay.mfi.LocalMfiAuthenticationClient
import com.shilapi.xcertplay.network.CarPlayBonjour
+import com.shilapi.xcertplay.network.diagnosticSummary
import com.shilapi.xcertplay.network.CarPlayVpnService
import com.shilapi.xcertplay.network.LocalOnlyHotspotManager
import com.shilapi.xcertplay.network.ManualHotspotManager
@@ -148,8 +148,7 @@ class CarPlayController(
require(!config.locationReportingEnabled || locationProvider != null) {
"A location provider is required when location reporting is enabled"
}
- BydHudBridge.initialize(context.applicationContext)
- BydClusterBridge.initialize(context.applicationContext)
+ BydNavigationOutputs.start(context.applicationContext)
}
private enum class Phase { IDLE, MFI, WIRELESS, IPHONE, REENUMERATION, DATAPATHS, CONTROL }
@@ -225,6 +224,7 @@ class CarPlayController(
private val sessionListener = object : AirPlaySessionListener {
override fun onSessionActive(session: AirPlaySession) {
+ if (activeSession !== session) BydNavigationOutputs.start(appContext)
activeSession = session
debugLog(
"AirPlay session active controller=${session.controllerId ?: "unknown"} " +
@@ -236,8 +236,7 @@ class CarPlayController(
override fun onSessionEnded(session: AirPlaySession) {
if (activeSession === session) {
activeSession = null
- BydHudBridge.clear()
- BydClusterBridge.clear()
+ BydNavigationOutputs.endNow()
}
debugLog("AirPlay session ended peer=${session.host}")
uiListener?.onSessionEnded(session)
@@ -358,6 +357,7 @@ class CarPlayController(
if (closed) return
closed = true
}
+ BydNavigationOutputs.endNow()
closeReceivers()
availabilityPollGeneration.incrementAndGet()
wirelessGeneration.incrementAndGet()
@@ -415,8 +415,7 @@ class CarPlayController(
// HUD (SOME/IP) and cluster (AMap broadcast) keep separate state so one failing cannot stall the other.
private fun onRouteFrame(frame: com.shilapi.xcertplay.iap2.wire.Iap2Frame) {
- runCatching { BydHudBridge.onFrame(frame) }.onFailure { debugLog("BYD HUD frame failed: $it") }
- runCatching { BydClusterBridge.onFrame(frame) }.onFailure { debugLog("BYD cluster frame failed: $it") }
+ BydNavigationOutputs.onFrame(frame)
}
private fun startMfi() {
@@ -820,6 +819,8 @@ class CarPlayController(
debugLog(
"wireless hotspot backend=${hotspotInfo.backend.label} " +
"iface=${hotspotInfo.interfaceName ?: "unknown"} " +
+ "family=${if (hostAddress is Inet6Address) "IPv6" else "IPv4"} " +
+ "identitySource=${if (deviceIdentifier == hotspotInfo.bssid) "interface" else "saved"} " +
"host=$hostAddressText " +
"band=${hotspotInfo.bandLabel} channel=${hotspotInfo.channel} " +
"frequency=${hotspotInfo.frequencyMHz?.toString() ?: "unknown"}MHz",
@@ -884,12 +885,15 @@ class CarPlayController(
config = wirelessAirPlayConfig,
identity = identity,
advertisedHost = hostAddress.hostAddress,
- useInterfaceMdns = hotspotInfo.backend == WirelessHotspotBackend.WIFI_P2P,
- onEvent = { event -> debugLog("wireless bonjour: $event") },
+ // Bind discovery and its connect probe to the same AP/address family as AirPlay.
+ // The car hotspot previously used system NSD, which could resolve another interface
+ // or IPv6 while the listener/probe was bound to the AP's IPv4 address.
+ useInterfaceMdns = true,
+ onEvent = { event -> debugLog("wireless bonjour: ${event.diagnosticSummary()}") },
)
bonjour = bonjourClient
bonjourClient.start()
- debugLog("wireless Bonjour services started")
+ debugLog("wireless Bonjour services started mode=interface iface=${hotspotInfo.interfaceName ?: "unknown"}")
if (isStaleWirelessRun(generation)) {
closeWirelessStack()
return
@@ -1553,6 +1557,7 @@ class CarPlayController(
band = config.manualHotspotBand,
channel = config.manualHotspotChannel,
security = config.manualHotspotSecurity,
+ onDiagnostic = ::debugLog,
)
}
hotspot = manager
diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt
index 2dc3d71..80bd55e 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt
@@ -112,12 +112,13 @@ class NcmUsbBridge internal constructor(
override fun close() {
statusRunning.set(false)
- synchronized(stateLock) {
+ val requestToClose = synchronized(stateLock) {
if (closed) return
closed = true
+ readRequest
}
// Wakes a reader blocked in requestWait(); it then observes the closed state.
- runCatching { readRequest?.cancel() }
+ runCatching { requestToClose?.cancel() }
statusThread?.let { thread ->
thread.interrupt()
try {
@@ -134,7 +135,7 @@ class NcmUsbBridge internal constructor(
}
}
connection.close()
- runCatching { readRequest?.close() }
+ runCatching { requestToClose?.close() }
}
private fun drainStatus(endpoint: UsbEndpoint) {
@@ -219,22 +220,27 @@ class NcmUsbBridge internal constructor(
private fun readChunk(timeoutMillis: Long): Int? {
checkOpen()
val request = try {
- readRequest ?: UsbRequest().also {
- if (!it.initialize(connection, inEndpoint)) {
- it.close()
- throw failSession("Android could not initialize the NCM read request")
+ // Publish and queue atomically with close(), so detach cannot miss a new request.
+ synchronized(stateLock) {
+ checkOpenLocked()
+ val current = readRequest ?: UsbRequest().also {
+ if (!it.initialize(connection, inEndpoint)) {
+ it.close()
+ throw failSession("Android could not initialize the NCM read request")
+ }
+ readRequest = it
}
- readRequest = it
+ if (!readQueued) {
+ directReadBuffer.clear()
+ if (!current.queue(directReadBuffer)) throw failSession("Android could not queue the NCM read request")
+ readQueued = true
+ }
+ current
}
} catch (error: RuntimeException) {
throw failSession("NCM read failed", error)
}
try {
- if (!readQueued) {
- directReadBuffer.clear()
- if (!request.queue(directReadBuffer)) throw failSession("Android could not queue the NCM read request")
- readQueued = true
- }
val completed = try {
connection.requestWait(timeoutMillis.coerceAtLeast(1))
} catch (_: TimeoutException) {
diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/StreamReceiveStatsTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/StreamReceiveStatsTest.kt
new file mode 100644
index 0000000..5c2271c
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/StreamReceiveStatsTest.kt
@@ -0,0 +1,45 @@
+package com.shilapi.xcertplay.airplay
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class StreamReceiveStatsTest {
+ @Test fun separatesSocketWaitFromLocalProcessingAndTracksSequenceWrap() {
+ var clock = 0L
+ val output = mutableListOf()
+ val stats = StreamReceiveStats("audio", output::add) { clock }
+ fun packet(sequence: Int) {
+ stats.reading()
+ clock += 400_000_000L
+ stats.received(100, sequence)
+ clock += 200_000L
+ stats.processed()
+ }
+ packet(65535)
+ packet(0)
+ packet(3) // two missing, then one late packet arrives
+ packet(2)
+ packet(4)
+ stats.flush(ended = true)
+ assertEquals(1, output.size)
+ assertTrue(output.single().contains("readMaxMs=400 processMaxUs=200 seqForwardGaps=2 lateOrDuplicate=1"))
+ assertTrue(output.single().contains("packets=5 bytes=500"))
+ }
+
+ @Test fun reportResetsWindowButRetainsSequenceContinuity() {
+ var clock = 0L
+ val output = mutableListOf()
+ val stats = StreamReceiveStats("audio", output::add) { clock }
+ stats.reading()
+ clock = 5_000_000_000L
+ stats.received(10, 1)
+ stats.processed()
+ stats.reading()
+ stats.received(20, 3)
+ stats.processed()
+ stats.flush(true)
+ assertEquals(2, output.size)
+ assertTrue(output.last().contains("packets=1 bytes=20 readMaxMs=0"))
+ assertTrue(output.last().contains("seqForwardGaps=1"))
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydClusterFrameTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydClusterFrameTest.kt
index 27d25c8..feeca4b 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/hud/BydClusterFrameTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydClusterFrameTest.kt
@@ -31,6 +31,11 @@ class BydClusterFrameTest {
assertEquals(0, frame(type = 1, distance = -5).distanceMeters)
}
+ @Test fun `unknown maneuver is not advertised as straight`() {
+ for (type in listOf(0, 15, 16, 17, 54, 255)) assertEquals(0, frame(type).icon)
+ for (type in listOf(3, 5, 8, 9, 11, 51)) assertEquals(9, frame(type).icon)
+ }
+
private fun frame(type: Int, distance: Int = 0, drivingSide: Int = 0) =
BydClusterFrame.from(BydAppleManeuver(distance, type, drivingSide))
}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydFactoryTurnCodeTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydFactoryTurnCodeTest.kt
new file mode 100644
index 0000000..49e3f0f
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydFactoryTurnCodeTest.kt
@@ -0,0 +1,27 @@
+package com.shilapi.xcertplay.hud
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class BydFactoryTurnCodeTest {
+ @Test fun factoryUsesDifferentCodesFromBroadcasts() {
+ assertEquals(1, BydFactoryTurnCode.map(2, 0))
+ assertEquals(2, BydFactoryTurnCode.map(3, 0))
+ assertEquals(5, BydFactoryTurnCode.map(5, 0))
+ assertEquals(11, BydFactoryTurnCode.map(9, 0))
+ assertEquals(48, BydFactoryTurnCode.map(15, 0))
+ }
+ @Test fun roundaboutDirectionAndBounds() {
+ assertEquals(25, BydFactoryTurnCode.map(11, 1))
+ assertEquals(34, BydFactoryTurnCode.map(12, 10))
+ assertEquals(35, BydFactoryTurnCode.map(17, 1))
+ assertEquals(44, BydFactoryTurnCode.map(18, 10))
+ assertEquals(13, BydFactoryTurnCode.map(11, 11))
+ assertEquals(14, BydFactoryTurnCode.map(17, 0))
+ }
+ @Test fun invalidInstructionsAreNotMadeIntoStraightArrows() {
+ assertNull(BydFactoryTurnCode.map(-1, 0))
+ assertNull(BydFactoryTurnCode.map(0, 0))
+ assertNull(BydFactoryTurnCode.map(29, 0))
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudPayloadTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudPayloadTest.kt
index fa842fa..33763a7 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudPayloadTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudPayloadTest.kt
@@ -5,20 +5,20 @@ import org.junit.Test
class BydHudPayloadTest {
@Test
- fun guidanceMatchesBydDirectPayloadWithoutGraphics() {
+ fun guidanceMatchesFixedFieldTwoWireFixture() {
assertArrayEquals(
byteArrayOf(
- 0x0a, 0x15, 0x10, 0x00, 0x30, 0x01, 0x3a, 0x00, 0x42, 0x00,
+ 0x0a, 0x15, 0x10, 0x02, 0x30, 0x01, 0x3a, 0x00, 0x42, 0x00,
0x48, 0x35, 0x52, 0x00, 0x80.toByte(), 0x01, 0x02,
0xd2.toByte(), 0x01, 0x00, 0xe0.toByte(), 0x01, 0x01,
),
- BydHudPayload.guidance(distanceMeters = 53, maneuver = 1, sequence = 0),
+ BydHudPayload.guidance(distanceMeters = 53, maneuver = 1),
)
}
@Test
fun shortDistanceIsLiftedAndTextIsCarried() {
- val payload = BydHudPayload.guidance(distanceMeters = 4, maneuver = 11, sequence = 1, road = "A", eta = "14:36")
+ val payload = BydHudPayload.guidance(distanceMeters = 4, maneuver = 11, road = "A", eta = "14:36")
val hex = payload.joinToString("") { "%02x".format(it) }
assert(hex.contains("480b")) { hex } // field 9 = 11 m
assert(hex.contains("520141")) { hex } // field 10 = "A"
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudProtocolTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudProtocolTest.kt
new file mode 100644
index 0000000..9ba6bf7
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudProtocolTest.kt
@@ -0,0 +1,19 @@
+package com.shilapi.xcertplay.hud
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class BydHudProtocolTest {
+ @Test fun onlyStartAcceptsAlreadyStarted() {
+ assertTrue(BydHudProtocol.serviceStarted(0))
+ assertTrue(BydHudProtocol.serviceStarted(13))
+ assertTrue(BydHudProtocol.eventAccepted(0))
+ assertFalse(BydHudProtocol.eventAccepted(13))
+ }
+ @Test fun positiveGatewayErrorsAreNotSuccess() {
+ for (code in listOf(-1, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 15)) {
+ assertFalse("start=$code", BydHudProtocol.serviceStarted(code))
+ assertFalse("event=$code", BydHudProtocol.eventAccepted(code))
+ }
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudRouteStateTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudRouteStateTest.kt
index 75fe38a..bc0e498 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudRouteStateTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydHudRouteStateTest.kt
@@ -125,6 +125,24 @@ class BydHudRouteStateTest {
assertEquals(26, guidance.gaode)
}
+ @Test
+ fun `silent route expires and fresh update restores cached maneuver`() {
+ var now = 0L
+ val state = populatedState { now }
+ now = 30_000_000_000L
+ assertNull(state.current())
+ state.accept(BydHudRouteState.ROUTE_GUIDANCE_UPDATE, tlvs(tlv(0x0a, 0, 0, 0, 20)))
+ assertEquals(20, state.current()!!.distanceMeters)
+ }
+
+ @Test
+ fun `clear prevents a keepalive from restoring guidance`() {
+ val state = populatedState()
+ state.clear()
+ assertNull(state.current())
+ assertNull(state.currentApple())
+ }
+
private fun utf8z(value: String): IntArray =
(value.toByteArray(Charsets.UTF_8).map { it.toInt() and 0xff } + 0).toIntArray()
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneSessionTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneSessionTest.kt
new file mode 100644
index 0000000..d82e991
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneSessionTest.kt
@@ -0,0 +1,119 @@
+package com.shilapi.xcertplay.hud
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class BydStandaloneSessionTest {
+ private class Rig(recovery: Boolean = false) {
+ var now = 0L
+ var failPacket: String? = null
+ var journalFails = false
+ val events = mutableListOf()
+ val session = BydStandaloneSession(
+ send = { packet ->
+ events += packet
+ if (packet == failPacket) throw IllegalStateException("dispatch failed")
+ },
+ rememberPendingClear = { dirty ->
+ if (journalFails) throw IllegalStateException("disk failed")
+ events += "pending=$dirty"
+ },
+ needsRecovery = recovery,
+ nanoTime = { now },
+ )
+ }
+
+ @Test fun startsOnceUpdatesTurnAndClearsOnRouteEnd() {
+ val r = Rig()
+ r.session.update(2, 0, 500)
+ r.session.update(3, 0, 800)
+ r.session.clear()
+ assertEquals(listOf("pending=true", BydStandalonePackets.start(),
+ BydStandalonePackets.guidance(2, 0, 500), BydStandalonePackets.guidance(3, 0, 800),
+ BydStandalonePackets.clear(), "pending=false"), r.events)
+ r.session.clear()
+ assertEquals(6, r.events.size)
+ }
+
+ @Test fun unchangedGuidanceIsRateLimitedButKeptAlive() {
+ val r = Rig()
+ r.session.update(9, 0, 0)
+ repeat(9) { r.now += 100_000_000L; r.session.update(9, 0, 0) }
+ assertEquals(3, r.events.size)
+ r.now = 1_000_000_000L
+ r.session.update(9, 0, 0)
+ assertEquals(4, r.events.size)
+ }
+
+ @Test fun unknownManeuverEndsGuidanceInsteadOfInventingStraight() {
+ val r = Rig()
+ r.session.update(2, 0, 500)
+ r.session.update(0, 0, 500)
+ assertEquals(listOf(BydStandalonePackets.clear(), "pending=false"), r.events.takeLast(2))
+ val count = r.events.size
+ r.session.update(999, 0, 500)
+ assertEquals(count, r.events.size)
+ }
+
+ @Test fun invalidDistanceEndsGuidance() {
+ val r = Rig()
+ r.session.update(2, 0, 500)
+ r.session.update(2, 0, 16777215)
+ assertEquals(BydStandalonePackets.clear(), r.events[r.events.lastIndex - 1])
+ assertNull(BydStandalonePackets.guidance(2, 0, -1))
+ assertNotNull(BydStandalonePackets.guidance(2, 0, 16777214))
+ }
+
+ @Test fun failedGuidanceAfterStartSendsEndAndNextUpdateStartsAgain() {
+ val r = Rig()
+ r.failPacket = BydStandalonePackets.guidance(2, 0, 500)
+ assertThrows(IllegalStateException::class.java) { r.session.update(2, 0, 500) }
+ assertEquals(listOf(BydStandalonePackets.clear(), "pending=false"), r.events.takeLast(2))
+ r.failPacket = null
+ r.events.clear()
+ r.session.update(3, 0, 800)
+ assertEquals(listOf("pending=true", BydStandalonePackets.start()), r.events.take(2))
+ }
+
+ @Test fun failedEndIsRetriedBeforeAnyNewGuidance() {
+ val r = Rig()
+ r.session.update(2, 0, 500)
+ r.failPacket = BydStandalonePackets.clear()
+ assertThrows(IllegalStateException::class.java) { r.session.clear() }
+ r.events.clear()
+ assertThrows(IllegalStateException::class.java) { r.session.update(3, 0, 800) }
+ assertEquals(listOf(BydStandalonePackets.clear()), r.events)
+ r.failPacket = null
+ r.events.clear()
+ r.session.update(3, 0, 800)
+ assertEquals(listOf(BydStandalonePackets.clear(), "pending=false", "pending=true",
+ BydStandalonePackets.start(), BydStandalonePackets.guidance(3, 0, 800)), r.events)
+ }
+
+ @Test fun interruptedSessionClearsBeforeItStartsAnotherRoute() {
+ val r = Rig(recovery = true)
+ r.session.update(2, 0, 500)
+ assertEquals(listOf(BydStandalonePackets.clear(), "pending=false", "pending=true",
+ BydStandalonePackets.start()), r.events.take(4))
+ }
+
+ @Test fun cannotStartIfRecoveryJournalCannotBeSaved() {
+ val r = Rig()
+ r.journalFails = true
+ assertThrows(IllegalStateException::class.java) { r.session.update(2, 0, 500) }
+ assertTrue(r.events.isEmpty())
+ }
+
+ @Test fun fixedWireVectorsAndRoundaboutMapping() {
+ assertEquals("43,E0,00,3A,01,02", BydStandalonePackets.start())
+ assertEquals("43,E0,00,3A,01,01", BydStandalonePackets.clear())
+ assertEquals("43,F0,10,18,04,00,00,01,F4,43,F0,10,10,04,00,00,00,01,43,F0,10,30,04,00,00,00,01",
+ BydStandalonePackets.guidance(2, 0, 500))
+ assertEquals("43,F0,10,18,04,00,00,03,20,43,F0,10,10,04,00,00,00,02,43,F0,10,30,04,00,00,00,02",
+ BydStandalonePackets.guidance(3, 0, 800))
+ val bytes = BydStandalonePackets.guidance(11, 10, 0)!!.split(',')
+ assertEquals(27, bytes.size)
+ assertEquals("22", bytes[17]) // Factory right-roundabout exit 10 = 34.
+ assertEquals("22", bytes[26])
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneStreetTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneStreetTest.kt
new file mode 100644
index 0000000..6b9a458
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/BydStandaloneStreetTest.kt
@@ -0,0 +1,65 @@
+package com.shilapi.xcertplay.hud
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class BydStandaloneStreetTest {
+ private fun content(road: String): ByteArray {
+ val bytes = BydStandalonePackets.streetName(road).split(',').map { it.toInt(16).toByte() }
+ assertTrue(bytes.size <= 182)
+ return bytes.chunked(13).flatMapIndexed { index, record ->
+ assertEquals(13, record.size)
+ assertEquals(0x43, record[0].toInt() and 255)
+ assertEquals(0xFA, record[1].toInt() and 255)
+ assertEquals((index + 1) shl 4, record[2].toInt() and 255)
+ assertEquals(8, record[3].toInt() and 255)
+ assertEquals(8, record[4].toInt() and 255)
+ assertEquals(0xA1 + index, record[5].toInt() and 255)
+ record.drop(6)
+ }.toByteArray()
+ }
+ private fun decoded(road: String): String {
+ val data = content(road)
+ val size = data[0].toInt() and 255
+ val text = data.copyOfRange(1, size + 1)
+ assertEquals(text.sumOf { it.toInt() and 255 } and 255, data[size + 1].toInt() and 255)
+ assertTrue(data.drop(size + 2).all { it == 0xFF.toByte() })
+ return text.toString(Charsets.UTF_16LE)
+ }
+ @Test fun matchesActualArm64EncoderEnglishAndArabicVectors() {
+ assertEquals("43,FA,10,08,08,A1,16,4D,00,75,00,73,00,43,FA,20,08,08,A2,63,00,61,00,74,00,20,43,FA,30,08,08,A3,00,52,00,6F,00,61,00,43,FA,40,08,08,A4,64,00,13,FF,FF,FF,FF", BydStandalonePackets.streetName("Muscat Road"))
+ assertEquals("43,FA,10,08,08,A1,12,34,06,27,06,31,06,43,FA,20,08,08,A2,39,06,20,00,45,06,33,43,FA,30,08,08,A3,06,42,06,37,06,06,FF", BydStandalonePackets.streetName("شارع مسقط"))
+ }
+ @Test fun textIsBoundedWithoutBreakingUnicodePairs() {
+ assertEquals("A".repeat(48), decoded("A".repeat(500)))
+ assertEquals("A".repeat(47), decoded("A".repeat(47) + "🚗End"))
+ assertEquals("A".repeat(46) + "🚗", decoded("A".repeat(46) + "🚗End"))
+ assertEquals("AB", decoded("A\uD800B\uDC00"))
+ }
+ @Test fun blankAndControlsReplacePriorTextWithSafeWhitespace() {
+ assertEquals(" ", decoded(""))
+ assertEquals(" ", decoded("\n\u0000\t"))
+ assertEquals("A B C", decoded("A\u0000B\nC"))
+ }
+ @Test fun everyBoundaryHasCorrectLengthChecksumPaddingAndReceiverBound() {
+ for (length in 1..48) {
+ val road = "R".repeat(length)
+ assertEquals(road, decoded(road))
+ assertTrue(BydStandalonePackets.guidance(2, 0, 500, road)!!.split(',').size <= 252)
+ }
+ }
+ @Test fun changedStreetIsPublishedAndMissingStreetReplacesIt() {
+ val sent = mutableListOf()
+ val session = BydStandaloneSession(sent::add, {}, nanoTime = { 0L })
+ session.update(2, 0, 500, "First Road")
+ session.update(2, 0, 500, "Second Road")
+ assertEquals(BydStandalonePackets.guidance(2, 0, 500, "Second Road"), sent.last())
+ val count = sent.size
+ session.update(2, 0, 500, "Second Road")
+ assertEquals(count, sent.size)
+ session.update(2, 0, 500, "")
+ assertEquals(BydStandalonePackets.guidance(2, 0, 500, ""), sent.last())
+ session.clear()
+ assertEquals(BydStandalonePackets.clear(), sent.last())
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/hud/NavigationOutputWorkerTest.kt b/shared/src/test/java/com/shilapi/xcertplay/hud/NavigationOutputWorkerTest.kt
new file mode 100644
index 0000000..d7382d1
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/hud/NavigationOutputWorkerTest.kt
@@ -0,0 +1,70 @@
+package com.shilapi.xcertplay.hud
+
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import org.junit.Assert.*
+import org.junit.Test
+
+class NavigationOutputWorkerTest {
+ private fun await(latch: CountDownLatch) = assertTrue(latch.await(3, TimeUnit.SECONDS))
+
+ @Test fun `blocked HUD does not block caller or independent cluster`() {
+ val entered = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val clusterRan = CountDownLatch(1)
+ val hud = NavigationOutputWorker("test-hud") {}
+ val cluster = NavigationOutputWorker("test-cluster") {}
+ hud.start {}
+ cluster.start {}
+ try {
+ hud.submit { entered.countDown(); release.await(3, TimeUnit.SECONDS) }
+ await(entered)
+ cluster.submit { clusterRan.countDown() }
+ await(clusterRan)
+ } finally { release.countDown(); hud.clear(); cluster.clear() }
+ }
+
+ @Test fun `clear discards pending guidance and rejects late frames until restart`() {
+ val entered = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val cleared = CountDownLatch(1)
+ val starts = AtomicInteger()
+ val frames = AtomicInteger()
+ val worker = NavigationOutputWorker("test-cleanup") { if (starts.get() > 0) cleared.countDown() }
+ worker.start { starts.incrementAndGet() }
+ try {
+ worker.submit { entered.countDown(); release.await(3, TimeUnit.SECONDS) }
+ await(entered)
+ worker.submit { frames.incrementAndGet() }
+ worker.clear()
+ worker.submit { frames.incrementAndGet() }
+ release.countDown()
+ await(cleared)
+ assertEquals(0, frames.get())
+ val resumed = CountDownLatch(1)
+ worker.start {}
+ worker.submit { resumed.countDown() }
+ await(resumed)
+ } finally { release.countDown(); worker.clear() }
+ }
+
+ @Test fun `overflow clears incremental stream instead of replaying partial route`() {
+ val entered = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val cleared = CountDownLatch(1)
+ val initialized = CountDownLatch(1)
+ val frames = AtomicInteger()
+ val worker = NavigationOutputWorker("test-overflow") { if (initialized.count == 0L) cleared.countDown() }
+ worker.start { initialized.countDown() }
+ await(initialized)
+ try {
+ worker.submit { entered.countDown(); release.await(3, TimeUnit.SECONDS) }
+ await(entered)
+ repeat(140) { worker.submit { frames.incrementAndGet() } }
+ release.countDown()
+ await(cleared)
+ assertEquals(0, frames.get())
+ } finally { release.countDown(); worker.clear() }
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/AudioBufferProgressTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/AudioBufferProgressTest.kt
new file mode 100644
index 0000000..220336a
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/media/AudioBufferProgressTest.kt
@@ -0,0 +1,35 @@
+package com.shilapi.xcertplay.media
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class AudioBufferProgressTest {
+ @Test fun musicRebuffersOnlyAfterHardwareAndIncomingQueuesDrain() {
+ val buffer = AudioBufferProgress(4)
+ buffer.written(4000)
+ assertFalse(buffer.shouldRebuffer("media", true, true, true, 999))
+ assertFalse(buffer.shouldRebuffer("media", true, true, false, 1000))
+ assertFalse(buffer.shouldRebuffer("media", true, false, true, 1000))
+ assertTrue(buffer.shouldRebuffer("media", true, true, true, 1000))
+ assertFalse(buffer.shouldRebuffer("media", false, true, true, 1000))
+ buffer.written(400)
+ assertEquals(400, buffer.queuedBytes(1000))
+ assertFalse(buffer.shouldRebuffer("media", true, true, true, 1000))
+ }
+
+ @Test fun doesNotAddRebufferDelayToCallsOrSpeech() {
+ val buffer = AudioBufferProgress(2)
+ for (type in listOf("telephony", "speechrecognition", "default", "alert")) {
+ assertFalse(buffer.shouldRebuffer(type, true, true, true, 0))
+ }
+ }
+
+ @Test fun unsignedPlaybackHeadWrapKeepsQueuedAudio() {
+ val buffer = AudioBufferProgress(2)
+ repeat(8) { buffer.written(1_073_741_824) }
+ buffer.written(40)
+ assertEquals(42, buffer.queuedBytes(-1))
+ assertEquals(40, buffer.queuedBytes(0))
+ assertEquals(0, buffer.queuedBytes(20))
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt
index 0cd2409..5499a0b 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt
@@ -4,9 +4,20 @@ import com.shilapi.xcertplay.airplay.AirPlayConfig
import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig
import com.shilapi.xcertplay.airplay.AirPlayIdentity
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Test
class CarPlayBonjourTest {
+ @Test fun discoveryDiagnosticsKeepOutcomeWithoutPhoneIdentifiers() {
+ val endpoint = CarPlayBonjourEndpoint("Private phone", "192.168.43.25", 7000, "AA:BB:CC:DD:EE:FF")
+ assertEquals("control resolved family=IPv4 port=7000", CarPlayBonjourEvent.Resolved(endpoint).diagnosticSummary())
+ assertEquals("control probe attempts=2 status=200 error=none",
+ CarPlayBonjourEvent.Probed(endpoint, 2, "HTTP/1.1 200 Private phone", null).diagnosticSummary())
+ val failed = CarPlayBonjourEvent.Probed(endpoint, 3, null, java.io.IOException("Private phone 192.168.43.25"))
+ .diagnosticSummary()
+ assertEquals("control probe attempts=3 status=none error=IOException", failed)
+ assertFalse(failed.contains("Private phone"))
+ }
private val config = AirPlayConfig(
deviceName = "xcertplay",
deviceId = "02:00:00:00:00:02",
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHostAddressTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHostAddressTest.kt
new file mode 100644
index 0000000..cc06b72
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHostAddressTest.kt
@@ -0,0 +1,28 @@
+package com.shilapi.xcertplay.network
+
+import java.net.Inet6Address
+import java.net.InetAddress
+import org.junit.Assert.*
+import org.junit.Test
+
+class WirelessHostAddressTest {
+ @Test fun manualApPrefersScopedLinkLocalEvenWhenIpv4ComesFirst() {
+ val result = wirelessHostAddress(listOf(ip("192.168.43.1"), ip("fe80::1234")), 7) as Inet6Address
+ assertTrue(result.isLinkLocalAddress)
+ assertEquals(7, result.scopeId)
+ }
+
+ @Test fun replacesScopeFromAnotherInterface() {
+ val wrongScope = Inet6Address.getByAddress(null, ip("fe80::1234").address, 3)
+ assertEquals(8, (wirelessHostAddress(listOf(wrongScope), 8) as Inet6Address).scopeId)
+ }
+
+ @Test fun fallsBackToIpv4WithoutUsableLinkLocal() {
+ val ipv4 = ip("192.168.43.1")
+ assertEquals(ipv4, wirelessHostAddress(listOf(ip("::1"), ip("2001:db8::1"), ipv4), 7))
+ assertEquals(ipv4, wirelessHostAddress(listOf(ip("fe80::1234"), ipv4), 0))
+ assertNull(wirelessHostAddress(listOf(ip("0.0.0.0"), ip("127.0.0.1"), ip("224.0.0.251")), 7))
+ }
+
+ private fun ip(value: String) = InetAddress.getByName(value)
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt
index 19ea3ef..974259e 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt
@@ -27,11 +27,21 @@ class WirelessHotspotManagerTest {
fun convertsKnownChannelAndBandToFrequency() {
assertEquals(2412, wifiChannelToFrequencyMhz(1, band = 1))
assertEquals(5180, wifiChannelToFrequencyMhz(36, band = 2))
- assertEquals(5955, wifiChannelToFrequencyMhz(1, band = 3))
+ assertEquals(5955, wifiChannelToFrequencyMhz(1, band = 4))
+ assertNull(wifiChannelToFrequencyMhz(1, band = 3)) // Multi-band auto is not 6 GHz.
assertNull(wifiChannelToFrequencyMhz(0, band = 2))
assertNull(wifiChannelToFrequencyMhz(36, band = null))
}
+ @Test fun legacyHotspotBandUsesItsOwnConstants() {
+ assertEquals(2412, wifiChannelToFrequencyMhz(1, legacyHotspotBandToSoftApBand(0)))
+ assertEquals(5745, wifiChannelToFrequencyMhz(149, legacyHotspotBandToSoftApBand(1)))
+ assertNull(wifiChannelToFrequencyMhz(1, legacyHotspotBandToSoftApBand(-1)))
+ assertEquals("2.4 / 5 GHz (auto)", wifiBandLabel(3))
+ assertEquals("6 GHz", wifiBandLabel(4))
+ assertNull(legacyHotspotBandToSoftApBand(null))
+ }
+
@Test
fun doesNotReportDesiredChannelWhenAndroidDoesNotExposeOne() {
assertEquals(
diff --git a/site/ar/index.html b/site/ar/index.html
index 31e736d..a7cfe47 100644
--- a/site/ar/index.html
+++ b/site/ar/index.html
@@ -5,11 +5,11 @@
DiPlayمشروع مجتمعي مستقل
-نسخة تجريبية عامة · 0.1.0آيفونك.
على شاشة سيارتك.
خرائطك وموسيقاك ومحادثاتك على شاشة السيارة. يوفّر DiPlay كار بلاي سلكياً ولاسلكياً لشاشات أندرويد المتوافقة، بواجهة مألوفة من DiAuto.
لا حاجة إلى جيلبريك أو قطعة خارجية أو حساب أو خادم مصادقة.
ثبّت التطبيق على شاشة السيارة، وليس الآيفون. يتطلب الاتصال اللاسلكي أندرويد 10 أو أحدث ودعم Wi-Fi Direct. يعتمد التوافق على الشاشة ونظامها وإصدار iOS.
+نسخة تجريبية عامة · 0.2.0آيفونك.
على شاشة سيارتك.
خرائطك وموسيقاك ومحادثاتك على شاشة السيارة. يوفّر DiPlay كار بلاي سلكياً ولاسلكياً لشاشات أندرويد المتوافقة، بواجهة مألوفة من DiAuto.
لا حاجة إلى جيلبريك أو قطعة خارجية أو حساب أو خادم مصادقة.
ثبّت التطبيق على شاشة السيارة، وليس الآيفون. يتطلب الاتصال اللاسلكي أندرويد 10 أو أحدث ودعم Wi-Fi Direct. يعتمد التوافق على الشاشة ونظامها وإصدار iOS.
تجربة مألوفة
01التثبيت على شاشة السيارة
- والسيارة متوقفة، نزّل ملف APK وثبّته بالطريقة التي تدعمها شاشة سيارتك.
- افتح DiPlay وامنح أذونات البلوتوث والواي فاي/الموقع والميكروفون المطلوبة.
- اقرن الآيفون من إعدادات بلوتوث السيارة. أبقِ البلوتوث والواي فاي مفعّلين، ثم اختر Connect phone وحدّد الآيفون المقترن واسمح بكار بلاي على الهاتف.
- للاتصال السلكي، استخدم كابل USB يدعم نقل البيانات واختر Connect with USB. وافق على طلبات USB أو الوثوق أو اتصال VPN المحلي إن ظهرت.
لا تحتاج إلى ADB للاستخدام اليومي. تختلف طريقة تثبيت APK حسب السيارة. لا حاجة لإدخال عنوان MAC يدوياً.
دليل التثبيت والمساعدة في الاتصال ↗
-02في هذا الإصدار
- كار بلاي سلكي ولاسلكي بمصادقة محلية.
- تفضيل 5 غيغاهرتز مع الرجوع إلى قنوات ثابتة على 2.4 غيغاهرتز وتذكّر الإعدادات الناجحة.
- التحكم بحجم الأيقونات والنصوص والدقة ومعدل الإطارات، مع إعادة الاتصال عند التطبيق.
- حفظ تقارير التشخيص محلياً لتشاركها بنفسك.
ملاحظات الإصدار وبصمات التحقق ↗قبل التجربة
نسخة تجريبية عامة غير معتمدة من Apple. تستخدم المصادقة هوية ملحق تجريبية مستخرجة من برنامج ثابت منشور، ولا يُضمن استمرار قبولها مستقبلاً. قد يستمر التقطّع أو عدم تطبيق حجم الأيقونات على بعض الشاشات. ابدأ بمعدل 30 إطاراً مع تعطيل HEVC، وجرّب دقة أقل عند الحاجة.
+02في هذا الإصدار
- كار بلاي سلكي ولاسلكي بمصادقة محلية.
- تفضيل 5 غيغاهرتز مع الرجوع إلى قنوات ثابتة على 2.4 غيغاهرتز وتذكّر الإعدادات الناجحة.
- التحكم بحجم الأيقونات والنصوص والدقة ومعدل الإطارات، مع إعادة الاتصال عند التطبيق.
- حفظ تقارير التشخيص محلياً لتشاركها بنفسك.
ملاحظات الإصدار وبصمات التحقق ↗قبل التجربة
نسخة تجريبية عامة غير معتمدة من Apple. تستخدم المصادقة هوية ملحق تجريبية مستخرجة من برنامج ثابت منشور، ولا يُضمن استمرار قبولها مستقبلاً. قد يستمر التقطّع أو عدم تطبيق حجم الأيقونات على بعض الشاشات. ابدأ بمعدل 30 إطاراً مع تعطيل HEVC، وجرّب دقة أقل عند الحاجة.
تابع التحديثات
الأخبار والإصدارات الجديدة على تيليجرام.
تابع على تيليجرام ↗
التحديثات والتوقيع
يستخدم الإصدار 0.1.0 مفتاح التوقيع نفسه للنسخ التجريبية السابقة من DiPlay. ثبّته فوق النسخة الحالية للاحتفاظ بالإعدادات. يتضمن الإصدار المصدر وتعليمات البناء وبصمات التحقق من APK.
-
+

