From 505865a521f404ae74cb7d3a5c48987b5d726526 Mon Sep 17 00:00:00 2001 From: Siubeng Date: Thu, 15 Oct 2026 03:23:42 +0000 Subject: [PATCH 1/2] feat: organize settings into categorized pages --- README.md | 10 +- README.zh-CN.md | 12 +- .../com/shilapi/xcertplay/DiPlayActivity.kt | 451 +++++++++++------- .../java/com/shilapi/xcertplay/ServerUi.kt | 27 +- .../main/res/drawable/ic_settings_back.xml | 7 + .../xcertplay/BrowserDeviceManagementTest.kt | 3 +- .../CarPlayExperienceSettingsTest.kt | 17 +- .../shilapi/xcertplay/HttpsSettingsUiTest.kt | 34 +- .../xcertplay/LocationSourceSettingsTest.kt | 3 +- .../xcertplay/ManualHotspotSettingsTest.kt | 3 +- .../com/shilapi/xcertplay/OemSettingsTest.kt | 3 +- .../shilapi/xcertplay/ServerNavigationTest.kt | 6 +- .../SettingsCategoryNavigationTest.kt | 159 ++++++ .../shilapi/xcertplay/SettingsNavigation.kt | 22 + .../xcertplay/TeslaVpnSettingsUiTest.kt | 3 +- docs/BROWSER_AUDIO.md | 12 +- docs/TESLA_HTTP.md | 6 +- docs/WEBRTC_RECOVERY.md | 2 +- 18 files changed, 557 insertions(+), 223 deletions(-) create mode 100644 common/src/main/res/drawable/ic_settings_back.xml create mode 100644 common/src/test/java/com/shilapi/xcertplay/SettingsCategoryNavigationTest.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/SettingsNavigation.kt diff --git a/README.md b/README.md index 77369cf..7b43811 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ Install the app on an Android device and open the displayed address in your car' - **Background service**: Background operation and automatic connection. - **Display and audio settings**: Adjustable display size, resolution, frame rate, and audio buffering. - **CarPlay preferences**: Follow Android's day/night mode or force day/night; select Android/browser audio output and Android/browser/disabled microphone input. Preview custom 0–40% safe margins on each edge; microphone and safe-area changes reconnect the iPhone. Keep CarPlay when browsers go offline, or disconnect the iPhone after 1/5 minutes; browser reconnection cancels the timer. -- **Custom car branding**: In **Settings → OEM car name and icon**, rename CarPlay's return-to-car entry, select and crop a square image, or restore the default NIO name and icon. Changes apply on the next iPhone connection. Images are stored privately in the app, so the original file is no longer needed. -- **Location source**: **Settings → Location → Location source** defaults to the iPhone's own location, with no external location advertised or reported by WheelPlay. Select the Android device to provide its position when requested by the iPhone, after reconnecting and granting precise location permission. iOS and the navigation app determine location fusion; wireless connections on older Android versions can still require location permission. +- **Custom car branding**: In **Settings → Vehicle & location → OEM car name and icon**, rename CarPlay's return-to-car entry, select and crop a square image, or restore the default NIO name and icon. Changes apply on the next iPhone connection. Images are stored privately in the app, so the original file is no longer needed. +- **Location source**: **Settings → Vehicle & location → Location source** defaults to the iPhone's own location, with no external location advertised or reported by WheelPlay. Select the Android device to provide its position when requested by the iPhone, after reconnecting and granting precise location permission. iOS and the navigation app determine location fusion; wireless connections on older Android versions can still require location permission. - **Diagnostic export**: Save diagnostic reports locally for troubleshooting. ## Getting started @@ -33,7 +33,7 @@ Manage remembered browsers under Settings → 浏览器设备 (Browser devices) Device memory is scoped to the current browser and origin (scheme, host and port). Clearing or disabling browser storage, switching HTTP / HTTPS, or changing the address requires fresh pairing. Android persists only hashes of random device secrets; the six-digit code is never stored as a remembered credential, and browser credentials are excluded from app backups. -For 60 fps, select WebRTC video passthrough under Settings → Display & Audio and set the negotiated frame rate to 60. Apply the settings and reconnect for the changes to take effect. Actual frame rate depends on the iPhone's output, network conditions, and browser decoding capabilities; static scenes may run at a lower frame rate. WebRTC uses local-network UDP without external STUN/TURN servers. It automatically falls back to JPEG if UDP is unavailable or the source video codec is incompatible. Allowing HEVC on the iPhone enables H.265 passthrough to browsers that support HEVC over WebRTC; H.264 is recommended for older browsers. +For 60 fps, select WebRTC video passthrough under Settings → Display & streaming and set the negotiated frame rate to 60. Apply the settings and reconnect for the changes to take effect. Actual frame rate depends on the iPhone's output, network conditions, and browser decoding capabilities; static scenes may run at a lower frame rate. WebRTC uses local-network UDP without external STUN/TURN servers. It automatically falls back to JPEG if UDP is unavailable or the source video codec is incompatible. Allowing HEVC on the iPhone enables H.265 passthrough to browsers that support HEVC over WebRTC; H.264 is recommended for older browsers. The WebRTC interruption tolerance setting offers 4, 10 (default), or 30 seconds and applies on the next browser connection. Recoverable interruptions keep the last video frame visible while waiting for recovery; persistent problems fall back to JPEG after the grace period. Unsupported codecs and terminal connection failures still fall back immediately. This does not enlarge the video buffer or change control and audio connection rules. Refresh the webpage after updating the App. See the [stability and troubleshooting notes](docs/WEBRTC_RECOVERY.md) (Chinese). @@ -41,7 +41,7 @@ If the video element renders incorrectly in your car's browser, enable “Use Ca Canvas mode requires testing on car browsers such as NIO's. Changing the rendering path may help if an overlay covers only the native video element; it cannot guarantee success if the system stops decoding or overlays the entire page. Test while the vehicle is parked. Drivers should use only essential navigation interfaces while driving. -Select Audio output and Microphone source under Settings → Display & Audio. Output changes apply immediately; microphone source changes reconnect the iPhone. Disabling microphone input stops capture/uplink and removes input capability advertisements. Current routes are shown; unavailable browsers fall back to Android, while disabled microphone input blocks all fallback capture. Browser microphone input requires a trusted local HTTPS certificate, pairing through HTTPS, and granting microphone permission. See the [browser audio guide](docs/BROWSER_AUDIO.md) (Chinese). If trusted HTTPS or AudioWorklet is unavailable, use the Android microphone. +Select Audio output and Microphone source under Settings → Audio & microphone. Output changes apply immediately; microphone source changes reconnect the iPhone. Disabling microphone input stops capture/uplink and removes input capability advertisements. Current routes are shown; unavailable browsers fall back to Android, while disabled microphone input blocks all fallback capture. Browser microphone input requires a trusted local HTTPS certificate, pairing through HTTPS, and granting microphone permission. See the [browser audio guide](docs/BROWSER_AUDIO.md) (Chinese). If trusted HTTPS or AudioWorklet is unavailable, use the Android microphone. ## Requirements and limitations @@ -52,7 +52,7 @@ Tesla compatibility helps car browsers that block private IP ranges reach the ph Both methods clean up when disabled or when the Web service stops. Ordinary LAN Web listeners remain independent; optional HTTPS still requires a trusted certificate. See the [Tesla compatibility guide](docs/TESLA_HTTP.md) (Chinese). -Settings → Web listeners (Web 监听) contains the HTTP port (default `8080`), a self-managed Web hostname shared by HTTP and HTTPS, and an optional HTTPS switch. No Root access is required; listener ports range from `1024–65535`. Enabling HTTPS reveals its port (default `8443`) and inline certificate types: local default, PEM, and PKCS#12. Android's file picker imports PEM chains plus private keys (including encrypted keys) or PKCS#12 (`.p12` / `.pfx`) with passwords and key-alias selection. Identities are validated and saved in private non-backup storage; failed imports retain the current certificate, and occupied ports retain the current listener. Disabling HTTPS stops TLS while retaining its settings and certificate. See [Web listener and certificate setup](docs/BROWSER_AUDIO.md). +Settings → Web service (Web 服务) contains the HTTP port (default `8080`), a self-managed Web hostname shared by HTTP and HTTPS, and an optional HTTPS switch. No Root access is required; listener ports range from `1024–65535`. Enabling HTTPS reveals its port (default `8443`) and inline certificate types: local default, PEM, and PKCS#12. Android's file picker imports PEM chains plus private keys (including encrypted keys) or PKCS#12 (`.p12` / `.pfx`) with passwords and key-alias selection. Identities are validated and saved in private non-backup storage; failed imports retain the current certificate, and occupied ports retain the current listener. Disabling HTTPS stops TLS while retaining its settings and certificate. See [Web listener and certificate setup](docs/BROWSER_AUDIO.md). - The server device requires Android 9 or later. The car's browser must support modern web features and touch interaction. - The server device and car must be able to reach each other over the local network. Wireless connection compatibility depends on the devices' networking capabilities. diff --git a/README.zh-CN.md b/README.zh-CN.md index c7dc861..25c3fa4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,10 +17,12 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone - **后台服务**:支持后台运行及自动连接。 - **显示与音频设置**:可调整画面尺寸、分辨率、帧率和音频缓冲。 - **CarPlay 使用偏好**:可选择跟随 Android/始终日间/始终夜间、Android/浏览器音频输出,以及 Android/浏览器/禁用麦克风。自定义四边 0–40% 安全区域,并预览布局;修改麦克风或安全区域会重新连接。浏览器离线策略可选保持连接或等待 1/5 分钟后断开 iPhone,重连浏览器会取消计时。 -- **自定义车厂入口**:在「设置 → OEM 车厂名称与图标」修改 CarPlay「返回车机」入口的名称,选择图片并裁剪为正方形,或恢复默认 NIO 名称与图标;下次连接 iPhone 生效。图片保存在应用私有目录,无需保留原文件。 -- **定位来源**:「设置 → 定位 → 定位来源」默认选择 iPhone 自身定位,此时 WheelPlay 不声明或上报外部定位。可选择 Android 设备定位,在下次连接并取得精确定位权限后,按 iPhone 请求提供设备位置。具体定位融合由 iOS 和导航 App 决定;旧版 Android 的无线连接仍可能需要定位权限。 +- **自定义车厂入口**:在「设置 → 车厂与定位 → OEM 车厂名称与图标」修改 CarPlay「返回车机」入口的名称,选择图片并裁剪为正方形,或恢复默认 NIO 名称与图标;下次连接 iPhone 生效。图片保存在应用私有目录,无需保留原文件。 +- **定位来源**:「设置 → 车厂与定位 → 定位来源」默认选择 iPhone 自身定位,此时 WheelPlay 不声明或上报外部定位。可选择 Android 设备定位,在下次连接并取得精确定位权限后,按 iPhone 请求提供设备位置。具体定位融合由 iOS 和导航 App 决定;旧版 Android 的无线连接仍可能需要定位权限。 - **诊断导出**:支持将诊断报告保存到本地,便于排查问题。 +设置首页提供九个分类入口:iPhone 连接、画面与串流、音频与麦克风、车厂与定位、浏览器设备、Web 服务、Tesla 兼容、CarPlay 认证资源、帮助与关于。点击进入二级页,顶部返回按钮或系统返回键回到分类首页;切换底部标签和旋转屏幕后保留当前分类及滚动位置。 + ## 使用方式 1. 在 Android 服务设备上安装并启动 WheelPlay。 @@ -33,7 +35,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone 设备记忆保存于当前浏览器及访问地址(协议、主机、端口)的本地存储;清理浏览器数据、禁用存储、切换 HTTP / HTTPS 或访问地址后需要重新配对。Android 仅持久保存随机设备密钥的哈希,凭据不包含六位配对码,也不进入应用备份。 -追求 60 fps 时,在「设置 → 画面与音频」选择 WebRTC 视频直通,并将协商帧率设为 60;应用设置并重新连接后生效。实际帧率取决于 iPhone 输出、网络和浏览器解码能力,静态画面可能降低帧率。WebRTC 使用局域网 UDP,不依赖外部 STUN/TURN;UDP 不通或源视频编码不兼容时自动回退 JPEG。允许 iPhone 使用 HEVC 后,可向支持 HEVC / WebRTC 的浏览器直通 H.265;较旧浏览器建议保持 H.264。 +追求 60 fps 时,在「设置 → 画面与串流」选择 WebRTC 视频直通,并将协商帧率设为 60;应用设置并重新连接后生效。实际帧率取决于 iPhone 输出、网络和浏览器解码能力,静态画面可能降低帧率。WebRTC 使用局域网 UDP,不依赖外部 STUN/TURN;UDP 不通或源视频编码不兼容时自动回退 JPEG。允许 iPhone 使用 HEVC 后,可向支持 HEVC / WebRTC 的浏览器直通 H.265;较旧浏览器建议保持 H.264。 「WebRTC 卡顿容忍时间」可选 4 秒、10 秒(默认)或 30 秒,下次浏览器连接生效。短暂断流时保留最后一帧并等待恢复,持续异常超过阈值才回退 JPEG;不支持的编码或彻底失败的连接仍立即回退。该设置不会扩大视频缓冲,控制与音频保持各自的连接规则。更新 App 后请刷新网页。详见 [稳定性分析与排查](docs/WEBRTC_RECOVERY.md)。 @@ -41,7 +43,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素,改变显示路径可能有效;若系统停止解码或在整个页面上叠加遮罩,则无法保证有效。请在车辆停稳后验证,驾驶员行驶中只应使用必要的导航界面。 -在 App「设置 → 画面与音频」选择「音频输出」和「麦克风来源」。音频输出即时切换;麦克风来源修改后重新连接,禁用时停止采集与上传并取消输入能力声明。页面显示当前路由,浏览器未就绪时回退 Android;禁用麦克风时始终禁止回退采集。浏览器麦克风需要先配置该设备的本地 HTTPS 证书,再从 HTTPS 入口重新配对并授权;步骤见 [浏览器音频说明](docs/BROWSER_AUDIO.md)。若车机浏览器不支持可信 HTTPS 或 AudioWorklet,继续使用 Android 麦克风。 +在 App「设置 → 音频与麦克风」选择「音频输出」和「麦克风来源」。音频输出即时切换;麦克风来源修改后重新连接,禁用时停止采集与上传并取消输入能力声明。页面显示当前路由,浏览器未就绪时回退 Android;禁用麦克风时始终禁止回退采集。浏览器麦克风需要先配置该设备的本地 HTTPS 证书,再从 HTTPS 入口重新配对并授权;步骤见 [浏览器音频说明](docs/BROWSER_AUDIO.md)。若车机浏览器不支持可信 HTTPS 或 AudioWorklet,继续使用 Android 麦克风。 ## 使用要求与限制 @@ -54,7 +56,7 @@ Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素 ## Web 监听配置 -「设置 → Web 监听」集中配置 HTTP 端口(默认 `8080`)、HTTP / HTTPS 共用的自备 Web 域名和可选 HTTPS,无需 Root。实际监听端口范围为 `1024–65535`;打开 HTTPS 后显示 Web HTTPS 端口(默认 `8443`)及页面内的证书类型选择器:默认本地证书、PEM、PKCS#12。系统文件选择器支持 PEM 证书链+私钥(含加密私钥),或 PKCS#12(`.p12` / `.pfx`,可选择私钥条目)。导入前验证证书与私钥,保存到 App 私有且不备份的存储;失败保留当前证书,端口绑定失败保留原监听器。关闭 HTTPS 仅停止 TLS 监听,保留配置与证书。详见 [Web 监听与证书说明](docs/BROWSER_AUDIO.md)。 +「设置 → Web 服务」集中配置 HTTP 端口(默认 `8080`)、HTTP / HTTPS 共用的自备 Web 域名和可选 HTTPS,无需 Root。实际监听端口范围为 `1024–65535`;打开 HTTPS 后显示 Web HTTPS 端口(默认 `8443`)及页面内的证书类型选择器:默认本地证书、PEM、PKCS#12。系统文件选择器支持 PEM 证书链+私钥(含加密私钥),或 PKCS#12(`.p12` / `.pfx`,可选择私钥条目)。导入前验证证书与私钥,保存到 App 私有且不备份的存储;失败保留当前证书,端口绑定失败保留原监听器。关闭 HTTPS 仅停止 TLS 监听,保留配置与证书。详见 [Web 监听与证书说明](docs/BROWSER_AUDIO.md)。 ## Tesla 兼容模式(实验) diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 2af8ad5..941b6e2 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -61,6 +61,21 @@ class DiPlayActivity : AppCompatActivity() { private val ui by lazy { ServerUi(this) } private val handler = Handler(Looper.getMainLooper()) private var page = "service" + private enum class SettingsCategory(val title: String, val summary: String) { + CONNECTION("iPhone 连接", "自动连接、已配对的 iPhone、无线连接"), + DISPLAY("画面与串流", "昼夜模式、尺寸、安全区域、视频编码"), + AUDIO("音频与麦克风", "音频输出、麦克风来源、音频缓冲"), + VEHICLE("车厂与定位", "OEM 名称和图标、定位来源、导航输出"), + BROWSER("浏览器设备", "设备管理、浏览器离线后的处理"), + WEB("Web 服务", "HTTP / HTTPS 端口、域名、证书"), + TESLA("Tesla 兼容(实验)", "免 Root VPN、Root 路由、热点访问 IP"), + AUTHENTICATION("CarPlay 认证资源", "从 DiPlay APK 导入或选择自定义资源"), + HELP("帮助与关于", "应用权限、连接帮助、诊断报告、源码") + } + private var settingsCategory: SettingsCategory? = null + private var settingsView: ScrollView? = null + private val settingsScrollPositions = mutableMapOf() + private val settingsPageKey get() = settingsCategory?.name ?: "index" private lateinit var shell: ServerUi.Shell private var webAddress: TextView? = null private var webInterface: TextView? = null @@ -227,6 +242,15 @@ class DiPlayActivity : AppCompatActivity() { awaitingTeslaVpnConsent = savedInstanceState?.getBoolean("awaitingTeslaVpnConsent") ?: false page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page") ?: if (setupError == null) "service" else "settings" + settingsCategory = savedInstanceState?.getString("settingsCategory")?.let { name -> + SettingsCategory.entries.firstOrNull { it.name == name } + } + if (savedInstanceState == null && page == "settings" && setupError != null) { + settingsCategory = SettingsCategory.AUTHENTICATION + } + savedInstanceState?.getBundle("settingsScrollPositions")?.let { positions -> + positions.keySet().forEach { settingsScrollPositions[it] = positions.getInt(it) } + } createShell() startWebServiceIfReady() handleWirelessRecovery() @@ -241,7 +265,11 @@ class DiPlayActivity : AppCompatActivity() { if (savedInstanceState == null) handleAuthenticationIntent(intent) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (page != "service") navigate("service") else moveTaskToBack(true) + when { + page == "settings" && settingsCategory != null -> showSettingsCategory(null) + page != "service" -> navigate("service") + else -> moveTaskToBack(true) + } } }) } @@ -251,12 +279,21 @@ class DiPlayActivity : AppCompatActivity() { if (!handleAuthenticationIntent(intent)) navigate(intent.getStringExtra("page") ?: "service") } override fun onSaveInstanceState(outState: Bundle) { + rememberSettingsScroll() outState.putString("page", page) + outState.putString("settingsCategory", settingsCategory?.name) + outState.putBundle("settingsScrollPositions", Bundle().apply { + settingsScrollPositions.forEach { (key, offset) -> putInt(key, offset) } + }) outState.putString("selectedPrivateKey", selectedPrivateKey?.toString()) outState.putBoolean("awaitingTeslaVpnConsent", awaitingTeslaVpnConsent) super.onSaveInstanceState(outState) } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); createShell() } + override fun onConfigurationChanged(newConfig: Configuration) { + rememberSettingsScroll() + super.onConfigurationChanged(newConfig) + createShell() + } override fun onResume() { super.onResume(); refreshAuthentication(); render(); ServerWindow.showSystemBars(window); handler.removeCallbacks(tick); handler.post(tick) WebSession.setPhoneConnectHandler { @@ -290,6 +327,9 @@ class DiPlayActivity : AppCompatActivity() { private fun navigate(destination: String) { val target = normalizedPage(destination) + if (target == "settings") { + showSettingsCategory(if (setupError != null) SettingsCategory.AUTHENTICATION else null) + } shell.select(target) page = target if (destination == "wireless-recovery") confirmWirelessReset() @@ -301,7 +341,7 @@ class DiPlayActivity : AppCompatActivity() { page = initial shell.put("service", ui.content(::service)) refreshPhone() - shell.put("settings", ui.content(::settings)) + refreshSettings(settingsScrollPositions[settingsPageKey] ?: 0) setContentView(shell.root) refreshService() } @@ -314,9 +354,31 @@ class DiPlayActivity : AppCompatActivity() { private fun render() { refreshPhone() + refreshSettings() + } + + private fun rememberSettingsScroll() { + settingsView?.takeIf { it.isLaidOut }?.let { + settingsScrollPositions[settingsPageKey] = it.scrollY + } + } + + private fun showSettingsCategory(category: SettingsCategory?) { + rememberSettingsScroll() + settingsCategory = category + refreshSettings(settingsScrollPositions[settingsPageKey] ?: 0) + } + + private fun refreshSettings(scrollY: Int? = null) { + // onCreate can be followed by several renders before the restored view is laid out. + val offset = scrollY ?: settingsView?.takeIf { it.isLaidOut }?.scrollY + ?: settingsScrollPositions[settingsPageKey] ?: 0 exportButton = null teslaStatus = null - shell.put("settings", ui.content(::settings)) + audioOutputStatus = null + microphoneSourceStatus = null + renderedRootState = rootState() + settingsView = ui.content(::settings).also { shell.put("settings", it, offset) } } private fun service(content: LinearLayout) { @@ -460,57 +522,145 @@ class DiPlayActivity : AppCompatActivity() { } private fun settings(content: LinearLayout) { - content.addView(label("设置", 30, TEXT, true)) - content.addView(label("昼夜模式、音频输出和浏览器离线策略立即生效。修改画面、麦克风来源或串流参数会重新连接;其他设置在下次连接生效。", 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) - ui.card(content, "本项目基于 DiPlay 修改", R.drawable.ic_server_launch) { body -> - body.addView(label("如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。", 15, MUTED)) - body.contentDescription = "本项目基于 DiPlay 修改。点击打开 DiPlay GitHub 项目" - body.setOnClickListener { - runCatching { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) - }.onFailure { toast("未找到可打开网页的浏览器") } + val category = settingsCategory + if (category == null) { + content.addView(label("设置", 30, TEXT, true)) + content.addView(label("选择分类以调整 CarPlay 和浏览器体验。", 16, MUTED).apply { + setPadding(0, dp(8), 0, dp(16)) + }) + ui.card(content, "本项目基于 DiPlay 修改", R.drawable.ic_server_launch) { body -> + body.addView(label("如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。", 15, MUTED)) + body.contentDescription = "本项目基于 DiPlay 修改。点击打开 DiPlay GitHub 项目" + body.setOnClickListener { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) + }.onFailure { toast("未找到可打开网页的浏览器") } + } } + for (entry in SettingsCategory.entries) { + val summary = if (entry == SettingsCategory.AUTHENTICATION && setupError != null) + "尚未配置有效资源 · 导入后可启动 CarPlay" else entry.summary + content.addView(ui.category(entry.title, summary) { showSettingsCategory(entry) }) + } + return } - section(content, "CarPlay 认证资源") { card -> - card.addView(label(if (DiPlayBootstrap.importing) "正在本机导入…" else if (setupError == null) - "资源已就绪 · 私钥与证书校验通过" else "尚未配置有效资源", 16, if (setupError == null) TEXT else WARNING)) - card.addView(label("开源安装包不包含认证资源。可选择现有的两份文件,或下载 DiPlay 官网 APK 后在此导入,无需安装 DiPlay。", 14, MUTED)) - card.addView(button("从 DiPlay APK 导入", false) { - if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } - }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) - card.addView(button("手动选择认证资源", false) { - if (canImportAuthentication()) MaterialAlertDialogBuilder(this) - .setTitle("选择两份匹配的资源") - .setMessage("先选择 identity.pk8(PKCS#8 私钥),再选择 certificate.p7b(P-256 证书)。两份文件必须来自同一套资源;取消或校验失败不会替换原有资源。") - .setNegativeButton("取消", null) - .setPositiveButton("选择私钥") { _, _ -> launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } }.show() - }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) - card.addView(ui.preference("DiPlay 官网下载", "下载 APK 后返回导入,或分享给 WheelPlay") { - openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) - }) - card.addView(label("仅提取认证资源并保存在此设备的应用私有目录,不上传。导入结束后删除临时 APK;卸载 WheelPlay 将删除已导入资源。", 14, MUTED)) + content.addView(button("返回设置", false, R.drawable.ic_settings_back) { + showSettingsCategory(null) + }, LinearLayout.LayoutParams(-2, -2).apply { bottomMargin = dp(16) }) + content.addView(label(category.title, 30, TEXT, true)) + val note = when (category) { + SettingsCategory.DISPLAY -> "昼夜模式立即生效;修改画面或串流参数会重新连接。" + SettingsCategory.AUDIO -> "音频输出和缓冲立即生效;修改麦克风来源会重新连接。" + SettingsCategory.VEHICLE, SettingsCategory.CONNECTION -> "修改后在下次连接 iPhone 生效。" + else -> category.summary + } + content.addView(label(note, 16, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) + when (category) { + SettingsCategory.CONNECTION -> connectionSettings(content) + SettingsCategory.DISPLAY -> displaySettings(content) + SettingsCategory.AUDIO -> audioSettings(content) + SettingsCategory.VEHICLE -> vehicleSettings(content) + SettingsCategory.BROWSER -> browserSettings(content) + SettingsCategory.WEB -> webSettings(content) + SettingsCategory.TESLA -> teslaSettings(content) + SettingsCategory.AUTHENTICATION -> authenticationSettings(content) + SettingsCategory.HELP -> helpSettings(content) } + } + + private fun connectionSettings(content: LinearLayout) { section(content, "自动连接") { card -> toggle(card, "打开应用时自动连接", "使用上次的连接方式和已选择的 iPhone。", DiPlayPreferences.autoConnect(this)) { DiPlayPreferences.saveAutoConnect(this, it) } toggle(card, "设备启动后打开", "需要系统允许应用自启动。", AirPlayPersistence.loadAutoStartOnBoot(this)) { AirPlayPersistence.saveAutoStartOnBoot(this, it) } card.addView(ui.preference("iPhone", selectedPhoneName()) { choosePhone() }, matchButton()) } section(content, "无线连接") { card -> wirelessLinkControls(card) } + } + + private fun displaySettings(content: LinearLayout) { + val card = content + val appearances = CarPlayAppearance.entries + experienceChoice(card, "CarPlay 昼夜模式", appearances.map { it.label }, + appearances.indexOf(AirPlayPersistence.loadAppearance(this))) { + WebSession.setAppearance(this, appearances[it]) + } + card.addView(label("即时发送给 iPhone;跟随 Android 时使用此服务设备的系统昼夜模式。", 14, MUTED)) + carPlaySizeControl(card) + safeAreaControl(card) + toggle(card, "自适应浏览器尺寸", "按车机浏览器的实际画面比例协商 CarPlay 分辨率;浏览器尺寸变化时会重新连接。", AirPlayPersistence.loadAdaptiveBrowserSize(this)) { + AirPlayPersistence.saveAdaptiveBrowserSize(this, it) + WebSession.setAdaptiveBrowserSize(it) + } + choice(card, "分辨率", listOf("原始分辨率", "80% · 降低负载", "60% · 最低负载"), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } + choice(card, "协商帧率", listOf("30 fps · 较低负载", "60 fps · 更流畅"), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } + val technologies = com.shilapi.xcertplay.web.StreamTechnology.entries + choice(card, "串流技术", technologies.map { it.label }, + technologies.indexOf(AirPlayPersistence.loadStreamTechnology(this))) { + AirPlayPersistence.saveStreamTechnology(this, technologies[it]) + } + card.addView(label("WebRTC 支持 H.264 / HEVC 直通,浏览器不支持时自动回退 JPEG(约 15 fps)。追求 60 fps 请将协商帧率设为 60;实际帧率以网页显示为准。", 14, MUTED)) + val recoveryPolicies = com.shilapi.xcertplay.web.RtcRecoveryPolicy.entries + choice(card, "WebRTC 卡顿容忍时间", recoveryPolicies.map { it.label }, + recoveryPolicies.indexOf(AirPlayPersistence.loadRtcRecoveryPolicy(this))) { + AirPlayPersistence.saveRtcRecoveryPolicy(this, recoveryPolicies[it]) + } + card.addView(label("短暂断流时保留最后一帧并等待恢复,持续异常超过所选时间才回退 JPEG。不增加视频缓冲;不支持的编码或彻底失败的连接仍立即回退。下次浏览器连接生效。", 14, MUTED)) + toggle(card, "允许 iPhone 使用 HEVC", "下次连接生效。需要浏览器支持 HEVC / WebRTC,否则回退 JPEG;关闭时使用 H.264。", AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } + toggle(card, "右舵布局", "将 CarPlay 控件移到右侧。", AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } + } + + private fun audioSettings(content: LinearLayout) { + val card = content + val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets + choice(card, "音频缓冲", listOf("300 ms · 默认", "500 ms", "1000 ms · 更稳定"), + bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) { + WebSession.setMediaBufferMillis(this, bufferPresets[it]) + } + val outputs = CarPlayAudioOutput.entries + experienceChoice(card, "音频输出", outputs.map { it.label }, if (AirPlayPersistence.loadBrowserAudioPlayback(this)) 1 else 0) { + WebSession.setBrowserAudioPlayback(this, outputs[it] == CarPlayAudioOutput.BROWSER) + refreshAudioRoutes() + } + audioOutputStatus = label("", 14, MUTED).also(card::addView) + card.addView(label("即时切换。浏览器未就绪或离线时回退 Android;浏览器可能需要点击页面才能播放声音。", 14, MUTED)) + val microphones = CarPlayMicrophoneSource.entries + experienceChoice(card, "麦克风来源", microphones.map { it.label }, + microphones.indexOf(AirPlayPersistence.loadMicrophoneSource(this)), reconnect = true) { + WebSession.setMicrophoneSource(this, microphones[it]) + refreshAudioRoutes() + } + microphoneSourceStatus = label("", 14, MUTED).also(card::addView) + card.addView(label("修改后重新连接。浏览器麦克风需要可信 HTTPS 和授权,未就绪时回退 Android;禁用会停止上传并取消麦克风能力声明。", 14, MUTED)) + refreshAudioRoutes() + } + + private fun vehicleSettings(content: LinearLayout) { section(content, "OEM 车厂名称与图标") { card -> oemControls(card) } section(content, "定位") { card -> locationSourceControl(card) card.addView(label("默认由 iPhone 和导航 App 定位。选择 Android 后,在 iPhone 请求时提供这台设备的定位数据,需要 Android 精确定位权限。下次连接 iPhone 生效。", 14, MUTED)) } - section(content, "浏览器设备") { card -> - card.addView(label("配对后的浏览器会被记住,下次打开时自动恢复配对;选择启动选项后,点击网页上的「启动显示」。", 16, MUTED)) - card.addView(ui.preference("连接过的设备", "查看、重命名或移除浏览器") { showBrowserDevices() }) - val policies = BrowserOfflinePolicy.entries - experienceChoice(card, "浏览器离线后的处理", policies.map { it.label }, - policies.indexOf(AirPlayPersistence.loadBrowserOfflinePolicy(this))) { - WebSession.setBrowserOfflinePolicy(this, policies[it]) - } - card.addView(label("计时从 CarPlay 会话启动且无浏览器连接时开始;浏览器重连会取消计时。超时只断开 iPhone,Web 服务保持运行。", 14, MUTED)) + val simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) + if (com.shilapi.xcertplay.hud.BydOutputSettings.available(this)) section(content, "$simulatedManufacturerName 导航输出") { card -> + toggle(card, "抬头显示与仪表导航", + "在兼容的 $simulatedManufacturerName 设备上显示导航箭头、距离与路名,支持情况取决于车型。", + com.shilapi.xcertplay.hud.BydOutputSettings.enabled(this)) { com.shilapi.xcertplay.hud.BydOutputSettings.setEnabled(this, it) } + } + } + + private fun browserSettings(content: LinearLayout) { + val card = content + card.addView(label("配对后的浏览器会被记住,下次打开时自动恢复配对;选择启动选项后,点击网页上的「启动显示」。", 16, MUTED)) + card.addView(ui.preference("连接过的设备", "查看、重命名或移除浏览器") { showBrowserDevices() }) + val policies = BrowserOfflinePolicy.entries + experienceChoice(card, "浏览器离线后的处理", policies.map { it.label }, + policies.indexOf(AirPlayPersistence.loadBrowserOfflinePolicy(this))) { + WebSession.setBrowserOfflinePolicy(this, policies[it]) } + card.addView(label("计时从 CarPlay 会话启动且无浏览器连接时开始;浏览器重连会取消计时。超时只断开 iPhone,Web 服务保持运行。", 14, MUTED)) + } + + private fun webSettings(content: LinearLayout) { section(content, "Web 监听") { card -> card.addView(ui.preference("HTTP 端口", WebListenSettings.httpPort(this).toString()) { textInput("HTTP 监听端口(1024–65535)", WebListenSettings.httpPort(this).toString(), false) { value -> @@ -539,134 +689,102 @@ class DiPlayActivity : AppCompatActivity() { certificateControls(card) } } - section(content, "Tesla 兼容(实验)") { card -> - renderedRootState = rootState() - val config = TeslaHttpCompatibility.config(this) - val compatibilityDescription = label("主要用于帮助屏蔽私有网段访问的车机绕过地址限制,访问手机上的本地服务。除 Tesla 外,有同类限制的其他品牌车机也可尝试。", 14, MUTED) - card.addView(ui.preference("连接方案", config.mode.label) { - MaterialAlertDialogBuilder(this).setTitle("Tesla 连接方案") - .setSingleChoiceItems(TeslaHttpMode.entries.map { it.label }.toTypedArray(), config.mode.ordinal) { dialog, index -> - val selected = TeslaHttpMode.entries[index] - if (selected != config.mode) { - TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(mode = selected, enabled = false)) - render(); refreshService() - } - dialog.dismiss() - }.setNegativeButton("取消", null).show() - }.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }) - if (config.mode == TeslaHttpMode.VPN) { - card.addView(compatibilityDescription) - toggle(card, "启用免 Root Tesla 兼容", "需要 Android VPN 授权,无需 Root 或 Shizuku;可能与其他 VPN 冲突。", config.enabled, - enabled = !webBusy && !awaitingTeslaVpnConsent) { setTeslaHttpEnabled(it) } - teslaStatus = label("", 14, MUTED) - card.addView(teslaStatus) - card.addView(ui.preference("访问 IP 地址", config.vpnAddress) { - textInput("免 Root 访问 IP 地址", config.vpnAddress, false) { value -> - runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(vpnAddress = value)) } - .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") } + } + + private fun teslaSettings(content: LinearLayout) { + val card = content + renderedRootState = rootState() + val config = TeslaHttpCompatibility.config(this) + val compatibilityDescription = label("主要用于帮助屏蔽私有网段访问的车机绕过地址限制,访问手机上的本地服务。除 Tesla 外,有同类限制的其他品牌车机也可尝试。", 14, MUTED) + card.addView(ui.preference("连接方案", config.mode.label) { + MaterialAlertDialogBuilder(this).setTitle("Tesla 连接方案") + .setSingleChoiceItems(TeslaHttpMode.entries.map { it.label }.toTypedArray(), config.mode.ordinal) { dialog, index -> + val selected = TeslaHttpMode.entries[index] + if (selected != config.mode) { + TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(mode = selected, enabled = false)) + render(); refreshService() } - }.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }) - card.addView(label("请让车机连接此手机的系统热点,使用 http://${config.vpnAddress}:${WebListenSettings.httpPort(this)}。此方案可能不兼容部分手机或车机,系统更新后可能失效。有线 CarPlay 已连接时,启用或修改 IP 将等待断开后生效。", 14, MUTED)) - card.addView(label("直接使用 Web 监听端口,无低位端口映射。可选 HTTPS 仍需车机信任证书;WebRTC 视频需单独验证。", 14, MUTED)) - card.addView(button("授权或重试 VPN", false) { setTeslaHttpEnabled(true) } - .apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }, ui.secondaryButtonLayout(12)) - card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) - return@section - } - val rootRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } - rootRow.addView(button("请求 Root 权限", false) { - configureWeb("Root 已授权") { RootAccess.request().also { TeslaHttpCompatibility.retry() } } - }.apply { isEnabled = !webBusy && !RootAccess.checking }) - rootRow.addView(label(RootAccess.status, 14, MUTED).apply { maxLines = 3; ellipsize = android.text.TextUtils.TruncateAt.END }, - LinearLayout.LayoutParams(0, -2, 1f).apply { marginStart = dp(12) }) - card.addView(rootRow) + dialog.dismiss() + }.setNegativeButton("取消", null).show() + }.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }) + if (config.mode == TeslaHttpMode.VPN) { card.addView(compatibilityDescription) - if (RootAccess.granted) { - toggle(card, "启用 Tesla 兼容", "将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。", config.enabled, enabled = !webBusy) { - setTeslaHttpEnabled(it) + toggle(card, "启用免 Root Tesla 兼容", "需要 Android VPN 授权,无需 Root 或 Shizuku;可能与其他 VPN 冲突。", config.enabled, + enabled = !webBusy && !awaitingTeslaVpnConsent) { setTeslaHttpEnabled(it) } + teslaStatus = label("", 14, MUTED) + card.addView(teslaStatus) + card.addView(ui.preference("访问 IP 地址", config.vpnAddress) { + textInput("免 Root 访问 IP 地址", config.vpnAddress, false) { value -> + runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(vpnAddress = value)) } + .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") } } - teslaStatus = label("", 14, MUTED) - card.addView(teslaStatus) - card.addView(ui.preference("映射 HTTP 端口", "${config.httpMappingPort} → ${WebListenSettings.httpPort(this)}") { - textInput("映射 HTTP 端口(1–1023)", config.httpMappingPort.toString(), false) { value -> - saveTeslaMapping(value, https = false) - } - }.apply { isEnabled = !webBusy }) - card.addView(ui.preference("映射 HTTPS 端口", "${config.httpsMappingPort} → " + - if (WebListenSettings.httpsEnabled(this)) WebListenSettings.httpsPort(this).toString() else "HTTPS 已关闭") { - textInput("映射 HTTPS 端口(1–1023)", config.httpsMappingPort.toString(), false) { value -> - saveTeslaMapping(value, https = true) - } - }.apply { isEnabled = !webBusy }) - card.addView(ui.preference("访问 IP 地址", config.address) { - textInput("访问 IP 地址", config.address, false) { value -> - runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(address = value)) } - .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") } - } - }.apply { isEnabled = !webBusy }) - card.addView(label("映射只作用于此访问 IP,不改变 Web 监听端口。HTTPS 映射仅在 HTTPS 开启时生效;请开启系统热点,自定义证书需覆盖访问 IP 或域名。", 14, MUTED)) - card.addView(button("重新启动 Root 路由", false) { TeslaHttpCompatibility.retry() }, ui.secondaryButtonLayout(12)) - card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) - } + }.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }) + card.addView(label("请让车机连接此手机的系统热点,使用 http://${config.vpnAddress}:${WebListenSettings.httpPort(this)}。此方案可能不兼容部分手机或车机,系统更新后可能失效。有线 CarPlay 已连接时,启用或修改 IP 将等待断开后生效。", 14, MUTED)) + card.addView(label("直接使用 Web 监听端口,无低位端口映射。可选 HTTPS 仍需车机信任证书;WebRTC 视频需单独验证。", 14, MUTED)) + card.addView(button("授权或重试 VPN", false) { setTeslaHttpEnabled(true) } + .apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }, ui.secondaryButtonLayout(12)) + card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) + return } - section(content, "画面与音频") { card -> - val appearances = CarPlayAppearance.entries - experienceChoice(card, "CarPlay 昼夜模式", appearances.map { it.label }, - appearances.indexOf(AirPlayPersistence.loadAppearance(this))) { - WebSession.setAppearance(this, appearances[it]) - } - card.addView(label("即时发送给 iPhone;跟随 Android 时使用此服务设备的系统昼夜模式。", 14, MUTED)) - carPlaySizeControl(card) - safeAreaControl(card) - toggle(card, "自适应浏览器尺寸", "按车机浏览器的实际画面比例协商 CarPlay 分辨率;浏览器尺寸变化时会重新连接。", AirPlayPersistence.loadAdaptiveBrowserSize(this)) { - AirPlayPersistence.saveAdaptiveBrowserSize(this, it) - WebSession.setAdaptiveBrowserSize(it) - } - choice(card, "分辨率", listOf("原始分辨率", "80% · 降低负载", "60% · 最低负载"), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } - val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets - choice(card, "音频缓冲", listOf("300 ms · 默认", "500 ms", "1000 ms · 更稳定"), - bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) { - WebSession.setMediaBufferMillis(this, bufferPresets[it]) - } - val outputs = CarPlayAudioOutput.entries - experienceChoice(card, "音频输出", outputs.map { it.label }, if (AirPlayPersistence.loadBrowserAudioPlayback(this)) 1 else 0) { - WebSession.setBrowserAudioPlayback(this, outputs[it] == CarPlayAudioOutput.BROWSER) - refreshAudioRoutes() - } - audioOutputStatus = label("", 14, MUTED).also(card::addView) - card.addView(label("即时切换。浏览器未就绪或离线时回退 Android;浏览器可能需要点击页面才能播放声音。", 14, MUTED)) - val microphones = CarPlayMicrophoneSource.entries - experienceChoice(card, "麦克风来源", microphones.map { it.label }, - microphones.indexOf(AirPlayPersistence.loadMicrophoneSource(this)), reconnect = true) { - WebSession.setMicrophoneSource(this, microphones[it]) - refreshAudioRoutes() + val rootRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } + rootRow.addView(button("请求 Root 权限", false) { + configureWeb("Root 已授权") { RootAccess.request().also { TeslaHttpCompatibility.retry() } } + }.apply { isEnabled = !webBusy && !RootAccess.checking }) + rootRow.addView(label(RootAccess.status, 14, MUTED).apply { maxLines = 3; ellipsize = android.text.TextUtils.TruncateAt.END }, + LinearLayout.LayoutParams(0, -2, 1f).apply { marginStart = dp(12) }) + card.addView(rootRow) + card.addView(compatibilityDescription) + if (RootAccess.granted) { + toggle(card, "启用 Tesla 兼容", "将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。", config.enabled, enabled = !webBusy) { + setTeslaHttpEnabled(it) } - microphoneSourceStatus = label("", 14, MUTED).also(card::addView) - card.addView(label("修改后重新连接。浏览器麦克风需要可信 HTTPS 和授权,未就绪时回退 Android;禁用会停止上传并取消麦克风能力声明。", 14, MUTED)) - refreshAudioRoutes() - choice(card, "协商帧率", listOf("30 fps · 较低负载", "60 fps · 更流畅"), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } - val technologies = com.shilapi.xcertplay.web.StreamTechnology.entries - choice(card, "串流技术", technologies.map { it.label }, - technologies.indexOf(AirPlayPersistence.loadStreamTechnology(this))) { - AirPlayPersistence.saveStreamTechnology(this, technologies[it]) - } - card.addView(label("WebRTC 支持 H.264 / HEVC 直通,浏览器不支持时自动回退 JPEG(约 15 fps)。追求 60 fps 请将协商帧率设为 60;实际帧率以网页显示为准。", 14, MUTED)) - val recoveryPolicies = com.shilapi.xcertplay.web.RtcRecoveryPolicy.entries - choice(card, "WebRTC 卡顿容忍时间", recoveryPolicies.map { it.label }, - recoveryPolicies.indexOf(AirPlayPersistence.loadRtcRecoveryPolicy(this))) { - AirPlayPersistence.saveRtcRecoveryPolicy(this, recoveryPolicies[it]) - } - card.addView(label("短暂断流时保留最后一帧并等待恢复,持续异常超过所选时间才回退 JPEG。不增加视频缓冲;不支持的编码或彻底失败的连接仍立即回退。下次浏览器连接生效。", 14, MUTED)) - toggle(card, "允许 iPhone 使用 HEVC", "下次连接生效。需要浏览器支持 HEVC / WebRTC,否则回退 JPEG;关闭时使用 H.264。", AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } - toggle(card, "右舵布局", "将 CarPlay 控件移到右侧。", AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } - - } - val simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) - if (com.shilapi.xcertplay.hud.BydOutputSettings.available(this)) section(content, "$simulatedManufacturerName 导航输出") { card -> - toggle(card, "抬头显示与仪表导航", - "在兼容的 $simulatedManufacturerName 设备上显示导航箭头、距离与路名,支持情况取决于车型。", - com.shilapi.xcertplay.hud.BydOutputSettings.enabled(this)) { com.shilapi.xcertplay.hud.BydOutputSettings.setEnabled(this, it) } + teslaStatus = label("", 14, MUTED) + card.addView(teslaStatus) + card.addView(ui.preference("映射 HTTP 端口", "${config.httpMappingPort} → ${WebListenSettings.httpPort(this)}") { + textInput("映射 HTTP 端口(1–1023)", config.httpMappingPort.toString(), false) { value -> + saveTeslaMapping(value, https = false) + } + }.apply { isEnabled = !webBusy }) + card.addView(ui.preference("映射 HTTPS 端口", "${config.httpsMappingPort} → " + + if (WebListenSettings.httpsEnabled(this)) WebListenSettings.httpsPort(this).toString() else "HTTPS 已关闭") { + textInput("映射 HTTPS 端口(1–1023)", config.httpsMappingPort.toString(), false) { value -> + saveTeslaMapping(value, https = true) + } + }.apply { isEnabled = !webBusy }) + card.addView(ui.preference("访问 IP 地址", config.address) { + textInput("访问 IP 地址", config.address, false) { value -> + runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(address = value)) } + .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") } + } + }.apply { isEnabled = !webBusy }) + card.addView(label("映射只作用于此访问 IP,不改变 Web 监听端口。HTTPS 映射仅在 HTTPS 开启时生效;请开启系统热点,自定义证书需覆盖访问 IP 或域名。", 14, MUTED)) + card.addView(button("重新启动 Root 路由", false) { TeslaHttpCompatibility.retry() }, ui.secondaryButtonLayout(12)) + card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) } + } + + private fun authenticationSettings(content: LinearLayout) { + val card = content + card.addView(label(if (DiPlayBootstrap.importing) "正在本机导入…" else if (setupError == null) + "资源已就绪 · 私钥与证书校验通过" else "尚未配置有效资源", 16, if (setupError == null) TEXT else WARNING)) + card.addView(label("开源安装包不包含认证资源。可选择现有的两份文件,或下载 DiPlay 官网 APK 后在此导入,无需安装 DiPlay。", 14, MUTED)) + card.addView(button("从 DiPlay APK 导入", false) { + if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } + }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) + card.addView(button("手动选择认证资源", false) { + if (canImportAuthentication()) MaterialAlertDialogBuilder(this) + .setTitle("选择两份匹配的资源") + .setMessage("先选择 identity.pk8(PKCS#8 私钥),再选择 certificate.p7b(P-256 证书)。两份文件必须来自同一套资源;取消或校验失败不会替换原有资源。") + .setNegativeButton("取消", null) + .setPositiveButton("选择私钥") { _, _ -> launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } }.show() + }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) + card.addView(ui.preference("DiPlay 官网下载", "下载 APK 后返回导入,或分享给 WheelPlay") { + openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) + }) + card.addView(label("仅提取认证资源并保存在此设备的应用私有目录,不上传。导入结束后删除临时 APK;卸载 WheelPlay 将删除已导入资源。", 14, MUTED)) + } + + private fun helpSettings(content: LinearLayout) { section(content, "权限与连接帮助") { card -> card.addView(label("附近设备权限用于连接 iPhone,麦克风用于 Siri 和通话。旧版 Android 的无线连接还需要定位权限,USB 模式可能请求本地 VPN 授权。", 16, MUTED)) card.addView(ui.preference("浏览器 HTTPS 证书", "查看本设备证书指纹") { @@ -919,7 +1037,9 @@ class DiPlayActivity : AppCompatActivity() { private fun handleAuthenticationIntent(incoming: Intent): Boolean { if (incoming.action != Intent.ACTION_SEND && incoming.action != Intent.ACTION_VIEW) return false - navigate("settings") + showSettingsCategory(SettingsCategory.AUTHENTICATION) + shell.select("settings") + page = "settings" val uri = AuthenticationImportIntents.apk(incoming) if (uri == null) { toast("无法读取 APK,请在设置中通过文件选择器导入"); return true } if (!canImportAuthentication()) return true @@ -933,9 +1053,8 @@ class DiPlayActivity : AppCompatActivity() { private fun rootState() = Triple(RootAccess.granted, RootAccess.checking, RootAccess.status) private fun refreshRootSettings() { - if (!webBusy && renderedRootState != rootState()) { - teslaStatus = null - shell.put("settings", ui.content(::settings)) + if (!webBusy && settingsCategory == SettingsCategory.TESLA && renderedRootState != rootState()) { + refreshSettings() } } diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt index 1b876a6..6b784d8 100644 --- a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt +++ b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt @@ -53,6 +53,29 @@ internal class ServerUi(private val context: Context) { fun preference(title: String, value: String, action: () -> Unit) = PreferenceRow(title, value, action) + /** Settings category with a full-width title and supporting text. */ + fun category(title: String, summary: String, action: () -> Unit) = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.CENTER_VERTICAL + minimumHeight = dp(80) + setPadding(0, dp(16), 0, dp(16)) + val attributes = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) + background = attributes.getDrawable(0) + attributes.recycle() + addView(column().apply { + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + addView(text(title, 17, TEXT, true)) + addView(text(summary, 14, MUTED).apply { setPadding(0, dp(4), 0, 0) }) + }, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(16) }) + addView(android.widget.ImageView(context).apply { + setImageResource(R.drawable.ic_settings_chevron) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24))) + contentDescription = "$title,$summary" + isFocusable = true + setOnClickListener { action() } + } + /** A whole-row preference target; its value is separate from its stable title. */ inner class PreferenceRow(private val title: String, value: String, action: () -> Unit) : LinearLayout(context) { private val valueView = text(value, 16, MUTED).apply { @@ -257,9 +280,9 @@ internal class ServerUi(private val context: Context) { tabs.setOnItemSelectedListener { item -> select(ids[item.itemId - 1]); true } } - fun put(id: String, view: View) { + fun put(id: String, view: View, scrollPosition: Int? = null) { val old = pages.put(id, view) - val scrollY = old?.scrollY ?: 0 + val scrollY = scrollPosition ?: old?.scrollY ?: 0 if (old != null) container.removeView(old) container.addView(view, FrameLayout.LayoutParams(-1, -1)) view.visibility = if (id == selected) View.VISIBLE else View.GONE diff --git a/common/src/main/res/drawable/ic_settings_back.xml b/common/src/main/res/drawable/ic_settings_back.xml new file mode 100644 index 0000000..beffdb6 --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_back.xml @@ -0,0 +1,7 @@ + + + diff --git a/common/src/test/java/com/shilapi/xcertplay/BrowserDeviceManagementTest.kt b/common/src/test/java/com/shilapi/xcertplay/BrowserDeviceManagementTest.kt index c8eeb1d..5fb033f 100644 --- a/common/src/test/java/com/shilapi/xcertplay/BrowserDeviceManagementTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/BrowserDeviceManagementTest.kt @@ -5,7 +5,6 @@ import android.view.View import android.view.ViewGroup import android.widget.EditText import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import com.shilapi.xcertplay.web.WebSession import org.junit.Assert.* import org.junit.Test @@ -39,7 +38,7 @@ class BrowserDeviceManagementTest { try { val devices = WebSession.rememberedBrowsers(activity) val original = devices.remember("Chrome · Android", "192.168.1.2")!! - views(activity.window.decorView).filterIsInstance().single().selectedItemId = 3 + SettingsNavigation.open(activity, "浏览器设备") val entry = views(activity.window.decorView).single { it.contentDescription?.toString()?.startsWith("连接过的设备,") == true } entry.performClick() assertTrue(dialog().listView.adapter.getItem(0).toString().contains("Chrome · Android")) diff --git a/common/src/test/java/com/shilapi/xcertplay/CarPlayExperienceSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/CarPlayExperienceSettingsTest.kt index 6429f6b..77245ac 100644 --- a/common/src/test/java/com/shilapi/xcertplay/CarPlayExperienceSettingsTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/CarPlayExperienceSettingsTest.kt @@ -4,7 +4,6 @@ import android.view.View import android.view.ViewGroup import android.widget.EditText import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import com.shilapi.xcertplay.airplay.AirPlayConfig import com.shilapi.xcertplay.airplay.AirPlayInfoPlist import com.shilapi.xcertplay.airplay.SafeAreaRect @@ -25,10 +24,20 @@ class CarPlayExperienceSettingsTest { private val context get() = RuntimeEnvironment.getApplication() private fun views(view: View): List = listOf(view) + if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() - private fun row(activity: DiPlayActivity, title: String) = views(activity.window.decorView) - .filterIsInstance().single { it.contentDescription.toString().startsWith("$title,") } + private fun row(activity: DiPlayActivity, title: String): ServerUi.PreferenceRow { + fun current() = views(activity.window.decorView).filterIsInstance() + .singleOrNull { it.contentDescription.toString().startsWith("$title,") } + current()?.let { return it } + val category = when (title) { + "音频输出", "麦克风来源" -> "音频与麦克风" + "浏览器离线后的处理" -> "浏览器设备" + else -> "画面与串流" + } + SettingsNavigation.open(activity, category) + return current()!! + } private fun openSettings(activity: DiPlayActivity) { - views(activity.window.decorView).filterIsInstance().single().selectedItemId = 3 + SettingsNavigation.open(activity, "画面与串流") } private fun choose(activity: DiPlayActivity, title: String, index: Int, save: Boolean = true) { row(activity, title).performClick() diff --git a/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt index b97b819..bca6f39 100644 --- a/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt @@ -7,7 +7,6 @@ import android.widget.EditText import android.widget.TextView import android.widget.ScrollView import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.button.MaterialButton import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.radiobutton.MaterialRadioButton @@ -43,7 +42,7 @@ class HttpsSettingsUiTest { if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() private fun all(activity: DiPlayActivity) = views(activity.window.decorView) private fun openSettings(activity: DiPlayActivity) { - all(activity).filterIsInstance().single().selectedItemId = 3 + SettingsNavigation.open(activity, "Tesla 兼容(实验)") } private fun rootButton(activity: DiPlayActivity) = all(activity).filterIsInstance() .single { it.text.toString() == "请求 Root 权限" } @@ -51,7 +50,7 @@ class HttpsSettingsUiTest { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8) while (System.nanoTime() < deadline) { shadowOf(android.os.Looper.getMainLooper()).idle() - if (predicate() && rootButton(activity).isEnabled) return + if (predicate() && !RootAccess.checking) return Thread.sleep(10) } fail("Settings update did not finish") @@ -80,7 +79,7 @@ class HttpsSettingsUiTest { assertTrue(entered.await(2, TimeUnit.SECONDS)) assertFalse(rootButton(activity).isEnabled) assertFalse(hasRow(activity, "映射 HTTP 端口")) - assertTrue(hasRow(activity, "HTTP 端口")) + assertFalse(hasRow(activity, "HTTP 端口")) release.countDown() waitForUi(activity) { shadowOf(android.os.Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS) @@ -116,7 +115,7 @@ class HttpsSettingsUiTest { assertEquals(1, requests.get()) assertFalse(RootAccess.granted) assertFalse(hasRow(activity, "映射 HTTP 端口")) - assertTrue(hasRow(activity, "Web 域名(可选)")) + assertFalse(hasRow(activity, "Web 域名(可选)")) rootButton(activity).performClick() waitForUi(activity) { requests.get() == 2 && !RootAccess.checking } } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() } @@ -136,7 +135,9 @@ class HttpsSettingsUiTest { val button = rootButton(activity) val row = button.parent as ViewGroup val section = row.parent as ViewGroup - assertSame(row, section.getChildAt(1)) + val mode = all(activity).single { it.contentDescription?.toString()?.startsWith("连接方案,") == true } + assertSame(section, mode.parent) + assertTrue(section.indexOfChild(mode) < section.indexOfChild(row)) assertTrue(hasRow(activity, "连接方案")) button.performClick() waitForUi(activity) { RootAccess.granted } @@ -160,6 +161,7 @@ class HttpsSettingsUiTest { assertFalse(RootAccess.granted) assertFalse(hasRow(activity, "映射 HTTP 端口")) assertFalse(hasRow(activity, "访问 IP 地址")) + SettingsNavigation.open(activity, "Web 服务") assertTrue(hasRow(activity, "HTTP 端口")) assertTrue(hasRow(activity, "Web 域名(可选)")) assertTrue(hasRow(activity, "Web HTTPS 端口")) @@ -171,7 +173,7 @@ class HttpsSettingsUiTest { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() try { - openSettings(activity) + SettingsNavigation.open(activity, "Web 服务") assertEquals(listOf("默认本地证书", "PEM", "PKCS#12"), all(activity).filterIsInstance().map { it.text.toString() }) for ((type, title) in listOf("PEM" to "选择 PEM 证书与私钥", "PKCS#12" to "选择 PKCS#12 文件")) { @@ -191,11 +193,11 @@ class HttpsSettingsUiTest { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() try { - openSettings(activity) + SettingsNavigation.open(activity, "Web 服务") val http = all(activity).single { it.contentDescription?.toString()?.startsWith("HTTP 端口,") == true } val web = http.parent as ViewGroup assertTrue(views(web).any { it.contentDescription?.toString()?.startsWith("Web 域名(可选),") == true }) - assertFalse(views(web).any { it === rootButton(activity) }) + assertFalse(views(web).filterIsInstance().any { it.text.toString() == "请求 Root 权限" }) all(activity).filterIsInstance().single { it.contentDescription == "启用 HTTPS" }.performClick() waitForUi(activity) { !WebListenSettings.httpsEnabled(activity) } assertFalse(hasRow(activity, "Web HTTPS 端口")) @@ -209,7 +211,7 @@ class HttpsSettingsUiTest { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() try { - openSettings(activity) + SettingsNavigation.open(activity, "Web 服务") all(activity).single { it.contentDescription?.toString() == "Web HTTPS 端口,8443" }.performClick() val dialog = ShadowDialog.getLatestDialog() as AlertDialog val input = views(dialog.window!!.decorView).filterIsInstance().single() @@ -227,20 +229,14 @@ class HttpsSettingsUiTest { try { openSettings(activity) fun capture(name: String) { - val scroll = all(activity).filterIsInstance().single { view -> - views(view).filterIsInstance().any { it.text.toString() == "Web 监听" } - } + val scroll = all(activity).filterIsInstance().single { it.visibility == View.VISIBLE } val body = scroll.getChildAt(0) body.measure(View.MeasureSpec.makeMeasureSpec(360, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)) body.layout(0, 0, body.measuredWidth, body.measuredHeight) - val headings = views(body).filterIsInstance() - val top = headings.single { it.text.toString() == "Web 监听" }.top - val bottom = headings.single { it.text.toString() == "画面与音频" }.top - val bitmap = android.graphics.Bitmap.createBitmap(body.width, bottom - top, android.graphics.Bitmap.Config.ARGB_8888) + val bitmap = android.graphics.Bitmap.createBitmap(body.width, body.height, android.graphics.Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(bitmap) canvas.drawColor(ServerUi.BG) - canvas.translate(0f, -top.toFloat()) body.draw(canvas) val file = java.io.File("build/reports/web-settings/$name.png") file.parentFile!!.mkdirs() @@ -251,6 +247,8 @@ class HttpsSettingsUiTest { rootButton(activity).performClick() waitForUi(activity) { RootAccess.granted } capture("with-root") + SettingsNavigation.open(activity, "Web 服务") + capture("web-service") } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() } } } diff --git a/common/src/test/java/com/shilapi/xcertplay/LocationSourceSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/LocationSourceSettingsTest.kt index 55ff763..63a4ab8 100644 --- a/common/src/test/java/com/shilapi/xcertplay/LocationSourceSettingsTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/LocationSourceSettingsTest.kt @@ -4,7 +4,6 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -22,7 +21,7 @@ class LocationSourceSettingsTest { private fun views(view: View): List = listOf(view) + if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() private fun openSettings(activity: DiPlayActivity) { - views(activity.window.decorView).filterIsInstance().single().selectedItemId = 3 + SettingsNavigation.open(activity, "车厂与定位") } private fun sourceRow(activity: DiPlayActivity) = views(activity.window.decorView) .filterIsInstance().single { it.contentDescription.toString().startsWith("定位来源,") } diff --git a/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt index 2710461..021771c 100644 --- a/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/ManualHotspotSettingsTest.kt @@ -4,7 +4,6 @@ import android.view.View import android.view.ViewGroup import android.widget.EditText import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import com.shilapi.xcertplay.orchestration.WirelessHotspotMode import org.junit.Assert.* import org.junit.Test @@ -46,7 +45,7 @@ class ManualHotspotSettingsTest { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() try { val activity = controller.get() - views(activity.window.decorView).filterIsInstance().single().selectedItemId = 3 + SettingsNavigation.open(activity, "iPhone 连接") block(activity) assertNull(shadowOf(activity).nextStartedActivity) } finally { controller.pause().stop().destroy() } diff --git a/common/src/test/java/com/shilapi/xcertplay/OemSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/OemSettingsTest.kt index ba8579a..377d0e3 100644 --- a/common/src/test/java/com/shilapi/xcertplay/OemSettingsTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/OemSettingsTest.kt @@ -15,7 +15,6 @@ import android.widget.EditText import android.widget.ImageView import android.widget.TextView import androidx.appcompat.app.AlertDialog -import com.google.android.material.bottomnavigation.BottomNavigationView import com.shilapi.xcertplay.airplay.AirPlayConfig import com.shilapi.xcertplay.airplay.AirPlayInfoPlist import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig @@ -42,7 +41,7 @@ class OemSettingsTest { private fun button(activity: Activity, title: String) = views(activity.window.decorView) .filterIsInstance