Release 0.2.0 with standalone BYD HUD and connection fixes
This commit is contained in:
1 parent
94c7563d88
commit
949d67c0e1
66 files changed
+1805
-261
No files matched your search
@@ -1,3 +1,14 @@
|
||||
# 0.2.0 — BYD navigation and connection improvements
|
||||
|
||||
- Standalone windshield HUD arrows, distance and street names on the verified DiLink5.1 firmware; no ADB, root or computer helper.
|
||||
- Retain contributor cluster/SOME-IP navigation, route parsing, BYD CarPlay icon and display-size presets.
|
||||
- Fix Car hotspot startup by using scoped IPv6 when available and binding discovery/probing to the AP interface. Physically confirmed on the development car.
|
||||
- Drain asynchronously decoded audio during packet gaps and rebuild the music buffer after starvation. Wi-Fi Direct is much better in the user retest; occasional audio cutouts remain for a later version.
|
||||
- Preserve bounded music-buffer choices, USB read improvements and decoder recovery; fix USB request/close races and keep vendor output outside phone callbacks.
|
||||
- Save audio/video/receive timing and discovery diagnostics without road names or protocol payloads.
|
||||
- HUD cleanup on normal end/disconnect/off/stale input; interrupted sessions recover on the next app launch. Force-stop may leave guidance visible until reopening.
|
||||
- Thanks to @romanchukg-cloud and @georgiyrr for PR #3 and vehicle testing.
|
||||
|
||||
# 0.1.0 release restored — 2026-09-25
|
||||
|
||||
- Rebuilt and signed the APK locally with explicitly supplied runtime authentication assets.
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
**CarPlay for compatible Android head units.** Wired and wireless, with the familiar DiAuto interface. Independent app: `com.shihab.diplay`.
|
||||
|
||||
[Download & website](https://shihabal3amri.github.io/DiPlay/) · [Release](https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0) · [Report a problem](https://github.com/shihabal3amri/DiPlay/issues/new/choose)
|
||||
[Download & website](https://shihabal3amri.github.io/DiPlay/) · [Release](https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0) · [Report a problem](https://github.com/shihabal3amri/DiPlay/issues/new/choose)
|
||||
|
||||

|
||||
|
||||
## 0.1.0 — public preview
|
||||
## 0.2.0 — public preview
|
||||
|
||||
Install on the **car**, not the iPhone. No jailbreak, dongle, Mac, account or authentication server is required for use. ADB is not needed during everyday use; your head unit must permit APK installation. Wireless requires Android 10+ and working Wi-Fi Direct; the APK supports Android 9+ for wired use.
|
||||
Install on the **car**, not the iPhone. No jailbreak, dongle, Mac, account or authentication server is required for use. ADB is not needed during everyday use; your head unit must permit APK installation. Wireless supports Wi-Fi Direct or the car’s existing hotspot; Wi-Fi Direct requires Android 10+; the APK supports Android 9+ for wired use.
|
||||
|
||||
- Wired USB and wireless CarPlay with local authentication.
|
||||
- BYD HUD navigation with arrows, distance and street names on verified firmware.
|
||||
- Car hotspot support, improved audio buffering and saved receive diagnostics.
|
||||
- Automatic address discovery, fixed-channel Wi-Fi fallbacks and successful-configuration memory.
|
||||
- Icon/text size, resolution and frame rate; applying a display change reconnects CarPlay.
|
||||
- Local diagnostic export. Reports are sent only if you choose to share them.
|
||||
@@ -18,7 +20,7 @@ Install on the **car**, not the iPhone. No jailbreak, dongle, Mac, account or au
|
||||
|
||||
This is **not an Apple-certified product**. The APK bundles an experimental accessory identity recovered from public Carlinkit firmware, not a newly provisioned MFi identity for DiPlay. A bundled private key is extractable. Acceptance after future iOS updates, reliability across head units and suitability of that identity for general distribution are unresolved. This release invites community testing; it is not a guarantee of universal compatibility.
|
||||
|
||||
Prior private builds have completed physical wired/wireless picture, touch and audio tests. Some testers still report stuttering or icon-size changes that do not take effect. The 0.1.0 changes passed automated and emulator checks; a new physical-car session was not available at publication. Start with H.264 / 30 fps and Default icon size. See the compatibility notes before testing.
|
||||
The release changes were tested on the development DiLink5.1 car: live windshield guidance and street names work, Car hotspot now starts CarPlay, and Wi-Fi Direct performance is substantially improved. Occasional audio cutouts remain and are deferred to a later update. Broader head-unit and iOS compatibility is not guaranteed. The HUD firmware scope and cleanup limits are documented in [BYD navigation](docs/BYD_NAVIGATION.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -2765,17 +2765,24 @@ class CarPlayHostActivity : ComponentActivity() {
|
||||
videoWidth: Int,
|
||||
videoHeight: Int,
|
||||
controllerGeneration: Int,
|
||||
): AndroidMediaSink = AndroidMediaSink(
|
||||
surface = null,
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled,
|
||||
advancedAudioChannelMapping = advancedAudioChannelMapping,
|
||||
onScreenStreamActiveChanged = { type, active ->
|
||||
onScreenStreamStateChanged(controllerGeneration, type, active)
|
||||
},
|
||||
mediaBufferMillis = AirPlayPersistence.loadMediaBufferMillis(this),
|
||||
)
|
||||
): AndroidMediaSink {
|
||||
// Capture this session's log: late decoder shutdown must not write into a new session.
|
||||
val diagnosticLog = sessionLog
|
||||
return AndroidMediaSink(
|
||||
surface = null,
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled,
|
||||
advancedAudioChannelMapping = advancedAudioChannelMapping,
|
||||
onScreenStreamActiveChanged = { type, active ->
|
||||
onScreenStreamStateChanged(controllerGeneration, type, active)
|
||||
},
|
||||
mediaBufferMillis = AirPlayPersistence.loadMediaBufferMillis(this),
|
||||
onAudioDiagnostic = { message ->
|
||||
diagnosticLog?.append(formattedLogLine(message, System.currentTimeMillis()))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaEngine(sink: AndroidMediaSink): CarPlayMediaEngine =
|
||||
CarPlayMediaEngine(
|
||||
|
||||
@@ -64,7 +64,7 @@ class DiPlayActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
com.shilapi.xcertplay.hud.BydNavigationOutputs.initialize(applicationContext)
|
||||
com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
window.statusBarColor = BG; window.navigationBarColor = BG
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
@@ -222,7 +222,7 @@ class DiPlayActivity : ComponentActivity() {
|
||||
}
|
||||
if (com.shilapi.xcertplay.hud.BydOutputSettings.available(this)) section(content, "BYD navigation") { card ->
|
||||
toggle(card, "Navigation on HUD and instrument cluster",
|
||||
"Arrow, distance, street and arrival time from Apple Maps on the windshield and the instrument cluster.",
|
||||
"Show phone navigation arrows, distance and street names on supported BYD displays. Vehicle compatibility varies.",
|
||||
com.shilapi.xcertplay.hud.BydOutputSettings.enabled(this)) { com.shilapi.xcertplay.hud.BydOutputSettings.setEnabled(this, it) }
|
||||
}
|
||||
section(content, "Permissions and connection help") { card ->
|
||||
|
||||
@@ -5,6 +5,21 @@ import org.junit.Test
|
||||
import java.nio.file.Files
|
||||
|
||||
class DiagnosticRedactorTest {
|
||||
@Test fun savedDriveReportKeepsMediaPerformanceCounters() {
|
||||
val folder = Files.createTempDirectory("diplay-media-report").toFile()
|
||||
try {
|
||||
val audio = "audio stats audioType=media codec=AAC_LC rx=215 dropped=0 underruns=+3 queue=2 playing=true maxGapMs=420 sinceRxMs=10 maxWriteMs=22 decoderDroppedTotal=0 outputBuffersTotal=212 ended=true"
|
||||
val video = "Video: video stats rx=29.8fps shown=29.8fps maxGap=150ms kbps=4000 recoveries=0 touch2frame avg=85ms max=110ms n=3 touchSendMax=1ms"
|
||||
SessionLogFile(folder.resolve("diplay.log")).use {
|
||||
it.reset("started")
|
||||
it.append(audio)
|
||||
it.append(video)
|
||||
}
|
||||
val report = folder.resolve("diplay.log").readText()
|
||||
assertTrue(report.contains(audio))
|
||||
assertTrue(report.contains(video))
|
||||
} finally { folder.deleteRecursively() }
|
||||
}
|
||||
@Test fun payloadAndCredentialLinesNeverReachReports() {
|
||||
for (line in listOf("TRACE IAP2 tx key", "hotspot passphrase=secret", "token=secret", "certificate bytes=607", "rx body={phone: 'Jane'}", "ok\nsecret", "wifi ssid=Home", "wireless name=Jane Smith’s iPhone")) {
|
||||
assertNull(line, DiagnosticRedactor.redact(line))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# BYD navigation displays
|
||||
|
||||
Phone navigation arrows, next-turn distance and street names can appear on supported BYD displays. Ordinary operation requires no ADB, root, laptop or helper process. The map app must provide structured navigation metadata; compatibility is not guaranteed for every map app or version.
|
||||
|
||||
## Validated windshield path
|
||||
|
||||
Live guidance and street names were physically confirmed in both DiAuto and DiPlay on DiLink5.1 / Android13, firmware `BYD-AUTO/IVI/IVI:13/TP1A.220624.014/eng.build20260722.221155:user/release-keys`. The standalone output is restricted to that firmware and the verified stock receiver version10601004/signing certificate. Other firmware is not implicitly enabled by this result. Existing cluster/SOME-IP outputs remain available on supported factory services; the DiPlay contributor independently reported DiLink5.0 cluster/HUD operation.
|
||||
|
||||
The app sends navigation-only broadcasts to the stock ClusterDebug receiver as its normal Android UID. Vendor output runs outside phone control callbacks. Street text uses the installed HAL's UTF-16LE chunk protocol, capped at48 UTF-16 units without splitting a surrogate pair. Output logs exclude street text.
|
||||
|
||||
Normal route end, disconnect, disabling navigation output and stale guidance trigger cleanup. Force-stop/process kill may leave the last instruction visible until the app opens again; a recovery journal handles that next launch. There is no guaranteed process-independent expiry. Run only one projection app at a time.
|
||||
|
||||
Enable BYD navigation in settings. In DiAuto it is opt-in under Navigation; in DiPlay it is enabled by default when available. Debug-only receivers/demos require Android's DUMP permission and are absent from release manifests. Development starter and vendor-access experiments are not part of the production navigation path.
|
||||
@@ -8,10 +8,14 @@ This public preview is an independent receiver, not an Apple-certified CarPlay a
|
||||
| Phone | Standard, non-jailbroken iPhone with CarPlay enabled; device/iOS compatibility varies |
|
||||
| Physical evidence | Previous private builds: wired and wireless picture, touch and audio confirmed on the development car with iPhone XS / iOS 18.7.10 |
|
||||
| Other cars | Mixed community reports across DiLink generations; not a certified model support list |
|
||||
| Current release | Automated and emulator checks, not a fresh physical-car validation of every change |
|
||||
| Current release | DiLink5.1: HUD/street names and Car hotspot confirmed; Wi-Fi Direct improved, occasional audio cutouts remain |
|
||||
| Wi-Fi | Prefer 5 GHz without an established station connection; align to a supported existing station channel; explicit 2.4 GHz fallback for firmware that rejects 5 GHz or automatic channel selection |
|
||||
| Video | Default H.264 / 30 fps; 60 fps and HEVC increase device-specific demands |
|
||||
|
||||
## BYD HUD and car hotspot
|
||||
|
||||
See [BYD navigation](BYD_NAVIGATION.md) for the exact verified firmware and lifecycle limits. Car hotspot now starts CarPlay on the development car using scoped IPv6. The phone must join the configured car hotspot. Neither result guarantees support on every firmware.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Some units stutter, particularly under higher video load. A 2.4 GHz link alone does not prove the cause: interference, firmware and decoder stalls can all contribute. Try Default icons, 30 fps and a lower resolution, then attach a report.
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
# Install and connect
|
||||
|
||||
1. Park the car. Download `DiPlay-0.1.0.apk` from the official GitHub release linked on the website.
|
||||
1. Park the car. Download `DiPlay-0.2.0.apk` from the official GitHub release linked on the website.
|
||||
2. Install on the Android head unit using its supported APK installation method. Do not install on the iPhone. Update over an existing DiPlay beta to retain settings and pairing records; the signing key is unchanged.
|
||||
3. Open DiPlay. Grant the permissions requested for the features you use: Bluetooth/Nearby devices, Wi-Fi/Location on older Android, and microphone for Siri/calls. Allow notifications for connection controls.
|
||||
4. Close other phone-projection apps before connecting.
|
||||
@@ -30,7 +30,11 @@ Use **Settings → Save diagnostic report** after reproducing a problem. Android
|
||||
APK installation restrictions are controlled by your car's firmware. ADB is optional if your car supports it, not an app runtime requirement:
|
||||
|
||||
```sh
|
||||
adb install -r DiPlay-0.1.0.apk
|
||||
adb install -r DiPlay-0.2.0.apk
|
||||
```
|
||||
|
||||
Only use a trusted computer. A different signing certificate cannot update this build; do not uninstall until you have saved any reports you need.
|
||||
|
||||
## BYD navigation
|
||||
|
||||
See [BYD navigation displays](BYD_NAVIGATION.md) for the firmware scope, map metadata requirements, settings and cleanup behavior. No runtime ADB starter is required.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Release review — 27 September 2026
|
||||
|
||||
# 0.2.0 — BYD navigation and connection improvements
|
||||
|
||||
- Standalone windshield HUD arrows, distance and street names on the verified DiLink5.1 firmware; no ADB, root or computer helper.
|
||||
- Retain contributor cluster/SOME-IP navigation, route parsing, BYD CarPlay icon and display-size presets.
|
||||
- Fix Car hotspot startup by using scoped IPv6 when available and binding discovery/probing to the AP interface. Physically confirmed on the development car.
|
||||
- Drain asynchronously decoded audio during packet gaps and rebuild the music buffer after starvation. Wi-Fi Direct is much better in the user retest; occasional audio cutouts remain for a later version.
|
||||
- Preserve bounded music-buffer choices, USB read improvements and decoder recovery; fix USB request/close races and keep vendor output outside phone callbacks.
|
||||
- Save audio/video/receive timing and discovery diagnostics without road names or protocol payloads.
|
||||
- HUD cleanup on normal end/disconnect/off/stale input; interrupted sessions recover on the next app launch. Force-stop may leave guidance visible until reopening.
|
||||
- Thanks to @romanchukg-cloud and @georgiyrr for PR #3 and vehicle testing.
|
||||
|
||||
## Validation scope
|
||||
|
||||
The user physically confirmed live standalone HUD guidance and street names in both apps. The latest DiPlay test also confirmed Car hotspot startup and substantially improved Wi-Fi Direct performance. Occasional audio cutouts remain; the user explicitly deferred them to another version and authorized pushing, merging and releasing these changes. The release packages now permit the verified standalone backend while retaining the exact firmware/stock-receiver guard. See [BYD navigation](BYD_NAVIGATION.md).
|
||||
|
||||
Earlier local review blockers for the standalone HUD path are resolved by physical tests. No claim is made that every vehicle, map app, force-stop sequence or USB failure mode was physically tested. Release checks include unit tests, build/lint, package/signature inspection and public-source credential checks. Android signing secrets and accessory private assets remain outside the repository/source archives.
|
||||
|
||||
Review corrections retain codec release/reconfigure during video recovery, synchronize USB request publication with close, interpret vendor status codes correctly, and separate vendor work from phone control callbacks. Runtime authentication assets follow the established explicit local release packaging.
|
||||
@@ -35,3 +35,9 @@ The public preview APK includes an accessory certificate/key pair recovered from
|
||||
## Download website
|
||||
|
||||
The static site layout, CSS and generator adapt DiAuto (AGPL-3.0). The AGPL license text is included with the source.
|
||||
|
||||
## BYD HUD maneuver icons
|
||||
|
||||
Required Notice: Copyright AndyShaman (https://github.com/AndyShaman/BYDMate)
|
||||
|
||||
The maneuver PNGs under `shared/src/main/assets/byd-hud-icons` were imported from BYDMate. Its PolyForm Noncommercial 1.0.0 terms and required notice are included alongside the assets. These files are separate from the project code license; upstream describes them as donor assets and their original provenance is not independently established. The validated DiLink5.1 windshield path uses factory turn codes rather than these images.
|
||||
@@ -17,8 +17,8 @@ android {
|
||||
applicationId = "com.shihab.diplay"
|
||||
minSdk = 28
|
||||
targetSdk = 37
|
||||
versionCode = 10
|
||||
versionName = "0.1.0"
|
||||
versionCode = 20
|
||||
versionName = "0.2.0"
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".hudtest"
|
||||
versionNameSuffix = "-hud-test"
|
||||
}
|
||||
release {
|
||||
optimization {
|
||||
enable = false
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.BYDAUTO_INSTRUMENT_COMMON" />
|
||||
<queries><package android:name="com.byd.clusterdebug" /></queries>
|
||||
<application android:label="DiPlay HUD Test" tools:replace="android:label">
|
||||
<receiver android:name="com.shilapi.xcertplay.hud.StarterBridgeReceiver" android:exported="true" android:permission="android.permission.DUMP" />
|
||||
<activity android:name="com.shilapi.xcertplay.hud.StandaloneHudDemoActivity" android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application></manifest>
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.shilapi.xcertplay.hud
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Parked, finite probe of stock IPC. No shell, socket, SDK privilege or helper. */
|
||||
class StandaloneHudDemoActivity : Activity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private lateinit var status: TextView
|
||||
private var showing = false
|
||||
private var started = 0L
|
||||
private var lastTurn = 0
|
||||
private var running = false
|
||||
private val target = ComponentName("com.byd.clusterdebug", "com.byd.clusterdebug.BroadcastReceiverCAN")
|
||||
private val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!running) return
|
||||
try {
|
||||
val elapsed = SystemClock.elapsedRealtime() - started
|
||||
if (elapsed >= 16_000) { clear("demo complete"); return }
|
||||
val turn = if (elapsed < 8_000) 1 else 2
|
||||
val distance = if (turn == 1) 500 else 800
|
||||
val road = if (turn == 1) "Muscat Road" else "Sultan Qaboos Street"
|
||||
transmit(BydStandalonePackets.guidance(if (turn == 1) 2 else 3, 0, distance, road)!!)
|
||||
if (turn != lastTurn) Log.i(TAG, "APP_GUIDANCE uid=${Process.myUid()} turn=$turn distance=$distance")
|
||||
lastTurn = turn
|
||||
status.text = (if (turn == 1) "LEFT — 500 m" else "RIGHT — 800 m") + "\n" + road
|
||||
handler.postDelayed(this, 1_000)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Demo failed", error)
|
||||
clear("send failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(state: Bundle?) {
|
||||
super.onCreate(state)
|
||||
status = TextView(this).apply { textSize = 32f; text = "Standalone HUD test ready" }
|
||||
setContentView(LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(40, 40, 40, 40)
|
||||
addView(status)
|
||||
addView(TextView(this@StandaloneHudDemoActivity).apply {
|
||||
text = "Parked test: Muscat Road / left 500 m, then Sultan Qaboos Street / right 800 m; 8 seconds each, then clear."
|
||||
textSize = 22f
|
||||
})
|
||||
addView(Button(this@StandaloneHudDemoActivity).apply {
|
||||
text = "Start 16-second test"
|
||||
setOnClickListener { startDemo() }
|
||||
})
|
||||
addView(Button(this@StandaloneHudDemoActivity).apply {
|
||||
text = "Clear HUD"
|
||||
setOnClickListener {
|
||||
try { validateTarget(); showing = true; clear("button") }
|
||||
catch (error: Exception) { status.text = "Clear unavailable: ${error.message}" }
|
||||
}
|
||||
})
|
||||
})
|
||||
try {
|
||||
validateTarget()
|
||||
Log.i(TAG, "PREFLIGHT_OK uid=${Process.myUid()} stock receiver verified")
|
||||
if (intent.getBooleanExtra("run", false) && state == null) handler.post { startDemo() }
|
||||
} catch (error: Exception) {
|
||||
status.text = "Test unavailable: ${error.message}"
|
||||
Log.e(TAG, "Preflight failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateTarget() {
|
||||
check(packageName == "com.shihab.diplay.hudtest" && Process.myUid() >= 10000)
|
||||
check(Build.FINGERPRINT == "BYD-AUTO/IVI/IVI:13/TP1A.220624.014/eng.build20260722.221155:user/release-keys") {
|
||||
"This test is restricted to the inspected firmware"
|
||||
}
|
||||
val info = packageManager.getPackageInfo(target.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
check(info.longVersionCode == 10601004L) { "Different stock receiver version" }
|
||||
check(info.applicationInfo!!.flags and ApplicationInfo.FLAG_SYSTEM != 0)
|
||||
val certs = info.signingInfo!!.apkContentsSigners
|
||||
check(certs.size == 1 && MessageDigest.getInstance("SHA-256").digest(certs[0].toByteArray())
|
||||
.joinToString("") { "%02x".format(it.toInt() and 255) } ==
|
||||
"efe3ca8ada0d10c655c3df9910ad2ebc121a47d9a6358434eb24074309933efc")
|
||||
val receiver = packageManager.getReceiverInfo(target, 0)
|
||||
check(receiver.enabled && receiver.exported && receiver.permission.isNullOrEmpty())
|
||||
}
|
||||
|
||||
private fun startDemo() {
|
||||
if (running) return
|
||||
try {
|
||||
validateTarget()
|
||||
|
||||
BydNavigationOutputs.setDiagnosticHold(true)
|
||||
showing = true // Preserve cleanup even if a later operation fails.
|
||||
transmit(StandaloneHudPackets.start())
|
||||
started = SystemClock.elapsedRealtime()
|
||||
running = true
|
||||
lastTurn = 0
|
||||
Log.i(TAG, "APP_START uid=${Process.myUid()} helper=none")
|
||||
// Start and subsequent records target the same manifest receiver.
|
||||
handler.postDelayed(tick, 250)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Standalone preflight/start failed", error)
|
||||
status.text = "Test unavailable: ${error.message}"
|
||||
clear("start failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun transmit(packet: String) {
|
||||
sendBroadcast(Intent("byd.hud.NAVIGATION_DEMO").setComponent(target)
|
||||
.putExtra("normal", packet).addFlags(Intent.FLAG_RECEIVER_FOREGROUND))
|
||||
}
|
||||
|
||||
private fun clear(reason: String) {
|
||||
running = false
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (showing) {
|
||||
try {
|
||||
transmit(StandaloneHudPackets.clear())
|
||||
// Broadcast delivery is not a hardware acknowledgement.
|
||||
Log.i(TAG, "APP_CLEAR_SENT uid=${Process.myUid()} reason=$reason")
|
||||
showing = false
|
||||
status.text = "Clear sent — check windshield"
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Clear failed; retry with Clear HUD", error)
|
||||
status.text = "Clear failed — tap Clear HUD"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onStop() { clear("activity stopped"); BydNavigationOutputs.setDiagnosticHold(false); super.onStop() }
|
||||
override fun onDestroy() { clear("activity destroyed"); super.onDestroy() }
|
||||
companion object { private const val TAG = "BYD-Standalone" }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.shilapi.xcertplay.hud
|
||||
|
||||
/** Fixed navigation-only vectors matching the inspected IVI HAL. Debug builds only. */
|
||||
internal object StandaloneHudPackets {
|
||||
fun start(): String = "43,E0,00,3A,01,02"
|
||||
fun clear(): String = "43,E0,00,3A,01,01"
|
||||
fun guidance(turn: Int, distanceMeters: Int): String {
|
||||
require(turn in setOf(1, 2, 11)) { "Demo permits only left, right and straight" }
|
||||
require(distanceMeters in 0..9999) { "Invalid demo distance" }
|
||||
return (record(0x43F01018, distanceMeters) + record(0x43F01010, turn) +
|
||||
record(0x43F01030, turn)).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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.shilapi.xcertplay.hud
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/** Only the shell (DUMP permission) can provision this temporary test bridge. */
|
||||
class StarterBridgeReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
"byd.hud.STARTER_CONFIG" -> {
|
||||
BydStarterBridge.configure(context, intent.getStringExtra("token") ?: return)
|
||||
resultData = "Starter configured"
|
||||
}
|
||||
"byd.hud.STARTER_DEMO" -> BydStarterBridge.demonstrate(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ SITE = ROOT / 'site'
|
||||
data = json.loads((SITE / 'content.json').read_text())
|
||||
BASE = 'https://shihabal3amri.github.io/DiPlay/'
|
||||
REPO = 'https://github.com/shihabal3amri/DiPlay'
|
||||
RELEASE = REPO + '/releases/tag/v0.1.0'
|
||||
DOWNLOAD = REPO + '/releases/download/v0.1.0/DiPlay-0.1.0.apk'
|
||||
RELEASE = REPO + '/releases/tag/v0.2.0'
|
||||
DOWNLOAD = REPO + '/releases/download/v0.2.0/DiPlay-0.2.0.apk'
|
||||
for lang, d in data.items():
|
||||
folder = SITE if lang == 'en' else SITE / lang
|
||||
folder.mkdir(exist_ok=True)
|
||||
@@ -25,7 +25,7 @@ for lang, d in data.items():
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="{e(d['promise'])}"><meta property="og:image" content="{BASE}assets/home.png"><meta property="og:url" content="{url}"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="{prefix}"><img src="{prefix}assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>{e(d['tag'])}</small></span></a><nav class="languages" aria-label="Language">{nav}</nav></header>
|
||||
<section class="hero"><span class="badge">{e(d['badge'])} · <bdi>0.1.0</bdi></span><h1>{e(d['title']).replace(chr(10),'<br>')}</h1><p class="intro">{e(d['intro'])}</p><div class="actions"><a class="button" href="{DOWNLOAD}">{e(d['download'])} <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">{e(d['install'])}</a></div><p class="promise">{e(d['promise'])}</p><p class="note">{e(d['requires'])}</p></section>
|
||||
<section class="hero"><span class="badge">{e(d['badge'])} · <bdi>0.2.0</bdi></span><h1>{e(d['title']).replace(chr(10),'<br>')}</h1><p class="intro">{e(d['intro'])}</p><div class="actions"><a class="button" href="{DOWNLOAD}">{e(d['download'])} <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">{e(d['install'])}</a></div><p class="promise">{e(d['promise'])}</p><p class="note">{e(d['requires'])}</p></section>
|
||||
<section class="gallery"><h2>{e(d['gallery'])}</h2><div class="screens">{pics}</div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>{e(d['setup'])}</h2><ol>{''.join('<li>'+e(x)+'</li>' for x in d['steps'])}</ol><p class="note">{e(d['bssid'])}</p><a href="{REPO}/blob/main/docs/INSTALL.md">{e(d['adb'])} ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>{e(d['whats'])}</h2><ul>{''.join('<li>'+e(x)+'</li>' for x in d['features'])}</ul><a href="{RELEASE}">{e(d['notes'])} ↗</a><h3>{e(d['compat'])}</h3><p>{e(d['compatText'])}</p></section></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# BYD HUD maneuver icons
|
||||
|
||||
`src/main/assets/byd-hud-icons/0x<code>.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.
|
||||
@@ -24,6 +24,7 @@
|
||||
android:required="false" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.byd.clusterdebug" />
|
||||
<package android:name="com.ts.car.someip.service" />
|
||||
<package android:name="com.byd.amapservice" />
|
||||
<intent>
|
||||
|
||||
@@ -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
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/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.
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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<Int, ByteArray>? = 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() {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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<Runnable>(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) }
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<NsdServiceInfo>()
|
||||
private val interfaceServices = LinkedBlockingQueue<Pair<CarPlayBonjourEndpoint, InetAddress>>()
|
||||
private val discoveryEvents = LinkedBlockingQueue<CarPlayBonjourEvent.Discovery>(32)
|
||||
private val seenServices = ConcurrentHashMap.newKeySet<String>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<InetAddress>, interfaceIndex: Int): InetAddress? {
|
||||
if (interfaceIndex > 0) {
|
||||
addresses.filterIsInstance<Inet6Address>().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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<String>()
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="لا حاجة إلى جيلبريك أو قطعة خارجية أو حساب أو خادم مصادقة."><meta property="og:image" content="https://shihabal3amri.github.io/DiPlay/assets/home.png"><meta property="og:url" content="https://shihabal3amri.github.io/DiPlay/ar/"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="../"><img src="../assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>مشروع مجتمعي مستقل</small></span></a><nav class="languages" aria-label="Language"><a href="../" lang="en" hreflang="en" dir="auto">English</a><a href="../ar/" lang="ar" hreflang="ar" dir="auto" aria-current="page">العربية</a><a href="../ru/" lang="ru" hreflang="ru" dir="auto">Русский</a><a href="../es/" lang="es" hreflang="es" dir="auto">Español</a><a href="../zh-Hans/" lang="zh-Hans" hreflang="zh-Hans" dir="auto">简体中文</a></nav></header>
|
||||
<section class="hero"><span class="badge">نسخة تجريبية عامة · <bdi>0.1.0</bdi></span><h1>آيفونك.<br>على شاشة سيارتك.</h1><p class="intro">خرائطك وموسيقاك ومحادثاتك على شاشة السيارة. يوفّر DiPlay كار بلاي سلكياً ولاسلكياً لشاشات أندرويد المتوافقة، بواجهة مألوفة من DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.1.0/DiPlay-0.1.0.apk">تنزيل APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">طريقة التثبيت</a></div><p class="promise">لا حاجة إلى جيلبريك أو قطعة خارجية أو حساب أو خادم مصادقة.</p><p class="note">ثبّت التطبيق على شاشة السيارة، وليس الآيفون. يتطلب الاتصال اللاسلكي أندرويد 10 أو أحدث ودعم Wi-Fi Direct. يعتمد التوافق على الشاشة ونظامها وإصدار iOS.</p></section>
|
||||
<section class="hero"><span class="badge">نسخة تجريبية عامة · <bdi>0.2.0</bdi></span><h1>آيفونك.<br>على شاشة سيارتك.</h1><p class="intro">خرائطك وموسيقاك ومحادثاتك على شاشة السيارة. يوفّر DiPlay كار بلاي سلكياً ولاسلكياً لشاشات أندرويد المتوافقة، بواجهة مألوفة من DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.2.0/DiPlay-0.2.0.apk">تنزيل APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">طريقة التثبيت</a></div><p class="promise">لا حاجة إلى جيلبريك أو قطعة خارجية أو حساب أو خادم مصادقة.</p><p class="note">ثبّت التطبيق على شاشة السيارة، وليس الآيفون. يتطلب الاتصال اللاسلكي أندرويد 10 أو أحدث ودعم Wi-Fi Direct. يعتمد التوافق على الشاشة ونظامها وإصدار iOS.</p></section>
|
||||
<section class="gallery"><h2>تجربة مألوفة</h2><div class="screens"><figure><a href="../assets/home.png"><img src="../assets/home.png" width="1920" height="1080" loading="lazy" alt="واجهة واضحة مع محاذاة أزرار الاتصال اللاسلكي وUSB."></a><figcaption>واجهة واضحة مع محاذاة أزرار الاتصال اللاسلكي وUSB.</figcaption></figure><figure><a href="../assets/settings.png"><img src="../assets/settings.png" width="1920" height="1080" loading="lazy" alt="إعدادات حجم العرض والأداء والاتصال. الصور تعرض واجهة التطبيق الإنجليزية."></a><figcaption>إعدادات حجم العرض والأداء والاتصال. الصور تعرض واجهة التطبيق الإنجليزية.</figcaption></figure></div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>التثبيت على شاشة السيارة</h2><ol><li>والسيارة متوقفة، نزّل ملف APK وثبّته بالطريقة التي تدعمها شاشة سيارتك.</li><li>افتح DiPlay وامنح أذونات البلوتوث والواي فاي/الموقع والميكروفون المطلوبة.</li><li>اقرن الآيفون من إعدادات بلوتوث السيارة. أبقِ البلوتوث والواي فاي مفعّلين، ثم اختر Connect phone وحدّد الآيفون المقترن واسمح بكار بلاي على الهاتف.</li><li>للاتصال السلكي، استخدم كابل USB يدعم نقل البيانات واختر Connect with USB. وافق على طلبات USB أو الوثوق أو اتصال VPN المحلي إن ظهرت.</li></ol><p class="note">لا تحتاج إلى ADB للاستخدام اليومي. تختلف طريقة تثبيت APK حسب السيارة. لا حاجة لإدخال عنوان MAC يدوياً.</p><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md">دليل التثبيت والمساعدة في الاتصال ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>في هذا الإصدار</h2><ul><li>كار بلاي سلكي ولاسلكي بمصادقة محلية.</li><li>تفضيل 5 غيغاهرتز مع الرجوع إلى قنوات ثابتة على 2.4 غيغاهرتز وتذكّر الإعدادات الناجحة.</li><li>التحكم بحجم الأيقونات والنصوص والدقة ومعدل الإطارات، مع إعادة الاتصال عند التطبيق.</li><li>حفظ تقارير التشخيص محلياً لتشاركها بنفسك.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">ملاحظات الإصدار وبصمات التحقق ↗</a><h3>قبل التجربة</h3><p>نسخة تجريبية عامة غير معتمدة من Apple. تستخدم المصادقة هوية ملحق تجريبية مستخرجة من برنامج ثابت منشور، ولا يُضمن استمرار قبولها مستقبلاً. قد يستمر التقطّع أو عدم تطبيق حجم الأيقونات على بعض الشاشات. ابدأ بمعدل 30 إطاراً مع تعطيل HEVC، وجرّب دقة أقل عند الحاجة.</p></section></div>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>في هذا الإصدار</h2><ul><li>كار بلاي سلكي ولاسلكي بمصادقة محلية.</li><li>تفضيل 5 غيغاهرتز مع الرجوع إلى قنوات ثابتة على 2.4 غيغاهرتز وتذكّر الإعدادات الناجحة.</li><li>التحكم بحجم الأيقونات والنصوص والدقة ومعدل الإطارات، مع إعادة الاتصال عند التطبيق.</li><li>حفظ تقارير التشخيص محلياً لتشاركها بنفسك.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">ملاحظات الإصدار وبصمات التحقق ↗</a><h3>قبل التجربة</h3><p>نسخة تجريبية عامة غير معتمدة من Apple. تستخدم المصادقة هوية ملحق تجريبية مستخرجة من برنامج ثابت منشور، ولا يُضمن استمرار قبولها مستقبلاً. قد يستمر التقطّع أو عدم تطبيق حجم الأيقونات على بعض الشاشات. ابدأ بمعدل 30 إطاراً مع تعطيل HEVC، وجرّب دقة أقل عند الحاجة.</p></section></div>
|
||||
<section class="card updates"><div><h2>تابع التحديثات</h2><p>الأخبار والإصدارات الجديدة على تيليجرام.</p></div><a class="button secondary" href="https://t.me/byd_localized">تابع على تيليجرام ↗</a></section>
|
||||
<section class="signing"><h2>التحديثات والتوقيع</h2><p>يستخدم الإصدار 0.1.0 مفتاح التوقيع نفسه للنسخ التجريبية السابقة من DiPlay. ثبّته فوق النسخة الحالية للاحتفاظ بالإعدادات. يتضمن الإصدار المصدر وتعليمات البناء وبصمات التحقق من APK.</p></section>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">الخصوصية</a><a href="https://github.com/shihabal3amri/DiPlay">الكود المصدري</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">ملاحظات الإصدار وبصمات التحقق</a><a href="https://github.com/shihabal3amri/DiPlay/issues">الإبلاغ عن مشكلة</a></nav><p>مشروع مستقل مبني على xcertplay وبتصميم واجهة من DiAuto. غير تابع لـ Apple أو BYD. كار بلاي وأيقونته ملك لشركة Apple.</p></footer>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">الخصوصية</a><a href="https://github.com/shihabal3amri/DiPlay">الكود المصدري</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">ملاحظات الإصدار وبصمات التحقق</a><a href="https://github.com/shihabal3amri/DiPlay/issues">الإبلاغ عن مشكلة</a></nav><p>مشروع مستقل مبني على xcertplay وبتصميم واجهة من DiAuto. غير تابع لـ Apple أو BYD. كار بلاي وأيقونته ملك لشركة Apple.</p></footer>
|
||||
</main></body></html>
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="Sin jailbreak, adaptador, cuenta ni servidor de autenticación."><meta property="og:image" content="https://shihabal3amri.github.io/DiPlay/assets/home.png"><meta property="og:url" content="https://shihabal3amri.github.io/DiPlay/es/"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="../"><img src="../assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>Proyecto comunitario independiente</small></span></a><nav class="languages" aria-label="Language"><a href="../" lang="en" hreflang="en" dir="auto">English</a><a href="../ar/" lang="ar" hreflang="ar" dir="auto">العربية</a><a href="../ru/" lang="ru" hreflang="ru" dir="auto">Русский</a><a href="../es/" lang="es" hreflang="es" dir="auto" aria-current="page">Español</a><a href="../zh-Hans/" lang="zh-Hans" hreflang="zh-Hans" dir="auto">简体中文</a></nav></header>
|
||||
<section class="hero"><span class="badge">VERSIÓN PRELIMINAR PÚBLICA · <bdi>0.1.0</bdi></span><h1>Tu iPhone.<br>En la pantalla de tu coche.</h1><p class="intro">Mapas, música y conversaciones en la pantalla del coche. DiPlay ofrece CarPlay por USB e inalámbrico en unidades Android compatibles, con la interfaz familiar de DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.1.0/DiPlay-0.1.0.apk">Descargar APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">Cómo instalar</a></div><p class="promise">Sin jailbreak, adaptador, cuenta ni servidor de autenticación.</p><p class="note">Instala la app en el coche, no en el iPhone. La conexión inalámbrica requiere Android 10 o posterior y Wi-Fi Direct funcional. La compatibilidad depende del equipo, el firmware y la versión de iOS.</p></section>
|
||||
<section class="hero"><span class="badge">VERSIÓN PRELIMINAR PÚBLICA · <bdi>0.2.0</bdi></span><h1>Tu iPhone.<br>En la pantalla de tu coche.</h1><p class="intro">Mapas, música y conversaciones en la pantalla del coche. DiPlay ofrece CarPlay por USB e inalámbrico en unidades Android compatibles, con la interfaz familiar de DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.2.0/DiPlay-0.2.0.apk">Descargar APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">Cómo instalar</a></div><p class="promise">Sin jailbreak, adaptador, cuenta ni servidor de autenticación.</p><p class="note">Instala la app en el coche, no en el iPhone. La conexión inalámbrica requiere Android 10 o posterior y Wi-Fi Direct funcional. La compatibilidad depende del equipo, el firmware y la versión de iOS.</p></section>
|
||||
<section class="gallery"><h2>Una experiencia familiar</h2><div class="screens"><figure><a href="../assets/home.png"><img src="../assets/home.png" width="1920" height="1080" loading="lazy" alt="Pantalla de inicio con los controles inalámbricos y USB alineados."></a><figcaption>Pantalla de inicio con los controles inalámbricos y USB alineados.</figcaption></figure><figure><a href="../assets/settings.png"><img src="../assets/settings.png" width="1920" height="1080" loading="lazy" alt="Tamaño de interfaz, rendimiento y conexión. Las capturas muestran la app en inglés."></a><figcaption>Tamaño de interfaz, rendimiento y conexión. Las capturas muestran la app en inglés.</figcaption></figure></div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>Instalar en el coche</h2><ol><li>Con el coche estacionado, descarga el APK e instálalo mediante el método que admita tu unidad.</li><li>Abre DiPlay y concede los permisos solicitados de Bluetooth, Wi-Fi/ubicación y micrófono.</li><li>Empareja el iPhone desde los ajustes Bluetooth del coche. Activa Bluetooth y Wi-Fi, pulsa Connect phone, selecciona el iPhone y permite CarPlay en el teléfono.</li><li>Para USB, usa un cable de datos y pulsa Connect with USB. Acepta las solicitudes de USB, confianza o VPN local que aparezcan.</li></ol><p class="note">No necesitas ADB para el uso diario. El método de instalación del APK depende del coche. No hay que introducir direcciones MAC.</p><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md">Guía de instalación y conexión ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>En esta versión</h2><ul><li>CarPlay por USB e inalámbrico con autenticación local.</li><li>Preferencia por 5 GHz, alternativa con canales fijos de 2,4 GHz y memoria de configuraciones correctas.</li><li>Ajustes de iconos, texto, resolución y frecuencia de imagen; aplicar vuelve a conectar CarPlay.</li><li>Informes de diagnóstico guardados localmente para compartir cuando tú decidas.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Notas y sumas de comprobación ↗</a><h3>Antes de probar</h3><p>Versión preliminar pública sin certificación de Apple. Usa una identidad de accesorio experimental obtenida de firmware público; no se garantiza su aceptación futura. Algunos equipos aún presentan tirones o no aplican los cambios de tamaño de iconos. Empieza con 30 fps y HEVC desactivado; reduce la resolución si hace falta.</p></section></div>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>En esta versión</h2><ul><li>CarPlay por USB e inalámbrico con autenticación local.</li><li>Preferencia por 5 GHz, alternativa con canales fijos de 2,4 GHz y memoria de configuraciones correctas.</li><li>Ajustes de iconos, texto, resolución y frecuencia de imagen; aplicar vuelve a conectar CarPlay.</li><li>Informes de diagnóstico guardados localmente para compartir cuando tú decidas.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Notas y sumas de comprobación ↗</a><h3>Antes de probar</h3><p>Versión preliminar pública sin certificación de Apple. Usa una identidad de accesorio experimental obtenida de firmware público; no se garantiza su aceptación futura. Algunos equipos aún presentan tirones o no aplican los cambios de tamaño de iconos. Empieza con 30 fps y HEVC desactivado; reduce la resolución si hace falta.</p></section></div>
|
||||
<section class="card updates"><div><h2>Recibe novedades</h2><p>Noticias y nuevas versiones en Telegram.</p></div><a class="button secondary" href="https://t.me/byd_localized">Seguir en Telegram ↗</a></section>
|
||||
<section class="signing"><h2>Actualizaciones y firma</h2><p>La versión 0.1.0 utiliza la misma clave de firma que las betas privadas de DiPlay. Instálala sobre la versión actual para conservar los ajustes. El código, las instrucciones de compilación y las sumas de comprobación acompañan al lanzamiento.</p></section>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Privacidad</a><a href="https://github.com/shihabal3amri/DiPlay">Código fuente</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Notas y sumas de comprobación</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Informar de un problema</a></nav><p>Proyecto independiente basado en xcertplay, con diseño de interfaz de DiAuto. Sin afiliación a Apple ni BYD. CarPlay y su icono pertenecen a Apple Inc.</p></footer>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Privacidad</a><a href="https://github.com/shihabal3amri/DiPlay">Código fuente</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Notas y sumas de comprobación</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Informar de un problema</a></nav><p>Proyecto independiente basado en xcertplay, con diseño de interfaz de DiAuto. Sin afiliación a Apple ni BYD. CarPlay y su icono pertenecen a Apple Inc.</p></footer>
|
||||
</main></body></html>
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="No jailbreak, dongle, account or authentication server required."><meta property="og:image" content="https://shihabal3amri.github.io/DiPlay/assets/home.png"><meta property="og:url" content="https://shihabal3amri.github.io/DiPlay/"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="./"><img src="./assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>Independent community project</small></span></a><nav class="languages" aria-label="Language"><a href="./" lang="en" hreflang="en" dir="auto" aria-current="page">English</a><a href="./ar/" lang="ar" hreflang="ar" dir="auto">العربية</a><a href="./ru/" lang="ru" hreflang="ru" dir="auto">Русский</a><a href="./es/" lang="es" hreflang="es" dir="auto">Español</a><a href="./zh-Hans/" lang="zh-Hans" hreflang="zh-Hans" dir="auto">简体中文</a></nav></header>
|
||||
<section class="hero"><span class="badge">PUBLIC PREVIEW · <bdi>0.1.0</bdi></span><h1>Your iPhone.<br>At home in your car.</h1><p class="intro">Maps, music and conversations on your car display. DiPlay brings wired and wireless CarPlay to compatible Android head units, with the familiar DiAuto interface.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.1.0/DiPlay-0.1.0.apk">Download APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">How to install</a></div><p class="promise">No jailbreak, dongle, account or authentication server required.</p><p class="note">Install on the car, not the iPhone. Wireless requires Android 10 or later and working Wi-Fi Direct. Compatibility depends on the head unit, firmware and iOS version.</p></section>
|
||||
<section class="hero"><span class="badge">PUBLIC PREVIEW · <bdi>0.2.0</bdi></span><h1>Your iPhone.<br>At home in your car.</h1><p class="intro">Maps, music and conversations on your car display. DiPlay brings wired and wireless CarPlay to compatible Android head units, with the familiar DiAuto interface.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.2.0/DiPlay-0.2.0.apk">Download APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">How to install</a></div><p class="promise">No jailbreak, dongle, account or authentication server required.</p><p class="note">Install on the car, not the iPhone. Wireless requires Android 10 or later and working Wi-Fi Direct. Compatibility depends on the head unit, firmware and iOS version.</p></section>
|
||||
<section class="gallery"><h2>A familiar drive</h2><div class="screens"><figure><a href="./assets/home.png"><img src="./assets/home.png" width="1920" height="1080" loading="lazy" alt="A clear home screen with aligned wireless and USB controls."></a><figcaption>A clear home screen with aligned wireless and USB controls.</figcaption></figure><figure><a href="./assets/settings.png"><img src="./assets/settings.png" width="1920" height="1080" loading="lazy" alt="Display size, performance and connection settings. Screenshots show the English app interface."></a><figcaption>Display size, performance and connection settings. Screenshots show the English app interface.</figcaption></figure></div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>Install on your car</h2><ol><li>While parked, download the APK and install it using the method supported by your head unit.</li><li>Open DiPlay and allow the requested Bluetooth, Wi-Fi/location and microphone permissions.</li><li>Pair your iPhone in the car’s Bluetooth settings. Keep Bluetooth and Wi-Fi on, choose Connect phone, select your paired iPhone and allow CarPlay on the phone.</li><li>For wired use, connect a USB data cable and choose Connect with USB. Approve any USB, Trust or local VPN prompts.</li></ol><p class="note">ADB is not required for everyday use. APK installation options depend on the car. No manual MAC address entry.</p><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md">Installation & connection help ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>In this release</h2><ul><li>Wired and wireless CarPlay with local authentication.</li><li>5 GHz preference, fixed-channel 2.4 GHz fallback and remembered successful configurations.</li><li>Icon/text size, resolution and frame-rate controls; Apply reconnects CarPlay.</li><li>Diagnostic reports saved locally for user-controlled sharing.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Release notes & checksums ↗</a><h3>Before you try it</h3><p>A public preview, not an Apple-certified product. Authentication uses an experimental accessory identity recovered from public firmware; future acceptance is not guaranteed. Some head units still show stuttering or ignore icon-size changes. Start with 30 fps and HEVC off; try a lower resolution if needed.</p></section></div>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>In this release</h2><ul><li>Wired and wireless CarPlay with local authentication.</li><li>5 GHz preference, fixed-channel 2.4 GHz fallback and remembered successful configurations.</li><li>Icon/text size, resolution and frame-rate controls; Apply reconnects CarPlay.</li><li>Diagnostic reports saved locally for user-controlled sharing.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Release notes & checksums ↗</a><h3>Before you try it</h3><p>A public preview, not an Apple-certified product. Authentication uses an experimental accessory identity recovered from public firmware; future acceptance is not guaranteed. Some head units still show stuttering or ignore icon-size changes. Start with 30 fps and HEVC off; try a lower resolution if needed.</p></section></div>
|
||||
<section class="card updates"><div><h2>Get release updates</h2><p>News and new builds on Telegram.</p></div><a class="button secondary" href="https://t.me/byd_localized">Follow on Telegram ↗</a></section>
|
||||
<section class="signing"><h2>Updates & signing</h2><p>Version 0.1.0 uses the same signing key as the DiPlay private betas. Install over your existing copy to preserve settings. Source, build instructions and APK checksums accompany the release.</p></section>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Privacy</a><a href="https://github.com/shihabal3amri/DiPlay">Source code</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Release notes & checksums</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Report an issue</a></nav><p>Independent project based on xcertplay, with interface design from DiAuto. Not affiliated with Apple or BYD. CarPlay and its icon belong to Apple Inc.</p></footer>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Privacy</a><a href="https://github.com/shihabal3amri/DiPlay">Source code</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Release notes & checksums</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Report an issue</a></nav><p>Independent project based on xcertplay, with interface design from DiAuto. Not affiliated with Apple or BYD. CarPlay and its icon belong to Apple Inc.</p></footer>
|
||||
</main></body></html>
|
||||
+3
-3
@@ -5,11 +5,11 @@
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="Без джейлбрейка, адаптера, аккаунта и сервера аутентификации."><meta property="og:image" content="https://shihabal3amri.github.io/DiPlay/assets/home.png"><meta property="og:url" content="https://shihabal3amri.github.io/DiPlay/ru/"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="../"><img src="../assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>Независимый проект сообщества</small></span></a><nav class="languages" aria-label="Language"><a href="../" lang="en" hreflang="en" dir="auto">English</a><a href="../ar/" lang="ar" hreflang="ar" dir="auto">العربية</a><a href="../ru/" lang="ru" hreflang="ru" dir="auto" aria-current="page">Русский</a><a href="../es/" lang="es" hreflang="es" dir="auto">Español</a><a href="../zh-Hans/" lang="zh-Hans" hreflang="zh-Hans" dir="auto">简体中文</a></nav></header>
|
||||
<section class="hero"><span class="badge">ОТКРЫТОЕ ТЕСТИРОВАНИЕ · <bdi>0.1.0</bdi></span><h1>Ваш iPhone.<br>На экране автомобиля.</h1><p class="intro">Карты, музыка и общение на экране автомобиля. DiPlay добавляет проводной и беспроводной CarPlay на совместимые головные устройства Android со знакомым интерфейсом DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.1.0/DiPlay-0.1.0.apk">Скачать APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">Как установить</a></div><p class="promise">Без джейлбрейка, адаптера, аккаунта и сервера аутентификации.</p><p class="note">Установите приложение на головное устройство, а не на iPhone. Для беспроводного подключения нужны Android 10 или новее и работающий Wi-Fi Direct. Совместимость зависит от устройства, прошивки и версии iOS.</p></section>
|
||||
<section class="hero"><span class="badge">ОТКРЫТОЕ ТЕСТИРОВАНИЕ · <bdi>0.2.0</bdi></span><h1>Ваш iPhone.<br>На экране автомобиля.</h1><p class="intro">Карты, музыка и общение на экране автомобиля. DiPlay добавляет проводной и беспроводной CarPlay на совместимые головные устройства Android со знакомым интерфейсом DiAuto.</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.2.0/DiPlay-0.2.0.apk">Скачать APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">Как установить</a></div><p class="promise">Без джейлбрейка, адаптера, аккаунта и сервера аутентификации.</p><p class="note">Установите приложение на головное устройство, а не на iPhone. Для беспроводного подключения нужны Android 10 или новее и работающий Wi-Fi Direct. Совместимость зависит от устройства, прошивки и версии iOS.</p></section>
|
||||
<section class="gallery"><h2>Знакомый интерфейс</h2><div class="screens"><figure><a href="../assets/home.png"><img src="../assets/home.png" width="1920" height="1080" loading="lazy" alt="Главный экран с выровненными элементами беспроводного и USB-подключения."></a><figcaption>Главный экран с выровненными элементами беспроводного и USB-подключения.</figcaption></figure><figure><a href="../assets/settings.png"><img src="../assets/settings.png" width="1920" height="1080" loading="lazy" alt="Размер интерфейса, производительность и подключение. На снимках — английский интерфейс приложения."></a><figcaption>Размер интерфейса, производительность и подключение. На снимках — английский интерфейс приложения.</figcaption></figure></div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>Установка в автомобиле</h2><ol><li>На стоянке скачайте APK и установите его способом, поддерживаемым головным устройством.</li><li>Откройте DiPlay и предоставьте запрошенные разрешения Bluetooth, Wi-Fi/геолокации и микрофона.</li><li>Выполните сопряжение iPhone в настройках Bluetooth автомобиля. Включите Bluetooth и Wi-Fi, нажмите Connect phone, выберите iPhone и разрешите CarPlay на телефоне.</li><li>Для проводного подключения используйте USB-кабель с передачей данных и нажмите Connect with USB. Подтвердите запросы USB, доверия и локального VPN, если они появятся.</li></ol><p class="note">Для повседневной работы ADB не нужен. Способ установки APK зависит от автомобиля. Вводить MAC-адрес вручную не требуется.</p><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md">Инструкция и помощь с подключением ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>В этом выпуске</h2><ul><li>Проводной и беспроводной CarPlay с локальной аутентификацией.</li><li>Приоритет 5 ГГц, резервные фиксированные каналы 2,4 ГГц и запоминание успешных настроек.</li><li>Размер значков и текста, разрешение и частота кадров; применение настроек переподключает CarPlay.</li><li>Локальное сохранение диагностических отчётов для отправки по вашему выбору.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Описание выпуска и контрольные суммы ↗</a><h3>Перед установкой</h3><p>Предварительная версия для открытого тестирования, без сертификации Apple. Используется экспериментальная идентификация аксессуара из общедоступной прошивки; её принятие в будущем не гарантируется. На некоторых устройствах остаются рывки изображения или не применяется размер значков. Начните с 30 кадров/с и выключенного HEVC; при необходимости снизьте разрешение.</p></section></div>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>В этом выпуске</h2><ul><li>Проводной и беспроводной CarPlay с локальной аутентификацией.</li><li>Приоритет 5 ГГц, резервные фиксированные каналы 2,4 ГГц и запоминание успешных настроек.</li><li>Размер значков и текста, разрешение и частота кадров; применение настроек переподключает CarPlay.</li><li>Локальное сохранение диагностических отчётов для отправки по вашему выбору.</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Описание выпуска и контрольные суммы ↗</a><h3>Перед установкой</h3><p>Предварительная версия для открытого тестирования, без сертификации Apple. Используется экспериментальная идентификация аксессуара из общедоступной прошивки; её принятие в будущем не гарантируется. На некоторых устройствах остаются рывки изображения или не применяется размер значков. Начните с 30 кадров/с и выключенного HEVC; при необходимости снизьте разрешение.</p></section></div>
|
||||
<section class="card updates"><div><h2>Следите за обновлениями</h2><p>Новости и новые версии в Telegram.</p></div><a class="button secondary" href="https://t.me/byd_localized">Подписаться в Telegram ↗</a></section>
|
||||
<section class="signing"><h2>Обновления и подпись</h2><p>Версия 0.1.0 подписана тем же ключом, что и закрытые бета-версии DiPlay. Устанавливайте поверх текущей версии, чтобы сохранить настройки. Исходный код, инструкции сборки и контрольные суммы APK доступны в выпуске.</p></section>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Конфиденциальность</a><a href="https://github.com/shihabal3amri/DiPlay">Исходный код</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">Описание выпуска и контрольные суммы</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Сообщить о проблеме</a></nav><p>Независимый проект на базе xcertplay с дизайном интерфейса DiAuto. Не связан с Apple или BYD. CarPlay и его значок принадлежат Apple Inc.</p></footer>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">Конфиденциальность</a><a href="https://github.com/shihabal3amri/DiPlay">Исходный код</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">Описание выпуска и контрольные суммы</a><a href="https://github.com/shihabal3amri/DiPlay/issues">Сообщить о проблеме</a></nav><p>Независимый проект на базе xcertplay с дизайном интерфейса DiAuto. Не связан с Apple или BYD. CarPlay и его значок принадлежат Apple Inc.</p></footer>
|
||||
</main></body></html>
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta property="og:title" content="DiPlay — CarPlay for compatible Android head units"><meta property="og:description" content="无需越狱、转接盒、账号或认证服务器。"><meta property="og:image" content="https://shihabal3amri.github.io/DiPlay/assets/home.png"><meta property="og:url" content="https://shihabal3amri.github.io/DiPlay/zh-Hans/"><meta property="og:type" content="website">
|
||||
</head><body><main>
|
||||
<header><a class="brand" href="../"><img src="../assets/icon.png" width="56" height="56" alt=""><span><strong>DiPlay</strong><small>独立社区项目</small></span></a><nav class="languages" aria-label="Language"><a href="../" lang="en" hreflang="en" dir="auto">English</a><a href="../ar/" lang="ar" hreflang="ar" dir="auto">العربية</a><a href="../ru/" lang="ru" hreflang="ru" dir="auto">Русский</a><a href="../es/" lang="es" hreflang="es" dir="auto">Español</a><a href="../zh-Hans/" lang="zh-Hans" hreflang="zh-Hans" dir="auto" aria-current="page">简体中文</a></nav></header>
|
||||
<section class="hero"><span class="badge">公开预览版 · <bdi>0.1.0</bdi></span><h1>你的 iPhone。<br>你的车载屏幕。</h1><p class="intro">在车载屏幕上使用地图、音乐和通话。DiPlay 为兼容的安卓车机带来有线和无线 CarPlay,采用熟悉的 DiAuto 界面。</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.1.0/DiPlay-0.1.0.apk">下载 APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">安装方法</a></div><p class="promise">无需越狱、转接盒、账号或认证服务器。</p><p class="note">请安装在车机上,而非 iPhone 上。无线连接需要 Android 10 或更高版本及可用的 Wi-Fi Direct。兼容性取决于车机、固件和 iOS 版本。</p></section>
|
||||
<section class="hero"><span class="badge">公开预览版 · <bdi>0.2.0</bdi></span><h1>你的 iPhone。<br>你的车载屏幕。</h1><p class="intro">在车载屏幕上使用地图、音乐和通话。DiPlay 为兼容的安卓车机带来有线和无线 CarPlay,采用熟悉的 DiAuto 界面。</p><div class="actions"><a class="button" href="https://github.com/shihabal3amri/DiPlay/releases/download/v0.2.0/DiPlay-0.2.0.apk">下载 APK <span aria-hidden="true">↓</span></a><a class="button secondary" href="#install">安装方法</a></div><p class="promise">无需越狱、转接盒、账号或认证服务器。</p><p class="note">请安装在车机上,而非 iPhone 上。无线连接需要 Android 10 或更高版本及可用的 Wi-Fi Direct。兼容性取决于车机、固件和 iOS 版本。</p></section>
|
||||
<section class="gallery"><h2>熟悉的驾乘体验</h2><div class="screens"><figure><a href="../assets/home.png"><img src="../assets/home.png" width="1920" height="1080" loading="lazy" alt="无线和 USB 连接控件整齐对齐的主界面。"></a><figcaption>无线和 USB 连接控件整齐对齐的主界面。</figcaption></figure><figure><a href="../assets/settings.png"><img src="../assets/settings.png" width="1920" height="1080" loading="lazy" alt="界面大小、性能和连接设置。截图展示的是英文应用界面。"></a><figcaption>界面大小、性能和连接设置。截图展示的是英文应用界面。</figcaption></figure></div></section>
|
||||
<div class="grid"><section class="card" id="install"><span class="eyebrow">01</span><h2>安装到车机</h2><ol><li>停车后下载 APK,并通过车机支持的方式安装。</li><li>打开 DiPlay,授予所需的蓝牙、Wi-Fi/位置及麦克风权限。</li><li>在车机蓝牙设置中配对 iPhone。保持蓝牙和 Wi-Fi 开启,点击 Connect phone,选择已配对的 iPhone,并在手机上允许 CarPlay。</li><li>有线连接请使用 USB 数据线,并点击 Connect with USB。如出现 USB、信任或本地 VPN 提示,请确认。</li></ol><p class="note">日常使用不需要 ADB。APK 安装方式取决于车机。无需手动输入 MAC 地址。</p><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md">安装与连接指南 ↗</a></section>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>本次更新</h2><ul><li>支持有线及无线 CarPlay,认证在本地完成。</li><li>优先使用 5 GHz,可回退至 2.4 GHz 固定信道,并记住成功的配置。</li><li>支持图标和文字大小、分辨率及帧率设置;应用更改时会重新连接 CarPlay。</li><li>诊断报告保存在本地,由你决定是否分享。</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">发布说明与校验值 ↗</a><h3>试用前请了解</h3><p>这是公开预览版,未经 Apple 认证。认证使用从公开固件中提取的实验性配件身份,无法保证未来持续可用。部分车机仍可能出现卡顿或图标大小调整不生效。建议先使用 30 fps 并关闭 HEVC,必要时降低分辨率。</p></section></div>
|
||||
<section class="card"><span class="eyebrow">02</span><h2>本次更新</h2><ul><li>支持有线及无线 CarPlay,认证在本地完成。</li><li>优先使用 5 GHz,可回退至 2.4 GHz 固定信道,并记住成功的配置。</li><li>支持图标和文字大小、分辨率及帧率设置;应用更改时会重新连接 CarPlay。</li><li>诊断报告保存在本地,由你决定是否分享。</li></ul><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">发布说明与校验值 ↗</a><h3>试用前请了解</h3><p>这是公开预览版,未经 Apple 认证。认证使用从公开固件中提取的实验性配件身份,无法保证未来持续可用。部分车机仍可能出现卡顿或图标大小调整不生效。建议先使用 30 fps 并关闭 HEVC,必要时降低分辨率。</p></section></div>
|
||||
<section class="card updates"><div><h2>获取更新</h2><p>在 Telegram 上关注项目消息和新版本。</p></div><a class="button secondary" href="https://t.me/byd_localized">关注 Telegram ↗</a></section>
|
||||
<section class="signing"><h2>更新与签名</h2><p>0.1.0 版沿用 DiPlay 内测版的签名密钥。直接覆盖安装即可保留设置。发布页面提供源代码、构建说明和 APK 校验值。</p></section>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">隐私</a><a href="https://github.com/shihabal3amri/DiPlay">源代码</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.1.0">发布说明与校验值</a><a href="https://github.com/shihabal3amri/DiPlay/issues">报告问题</a></nav><p>基于 xcertplay 的独立项目,界面设计源自 DiAuto。与 Apple 或比亚迪无隶属关系。CarPlay 及其图标归 Apple Inc. 所有。</p></footer>
|
||||
<footer><nav><a href="https://github.com/shihabal3amri/DiPlay/blob/main/docs/PRIVACY.md">隐私</a><a href="https://github.com/shihabal3amri/DiPlay">源代码</a><a href="https://github.com/shihabal3amri/DiPlay/releases/tag/v0.2.0">发布说明与校验值</a><a href="https://github.com/shihabal3amri/DiPlay/issues">报告问题</a></nav><p>基于 xcertplay 的独立项目,界面设计源自 DiAuto。与 Apple 或比亚迪无隶属关系。CarPlay 及其图标归 Apple Inc. 所有。</p></footer>
|
||||
</main></body></html>
|
||||
Reference in new issue
Block a user