diff --git a/README.md b/README.md
index 02caf01..c66c6db 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,9 @@ Enable browser audio and microphone input separately under Settings → Display
## Requirements and limitations
-An optional experimental Tesla HTTP mode creates a local virtual IPv4 address through Android's VPN API without changing the system hotspot subnet or requiring certificates. Connect the car to the server device's hotspot, enable the mode in Settings, approve VPN access, and try `http://100.96.0.1:8080/`. A custom shared-space address, HTTP port (default `8080`), and a self-managed HTTP hostname are supported. Port changes apply to all HTTP entries, including same-device preview; if a new port cannot bind, the running listener and saved configuration are retained. Hotspot routing and Tesla HTTP support require device testing; creating the interface does not prove browser reachability. See the [Tesla HTTP guide](docs/TESLA_HTTP.md) (Chinese). This mode shares WheelPlay's USB CarPlay VPN and may replace another VPN.
+An optional experimental Tesla compatibility mode uses an embedded Root routing adapter based on [VPN Hotspot](https://github.com/Mygod/VPNHotspot). A rooted Android device with `su`, `ip`, and `iptables` is required. Startup checks Root once in the background; its first settings row allows a manual retry. The mode switch, mapped HTTP/HTTPS ports, and custom access IPv4 address appear after authorization succeeds. Enable the system hotspot, connect the car, turn on the mode, and try `http://100.96.0.1/` once routing is ready. Mapped ports range from `1–1023`, defaulting to `80` / `443`, and forward only this address to the ordinary Web listeners. The adapter manages the `/32` address, scoped input/return rules, and port mappings in one transaction, with cleanup on hotspot changes and shutdown. It does not obtain Android VPN consent; wired CarPlay retains its IPv6 VPN independently. See the [Tesla compatibility guide](docs/TESLA_HTTP.md) (Chinese); Android hotspot and Tesla compatibility still need device testing.
+
+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).
- 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 34bc07b..3fbaa29 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -47,9 +47,13 @@ Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素
- 画面流畅度与连接稳定性受设备性能、网络质量及系统后台管理影响。
- 仅适用于可信局域网;HTTP 配对、WebSocket 控制与 JPEG 通道未加密,WebRTC 视频使用 DTLS-SRTP。浏览器麦克风使用本地 HTTPS/WSS,不应通过证书警告强行进入页面;请勿将服务暴露到公网。
-## Tesla HTTP 兼容模式(实验)
+## Web 监听配置
-在设置中开启此模式并授权本地 VPN,可尝试通过 `http://100.96.0.1:8080/` 访问。车机需要连接运行 WheelPlay 的 Android 设备热点;无需证书,也不修改系统热点网段。支持自定义共享地址、HTTP 端口(默认 `8080`)和自备 HTTP 域名。端口修改同时更新所有 HTTP 入口和本机预览;运行中绑定新端口失败时保留原端口及配置。接口创建成功不代表车机可达,热点路由及不同 Tesla 版本的 HTTP 支持仍需实机验证。此模式与有线 CarPlay 共用 VPN,可能替换其他 VPN。详见 [使用与验证说明](docs/TESLA_HTTP.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 兼容模式(实验)
+
+内置适配 [VPN Hotspot](https://github.com/Mygod/VPNHotspot) 的 Root 路由方案,需要已 Root 且提供 `su`、`ip`、`iptables` 的 Android 设备。App 启动时后台检查一次 Root,「设置 → Tesla 兼容(实验)」第一行可手动重试;授权成功后才显示模式开关、映射 HTTP / HTTPS 端口和自定义访问 IP。开启系统热点并让车机连接、打开此模式,等待路由就绪后尝试 `http://100.96.0.1/`。映射端口范围 `1–1023`,默认 `80` / `443`,只对访问 IP 生效,转发到 Web 监听高位端口。地址、热点投递、回程与映射规则统一管理,关闭模式或停止服务时清理;普通 Web 监听不依赖 Root。Tesla 模式不申请 VPN 授权;有线 CarPlay 的 IPv6 VPN 独立保留。热点路由及 Tesla 支持仍需实机验证。详见 [使用与验证说明](docs/TESLA_HTTP.md)。
## 构建
diff --git a/common/src/main/assets/vpnhotspot-license.txt b/common/src/main/assets/vpnhotspot-license.txt
new file mode 100644
index 0000000..b0e73fd
--- /dev/null
+++ b/common/src/main/assets/vpnhotspot-license.txt
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2018 Mygod
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/common/src/main/assets/web/app.js b/common/src/main/assets/web/app.js
index e673042..dd78101 100644
--- a/common/src/main/assets/web/app.js
+++ b/common/src/main/assets/web/app.js
@@ -485,11 +485,18 @@ if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', () => browserAudio.unlock(), { capture: true });
$('secure-setup').addEventListener('toggle', async () => {
if (!$('secure-setup').open) return;
- $('https-entry').href = `https://${location.hostname}:8443/`;
+ $('https-entry').removeAttribute('href');
try {
const response = await fetch('/tls.json', { cache: 'no-store' });
const info = await response.json();
- $('tls-fingerprint').textContent = info.fingerprint || 'HTTPS 正在准备,请稍后重新展开。';
+ if (info.available && Number.isInteger(info.port) && info.port >= 1 && info.port <= 65535) {
+ const endpoint = new URL(location.href);
+ endpoint.protocol = 'https:'; endpoint.port = String(info.port); endpoint.pathname = '/';
+ endpoint.search = ''; endpoint.hash = '';
+ $('https-entry').href = endpoint.href;
+ }
+ $('tls-fingerprint').textContent = info.enabled === false ? 'HTTPS 已关闭,请在 App 的 Web 监听中开启。'
+ : info.fingerprint || 'HTTPS 正在准备,请稍后重新展开。';
$('tls-error').textContent = info.error || '';
} catch (_) { $('tls-error').textContent = '证书信息暂时无法读取'; }
});
diff --git a/common/src/main/assets/web/browser-audio.js b/common/src/main/assets/web/browser-audio.js
index 5f87772..d178cef 100644
--- a/common/src/main/assets/web/browser-audio.js
+++ b/common/src/main/assets/web/browser-audio.js
@@ -5,7 +5,8 @@ export function decodeAudioPacket(buffer) {
if (header.getUint32(0, true) !== 0x31415057) return null;
const stream = header.getInt32(4, true), rate = header.getUint32(8, true), channels = header.getUint16(12, true);
if (rate < 8000 || rate > 96000 || channels < 1 || channels > 2 || (buffer.byteLength - 20) % (channels * 2)) return null;
- return { stream, rate, channels, sequence: header.getUint32(16, true), pcm: new Int16Array(buffer, 20) };
+ return { stream, rate, channels, media: !!(header.getUint16(14, true) & 1),
+ sequence: header.getUint32(16, true), pcm: new Int16Array(buffer, 20) };
}
// Continuous box-filter resampling preserves phase across render quanta and averages before
@@ -40,6 +41,7 @@ export function createBrowserAudio({
let context = null, playback = false, microphone = false, connected = false;
let playbackGeneration = 0, microphoneGeneration = 0;
let desiredPlayback = false, desiredMicrophone = false, gestureRetry = false;
+ let mediaBufferMillis = 300;
let stream = null, source = null, worklet = null, micId = null, micSequence = 0;
const streams = new Map(), nodes = new Set();
function state(error = '') { onState({ playback, microphone, connected, error }); }
@@ -109,16 +111,20 @@ export function createBrowserAudio({
send({ type: 'audio-ack', sequence: frame.sequence });
if (!playback || !connected || context?.state !== 'running') return true;
const now = context.currentTime;
- let next = streams.get(frame.stream) ?? now + .06;
- if (next < now || next - now > .25) { stopPlayback(frame.stream); next = now + .06; }
- if (nodes.size >= 64) { stopPlayback(); next = now + .06; }
+ const delay = frame.media ? mediaBufferMillis / 1000 : .06;
+ // A media burst after a radio gap must fit above the selected start buffer.
+ const maxAhead = frame.media ? delay + 1 : .25;
+ let next = streams.get(frame.stream) ?? now + delay;
+ if (next < now) { stopPlayback(frame.stream); next = now + delay; }
+ // Bound latency and allocation without cancelling sound that is already queued.
+ if (next - now > maxAhead || nodes.size >= 512) return true;
const count = frame.pcm.length / frame.channels;
const audioBuffer = context.createBuffer(frame.channels, count, frame.rate);
for (let channel = 0; channel < frame.channels; channel++) {
const output = audioBuffer.getChannelData(channel);
for (let i = 0; i < count; i++) output[i] = frame.pcm[i * frame.channels + channel] / 32768;
}
- const node = context.createBufferSource(); node.buffer = audioBuffer; node.stream = frame.stream;
+ const node = context.createBufferSource(); node.buffer = audioBuffer; node.stream = frame.stream; node.media = frame.media;
node.connect(context.destination); nodes.add(node);
node.onended = () => { nodes.delete(node); node.disconnect(); };
node.start(next); streams.set(frame.stream, next + count / frame.rate);
@@ -126,6 +132,11 @@ export function createBrowserAudio({
}
function control(message) {
if (message.type === 'audio-route') {
+ const nextBufferMillis = [300, 500, 1000].includes(message.mediaBufferMillis) ? message.mediaBufferMillis : 300;
+ if (nextBufferMillis !== mediaBufferMillis) {
+ mediaBufferMillis = nextBufferMillis;
+ for (const id of new Set([...nodes].filter(node => node.media).map(node => node.stream))) stopPlayback(id);
+ }
const nextPlayback = message.playback === true, nextMicrophone = message.microphone === true;
if (nextPlayback && !playback || nextMicrophone && !microphone) state('如果浏览器阻止自动播放,请点按画面继续。');
if (desiredPlayback !== nextPlayback) { desiredPlayback = nextPlayback; if (connected) setPlayback(nextPlayback); }
@@ -153,6 +164,7 @@ export function createBrowserAudio({
function close() {
playbackGeneration++; microphoneGeneration++; connected = false; microphone = playback = false;
desiredPlayback = desiredMicrophone = gestureRetry = false;
+ mediaBufferMillis = 300;
stopMicrophone(); stopPlayback();
const old = context; context = null; old?.close().catch(() => {}); state();
}
diff --git a/common/src/main/assets/web/index.html b/common/src/main/assets/web/index.html
index d9b249f..1c033e3 100644
--- a/common/src/main/assets/web/index.html
+++ b/common/src/main/assets/web/index.html
@@ -18,7 +18,7 @@
连接 CarPlay 车机屏幕
连接后隐藏顶部栏 让 CarPlay 画面占满整个页面;刷新页面可返回连接界面。
启动后隐藏顶栏并全屏 点击启动时进入全屏,连接后隐藏顶栏;退出全屏可恢复顶栏。不支持全屏时仅隐藏顶栏,刷新页面可返回连接界面。
使用 Canvas 显示视频(实验性) 视频在屏幕外解码,再绘制到画布;适用于视频元素显示异常的浏览器。可能增加耗电,本浏览器会记住选择。
-浏览器麦克风需要 HTTPS 普通局域网 HTTP 页面无法申请麦克风权限。先在 Android 应用的「设置 → 权限与连接帮助 → 浏览器 HTTPS 证书」查看本设备的指纹,逐字核对下方 SHA-256 指纹,再下载证书并在车机浏览器信任。随后打开 HTTPS 页面。
正在读取…
+浏览器麦克风需要 HTTPS 普通局域网 HTTP 页面无法申请麦克风权限。先在 Android 应用的「设置 → 权限与连接帮助 → 浏览器 HTTPS 证书」查看本设备的指纹,逐字核对下方 SHA-256 指纹,再下载证书并在车机浏览器信任。随后打开 HTTPS 页面。
正在读取…
diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt
index fcbfc64..636d1e9 100644
--- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt
@@ -8,9 +8,11 @@ import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.textfield.TextInputEditText
import com.shilapi.xcertplay.web.WebSession
+import com.shilapi.xcertplay.web.RootAccess
import com.shilapi.xcertplay.web.LanAddresses
import com.shilapi.xcertplay.web.TeslaHttpCompatibility
-import com.shilapi.xcertplay.network.CarPlayVpnService
+import com.shilapi.xcertplay.web.LanTls
+import com.shilapi.xcertplay.web.WebListenSettings
import com.shilapi.xcertplay.web.BrowserExperienceLink
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
@@ -48,6 +50,10 @@ import java.util.Locale
/** Connection and preference pages in the shared server navigation. */
class DiPlayActivity : AppCompatActivity() {
+ private class CertificateDocument : ActivityResultContracts.OpenDocument() {
+ override fun createIntent(context: android.content.Context, input: Array): Intent =
+ super.createIntent(context, input).addCategory(Intent.CATEGORY_OPENABLE)
+ }
private val ui by lazy { ServerUi(this) }
private val handler = Handler(Looper.getMainLooper())
private var page = "service"
@@ -55,14 +61,41 @@ class DiPlayActivity : AppCompatActivity() {
private var webAddress: TextView? = null
private var webInterface: TextView? = null
private var teslaStatus: TextView? = null
- private var pendingTeslaEnable = false
- private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val enable = pendingTeslaEnable
- pendingTeslaEnable = false
- if (enable && result.resultCode == RESULT_OK) saveTeslaHttpEnabled(true)
- else {
- toast("未授权本地 VPN,无法启动 Tesla HTTP 模式")
- render()
+ private var renderedRootState: Triple? = null
+ private var webBusy = false
+ private var pendingPemCertificate: ByteArray? = null
+ private val pemCertificatePicker = registerForActivityResult(CertificateDocument()) { uri ->
+ if (uri != null) {
+ webBusy = true
+ Thread({
+ val result = runCatching { readCertificateFile(uri) }
+ runOnUiThread {
+ webBusy = false
+ if (isFinishing || isDestroyed) { result.getOrNull()?.fill(0); return@runOnUiThread }
+ result.onSuccess { pendingPemCertificate = it; pemKeyPicker.launch(arrayOf("*/*")) }
+ .onFailure { toast("证书读取失败:${it.message ?: it.javaClass.simpleName}") }
+ }
+ }, "https-certificate-read").start()
+ }
+ }
+ private val pemKeyPicker = registerForActivityResult(CertificateDocument()) { uri ->
+ val certificate = pendingPemCertificate
+ pendingPemCertificate = null
+ if (uri == null || certificate == null) certificate?.fill(0)
+ else textInput("PEM 私钥密码(未加密则留空)", "", true, onCancel = { certificate.fill(0) }) { value ->
+ val password = value.toCharArray()
+ configureWeb("HTTPS 证书已导入,浏览器请重新连接") {
+ try {
+ val key = readCertificateFile(uri)
+ try { LanTls.preparePem(certificate, key, password).use { WebSession.installCertificate(this, it) } }
+ finally { key.fill(0) }
+ } finally { certificate.fill(0); password.fill('\u0000') }
+ }
+ }
+ }
+ private val pkcs12Picker = registerForActivityResult(CertificateDocument()) { uri ->
+ if (uri != null) textInput("PKCS#12 文件密码(可留空)", "", true) { value ->
+ choosePkcs12Identity(uri, value.toCharArray())
}
}
private var otherAddresses: LinearLayout? = null
@@ -89,7 +122,7 @@ class DiPlayActivity : AppCompatActivity() {
connect(notificationTransport)
}
private val tick = object : Runnable {
- override fun run() { refreshStatus(); refreshService(); handler.postDelayed(this, 1000) }
+ override fun run() { refreshStatus(); refreshService(); refreshRootSettings(); handler.postDelayed(this, 1000) }
}
private val bluetoothPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) choosePhone() else permissionHelp("附近设备", "请允许访问附近设备,以连接已配对的 iPhone。")
@@ -158,11 +191,11 @@ class DiPlayActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ RootAccess.checkOnStartup()
com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext)
ServerWindow.showSystemBars(window)
refreshAuthentication()
selectedPrivateKey = savedInstanceState?.getString("selectedPrivateKey")?.let(Uri::parse)
- pendingTeslaEnable = savedInstanceState?.getBoolean("pendingTeslaEnable") ?: false
page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page")
?: if (setupError == null) "service" else "settings"
createShell()
@@ -191,7 +224,6 @@ class DiPlayActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("page", page)
outState.putString("selectedPrivateKey", selectedPrivateKey?.toString())
- outState.putBoolean("pendingTeslaEnable", pendingTeslaEnable)
super.onSaveInstanceState(outState)
}
override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); createShell() }
@@ -210,13 +242,14 @@ class DiPlayActivity : AppCompatActivity() {
initialLaunch = false
if (setupError == null && !DiPlayBootstrap.importing && selectedPrivateKey == null &&
intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_VIEW &&
- !pendingTeslaEnable && !CarPlayBackgroundSession.hasSession() &&
+ !CarPlayBackgroundSession.hasSession() &&
DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) {
handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) }
}
}
}
override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() }
+ override fun onDestroy() { pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() }
private fun normalizedPage(value: String) = when (value) {
"home", "wireless-recovery" -> "phone"
@@ -252,6 +285,7 @@ class DiPlayActivity : AppCompatActivity() {
private fun render() {
refreshPhone()
exportButton = null
+ teslaStatus = null
shell.put("settings", ui.content(::settings))
}
@@ -321,8 +355,8 @@ class DiPlayActivity : AppCompatActivity() {
val compatibility = TeslaHttpCompatibility.status
teslaStatus?.text = if (!TeslaHttpCompatibility.config(this).enabled) "已关闭"
else if (!WebSession.running && setupError != null) "导入认证资源后启动"
- else compatibility.error ?: compatibility.address?.let { "虚拟地址已创建:$it;请用车机测试访问" }
- ?: "正在准备 HTTP 虚拟地址…"
+ else compatibility.error ?: compatibility.address?.let { "Root 热点路由已启用:$it(${compatibility.downstreams.joinToString()});请用车机测试访问" }
+ ?: "正在准备 Root 热点路由…"
webInterface?.visibility = if (primary == null) View.GONE else View.VISIBLE
if (displayedAddresses != addresses) {
displayedAddresses = addresses
@@ -424,46 +458,71 @@ class DiPlayActivity : AppCompatActivity() {
card.addView(label("配对后的浏览器会被记住,下次打开时自动恢复配对;选择启动选项后,点击网页上的「启动显示」。", 16, MUTED))
card.addView(ui.preference("连接过的设备", "查看、重命名或移除浏览器") { showBrowserDevices() })
}
- section(content, "Tesla HTTP 兼容(实验)") { card ->
- val config = TeslaHttpCompatibility.config(this)
- toggle(card, "Tesla HTTP 模式", "车机连接此设备热点后,尝试通过虚拟地址访问;无需证书。部分车机版本仍可能拒绝 HTTP。", config.enabled) {
- setTeslaHttpEnabled(it)
- }
- teslaStatus = label("", 14, MUTED)
- card.addView(teslaStatus)
- card.addView(ui.preference("HTTP 端口", config.port.toString()) {
- textInput("HTTP 端口(1–65535)", config.port.toString(), false) { value ->
+ section(content, "Web 监听") { card ->
+ card.addView(ui.preference("HTTP 端口", WebListenSettings.httpPort(this).toString()) {
+ textInput("HTTP 监听端口(1024–65535)", WebListenSettings.httpPort(this).toString(), false) { value ->
val port = value.toIntOrNull()
- if (port == null || port !in 1..65535) toast("HTTP 端口必须为 1–65535")
- else {
- val failure = WebSession.setHttpPort(this, port)
- if (failure != null) toast(failure)
- else {
- startWebServiceIfReady()
- toast("HTTP 端口已保存,浏览器请使用新地址重新连接")
- }
- render(); refreshService()
- }
+ if (port == null || port !in 1024..65535) toast("HTTP 监听端口必须为 1024–65535")
+ else configureWeb("HTTP 端口已保存,浏览器请使用新地址重新连接") { WebSession.setHttpPort(this, port) }
}
- })
- card.addView(label("HTTP 端口适用于全部 IP 和域名入口,修改后立即生效。端口被占用或系统禁止绑定时保留原端口。", 14, MUTED))
- card.addView(ui.preference("虚拟 IP", config.address) {
- if (CarPlayBackgroundSession.hasSession()) toast("请先断开 iPhone,再修改虚拟 IP")
- else textInput("虚拟 IP", config.address, false) { value ->
- if (CarPlayBackgroundSession.hasSession()) toast("请先断开 iPhone,再修改虚拟 IP")
- else runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(address = value)) }
- .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "虚拟 IP 无效") }
+ }.apply { isEnabled = !webBusy })
+ card.addView(ui.preference("Web 域名(可选)", WebListenSettings.hostname(this).ifEmpty { "使用 IP 地址" }) {
+ textInput("Web 域名(可选)", WebListenSettings.hostname(this), false) { value ->
+ configureWeb("Web 域名已保存") { WebSession.setHostname(this, value.lowercase(Locale.ROOT)) }
}
- })
- card.addView(ui.preference("HTTP 域名(可选)", config.hostname.ifEmpty { "使用虚拟 IP" }) {
- textInput("HTTP 域名(可选)", config.hostname, false) { value ->
- runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(hostname = value.lowercase(Locale.ROOT))) }
- .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "域名无效") }
+ }.apply { isEnabled = !webBusy })
+ card.addView(label("监听设置无需 Root。Web 域名同时用于 HTTP 和 HTTPS,需解析到实际访问的 IP;填写不会自动配置 DNS,自定义 HTTPS 证书需覆盖此域名。", 14, MUTED))
+ toggle(card, "启用 HTTPS", "为浏览器提供 HTTPS 访问;浏览器麦克风需要可信证书。", WebListenSettings.httpsEnabled(this), enabled = !webBusy) {
+ configureWeb(if (it) "HTTPS 已开启" else "HTTPS 已关闭") { WebSession.setHttpsEnabled(this, it) }
+ }
+ if (WebListenSettings.httpsEnabled(this)) {
+ card.addView(ui.preference("Web HTTPS 端口", WebListenSettings.httpsPort(this).toString()) {
+ textInput("HTTPS 监听端口(1024–65535)", WebListenSettings.httpsPort(this).toString(), false) { value ->
+ val port = value.toIntOrNull()
+ if (port == null || port !in 1024..65535) toast("HTTPS 监听端口必须为 1024–65535")
+ else configureWeb("HTTPS 端口已保存,浏览器请使用新端口") { WebSession.setHttpsPort(this, port) }
+ }
+ }.apply { isEnabled = !webBusy })
+ certificateControls(card)
+ }
+ }
+ section(content, "Tesla 兼容(实验)") { card ->
+ renderedRootState = rootState()
+ 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)
+ if (RootAccess.granted) {
+ val config = TeslaHttpCompatibility.config(this)
+ toggle(card, "启用 Tesla 兼容", "将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。", config.enabled, enabled = !webBusy) {
+ setTeslaHttpEnabled(it)
}
- })
- card.addView(label("自备域名需解析到上述虚拟 IP;填写域名不会自动配置 DNS。首次启用需要本地 VPN 授权,可能替换其他 VPN。浏览器麦克风仍需要可信 HTTPS。", 14, MUTED))
- card.addView(button("重新授权并启动", false) { setTeslaHttpEnabled(true) }, ui.secondaryButtonLayout(12))
- card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12))
+ 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))
+ }
}
section(content, "画面与音频") { card ->
carPlaySizeControl(card)
@@ -475,7 +534,7 @@ class DiPlayActivity : AppCompatActivity() {
val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets
choice(card, "音频缓冲", listOf("300 ms · 默认", "500 ms", "1000 ms · 更稳定"),
bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) {
- AirPlayPersistence.saveMediaBufferMillis(this, bufferPresets[it])
+ WebSession.setMediaBufferMillis(this, bufferPresets[it])
}
toggle(card, "转发音频到浏览器", "开启后将 CarPlay 音频发送到已配对的浏览器播放。", AirPlayPersistence.loadBrowserAudioPlayback(this)) {
WebSession.setBrowserAudioPlayback(this, it)
@@ -506,7 +565,8 @@ class DiPlayActivity : AppCompatActivity() {
val fingerprint = WebSession.tls?.fingerprint
MaterialAlertDialogBuilder(this)
.setTitle("浏览器 HTTPS 证书指纹")
- .setMessage(if (fingerprint == null) "HTTPS 服务正在准备或启动失败,请稍后重试。"
+ .setMessage(if (!WebListenSettings.httpsEnabled(this)) "HTTPS 已关闭,请在 Web 监听中开启。"
+ else if (fingerprint == null) "HTTPS 服务正在准备或启动失败,请稍后重试。"
else "请在车机安装证书前,与浏览器页面显示的 SHA-256 指纹逐字核对:\n\n$fingerprint")
.setPositiveButton("关闭", null)
.show()
@@ -574,33 +634,60 @@ class DiPlayActivity : AppCompatActivity() {
return true
}
- private fun setTeslaHttpEnabled(enabled: Boolean) {
- if (CarPlayBackgroundSession.hasSession()) {
- toast("请先断开 iPhone,再修改 Tesla HTTP 模式")
- handler.post { render(); refreshService() }
- return
+ 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))
+ }
+ }
+
+ private fun certificateControls(card: LinearLayout) {
+ card.addView(label("证书类型", 16, TEXT))
+ val current = WebListenSettings.certificateType(this)
+ val group = RadioGroup(this).apply { orientation = RadioGroup.VERTICAL }
+ for (type in WebListenSettings.CertificateType.entries) {
+ group.addView(com.google.android.material.radiobutton.MaterialRadioButton(this).apply {
+ id = View.generateViewId(); text = type.label; tag = type
+ minimumHeight = dp(48); isChecked = type == current; isEnabled = !webBusy
+ }, RadioGroup.LayoutParams(-1, -2))
}
- if (enabled) {
- val consent = CarPlayVpnService.prepare(this)
- if (consent != null) {
- pendingTeslaEnable = true
- runCatching { teslaVpnConsent.launch(consent) }.onFailure {
- pendingTeslaEnable = false
- toast("无法打开本地 VPN 授权,请检查系统设置")
- handler.post { render(); refreshService() }
+ group.setOnCheckedChangeListener { _, checked ->
+ val type = group.findViewById(checked)?.tag as? WebListenSettings.CertificateType ?: return@setOnCheckedChangeListener
+ if (type == WebListenSettings.CertificateType.DEFAULT) {
+ configureWeb("已恢复默认本地证书") {
+ WebSession.restoreDefaultCertificate(this).also { if (it == null) WebListenSettings.saveCertificateType(this, type) }
}
- return
+ } else {
+ WebListenSettings.saveCertificateType(this, type)
+ render()
}
}
- saveTeslaHttpEnabled(enabled)
+ card.addView(group)
+ when (current) {
+ WebListenSettings.CertificateType.DEFAULT -> Unit
+ WebListenSettings.CertificateType.PEM -> card.addView(button("选择 PEM 证书与私钥", false) {
+ pemCertificatePicker.launch(arrayOf("*/*"))
+ }.apply { isEnabled = !webBusy }, ui.secondaryButtonLayout(8))
+ WebListenSettings.CertificateType.PKCS12 -> card.addView(button("选择 PKCS#12 文件", false) {
+ pkcs12Picker.launch(arrayOf("*/*"))
+ }.apply { isEnabled = !webBusy }, ui.secondaryButtonLayout(8))
+ }
+ val identity = if (LanTls.hasCustom(this)) "当前使用自定义证书" else "当前使用默认本地证书"
+ card.addView(label(identity + "。证书和私钥保存在 App 私有存储。PEM 依次选择证书链、私钥;PKCS#12 可选择私钥条目。", 14, MUTED))
}
- private fun saveTeslaHttpEnabled(enabled: Boolean) {
- if (CarPlayBackgroundSession.hasSession()) {
- toast("请先断开 iPhone,再修改 Tesla HTTP 模式")
- handler.post { render(); refreshService() }
- return
- }
+ private fun saveTeslaMapping(value: String, https: Boolean) {
+ val port = value.toIntOrNull()
+ if (port == null || port !in 1..1023) { toast("映射端口必须为 1–1023"); return }
+ runCatching {
+ val config = TeslaHttpCompatibility.config(this)
+ TeslaHttpCompatibility.save(this, if (https) config.copy(httpsMappingPort = port) else config.copy(httpMappingPort = port))
+ }.onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "端口映射保存失败") }
+ }
+
+ private fun setTeslaHttpEnabled(enabled: Boolean) {
TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(enabled = enabled))
if (!WebSession.running) startWebServiceIfReady()
handler.post { render(); refreshService() }
@@ -750,7 +837,55 @@ class DiPlayActivity : AppCompatActivity() {
}.setNegativeButton("关闭", null).show()
}
- private fun textInput(title: String, current: String, secret: Boolean, save: (String) -> Unit) {
+ private fun readCertificateFile(uri: Uri): ByteArray = contentResolver.openInputStream(uri)?.use(LanTls::readImport)
+ ?: error("无法打开所选文件")
+
+ private fun configureWeb(success: String, action: () -> String?) {
+ if (webBusy) { toast("正在更新设置,请稍候"); return }
+ webBusy = true
+ render()
+ Thread({
+ val failure = runCatching(action).getOrElse { "Web 设置更新失败:${it.message ?: it.javaClass.simpleName}" }
+ runOnUiThread {
+ webBusy = false
+ if (!isFinishing && !isDestroyed) { toast(failure ?: success); render(); refreshService() }
+ }
+ }, "web-configure").start()
+ }
+
+ private fun choosePkcs12Identity(uri: Uri, password: CharArray) {
+ if (webBusy) { password.fill('\u0000'); toast("正在更新 HTTPS,请稍候"); return }
+ webBusy = true
+ Thread({
+ var bytes: ByteArray? = null
+ val result = runCatching {
+ readCertificateFile(uri).also { bytes = it }.let { LanTls.pkcs12Aliases(it, password) }
+ }
+ runOnUiThread {
+ webBusy = false
+ fun clear() { bytes?.fill(0); password.fill('\u0000') }
+ if (isFinishing || isDestroyed) { clear(); return@runOnUiThread }
+ result.onFailure { clear(); toast("PKCS#12 读取失败:请检查文件及密码") }
+ .onSuccess { aliases ->
+ val choose = { alias: String ->
+ textInput("私钥密码(留空使用文件密码)", "", true, onCancel = ::clear) { value ->
+ val keyPassword = if (value.isEmpty()) password.copyOf() else value.toCharArray()
+ configureWeb("PKCS#12 证书已导入,浏览器请重新连接") {
+ try { LanTls.preparePkcs12(bytes!!, password, alias, keyPassword).use { WebSession.installCertificate(this, it) } }
+ finally { clear(); keyPassword.fill('\u0000') }
+ }
+ }
+ }
+ if (aliases.size == 1) choose(aliases.single())
+ else MaterialAlertDialogBuilder(this).setTitle("选择 PKCS#12 私钥条目")
+ .setItems(aliases.toTypedArray()) { _, index -> choose(aliases[index]) }
+ .setNegativeButton("取消") { _, _ -> clear() }.setOnCancelListener { clear() }.show()
+ }
+ }
+ }, "https-pkcs12-read").start()
+ }
+
+ private fun textInput(title: String, current: String, secret: Boolean, onCancel: () -> Unit = {}, save: (String) -> Unit) {
val field = TextInputLayout(this).apply {
hint = title
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
@@ -759,6 +894,7 @@ class DiPlayActivity : AppCompatActivity() {
val input = TextInputEditText(field.context).apply {
setText(current)
setSingleLine()
+ isSaveEnabled = !secret
inputType = if (secret) {
android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
} else {
@@ -769,7 +905,7 @@ class DiPlayActivity : AppCompatActivity() {
val container = column().apply { setPadding(dp(24), dp(8), dp(24), 0); addView(field) }
MaterialAlertDialogBuilder(this).setTitle(title).setView(container)
.setPositiveButton("保存") { _, _ -> save(input.text.toString().let { if (secret) it else it.trim() }) }
- .setNegativeButton("取消", null).show()
+ .setNegativeButton("取消") { _, _ -> onCancel() }.setOnCancelListener { onCancel() }.show()
}
private fun carPlaySizeControl(parent: LinearLayout) {
@@ -946,8 +1082,8 @@ class DiPlayActivity : AppCompatActivity() {
appendLine("Saved video preference (may differ from active session): ${if (AirPlayPersistence.loadHevcEnabled(appContext)) "HEVC" else "H.264"}; ${AirPlayPersistence.loadFps(appContext)} fps")
appendLine("CarPlay size: ${com.shilapi.xcertplay.airplay.CarPlaySize.fromWidthMillimeters(AirPlayPersistence.loadWidthPhysicalMm(appContext)).label}")
appendLine("Saved resolution preference (may differ from active session): ${AirPlayPersistence.loadDisplayScaleTenths(appContext) * 10}%")
- appendLine("Tesla HTTP mode: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; aliasCreated=${TeslaHttpCompatibility.status.address != null}")
- TeslaHttpCompatibility.status.error?.let { appendLine("Tesla HTTP status: $it") }
+ appendLine("Tesla compatibility: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; rootRoutingReady=${TeslaHttpCompatibility.status.address != null}; downstreams=${TeslaHttpCompatibility.status.downstreams.joinToString()}")
+ TeslaHttpCompatibility.status.error?.let { appendLine("Tesla compatibility status: $it") }
appendLine("Session: ${if (CarPlayBackgroundSession.active) "active" else if (CarPlayBackgroundSession.hasSession()) "connecting" else "stopped"}")
appendLine("Head-unit board: ${Build.BOARD}; hardware: ${Build.HARDWARE}; build: ${Build.DISPLAY}")
appendLine()
@@ -994,11 +1130,11 @@ class DiPlayActivity : AppCompatActivity() {
private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1"
private fun section(parent: LinearLayout, title: String, build: (LinearLayout) -> Unit) =
ui.section(parent, title, content = build)
- private fun toggle(parent: LinearLayout, title: String, description: String, value: Boolean, save: (Boolean) -> Unit) {
+ private fun toggle(parent: LinearLayout, title: String, description: String, value: Boolean, enabled: Boolean = true, save: (Boolean) -> Unit) {
val line = row().apply { gravity = Gravity.CENTER_VERTICAL; setPadding(0, dp(12), 0, dp(12)) }
val text = column(); text.addView(label(title, 18, TEXT, true)); text.addView(label(description, 14, MUTED).apply { setPadding(0, dp(4), 0, 0) })
line.addView(text, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(16) })
- line.addView(MaterialSwitch(this).apply { contentDescription = title; isChecked = value; minHeight = dp(48); setOnCheckedChangeListener { _, checked -> save(checked) } })
+ line.addView(MaterialSwitch(this).apply { contentDescription = title; isChecked = value; isEnabled = enabled; minHeight = dp(48); setOnCheckedChangeListener { _, checked -> save(checked) } })
parent.addView(line)
}
private fun choice(parent: LinearLayout, title: String, options: List, current: Int, save: (Int) -> Unit) {
diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt
index f751304..89cb272 100644
--- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt
@@ -1,7 +1,8 @@
package com.shilapi.xcertplay
import com.shilapi.xcertplay.web.WebSession
-import com.shilapi.xcertplay.web.TeslaHttpCompatibility
+import com.shilapi.xcertplay.web.WebListenSettings
+import com.shilapi.xcertplay.web.RootAccess
import com.shilapi.xcertplay.media.CarPlayMediaSessionBridge
import com.shilapi.xcertplay.media.MediaCommand
import com.shilapi.xcertplay.media.NowPlayingState
@@ -40,6 +41,7 @@ class DiPlaySessionService : Service() {
override fun onCreate() {
super.onCreate()
+ RootAccess.checkOnStartup()
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "CarPlay 服务", NotificationManager.IMPORTANCE_LOW))
media = CarPlayMediaSessionBridge(this) { state, session, artwork ->
@@ -59,7 +61,7 @@ class DiPlaySessionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
media.close()
- WebSession.stop()
+ runWebAction { WebSession.stop() }
CarPlayBackgroundSession.stop()
stopSelf()
return START_NOT_STICKY
@@ -91,15 +93,25 @@ class DiPlaySessionService : Service() {
}
return START_NOT_STICKY
}
- WebSession.start(this)
- updateNotification()
- if (WebSession.running && wakeLock == null) {
- wakeLock = getSystemService(PowerManager::class.java)
- .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WheelPlay:server").apply { acquire() }
+ runWebAction {
+ if (!destroyed) WebSession.start(this)
+ updateNotification()
+ val acquireWakeLock = {
+ if (!destroyed && WebSession.running && wakeLock == null) {
+ wakeLock = getSystemService(PowerManager::class.java)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WheelPlay:server").apply { acquire() }
+ }
+ }
+ if (Looper.myLooper() == Looper.getMainLooper()) acquireWakeLock() else main.post { acquireWakeLock() }
}
return START_NOT_STICKY
}
+ private fun runWebAction(action: () -> Unit) {
+ // Ordinary Web listeners are unprivileged; Tesla routing has its own worker.
+ action()
+ }
+
private fun mediaAction(command: MediaCommand, icon: Int, title: Int): Notification.Action {
val intent = Intent(this, DiPlaySessionService::class.java).setAction(ACTION_MEDIA)
.setData(Uri.parse("wheelplay://media/${media.commandGeneration}/${command.name}"))
@@ -130,7 +142,7 @@ class DiPlaySessionService : Service() {
} else {
val stop = PendingIntent.getService(this, 1, Intent(this, DiPlaySessionService::class.java)
.setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
- val port = if (WebSession.running) WebSession.httpPort else TeslaHttpCompatibility.config(this).port
+ val port = if (WebSession.running) WebSession.httpPort else WebListenSettings.httpPort(this)
builder.setContentTitle(getString(R.string.app_name)).setContentText(
if (WebSession.running) "局域网串流服务运行中 · $port" else "正在准备串流服务…")
.addAction(Notification.Action.Builder(Icon.createWithResource(this, R.drawable.ic_media_stop),
@@ -151,7 +163,7 @@ class DiPlaySessionService : Service() {
destroyed = true
sessionSubscription?.close()
media.close()
- WebSession.stop()
+ runWebAction { WebSession.stop() }
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
CarPlayBackgroundSession.stop()
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/BrowserAudioBridge.kt b/common/src/main/java/com/shilapi/xcertplay/web/BrowserAudioBridge.kt
index 1a9288e..c23fed5 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/BrowserAudioBridge.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/BrowserAudioBridge.kt
@@ -3,6 +3,7 @@ package com.shilapi.xcertplay.web
import com.shilapi.xcertplay.airplay.AudioFormat
import com.shilapi.xcertplay.airplay.MicrophoneConfig
import com.shilapi.xcertplay.media.PcmInput
+import com.shilapi.xcertplay.media.MediaAudioBuffer
import com.shilapi.xcertplay.media.RemoteAudioRoute
import com.shilapi.xcertplay.media.isMediaPlayback
import org.json.JSONObject
@@ -20,6 +21,7 @@ internal class BrowserAudioBridge {
private var secureOwner = false
private var requestedPlayback = false
private var requestedCapture = false
+ private var mediaBufferMillis = MediaAudioBuffer.DEFAULT_MILLIS
private var playback = false
private var capture = false
private var sequence = 0L
@@ -33,9 +35,10 @@ internal class BrowserAudioBridge {
sendRoute()
}
- fun configure(playback: Boolean, capture: Boolean) {
+ fun configure(playback: Boolean, capture: Boolean, mediaBufferMillis: Int = MediaAudioBuffer.DEFAULT_MILLIS) {
val change = synchronized(lock) {
requestedPlayback = playback; requestedCapture = capture
+ this.mediaBufferMillis = MediaAudioBuffer.sanitize(mediaBufferMillis)
sendRoute()
this.playback = this.playback && playback
val nextCapture = this.capture && capture
@@ -70,7 +73,8 @@ internal class BrowserAudioBridge {
private fun sendRoute() {
if (owner != null) control?.invoke(JSONObject().put("type", "audio-route")
- .put("playback", requestedPlayback).put("microphone", requestedCapture && secureOwner).toString())
+ .put("playback", requestedPlayback).put("microphone", requestedCapture && secureOwner)
+ .put("mediaBufferMillis", mediaBufferMillis).toString())
}
fun detach(owner: Any) {
@@ -130,7 +134,9 @@ internal class BrowserAudioBridge {
val seq = ++sequence
val packet = ByteBuffer.allocate(20 + length).order(ByteOrder.LITTLE_ENDIAN)
.putInt(AUDIO_MAGIC).putInt(type).putInt(format.sampleRate)
- .putShort(format.channels.toShort()).putShort(0).putInt(seq.toInt())
+ .putShort(format.channels.toShort())
+ .putShort(if (format.isMediaPlayback()) AUDIO_FLAG_MEDIA.toShort() else 0)
+ .putInt(seq.toInt())
.put(pcm, offset, length).array()
Triple(send!!, packet, seq)
}
@@ -201,6 +207,7 @@ internal class BrowserAudioBridge {
companion object {
const val AUDIO_MAGIC = 0x31415057 // WPA1
+ const val AUDIO_FLAG_MEDIA = 1 // reserved WPA1 flags word at byte 14
const val MIC_MAGIC = 0x314d5057 // WPM1
}
}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/BrowserExperienceLink.kt b/common/src/main/java/com/shilapi/xcertplay/web/BrowserExperienceLink.kt
index ab49ef6..22e1f93 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/BrowserExperienceLink.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/BrowserExperienceLink.kt
@@ -1,11 +1,10 @@
package com.shilapi.xcertplay.web
-import com.shilapi.xcertplay.network.TeslaHttpConfig
/** Builds the same-device browser entry without exposing a LAN address. */
internal object BrowserExperienceLink {
private val pairingCode = Regex("[0-9]{6}")
- fun local(code: String, port: Int = TeslaHttpConfig.DEFAULT_PORT): String? =
+ fun local(code: String, port: Int = WebListenSettings.DEFAULT_HTTP_PORT): String? =
if (pairingCode.matches(code) && port in 1..65535) "http://127.0.0.1:$port/?code=$code" else null
}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt
index 6fdb263..955e6e0 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt
@@ -10,16 +10,16 @@ import java.net.NetworkInterface
/** Keep interface provenance; a private address alone does not imply LAN reachability. */
internal object LanAddresses {
enum class Kind(val label: String, val priority: Int) {
- TESLA_HTTP("Tesla HTTP(实验)", 5), ETHERNET("以太网", 10), WIFI("Wi-Fi", 10), HOTSPOT("热点", 20),
+ TESLA_HTTP("Tesla 兼容(实验)", 5), ETHERNET("以太网", 10), WIFI("Wi-Fi", 10), HOTSPOT("热点", 20),
USB("USB 网络", 30), OTHER("其它网络", 50), P2P("Wi-Fi Direct", 60),
VPN("VPN / 隧道", 90), CELLULAR("移动网络", 100)
}
data class Entry(val interfaceName: String, val address: String, val kind: Kind, val active: Boolean = false,
- val hostname: String = "", val port: Int = TeslaHttpConfig.DEFAULT_PORT) {
+ val hostname: String = "", val port: Int = WebListenSettings.DEFAULT_HTTP_PORT) {
val url get() = "http://${hostname.ifEmpty { address }}:$port"
val ipUrl get() = "http://$address:$port"
val description get() = "${kind.label} · $interfaceName" + when (kind) {
- Kind.TESLA_HTTP -> " · 请将车机连接此设备热点;HTTP 可用性需实测"
+ Kind.TESLA_HTTP -> " · Root 热点共享;HTTP 可用性需实测"
Kind.P2P -> " · 通常用于 iPhone 连接"
Kind.VPN, Kind.CELLULAR -> " · 通常无法从局域网访问"
else -> ""
@@ -46,13 +46,11 @@ internal object LanAddresses {
fun isDiscoverable(address: Inet4Address): Boolean =
address.isSiteLocalAddress || TeslaHttpConfig.isSharedAddress(address.hostAddress ?: "")
- fun entry(name: String, address: String, kind: Kind, active: Boolean,
- teslaAddress: String?, hostname: String, port: Int = TeslaHttpConfig.DEFAULT_PORT): Entry =
- if (kind == Kind.VPN && address == teslaAddress) Entry(name, address, Kind.TESLA_HTTP, active, hostname, port)
- else Entry(name, address, kind, active, port = port)
+ fun sharedEntry(address: String, hostname: String, port: Int = WebListenSettings.DEFAULT_HTTP_PORT): Entry =
+ Entry("Root 热点路由", address, Kind.TESLA_HTTP, hostname = hostname, port = port)
fun discover(context: Context, teslaAddress: String? = null, hostname: String = "",
- port: Int = TeslaHttpConfig.DEFAULT_PORT): List {
+ port: Int = WebListenSettings.DEFAULT_HTTP_PORT, mappedPort: Int? = null): List {
val transports = mutableMapOf()
var activeInterface: String? = null
// Framework metadata is authoritative; interface-name heuristics cover tethering/P2P.
@@ -77,11 +75,17 @@ internal object LanAddresses {
runCatching {
if (!iface.isUp || iface.isLoopback) emptyList() else iface.inetAddresses.toList()
.filterIsInstance().filter(::isDiscoverable)
- .map { entry(iface.name, it.hostAddress!!, classify(iface.name, transports[iface.name]),
- iface.name == activeInterface, teslaAddress, hostname, port) }
+ .map { Entry(iface.name, it.hostAddress!!, classify(iface.name, transports[iface.name]),
+ iface.name == activeInterface, port = port) }
}.getOrDefault(emptyList())
}
}.getOrDefault(emptyList())
- return ranked(entries)
+ // The HTTP alias lives on loopback, deliberately excluded from ordinary LAN discovery.
+ // Advertise it only after the root session has installed both input and return routing.
+ val shared = teslaAddress?.let { listOf(sharedEntry(it, hostname, mappedPort ?: port)) }.orEmpty()
+ val ranked = ranked(shared + entries)
+ return if (shared.isEmpty() && hostname.isNotEmpty()) ranked.mapIndexed { index, entry ->
+ if (index == 0) entry.copy(hostname = hostname) else entry
+ } else ranked
}
}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanTls.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanTls.kt
index c3b6772..6ef74a4 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/LanTls.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/LanTls.kt
@@ -14,23 +14,68 @@ import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
+import org.bouncycastle.asn1.ASN1ObjectIdentifier
+import org.bouncycastle.asn1.x9.X9ECParameters
+import org.bouncycastle.openssl.PEMParser
+import org.bouncycastle.openssl.PEMKeyPair
+import org.bouncycastle.openssl.PEMEncryptedKeyPair
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
+import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder
+import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder
+import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo
+import java.io.ByteArrayOutputStream
+import java.io.StringReader
+import java.io.InputStream
import java.io.File
import java.math.BigInteger
+import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.SecureRandom
+import java.security.Signature
+import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
+import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocketFactory
-/** Per-installation local CA, stored only in private non-backup storage. No private-key export. */
+/** Local CA or imported server identity, stored only in private non-backup storage. */
internal object LanTls {
- data class Endpoint(val sockets: SSLServerSocketFactory, val certificate: ByteArray, val fingerprint: String)
+ data class Endpoint(val sockets: SSLServerSocketFactory, val certificate: ByteArray, val fingerprint: String,
+ val custom: Boolean = false, val subject: String = "")
+ class Prepared(val encoded: ByteArray, val endpoint: Endpoint) : AutoCloseable {
+ override fun close() { encoded.fill(0) }
+ }
+ const val MAX_IMPORT_BYTES = 2 * 1024 * 1024
private val provider = BouncyCastleProvider()
- @Synchronized fun create(context: Context, addresses: List): Endpoint {
+ fun readImport(input: InputStream): ByteArray = ByteArrayOutputStream().use { output ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ require(output.size() + count <= MAX_IMPORT_BYTES) { "证书文件不能超过 2 MiB" }
+ output.write(buffer, 0, count)
+ }
+ output.toByteArray().also { require(it.isNotEmpty()) { "文件为空" } }
+ }
+ private fun customFile(context: Context) = AtomicFile(File(context.noBackupFilesDir, "browser-server-custom.p12"))
+ fun hasCustom(context: Context) = customFile(context).baseFile.exists()
+
+ @Synchronized fun create(context: Context, addresses: List, hostname: String = ""): Endpoint {
+ val file = customFile(context)
+ if (file.baseFile.exists()) {
+ val store = KeyStore.getInstance("PKCS12")
+ file.openRead().use { store.load(it, CharArray(0)) }
+ return endpoint(store, "server", true)
+ }
+ return createDefault(context, addresses, hostname)
+ }
+
+ @Synchronized fun createDefault(context: Context, addresses: List, hostname: String = ""): Endpoint {
val password = CharArray(0)
val store = KeyStore.getInstance("PKCS12")
val file = AtomicFile(File(context.noBackupFilesDir, "browser-audio-ca.p12"))
@@ -49,19 +94,120 @@ internal object LanTls {
val rootKey = store.getKey("ca", password) as PrivateKey
val keys = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
val leaf = certificate(X500Name(root.subjectX500Principal.name), X500Name("CN=WheelPlay LAN"),
- keys.public, rootKey, 7, false, (addresses + "127.0.0.1" + "::1").distinct())
+ keys.public, rootKey, 7, false, (addresses + "127.0.0.1" + "::1").distinct(), hostname)
val leafStore = KeyStore.getInstance("PKCS12").apply {
load(null, password); setKeyEntry("server", keys.private, password, arrayOf(leaf, root))
}
- val managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(leafStore, password) }
+ return endpoint(leafStore, "server", false)
+ }
+
+ private fun endpoint(store: KeyStore, alias: String, custom: Boolean): Endpoint {
+ val chain = store.getCertificateChain(alias).map { it as X509Certificate }
+ chain.forEach { it.checkValidity() }
+ val managers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(store, CharArray(0)) }
val tls = SSLContext.getInstance("TLS").apply { init(managers.keyManagers, null, SecureRandom()) }
- val fingerprint = java.security.MessageDigest.getInstance("SHA-256").digest(root.encoded)
+ val certificate = chain.last()
+ val fingerprint = java.security.MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
.joinToString(":") { "%02X".format(it.toInt() and 255) }
- return Endpoint(tls.serverSocketFactory, root.encoded, fingerprint)
+ return Endpoint(tls.serverSocketFactory, certificate.encoded, fingerprint, custom, chain.first().subjectX500Principal.name)
+ }
+
+ fun preparePem(certificates: ByteArray, privateKey: ByteArray, password: CharArray = CharArray(0)): Prepared {
+ require(certificates.size in 1..MAX_IMPORT_BYTES && privateKey.size in 1..MAX_IMPORT_BYTES) { "证书或私钥文件过大/为空" }
+ val chain = CertificateFactory.getInstance("X.509").generateCertificates(certificates.inputStream())
+ .map { it as X509Certificate }
+ val converter = JcaPEMKeyConverter().setProvider(provider)
+ val key = PEMParser(StringReader(privateKey.toString(Charsets.UTF_8))).use { parser ->
+ var value = parser.readObject()
+ // OpenSSL traditional EC PEM can prefix the key with named or explicit curve parameters.
+ if (value is ASN1ObjectIdentifier || value is X9ECParameters) value = parser.readObject()
+ val info = when (value) {
+ is PEMKeyPair -> value.privateKeyInfo
+ is PrivateKeyInfo -> value
+ is PEMEncryptedKeyPair -> value.decryptKeyPair(JcePEMDecryptorProviderBuilder().setProvider(provider).build(password)).privateKeyInfo
+ is PKCS8EncryptedPrivateKeyInfo -> value.decryptPrivateKeyInfo(JceOpenSSLPKCS8DecryptorProviderBuilder().setProvider(provider).build(password))
+ else -> error("请选择 PEM 私钥(PKCS#8、RSA 或 EC)")
+ }
+ require(parser.readObject() == null) { "私钥文件必须只包含一把私钥" }
+ converter.getPrivateKey(info)
+ }
+ return prepare(key, chain)
+ }
+
+ private fun loadPkcs12(bytes: ByteArray, password: CharArray): KeyStore {
+ require(bytes.size in 1..MAX_IMPORT_BYTES) { "PKCS#12 文件过大/为空" }
+ return runCatching { KeyStore.getInstance("PKCS12").apply { load(bytes.inputStream(), password) } }
+ .getOrElse { KeyStore.getInstance("PKCS12", provider).apply { load(bytes.inputStream(), password) } }
+ }
+
+ fun pkcs12Aliases(bytes: ByteArray, password: CharArray): List {
+ val store = loadPkcs12(bytes, password)
+ return store.aliases().toList().filter { store.isKeyEntry(it) }.also { require(it.isNotEmpty()) { "PKCS#12 中没有私钥条目" } }
+ }
+
+ fun preparePkcs12(bytes: ByteArray, password: CharArray, alias: String? = null,
+ keyPassword: CharArray = password): Prepared {
+ val store = loadPkcs12(bytes, password)
+ val aliases = store.aliases().toList().filter { store.isKeyEntry(it) }
+ val selected = alias ?: aliases.singleOrNull() ?: error("请选择 PKCS#12 中的私钥条目")
+ require(selected in aliases) { "所选条目没有私钥" }
+ val key = store.getKey(selected, keyPassword) as? PrivateKey ?: error("条目没有可用私钥,请检查私钥密码")
+ val chain = store.getCertificateChain(selected)?.map { it as X509Certificate } ?: error("条目缺少证书链")
+ return prepare(key, chain)
+ }
+
+ private fun prepare(privateKey: PrivateKey, chain: List): Prepared {
+ // PEM converters may name an EC key "ECDSA". Conscrypt's PrivateKeyEntry
+ // requires the same "EC" algorithm name as the certificate's public key.
+ val key = if (privateKey.algorithm.equals("ECDSA", ignoreCase = true))
+ KeyFactory.getInstance("EC", provider).generatePrivate(PKCS8EncodedKeySpec(privateKey.encoded))
+ else privateKey
+ require(chain.isNotEmpty() && chain.first().basicConstraints < 0) { "需要服务器证书,不能使用 CA 证书作为服务器身份" }
+ chain.forEach { it.checkValidity() }
+ val signatureAlgorithm = when (key.algorithm.uppercase()) {
+ "RSA" -> "SHA256withRSA"
+ "EC", "ECDSA" -> "SHA256withECDSA"
+ "ED25519" -> "Ed25519"
+ "ED448" -> "Ed448"
+ else -> error("不支持的私钥算法:${key.algorithm}")
+ }
+ val probe = ByteArray(32).also(SecureRandom()::nextBytes)
+ val signature = Signature.getInstance(signatureAlgorithm, provider).apply { initSign(key); update(probe) }.sign()
+ val matches = Signature.getInstance(signatureAlgorithm, provider).apply { initVerify(chain.first()); update(probe) }.verify(signature)
+ require(matches) { "服务器证书与私钥不匹配" }
+ for (i in 0 until chain.lastIndex) {
+ require(chain[i].issuerX500Principal == chain[i + 1].subjectX500Principal) { "证书链应按服务器证书、签发者顺序排列" }
+ chain[i].verify(chain[i + 1].publicKey, provider)
+ }
+ chain.first().extendedKeyUsage?.let {
+ require("1.3.6.1.5.5.7.3.1" in it || "2.5.29.37.0" in it) { "证书不允许 TLS 服务器用途" }
+ }
+ val store = KeyStore.getInstance("PKCS12").apply {
+ load(null, CharArray(0)); setKeyEntry("server", key, CharArray(0), chain.toTypedArray())
+ }
+ val bytes = ByteArrayOutputStream().use { store.store(it, CharArray(0)); it.toByteArray() }
+ return Prepared(bytes, endpoint(store, "server", true))
+ }
+
+ @Synchronized fun saveCustom(context: Context, prepared: Prepared) {
+ val file = customFile(context)
+ val output = file.startWrite()
+ try {
+ output.write(prepared.encoded)
+ file.finishWrite(output)
+ file.baseFile.setReadable(false, false); file.baseFile.setReadable(true, true)
+ file.baseFile.setWritable(false, false); file.baseFile.setWritable(true, true)
+ } catch (error: Exception) { file.failWrite(output); throw error }
+ }
+
+ @Synchronized fun clearCustom(context: Context) {
+ val file = customFile(context)
+ file.delete()
+ check(!file.baseFile.exists()) { "自定义证书清除失败" }
}
private fun certificate(issuer: X500Name, subject: X500Name, publicKey: java.security.PublicKey,
- signer: PrivateKey, days: Int, ca: Boolean, addresses: List): X509Certificate {
+ signer: PrivateKey, days: Int, ca: Boolean, addresses: List, hostname: String = ""): X509Certificate {
val now = System.currentTimeMillis()
val builder = JcaX509v3CertificateBuilder(issuer, BigInteger(128, SecureRandom()).abs().add(BigInteger.ONE),
Date(now - 300_000), Date(now + days * 86_400_000L), subject, publicKey)
@@ -71,7 +217,8 @@ internal object LanTls {
if (!ca) {
builder.addExtension(Extension.extendedKeyUsage, false, ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth))
builder.addExtension(Extension.subjectAlternativeName, false, GeneralNames(
- (addresses.map { GeneralName(GeneralName.iPAddress, it) } + GeneralName(GeneralName.dNSName, "localhost")).toTypedArray()))
+ (addresses.map { GeneralName(GeneralName.iPAddress, it) } + GeneralName(GeneralName.dNSName, "localhost") +
+ listOfNotNull(hostname.takeIf { it.isNotEmpty() }?.let { GeneralName(GeneralName.dNSName, it) })).toTypedArray()))
}
return JcaX509CertificateConverter().setProvider(provider).getCertificate(
builder.build(JcaContentSignerBuilder("SHA256withRSA").setProvider(provider).build(signer)))
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt
index 85d1885..838fc5d 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt
@@ -3,7 +3,6 @@ package com.shilapi.xcertplay.web
import android.content.Context
import android.os.SystemClock
import com.shilapi.xcertplay.airplay.AirPlayContact
-import com.shilapi.xcertplay.network.TeslaHttpConfig
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.json.JSONObject
@@ -14,8 +13,10 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/** Same-origin HTTP assets and a single paired, backpressured video/control WebSocket. */
-internal class LanWebServer(private val context: Context, private val code: String, port: Int = TeslaHttpConfig.DEFAULT_PORT,
+internal class LanWebServer(private val context: Context, private val code: String, port: Int = WebListenSettings.DEFAULT_HTTP_PORT,
private val secure: Boolean = false, val pairing: QrPairing = QrPairing()) : NanoWSD("0.0.0.0", port) {
+ init { require(port == 0 || port in 1024..65535) { "Web 监听请使用高位端口;低位端口由 Tesla 兼容映射" } }
+ val ready get() = isAlive
@Volatile private var viewer: Client? = null
private val timer = Executors.newSingleThreadScheduledExecutor()
@Volatile private var failures = 0
@@ -73,12 +74,14 @@ internal class LanWebServer(private val context: Context, private val code: Stri
if (session.uri.startsWith("/pair/")) return servePairing(session)
if (session.method != Method.GET) return response(Response.Status.METHOD_NOT_ALLOWED, "GET only")
if (session.uri == "/tls.json") return newFixedLengthResponse(Response.Status.OK, "application/json",
- JSONObject().put("available", WebSession.tls != null).put("fingerprint", WebSession.tls?.fingerprint)
+ JSONObject().put("available", WebSession.httpsReady).put("fingerprint", WebSession.tls?.fingerprint)
+ .put("port", WebSession.httpsPortFor(context, session.headers["host"])).put("customCertificate", WebSession.tls?.custom == true)
+ .put("enabled", WebListenSettings.httpsEnabled(context))
.put("error", WebSession.tlsError).toString()).apply { addHeader("Cache-Control", "no-store") }
if (session.uri == "/certificate.crt") {
val bytes = WebSession.tls?.certificate ?: return response(Response.Status.NOT_FOUND, "HTTPS not ready")
return newFixedLengthResponse(Response.Status.OK, "application/x-x509-ca-cert", bytes.inputStream(), bytes.size.toLong()).apply {
- addHeader("Content-Disposition", "attachment; filename=wheelplay-local-ca.crt")
+ addHeader("Content-Disposition", "attachment; filename=wheelplay-certificate.crt")
addHeader("Cache-Control", "no-store")
}
}
@@ -305,6 +308,7 @@ internal class LanWebServer(private val context: Context, private val code: Stri
"mic-config" -> WebStreamSender.Control.MIC_CONFIG
"mic-stop" -> WebStreamSender.Control.MIC_STOP
"audio-route" -> {
+ sender.configureAudioBuffer(data.getInt("mediaBufferMillis"))
if (!data.getBoolean("playback")) sender.clearAudio()
WebStreamSender.Control.AUDIO_ROUTE
}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/RootHttpRouting.kt b/common/src/main/java/com/shilapi/xcertplay/web/RootHttpRouting.kt
new file mode 100644
index 0000000..ad18ff8
--- /dev/null
+++ b/common/src/main/java/com/shilapi/xcertplay/web/RootHttpRouting.kt
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: Apache-2.0
+// Adapted from Mygod/VPNHotspot v2.17.1 Routing.kt and util/RootSession.kt.
+// Changes: local HTTP destination only, scoped return routes, and an EOF-owned root shell.
+package com.shilapi.xcertplay.web
+
+import com.shilapi.xcertplay.network.TeslaHttpConfig
+import com.shilapi.xcertplay.network.TeslaHttpStatus
+import java.io.IOException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+internal data class HttpDownstream(val name: String, val index: Int, val subnet: String) {
+ init {
+ require(Regex("[a-zA-Z0-9_.-]{1,15}").matches(name) && name != "lo")
+ require(index in 1..Int.MAX_VALUE - 1000)
+ val parts = subnet.split('/')
+ require(parts.size == 2 && parts[1].toIntOrNull() in 1..30)
+ val octets = parts[0].split('.')
+ require(octets.size == 4 && octets.all { Regex("0|[1-9][0-9]{0,2}").matches(it) && it.toInt() in 0..255 })
+ }
+}
+
+internal data class RootHttpPlan(val address: String, val port: Int, val downstreams: List, val httpsPort: Int? = null,
+ val httpMappingPort: Int? = null, val httpsMappingPort: Int? = null) {
+ init {
+ require(TeslaHttpConfig.isHttpAddress(address) && port in 1024..65535)
+ require(downstreams.isNotEmpty() && downstreams.distinctBy { it.name }.size == downstreams.size)
+ require(httpsPort == null || httpsPort in 1024..65535 && httpsPort != port)
+ require(httpMappingPort == null || httpMappingPort in 1..1023)
+ require(httpsMappingPort == null || httpsMappingPort in 1..1023 && httpsPort != null)
+ require(httpMappingPort == null || httpMappingPort != httpsMappingPort)
+ }
+
+ data class Command(val apply: String, val revert: String)
+
+ // Android tethering routes live in local_network (97), rather than 1000 + ifindex.
+ // See AOSP RouteController::addInterfaceToLocalNetwork and VPN_OUTPUT_TO_LOCAL.
+ // Only packets addressed to our local HTTP alias and their replies use these rules.
+ fun commands(): List = buildList {
+ val ip = "/system/bin/ip -4"
+ fun ipRule(rule: String) = add(Command("$ip rule add $rule priority 9000", "$ip rule del $rule priority 9000"))
+ fun filter(rule: String) = add(Command("iptables -w 2 -I $rule", "iptables -w 2 -D $rule"))
+ add(Command("$ip address add $address/32 dev lo", "$ip address del $address/32 dev lo"))
+ ipRule("to $address/32 lookup local")
+ for (downstream in downstreams) {
+ ipRule("from $address/32 to ${downstream.subnet} iif lo lookup 97")
+ for (listenerPort in listOfNotNull(port, httpsPort)) {
+ filter("INPUT -i ${downstream.name} -d $address/32 -p tcp --dport $listenerPort -j ACCEPT")
+ filter("OUTPUT -o ${downstream.name} -s $address/32 -d ${downstream.subnet} -p tcp --sport $listenerPort -m conntrack --ctstate ESTABLISHED -j ACCEPT")
+ }
+ }
+ mappings().forEach { addAll(it.commands()) }
+ }
+
+ private fun mappings() = listOfNotNull(httpMappingPort?.let { RootPortPlan(it, port, address) },
+ httpsMappingPort?.let { RootPortPlan(it, httpsPort!!, address) })
+
+ /** The shell owns the transaction. Closing stdin (including app death) reverses successful steps. */
+ fun script(): String = rootScript(commands(), mappings().joinToString("\n") { it.preflight() })
+
+ companion object {
+ const val READY = "WHEELPLAY_HTTP_ROUTING_READY"
+ const val CLEAN = "WHEELPLAY_HTTP_ROUTING_CLEAN"
+ }
+}
+
+internal fun rootScript(steps: List, preflight: String = ""): String = buildString {
+ appendLine("root_run() {")
+ appendLine(" printf 'WHEELPLAY_ROOT_STEP %s\\n' \"\$*\" >&2")
+ appendLine(" \"\$@\" || { printf 'WHEELPLAY_ROOT_FAILED %s\\n' \"\$*\" >&2; return 1; }")
+ appendLine("}")
+ appendLine("applied=0")
+ appendLine("cleanup() {")
+ appendLine(" failed=0")
+ for (i in steps.indices.reversed()) {
+ appendLine(" if [ \"\$applied\" -ge ${i + 1} ]; then ${steps[i].revert} || failed=1; fi")
+ }
+ appendLine(" [ \"\$failed\" = 0 ] && echo ${RootHttpPlan.CLEAN}")
+ appendLine(" return \"\$failed\"")
+ appendLine("}")
+ appendLine("trap 'code=\$?; trap - EXIT; cleanup || code=1; exit \"\$code\"' EXIT")
+ appendLine("trap 'exit 1' HUP INT TERM")
+ appendLine("[ \"\$(id -u)\" = 0 ] || { echo 'Root authorization required'; exit 1; }")
+ appendLine(preflight)
+ appendLine("echo WHEELPLAY_ROOT_APPLYING")
+ for ((i, step) in steps.withIndex()) {
+ appendLine("root_run ${step.apply} || exit 1")
+ appendLine("applied=${i + 1}")
+ }
+ appendLine("echo ${RootHttpPlan.READY}")
+ appendLine("while IFS= read -r command; do [ \"\$command\" = stop ] && break; done")
+}
+
+internal interface HttpRoutingSession : AutoCloseable { val alive: Boolean }
+internal class RootCleanupException(message: String, cause: Throwable? = null) : IOException(message, cause)
+
+/** Runs off the UI thread. No global firewall flushes, sysctl edits, or persistent system settings. */
+internal class RootHttpShell private constructor(private val process: Process) : HttpRoutingSession {
+ private val ready = CountDownLatch(1)
+ private val outputFinished = CountDownLatch(1)
+ @Volatile private var started = false
+ @Volatile private var cleaned = false
+ @Volatile private var attempted = false
+ @Volatile private var lastOutput = ""
+ @Volatile private var failureDetails: String? = null
+ private var currentCommand = ""
+ private var commandOutput = ""
+ override val alive get() = process.isAlive
+
+ init {
+ Thread({
+ try {
+ process.inputStream.bufferedReader().useLines { lines -> lines.forEach {
+ if (it.startsWith("WHEELPLAY_ROOT_STEP ")) {
+ currentCommand = it.removePrefix("WHEELPLAY_ROOT_STEP ").take(600)
+ commandOutput = ""
+ }
+ else if (it.startsWith("WHEELPLAY_ROOT_FAILED ")) {
+ if (failureDetails == null) failureDetails = "命令:$currentCommand\n$commandOutput".trimEnd()
+ }
+ else if (it == "WHEELPLAY_ROOT_APPLYING") attempted = true
+ else if (it == RootHttpPlan.READY) { started = true; ready.countDown() }
+ else if (it == RootHttpPlan.CLEAN) cleaned = true
+ else {
+ lastOutput = it.take(400)
+ commandOutput = (commandOutput + lastOutput + "\n").takeLast(1200)
+ }
+ } }
+ } finally { ready.countDown(); outputFinished.countDown() }
+ }, "http-root-output").apply { isDaemon = true; start() }
+ }
+
+ private fun awaitReady(): RootHttpShell {
+ if (!ready.await(30, TimeUnit.SECONDS) || !started || !alive) {
+ val cleanupError = runCatching { close() }.exceptionOrNull()
+ if (cleanupError != null) throw RootCleanupException("Root 路由启动失败且清理未确认,请检查规则或重启设备", cleanupError)
+ throw IOException("Root 路由未启动,请检查 Root 授权及 ip/iptables 支持:${failureDetails ?: lastOutput}")
+ }
+ return this
+ }
+
+ override fun close() {
+ if (alive) {
+ runCatching { process.outputStream.write("stop\n".toByteArray()); process.outputStream.flush() }
+ runCatching { process.outputStream.close() }
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroy()
+ if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly()
+ }
+ }
+ outputFinished.await(1, TimeUnit.SECONDS)
+ if (alive || attempted && !cleaned) throw RootCleanupException("Root 路由退出异常,请检查规则清理或重启设备")
+ }
+
+ companion object {
+ fun open(plan: RootHttpPlan, launch: (String) -> Process = ::launchRoot): HttpRoutingSession =
+ openScript(plan.script(), launch)
+
+ fun openScript(script: String, launch: (String) -> Process = ::launchRoot): HttpRoutingSession =
+ RootHttpShell(launch(script)).awaitReady()
+
+ private fun launchRoot(script: String): Process =
+ ProcessBuilder("su", "-c", script).redirectErrorStream(true).start()
+ }
+}
+
+/** Serial worker-owned state. A denied/failed plan is retried only after a change or an explicit retry. */
+internal class RootHttpRouting(private val open: (RootHttpPlan) -> HttpRoutingSession = { RootHttpShell.open(it) }) {
+ private var session: HttpRoutingSession? = null
+ private var activePlan: RootHttpPlan? = null
+ private var failedPlan: RootHttpPlan? = null
+ private var failure: String? = null
+ // A failed startup rollback returns no session whose close could confirm cleanup.
+ private var startupCleanupFailure: String? = null
+
+ fun retry() {
+ if (startupCleanupFailure != null) return
+ failedPlan = null; failure = null
+ }
+
+ fun reconcile(plan: RootHttpPlan?): TeslaHttpStatus {
+ startupCleanupFailure?.let { return TeslaHttpStatus(error = it) }
+ if (session != null && (plan != activePlan || session?.alive != true)) {
+ val oldPlan = activePlan
+ try { session!!.close() }
+ catch (error: Exception) {
+ failedPlan = oldPlan
+ failure = error.message ?: "Root 路由清理失败"
+ return TeslaHttpStatus(error = failure)
+ }
+ session = null; activePlan = null
+ if (plan == oldPlan) { failedPlan = plan; failure = "Root 路由已断开,请重新启动" }
+ }
+ if (plan == null) { retry(); return TeslaHttpStatus() }
+ if (session != null) return readyStatus(plan)
+ if (failedPlan == plan) return TeslaHttpStatus(error = failure)
+ return try {
+ session = open(plan); activePlan = plan; retry()
+ readyStatus(plan)
+ } catch (error: Exception) {
+ failedPlan = plan; failure = error.message ?: "无法启动 Root 路由,请检查 Root 授权"
+ if (error is RootCleanupException) startupCleanupFailure = failure
+ TeslaHttpStatus(error = failure)
+ }
+ }
+
+ private fun readyStatus(plan: RootHttpPlan) = TeslaHttpStatus(plan.address, downstreams = plan.downstreams.map { it.name },
+ httpMappingPort = plan.httpMappingPort, httpsMappingPort = plan.httpsMappingPort)
+}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/RootWebPorts.kt b/common/src/main/java/com/shilapi/xcertplay/web/RootWebPorts.kt
new file mode 100644
index 0000000..553115d
--- /dev/null
+++ b/common/src/main/java/com/shilapi/xcertplay/web/RootWebPorts.kt
@@ -0,0 +1,91 @@
+package com.shilapi.xcertplay.web
+
+/** Port-only DNAT preserves the destination IP needed by the hotspot's return route. */
+internal data class RootPortPlan(val publicPort: Int, val listenerPort: Int, val address: String) {
+ init {
+ require(publicPort in 1..1023)
+ require(listenerPort in 1024..65535)
+ require(com.shilapi.xcertplay.network.TeslaHttpConfig.isHttpAddress(address))
+ }
+
+ fun commands(): List = buildList {
+ for (binary in listOf("iptables")) {
+ for (chain in listOf("PREROUTING", "OUTPUT")) {
+ val destination = "-d $address/32 "
+ // The /32 alias is installed before these rules; an addrtype match is redundant.
+ val rule = "$chain $destination-p tcp --dport $publicPort " +
+ "-m comment --comment wheelplay-port-$publicPort -j DNAT --to-destination :$listenerPort"
+ add(RootHttpPlan.Command("$binary -w 2 -t nat -I $rule", "$binary -w 2 -t nat -D $rule"))
+ }
+ }
+ }
+
+ fun script(): String = rootScript(commands(), preflight())
+
+ fun preflight(): String {
+ val hex = "%04X".format(publicPort)
+ // Do not redirect a low port already owned by another listening service.
+ val preflight = """
+ [ -r /proc/net/tcp ] || { echo 'Cannot check occupied ports'; exit 1; }
+ port_tables=/proc/net/tcp
+ [ ! -r /proc/net/tcp6 ] || port_tables="${'$'}port_tables /proc/net/tcp6"
+ busy=0
+ awk '${'$'}4 == "0A" && ${'$'}2 ~ /:$hex${'$'}/ { found=1 } END { exit found ? 0 : 1 }' ${'$'}port_tables || busy=${'$'}?
+ case "${'$'}busy" in
+ 1) ;;
+ 0) echo 'Port $publicPort is already in use'; exit 1 ;;
+ *) echo 'Cannot check occupied ports'; exit 1 ;;
+ esac
+ for binary in iptables; do
+ wheelplay_rules=${'$'}(root_run "${'$'}binary" -w 2 -t nat -S) || exit 1
+ if printf '%s\n' "${'$'}wheelplay_rules" | awk '/--comment "?wheelplay-port-$publicPort"?( |${'$'})/ { found=1 } END { exit found ? 0 : 1 }'; then
+ echo 'Port $publicPort already has a WheelPlay forwarding rule'; exit 1
+ fi
+ done
+ """.trimIndent()
+ return preflight
+ }
+}
+
+/** Permission probes make no routing changes. Startup checks once; the button can retry explicitly. */
+internal object RootAccess {
+ @Volatile var granted = false
+ private set
+ @Volatile var status = "尚未请求 Root 权限"
+ private set
+ @Volatile var checking = false
+ private set
+ @Volatile internal var startupChecked = false
+ private val startupLock = Any()
+ private val probeLock = Any()
+ internal var authorize: () -> Unit = { RootHttpShell.openScript(rootScript(emptyList())).use { } }
+
+ fun checkOnStartup() {
+ synchronized(startupLock) {
+ if (startupChecked) return
+ startupChecked = true
+ checking = true
+ status = "正在检查 Root 权限…"
+ }
+ Thread({
+ request()
+ TeslaHttpCompatibility.retry()
+ }, "wheelplay-root-check").apply { isDaemon = true; start() }
+ }
+
+ fun request(): String? = synchronized(probeLock) {
+ synchronized(startupLock) { startupChecked = true }
+ checking = true
+ status = "正在请求 Root 权限…"
+ granted = false
+ return try {
+ authorize()
+ granted = true
+ status = "Root 已授权(本次检查)"
+ null
+ } catch (error: Exception) {
+ status = "Root 未授权:${error.message ?: error.javaClass.simpleName}"
+ status
+ } finally { checking = false }
+ }
+}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt b/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt
index eee1541..b607d36 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt
@@ -1,116 +1,134 @@
package com.shilapi.xcertplay.web
-import android.content.ComponentName
+import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-import android.content.ServiceConnection
+import android.content.IntentFilter
+import android.os.Build
import android.os.Handler
-import android.os.IBinder
import android.os.Looper
-import com.shilapi.xcertplay.network.CarPlayVpnService
import com.shilapi.xcertplay.network.TeslaHttpConfig
import com.shilapi.xcertplay.network.TeslaHttpStatus
+import java.net.Inet4Address
+import java.net.NetworkInterface
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
-/** A web-service-owned binding, independent of whether an iPhone session exists. Main thread only. */
+/** Web-service-owned root routing. Never binds or obtains consent for the USB CarPlay VPN. */
internal object TeslaHttpCompatibility {
private val main = Handler(Looper.getMainLooper())
- private var owner: Context? = null
- private var bound = false
- @Volatile private var service: CarPlayVpnService? = null
- @Volatile private var bindingStatus = TeslaHttpStatus()
- val status: TeslaHttpStatus get() = service?.browserStatus?.let {
- it.copy(error = it.error ?: bindingStatus.error)
- } ?: bindingStatus
+ private val worker = Executors.newSingleThreadScheduledExecutor { work ->
+ Thread(work, "tesla-http-routing").apply { isDaemon = true }
+ }
+ private val routing = RootHttpRouting()
+ private val revision = AtomicLong()
+ @Volatile private var owner: Context? = null
+ @Volatile private var tethered = emptySet()
+ @Volatile var status = TeslaHttpStatus()
+ private set
+
+ // Broadcast action/extra adapted from VPN Hotspot v2.17.1 TetheringManager.kt.
+ private const val TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != TETHER_STATE_CHANGED) return
+ tethered = tetheredInterfaces(intent)
+ refresh()
+ }
+ }
+
+ init { worker.scheduleWithFixedDelay(::updateRouting, 0, 2, TimeUnit.SECONDS) }
+
+ internal fun tetheredInterfaces(intent: Intent): Set =
+ (intent.getStringArrayListExtra("tetherArray") ?: intent.getStringArrayExtra("tetherArray")?.toList().orEmpty())
+ .filter { Regex("[a-zA-Z0-9_.-]{1,15}").matches(it) && it != "lo" }.toSet()
private fun preferences(context: Context) = context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE)
fun config(context: Context): TeslaHttpConfig {
+ WebListenSettings.migrate(context)
val prefs = preferences(context)
return runCatching {
TeslaHttpConfig(prefs.getBoolean("enabled", false),
prefs.getString("address", TeslaHttpConfig.DEFAULT_ADDRESS)!!,
- prefs.getString("hostname", "")!!,
- prefs.getInt("port", TeslaHttpConfig.DEFAULT_PORT))
+ prefs.getInt("http_mapping_port", 80), prefs.getInt("https_mapping_port", 443))
}.getOrDefault(TeslaHttpConfig())
}
fun save(context: Context, config: TeslaHttpConfig) {
+ val previous = config(context)
preferences(context).edit().putBoolean("enabled", config.enabled)
- .putString("address", config.address).putString("hostname", config.hostname)
- .putInt("port", config.port).apply()
- main.post { owner?.let(::applyConfig) }
- }
-
- private val connection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName, binder: IBinder) {
- if (!bound || owner == null) return
- service = (binder as CarPlayVpnService.LocalBinder).service
- bindingStatus = TeslaHttpStatus()
- owner?.let(::applyConfig)
- }
- override fun onServiceDisconnected(name: ComponentName) {
- service = null
- bindingStatus = TeslaHttpStatus(error = "本地 VPN 已断开,请重新开启 Tesla HTTP 模式")
- unbind()
- }
- override fun onBindingDied(name: ComponentName) = onServiceDisconnected(name)
- override fun onNullBinding(name: ComponentName) = onServiceDisconnected(name)
+ .putString("address", config.address).putInt("http_mapping_port", config.httpMappingPort)
+ .putInt("https_mapping_port", config.httpsMappingPort).apply()
+ refresh(retry = true)
+ if (previous.address != config.address)
+ WebSession.refreshGeneratedCertificate(context)
}
fun start(context: Context) {
main.post {
- owner = context.applicationContext
- applyConfig(owner!!)
+ if (owner != null) return@post
+ val application = context.applicationContext
+ owner = application
+ try {
+ val filter = IntentFilter(TETHER_STATE_CHANGED)
+ val sticky = if (Build.VERSION.SDK_INT >= 33)
+ application.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
+ else application.registerReceiver(receiver, filter)
+ if (sticky != null) tethered = tetheredInterfaces(sticky)
+ } catch (_: Exception) { tethered = emptySet() }
+ refresh(retry = true)
}
}
fun stop() {
main.post {
- // Clear the alias even when the NCM transport is still tearing down.
- // WebSession.stop is always followed by closing the CarPlay session/service binding.
- service?.clearBrowserAddress()
- unbind()
- owner = null
- bindingStatus = TeslaHttpStatus()
+ owner?.let { runCatching { it.unregisterReceiver(receiver) } }
+ owner = null; tethered = emptySet(); status = TeslaHttpStatus()
+ refresh()
}
}
- private fun applyConfig(context: Context) {
- val config = config(context)
- if (!config.enabled) {
- val result = service?.setBrowserAddress(null)
- if (result is CarPlayVpnService.AttachResult.Failed) {
- bindingStatus = TeslaHttpStatus(error = result.message)
- return
- }
- unbind()
- bindingStatus = TeslaHttpStatus()
- return
- }
- if (CarPlayVpnService.prepare(context) != null) {
- bindingStatus = TeslaHttpStatus(error = "请在设置中重新开启 Tesla HTTP 模式并授权本地 VPN")
- return
- }
- val current = service
- if (current != null) {
- val result = current.setBrowserAddress(config.address)
- bindingStatus = if (result is CarPlayVpnService.AttachResult.Failed) TeslaHttpStatus(error = result.message)
- else TeslaHttpStatus()
- return
- }
- if (bound) return
- bindingStatus = TeslaHttpStatus(error = "正在准备 HTTP 虚拟地址…")
+ fun retry() = refresh(retry = true)
+
+ internal fun plan(config: TeslaHttpConfig, downstreams: List, httpPort: Int?, httpsPort: Int?,
+ authorized: Boolean): RootHttpPlan? =
+ if (!authorized || !config.enabled || downstreams.isEmpty() || httpPort == null) null
+ else RootHttpPlan(config.address, httpPort, downstreams, httpsPort,
+ config.httpMappingPort, config.httpsMappingPort.takeIf { httpsPort != null })
+
+ private fun refresh(retry: Boolean = false) {
+ revision.incrementAndGet()
+ status = TeslaHttpStatus(error = owner?.takeIf { config(it).enabled }?.let { "正在准备 Root 热点路由…" })
+ worker.execute { if (retry) routing.retry(); updateRouting() }
+ }
+
+ private fun updateRouting() {
+ val version = revision.get()
+ val context = owner
+ val config = context?.let(::config)
try {
- bound = context.bindService(Intent(context, CarPlayVpnService::class.java), connection, Context.BIND_AUTO_CREATE)
- if (!bound) bindingStatus = TeslaHttpStatus(error = "无法启动本地 VPN 服务")
+ val downstreams = if (config?.enabled == true && RootAccess.granted) tethered.sorted().mapNotNull { name ->
+ val iface = NetworkInterface.getByName(name) ?: return@mapNotNull null
+ if (!iface.isUp || iface.isLoopback) return@mapNotNull null
+ // Match an actual tethered interface, never Wi-Fi/cellular name heuristics.
+ val address = iface.interfaceAddresses.firstOrNull { it.address is Inet4Address && it.networkPrefixLength in 1..30 }
+ ?: return@mapNotNull null
+ require(address.address.hostAddress != config.address) { "共享 IP 与热点网关相同,请选择其他地址" }
+ HttpDownstream(name, iface.index, "${address.address.hostAddress}/${address.networkPrefixLength}")
+ } else emptyList()
+ val plan = config?.let { plan(it, downstreams, WebSession.httpListenerPort, WebSession.httpsListenerPort, RootAccess.granted) }
+ val result = routing.reconcile(plan)
+ // Root consent can take time. Never publish completion for a stopped/reconfigured owner.
+ if (revision.get() == version && owner === context && (context == null || config(context) == config)) {
+ status = if (config?.enabled == true && plan == null && result.error == null)
+ TeslaHttpStatus(error = if (!RootAccess.granted) "请先请求 Root 权限"
+ else if (WebSession.httpListenerPort == null) "请先启动 Web 监听"
+ else "请先开启系统热点;未检测到可共享的 IPv4 热点接口") else result
+ } else routing.reconcile(null)
} catch (error: Exception) {
- bound = false
- bindingStatus = TeslaHttpStatus(error = "本地 VPN 启动失败:${error.javaClass.simpleName}")
+ routing.reconcile(null)
+ if (revision.get() == version && owner === context) status = TeslaHttpStatus(error = error.message ?: "Root 热点路由配置失败")
}
}
-
- private fun unbind() {
- if (bound) owner?.let { runCatching { it.unbindService(connection) } }
- bound = false
- service = null
- }
}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebListenSettings.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebListenSettings.kt
new file mode 100644
index 0000000..ad4ebf3
--- /dev/null
+++ b/common/src/main/java/com/shilapi/xcertplay/web/WebListenSettings.kt
@@ -0,0 +1,69 @@
+package com.shilapi.xcertplay.web
+
+import android.content.Context
+import com.shilapi.xcertplay.network.TeslaHttpConfig
+
+/** Ordinary listeners never request su; privileged ports belong to Tesla's mapping settings. */
+internal object WebListenSettings {
+ const val DEFAULT_HTTP_PORT = 8080
+ const val DEFAULT_HTTPS_PORT = 8443
+ enum class CertificateType(val label: String) { DEFAULT("默认本地证书"), PEM("PEM"), PKCS12("PKCS#12") }
+
+ @Synchronized internal fun migrate(context: Context) {
+ val prefs = context.getSharedPreferences("web_listen", Context.MODE_PRIVATE)
+ if (prefs.getBoolean("migrated", false)) return
+ val oldHttp = context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE)
+ val oldHttps = context.getSharedPreferences("web_https", Context.MODE_PRIVATE)
+ val http = oldHttp.getInt("port", DEFAULT_HTTP_PORT)
+ val https = oldHttps.getInt("port", DEFAULT_HTTPS_PORT)
+ var httpListener = http.takeIf { it in 1024..65535 } ?: DEFAULT_HTTP_PORT
+ var httpsListener = https.takeIf { it in 1024..65535 } ?: DEFAULT_HTTPS_PORT
+ if (httpListener == httpsListener) {
+ if (https !in 1024..65535) httpsListener = if (httpListener == DEFAULT_HTTPS_PORT) 8444 else DEFAULT_HTTPS_PORT
+ else httpListener = if (httpsListener == DEFAULT_HTTP_PORT) 8081 else DEFAULT_HTTP_PORT
+ }
+ val httpMapping = oldHttp.getInt("http_mapping_port", http.takeIf { it in 1..1023 } ?: 80)
+ val httpsMapping = https.takeIf { it in 1..1023 } ?: if (httpMapping == 443) 444 else 443
+ val hostname = oldHttp.getString("hostname", "").orEmpty().takeIf(TeslaHttpConfig::isHostname).orEmpty()
+ val mappings = oldHttp.edit()
+ if (!oldHttp.contains("http_mapping_port")) mappings.putInt("http_mapping_port", httpMapping)
+ if (!oldHttp.contains("https_mapping_port")) mappings.putInt("https_mapping_port", httpsMapping)
+ check(mappings.commit()) { "端口映射迁移失败" }
+ check(prefs.edit().putInt("http_port", httpListener)
+ .putInt("https_port", httpsListener)
+ .putString("hostname", hostname).putBoolean("https_enabled", true)
+ .putBoolean("migrated", true).commit()) { "Web 监听配置迁移失败" }
+ }
+
+ private fun preferences(context: Context): android.content.SharedPreferences {
+ migrate(context)
+ return context.getSharedPreferences("web_listen", Context.MODE_PRIVATE)
+ }
+ fun httpPort(context: Context) = preferences(context).getInt("http_port", DEFAULT_HTTP_PORT)
+ .takeIf { it in 1024..65535 } ?: DEFAULT_HTTP_PORT
+ fun httpsPort(context: Context) = preferences(context).getInt("https_port", DEFAULT_HTTPS_PORT)
+ .takeIf { it in 1024..65535 } ?: DEFAULT_HTTPS_PORT
+ fun httpsEnabled(context: Context) = preferences(context).getBoolean("https_enabled", true)
+ fun hostname(context: Context) = preferences(context).getString("hostname", "").orEmpty()
+ .takeIf(TeslaHttpConfig::isHostname).orEmpty()
+ fun certificateType(context: Context) = runCatching {
+ CertificateType.valueOf(preferences(context).getString("certificate_type",
+ if (LanTls.hasCustom(context)) "PEM" else "DEFAULT")!!)
+ }.getOrDefault(CertificateType.DEFAULT)
+ fun saveHttpPort(context: Context, port: Int) = savePort(context, "http_port", port)
+ fun saveHttpsPort(context: Context, port: Int) = savePort(context, "https_port", port)
+ private fun savePort(context: Context, key: String, port: Int) {
+ require(port in 1024..65535) { "Web 监听端口必须为 1024–65535;低位端口请在 Tesla 兼容中映射" }
+ check(preferences(context).edit().putInt(key, port).commit()) { "Web 端口保存失败" }
+ }
+ fun saveHttpsEnabled(context: Context, enabled: Boolean) {
+ check(preferences(context).edit().putBoolean("https_enabled", enabled).commit()) { "HTTPS 开关保存失败" }
+ }
+ fun saveHostname(context: Context, hostname: String) {
+ require(TeslaHttpConfig.isHostname(hostname)) { "请输入域名,不要包含协议、端口或路径" }
+ check(preferences(context).edit().putString("hostname", hostname).commit()) { "Web 域名保存失败" }
+ }
+ fun saveCertificateType(context: Context, type: CertificateType) {
+ check(preferences(context).edit().putString("certificate_type", type.name).commit()) { "证书类型保存失败" }
+ }
+}
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt
index 5acb6a4..4118260 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt
@@ -7,7 +7,6 @@ import android.os.Looper
import com.shilapi.xcertplay.AirPlayPersistence
import com.shilapi.xcertplay.CarPlayBackgroundSession
import com.shilapi.xcertplay.DiPlayBootstrap
-import com.shilapi.xcertplay.network.TeslaHttpConfig
import java.security.SecureRandom
/** Service-owned web endpoint; media sinks only publish immutable latest frames. */
@@ -24,7 +23,9 @@ internal object WebSession {
@Volatile var error: String? = null
private set
@Volatile private var server: LanWebServer? = null
- @Volatile var httpPort = TeslaHttpConfig.DEFAULT_PORT
+ @Volatile var httpPort = WebListenSettings.DEFAULT_HTTP_PORT
+ private set
+ @Volatile var httpsPort = WebListenSettings.DEFAULT_HTTPS_PORT
private set
@Volatile private var secureServer: LanWebServer? = null
@Volatile var tls: LanTls.Endpoint? = null
@@ -46,7 +47,10 @@ internal object WebSession {
val touch = TouchLease { contacts ->
CarPlayBackgroundSession.snapshot()?.controller?.sendTouch(contacts) ?: false
}
- val running get() = server != null
+ val running get() = server?.ready == true
+ val httpsReady get() = tls != null && secureServer?.ready == true
+ val httpListenerPort get() = server?.takeIf { it.ready }?.listeningPort
+ val httpsListenerPort get() = secureServer?.takeIf { it.ready }?.listeningPort
val hasViewer get() = server?.hasViewer == true || secureServer?.hasViewer == true
val audio = BrowserAudioBridge()
private val mainHandler = Handler(Looper.getMainLooper())
@@ -104,35 +108,40 @@ internal object WebSession {
}
@Synchronized fun start(context: Context) {
- if (server != null) return
+ if (server?.ready == true) return
+ if (server != null) stop()
if (runCatching { DiPlayBootstrap.ensure(context) }.isFailure) {
error = "认证资源未就绪,请先在设置中导入认证资源"
return
}
adaptiveBrowserSize = AirPlayPersistence.loadAdaptiveBrowserSize(context)
audio.configure(AirPlayPersistence.loadBrowserAudioPlayback(context),
- AirPlayPersistence.loadBrowserMicrophone(context))
+ AirPlayPersistence.loadBrowserMicrophone(context), AirPlayPersistence.loadMediaBufferMillis(context))
code = (100000 + SecureRandom().nextInt(900000)).toString()
- val port = TeslaHttpCompatibility.config(context).port
+ val port = WebListenSettings.httpPort(context)
val next = LanWebServer(context.applicationContext, code, port)
try {
next.start(5000, true); server = next; httpPort = port; error = null
+ httpsPort = WebListenSettings.httpsPort(context)
+ tlsError = null
TeslaHttpCompatibility.start(context)
val token = ++serverGeneration
+ if (!WebListenSettings.httpsEnabled(context)) return
Thread({
var secure: LanWebServer? = null
try {
- val endpoint = LanTls.create(context.applicationContext, addresses(context).map { it.address })
+ val endpoint = createTls(context.applicationContext)
synchronized(this) {
- if (serverGeneration != token || server == null) return@Thread
- secure = LanWebServer(context.applicationContext, code, 8443, true, next.pairing)
+ if (serverGeneration != token || server == null || !WebListenSettings.httpsEnabled(context)) return@Thread
+ secure = LanWebServer(context.applicationContext, code, httpsPort, true, next.pairing)
secure!!.makeSecure(endpoint.sockets, null)
secure!!.start(5000, true)
secureServer = secure; tls = endpoint; tlsError = null
+ TeslaHttpCompatibility.retry()
}
} catch (error: Exception) {
- secure?.stop()
- synchronized(this) { if (serverGeneration == token) tlsError = "HTTPS 启动失败:${error.javaClass.simpleName}" }
+ secure?.stopPreservingPairing()
+ synchronized(this) { if (serverGeneration == token) tlsError = "HTTPS 启动失败:${error.message ?: error.javaClass.simpleName}" }
}
}, "wheelplay-lan-tls").apply { isDaemon = true; start() }
}
@@ -141,32 +150,151 @@ internal object WebSession {
/** Bind before releasing the old listener, preserving media and pairing on failure. */
@Synchronized fun setHttpPort(context: Context, port: Int): String? {
- val config = TeslaHttpCompatibility.config(context).copy(port = port)
+ if (port !in 1024..65535) return "Web HTTP 监听端口必须为 1024–65535"
+ if (port == WebListenSettings.httpsPort(context)) return "HTTP 与 HTTPS 不能使用相同端口"
val current = server
+ val previousPort = httpPort
if (current != null && port != httpPort) {
val next = LanWebServer(context.applicationContext, code, port, pairing = current.pairing)
try {
next.start(5000, true)
+ WebListenSettings.saveHttpPort(context, port)
} catch (e: Exception) {
next.stopPreservingPairing()
return "$port 端口启动失败:${e.message ?: e.javaClass.simpleName};原端口 $httpPort 继续运行"
}
+ try { current.stopPreservingPairing() }
+ catch (error: Exception) {
+ runCatching { next.stopPreservingPairing() }
+ this.error = "原端口规则清理失败,请检查规则或重新启动设备:${error.message}"
+ return this.error
+ }
server = next
httpPort = port
- current.stopPreservingPairing()
}
- TeslaHttpCompatibility.save(context, config)
+ if (current == null || port == previousPort) WebListenSettings.saveHttpPort(context, port)
+ httpPort = port
+ TeslaHttpCompatibility.retry()
return null
}
+ fun setHttpsPort(context: Context, port: Int): String? {
+ if (port !in 1024..65535) return "Web HTTPS 监听端口必须为 1024–65535"
+ if (port == WebListenSettings.httpPort(context)) return "HTTP 与 HTTPS 不能使用相同端口"
+ return runCatching {
+ if (!WebListenSettings.httpsEnabled(context)) {
+ WebListenSettings.saveHttpsPort(context, port); httpsPort = port; return null
+ }
+ val endpoint = tls ?: createTls(context)
+ replaceHttps(context, port, endpoint) { WebListenSettings.saveHttpsPort(context, port) }
+ .also { if (it == null) TeslaHttpCompatibility.retry() }
+ }.getOrElse { "HTTPS 配置失败:${it.message ?: it.javaClass.simpleName}" }
+ }
+
+ fun installCertificate(context: Context, prepared: LanTls.Prepared): String? =
+ replaceHttps(context, WebListenSettings.httpsPort(context), prepared.endpoint) { LanTls.saveCustom(context, prepared) }
+
+ @Synchronized fun setHttpsEnabled(context: Context, enabled: Boolean): String? = runCatching {
+ if (enabled) {
+ replaceHttps(context, WebListenSettings.httpsPort(context), tls ?: createTls(context), activate = true) {
+ WebListenSettings.saveHttpsEnabled(context, true)
+ }.also { if (it == null) TeslaHttpCompatibility.retry() }
+ } else {
+ WebListenSettings.saveHttpsEnabled(context, false)
+ serverGeneration++
+ val old = secureServer; secureServer = null; tls = null; tlsError = null
+ old?.stopPreservingPairing()
+ TeslaHttpCompatibility.retry()
+ null
+ }
+ }.getOrElse { "HTTPS 开关更新失败:${it.message ?: it.javaClass.simpleName}" }
+
+ fun setHostname(context: Context, hostname: String): String? = runCatching {
+ WebListenSettings.saveHostname(context, hostname)
+ refreshGeneratedCertificate(context)
+ null
+ }.getOrElse { it.message ?: "Web 域名保存失败" }
+
+ fun restoreDefaultCertificate(context: Context): String? = runCatching {
+ val config = TeslaHttpCompatibility.config(context)
+ val endpoint = LanTls.createDefault(context, (addresses(context).map { it.address } + config.address).distinct(), WebListenSettings.hostname(context))
+ replaceHttps(context, WebListenSettings.httpsPort(context), endpoint) { LanTls.clearCustom(context) }
+ }.getOrElse { "默认 HTTPS 证书恢复失败:${it.message ?: it.javaClass.simpleName}" }
+
+ private fun createTls(context: Context): LanTls.Endpoint {
+ val config = TeslaHttpCompatibility.config(context)
+ return LanTls.create(context, (addresses(context).map { it.address } + config.address).distinct(), WebListenSettings.hostname(context))
+ }
+
+ /** A changed port binds first. Certificate replacement on the same port rolls back on failure. */
+ @Synchronized private fun replaceHttps(context: Context, port: Int, endpoint: LanTls.Endpoint, activate: Boolean = false, persist: () -> Unit): String? {
+ val http = server
+ if (http == null || !activate && !WebListenSettings.httpsEnabled(context)) return runCatching { persist(); httpsPort = port; null }
+ .getOrElse { "HTTPS 配置保存失败:${it.javaClass.simpleName}" }
+ if (port == httpPort) return "HTTP 与 HTTPS 不能使用相同端口"
+ val old = secureServer
+ val oldTls = tls
+ val oldPort = httpsPort
+ val samePort = old != null && oldPort == port
+ val next = LanWebServer(context.applicationContext, code, port, true, http.pairing)
+ if (samePort) { old!!.stopPreservingPairing(); secureServer = null }
+ try {
+ next.makeSecure(endpoint.sockets, null)
+ next.start(5000, true)
+ persist()
+ } catch (error: Exception) {
+ next.stopPreservingPairing()
+ if (samePort && oldTls != null) {
+ val restored = LanWebServer(context.applicationContext, code, oldPort, true, http.pairing)
+ try {
+ restored.makeSecure(oldTls.sockets, null); restored.start(5000, true)
+ secureServer = restored
+ } catch (_: Exception) {
+ restored.stopPreservingPairing(); tls = null
+ tlsError = "HTTPS 原监听器恢复失败,请重新启动服务"
+ return "HTTPS 更新失败,原证书和端口配置保留;请重新启动服务"
+ }
+ }
+ return "HTTPS 更新失败:${error.message ?: error.javaClass.simpleName};原配置保留"
+ }
+ serverGeneration++ // Discard older initialization only after a successful replacement.
+ secureServer = next; tls = endpoint; httpsPort = port; tlsError = null
+ if (!samePort) old?.stopPreservingPairing()
+ return null
+ }
+
+ fun refreshGeneratedCertificate(context: Context) {
+ val generation = synchronized(this) { if (server == null || !WebListenSettings.httpsEnabled(context) || LanTls.hasCustom(context)) return; serverGeneration }
+ val hostname = WebListenSettings.hostname(context)
+ Thread({
+ val config = TeslaHttpCompatibility.config(context)
+ val result = runCatching { createTls(context) }
+ synchronized(this) {
+ if (serverGeneration != generation || server == null || LanTls.hasCustom(context) ||
+ TeslaHttpCompatibility.config(context) != config || WebListenSettings.hostname(context) != hostname ||
+ !WebListenSettings.httpsEnabled(context)) return@Thread
+ result.onSuccess { replaceHttps(context, httpsPort, it) {} }
+ .onFailure { tlsError = "HTTPS 证书刷新失败:${it.javaClass.simpleName}" }
+ }
+ }, "https-address-refresh").apply { isDaemon = true; start() }
+ }
+
@Synchronized fun setBrowserAudioPlayback(context: Context, enabled: Boolean) {
AirPlayPersistence.saveBrowserAudioPlayback(context, enabled)
- audio.configure(enabled, AirPlayPersistence.loadBrowserMicrophone(context))
+ audio.configure(enabled, AirPlayPersistence.loadBrowserMicrophone(context),
+ AirPlayPersistence.loadMediaBufferMillis(context))
}
@Synchronized fun setBrowserMicrophone(context: Context, enabled: Boolean) {
AirPlayPersistence.saveBrowserMicrophone(context, enabled)
- audio.configure(AirPlayPersistence.loadBrowserAudioPlayback(context), enabled)
+ audio.configure(AirPlayPersistence.loadBrowserAudioPlayback(context), enabled,
+ AirPlayPersistence.loadMediaBufferMillis(context))
+ }
+
+ @Synchronized fun setMediaBufferMillis(context: Context, millis: Int) {
+ AirPlayPersistence.saveMediaBufferMillis(context, millis)
+ audio.configure(AirPlayPersistence.loadBrowserAudioPlayback(context),
+ AirPlayPersistence.loadBrowserMicrophone(context), AirPlayPersistence.loadMediaBufferMillis(context))
}
fun setAdaptiveBrowserSize(enabled: Boolean) {
@@ -217,9 +345,12 @@ internal object WebSession {
@Synchronized fun stop() {
serverGeneration++
TeslaHttpCompatibility.stop()
- val oldSecure = secureServer; secureServer = null; oldSecure?.stop(); tls = null; tlsError = null
+ val oldSecure = secureServer; secureServer = null
+ val cleanupError = runCatching { oldSecure?.stop() }.exceptionOrNull()
+ tls = null; tlsError = cleanupError?.message
videoSource?.detach(); videoSource = null
- val old = server; server = null; old?.stop()
+ val old = server; server = null
+ runCatching { old?.stop() }.exceptionOrNull()?.let { error = it.message }
browserViewport = null
frame = null; videoActive = false; code = ""; generation++
}
@@ -227,8 +358,19 @@ internal object WebSession {
fun inspectPairing(payload: String) = server?.pairing?.inspect(payload)
fun approvePairing(payload: String) = server?.pairing?.approve(payload) == true
- fun addresses(context: Context): List = LanAddresses.discover(
- context, TeslaHttpCompatibility.status.address, TeslaHttpCompatibility.config(context).hostname, httpPort)
+ fun addresses(context: Context): List {
+ val status = TeslaHttpCompatibility.status
+ return LanAddresses.discover(context, status.address, WebListenSettings.hostname(context), httpPort, status.httpMappingPort)
+ }
+
+ fun httpsPortFor(context: Context, authority: String?, status: com.shilapi.xcertplay.network.TeslaHttpStatus = TeslaHttpCompatibility.status): Int {
+ val host = runCatching { java.net.URI("http://$authority") }.getOrNull() ?: return httpsPort
+ val http = if (host.port < 0) 80 else host.port
+ return status.httpsMappingPort?.takeIf {
+ status.address != null && http == status.httpMappingPort &&
+ (host.host == status.address || host.host?.equals(WebListenSettings.hostname(context), true) == true)
+ } ?: httpsPort
+ }
private const val MIN_VIEWPORT_PIXELS = 320
private const val MAX_VIEWPORT_PIXELS = 8192
diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt
index 0738f70..634ee76 100644
--- a/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt
@@ -1,6 +1,7 @@
package com.shilapi.xcertplay.web
import android.os.SystemClock
+import com.shilapi.xcertplay.media.MediaAudioBuffer
import fi.iki.elonen.NanoWSD.WebSocketFrame
import java.io.Closeable
import java.util.concurrent.ExecutorService
@@ -23,14 +24,44 @@ internal class WebStreamSender(
private var inFlight = false
private var sentAt = 0L
private var sequence = -1L
- private data class Audio(val frame: WebSocketFrame, val sequence: Long, val at: Long)
+ private data class Audio(val frame: WebSocketFrame, val sequence: Long, val at: Long,
+ val stream: Int, val media: Boolean, val durationMillis: Double)
private val audio = java.util.ArrayDeque()
private val audioInFlight = linkedMapOf()
+ private var mediaBufferMillis = MediaAudioBuffer.DEFAULT_MILLIS
+
+ fun configureAudioBuffer(millis: Int) = synchronized(lock) {
+ mediaBufferMillis = MediaAudioBuffer.sanitize(millis)
+ }
+
+ private fun audioFrame(packet: ByteArray, sequence: Long): Audio {
+ val header = java.nio.ByteBuffer.wrap(packet).order(java.nio.ByteOrder.LITTLE_ENDIAN)
+ if (packet.size >= 22 && header.int == BrowserAudioBridge.AUDIO_MAGIC) {
+ val stream = header.int
+ val rate = header.int
+ val channels = header.short.toInt()
+ val media = header.short.toInt() and BrowserAudioBridge.AUDIO_FLAG_MEDIA != 0
+ if (rate in 8_000..96_000 && channels in 1..2 && (packet.size - 20) % (channels * 2) == 0) {
+ return Audio(WebSocketFrame(WebSocketFrame.OpCode.Binary, true, packet), sequence, now(),
+ stream, media, (packet.size - 20) * 1000.0 / (rate * channels * 2))
+ }
+ }
+ return Audio(WebSocketFrame(WebSocketFrame.OpCode.Binary, true, packet), sequence, now(), 0, false, 100.0 / 16)
+ }
+
+ private fun audioBudgetMillis(frame: Audio) = if (frame.media) mediaBufferMillis + 200 else 100
fun audio(packet: ByteArray, sequence: Long) = synchronized(lock) {
if (!closed) {
- while (audio.size >= 16) audio.removeFirst()
- audio.addLast(Audio(WebSocketFrame(WebSocketFrame.OpCode.Binary, true, packet), sequence, now()))
+ val next = audioFrame(packet, sequence)
+ audio.addLast(next)
+ var queuedMillis = audio.filter { it.stream == next.stream }.sumOf { it.durationMillis }
+ while (queuedMillis > audioBudgetMillis(next) && audio.count { it.stream == next.stream } > 1) {
+ val oldest = audio.first { it.stream == next.stream }
+ audio.remove(oldest); queuedMillis -= oldest.durationMillis
+ }
+ // Duration is the main bound; this also caps pathological tiny PCM packets.
+ while (audio.size > 128) audio.removeFirst()
scheduleLocked()
}
}
@@ -86,7 +117,7 @@ internal class WebStreamSender(
controls.remove(control.key)
control.value
} else if (audio.isNotEmpty() && audioInFlight.size < 4) {
- while (audio.isNotEmpty() && now() - audio.first.at > 100) audio.removeFirst()
+ audio.removeAll { now() - it.at > audioBudgetMillis(it) }
if (audio.isEmpty()) { scheduled = false; scheduleLocked(); return }
val next = audio.removeFirst()
audioInFlight[next.sequence] = now()
diff --git a/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt
new file mode 100644
index 0000000..b01c0d1
--- /dev/null
+++ b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt
@@ -0,0 +1,251 @@
+package com.shilapi.xcertplay
+
+import android.content.Intent
+import android.view.View
+import android.view.ViewGroup
+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
+import com.shilapi.xcertplay.web.WebSession
+import com.shilapi.xcertplay.web.RootAccess
+import com.shilapi.xcertplay.web.WebListenSettings
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicInteger
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import org.robolectric.shadows.ShadowDialog
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp")
+class HttpsSettingsUiTest {
+ @Before fun startWithoutRootAuthorization() {
+ val original = RootAccess.authorize
+ try {
+ RootAccess.authorize = { throw java.io.IOException("Permission denied") }
+ RootAccess.request()
+ } finally { RootAccess.authorize = original }
+ }
+
+ 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 all(activity: DiPlayActivity) = views(activity.window.decorView)
+ private fun openSettings(activity: DiPlayActivity) {
+ all(activity).filterIsInstance().single().selectedItemId = 3
+ }
+ private fun rootButton(activity: DiPlayActivity) = all(activity).filterIsInstance()
+ .single { it.text.toString() == "请求 Root 权限" }
+ private fun waitForUi(activity: DiPlayActivity, predicate: () -> Boolean) {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8)
+ while (System.nanoTime() < deadline) {
+ shadowOf(android.os.Looper.getMainLooper()).idle()
+ if (predicate() && rootButton(activity).isEnabled) return
+ Thread.sleep(10)
+ }
+ fail("Settings update did not finish")
+ }
+ private fun hasRow(activity: DiPlayActivity, title: String) = all(activity).any {
+ it.contentDescription?.toString()?.startsWith("$title,") == true
+ }
+
+ @Test fun startupChecksRootInBackgroundAndShowsSettingsWithoutPressingTheButton() {
+ val original = RootAccess.authorize
+ val entered = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val requests = AtomicInteger()
+ RootAccess.startupChecked = false
+ RootAccess.authorize = {
+ requests.incrementAndGet()
+ entered.countDown()
+ check(release.await(8, TimeUnit.SECONDS))
+ }
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ assertTrue(entered.await(2, TimeUnit.SECONDS))
+ assertFalse(rootButton(activity).isEnabled)
+ assertFalse(hasRow(activity, "映射 HTTP 端口"))
+ assertTrue(hasRow(activity, "HTTP 端口"))
+ release.countDown()
+ waitForUi(activity) {
+ shadowOf(android.os.Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
+ RootAccess.granted && hasRow(activity, "映射 HTTP 端口")
+ }
+ RootAccess.checkOnStartup()
+ assertEquals(1, requests.get())
+ rootButton(activity).performClick()
+ waitForUi(activity) { requests.get() == 2 && !RootAccess.checking }
+ } finally {
+ release.countDown()
+ RootAccess.authorize = original
+ controller.pause().stop().destroy(); WebSession.stop()
+ }
+ }
+
+ @Test fun deniedStartupCheckDoesNotRepeatUntilManualRetry() {
+ val original = RootAccess.authorize
+ val requests = AtomicInteger()
+ RootAccess.startupChecked = false
+ RootAccess.authorize = { requests.incrementAndGet(); throw java.io.IOException("Permission denied") }
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ waitForUi(activity) {
+ shadowOf(android.os.Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS)
+ requests.get() == 1 && !RootAccess.checking
+ }
+ RootAccess.checkOnStartup()
+ assertEquals(1, requests.get())
+ assertFalse(RootAccess.granted)
+ assertFalse(hasRow(activity, "映射 HTTP 端口"))
+ assertTrue(hasRow(activity, "Web 域名(可选)"))
+ rootButton(activity).performClick()
+ waitForUi(activity) { requests.get() == 2 && !RootAccess.checking }
+ } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test fun rootRequestIsFirstAndMappingControlsAppearOnlyAfterAccessIsGranted() {
+ val original = RootAccess.authorize
+ var requests = 0
+ RootAccess.authorize = { requests++ }
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ assertEquals(0, requests)
+ assertFalse(hasRow(activity, "映射 HTTP 端口"))
+ assertFalse(hasRow(activity, "访问 IP 地址"))
+ val button = rootButton(activity)
+ val row = button.parent as ViewGroup
+ val section = row.parent as ViewGroup
+ assertSame(row, section.getChildAt(0))
+ button.performClick()
+ waitForUi(activity) { RootAccess.granted }
+ assertEquals(1, requests)
+ assertTrue(hasRow(activity, "映射 HTTP 端口"))
+ assertTrue(hasRow(activity, "映射 HTTPS 端口"))
+ assertTrue(hasRow(activity, "访问 IP 地址"))
+ assertFalse(WebSession.running)
+ } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test fun deniedRootKeepsMappingControlsHiddenAndWebSettingsAvailable() {
+ val original = RootAccess.authorize
+ RootAccess.authorize = { throw java.io.IOException("Permission denied") }
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ rootButton(activity).performClick()
+ waitForUi(activity) { RootAccess.status.contains("未授权") }
+ assertFalse(RootAccess.granted)
+ assertFalse(hasRow(activity, "映射 HTTP 端口"))
+ assertFalse(hasRow(activity, "访问 IP 地址"))
+ assertTrue(hasRow(activity, "HTTP 端口"))
+ assertTrue(hasRow(activity, "Web 域名(可选)"))
+ assertTrue(hasRow(activity, "Web HTTPS 端口"))
+ assertTrue(all(activity).filterIsInstance().any { it.text.toString().contains("Web 域名同时用于 HTTP 和 HTTPS") })
+ } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test fun flatCertificateTypesOpenTheSystemDocumentPickerWithoutRoot() {
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ 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 文件")) {
+ all(activity).filterIsInstance().single { it.text.toString() == type }.performClick()
+ all(activity).filterIsInstance().single { it.text.toString() == title }.performClick()
+ shadowOf(android.os.Looper.getMainLooper()).idle()
+ val intent = shadowOf(activity).nextStartedActivity
+ assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
+ assertTrue(intent.hasCategory(Intent.CATEGORY_OPENABLE))
+ assertEquals("*/*", intent.type)
+ assertFalse(RootAccess.granted)
+ }
+ } finally { controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test fun httpsSwitchHidesItsPortAndCertificateTypesWhileHttpStaysInWebSection() {
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ 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) })
+ all(activity).filterIsInstance().single { it.contentDescription == "启用 HTTPS" }.performClick()
+ waitForUi(activity) { !WebListenSettings.httpsEnabled(activity) }
+ assertFalse(hasRow(activity, "Web HTTPS 端口"))
+ assertTrue(all(activity).filterIsInstance().isEmpty())
+ assertTrue(hasRow(activity, "HTTP 端口"))
+ assertTrue(hasRow(activity, "Web 域名(可选)"))
+ } finally { controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test fun httpsPortFieldShowsItsPersistedDefault() {
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ all(activity).single { it.contentDescription?.toString() == "Web HTTPS 端口,8443" }.performClick()
+ val dialog = ShadowDialog.getLatestDialog() as AlertDialog
+ val input = views(dialog.window!!.decorView).filterIsInstance().single()
+ assertEquals("8443", input.text.toString())
+ dialog.dismiss()
+ } finally { controller.pause().stop().destroy(); WebSession.stop() }
+ }
+
+ @Test @GraphicsMode(GraphicsMode.Mode.NATIVE)
+ fun renderWebAndTeslaSettingsBeforeAndAfterRootGrant() {
+ val original = RootAccess.authorize
+ RootAccess.authorize = { }
+ val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup()
+ val activity = controller.get()
+ try {
+ openSettings(activity)
+ fun capture(name: String) {
+ val scroll = all(activity).filterIsInstance().single { view ->
+ views(view).filterIsInstance().any { it.text.toString() == "Web 监听" }
+ }
+ 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 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()
+ file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
+ bitmap.recycle()
+ }
+ capture("without-root")
+ rootButton(activity).performClick()
+ waitForUi(activity) { RootAccess.granted }
+ capture("with-root")
+ } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() }
+ }
+}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/BrowserAudioBridgeTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/BrowserAudioBridgeTest.kt
index c04db9e..dfa7172 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/BrowserAudioBridgeTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/BrowserAudioBridgeTest.kt
@@ -37,7 +37,7 @@ class BrowserAudioBridgeTest {
val header = ByteBuffer.wrap(packets.single()).order(ByteOrder.LITTLE_ENDIAN)
assertEquals(BrowserAudioBridge.AUDIO_MAGIC, header.int)
assertEquals(1, header.int); assertEquals(48_000, header.int); assertEquals(2, header.short.toInt())
- assertEquals(0, header.short.toInt()); assertEquals(1, header.int)
+ assertEquals(BrowserAudioBridge.AUDIO_FLAG_MEDIA, header.short.toInt()); assertEquals(1, header.int)
assertArrayEquals(byteArrayOf(1,2,3,4), packets.single().copyOfRange(20, 24))
var routeChanges = 0
route.onMicrophoneRouteChanged { routeChanges++ }
@@ -90,4 +90,28 @@ class BrowserAudioBridgeTest {
route.close()
}
+ @Test fun selectedBufferReachesNewAndExistingViewersAndOnlyMediaPcmIsFlagged() {
+ val bridge = BrowserAudioBridge(); val owner = Any()
+ val controls = mutableListOf(); val packets = mutableListOf()
+ bridge.configure(true, false, 1000)
+ bridge.attach(owner, false, { packet, _ -> packets.add(packet) }, controls::add)
+ assertEquals(1000, org.json.JSONObject(controls.last()).getInt("mediaBufferMillis"))
+ bridge.ready(owner, true, false)
+ val route = bridge.createRoute()
+ try {
+ assertTrue(route.output(1, format, byteArrayOf(1,2,3,4), 0, 4))
+ assertTrue(route.output(2, format.copy(audioType = "telephony"), byteArrayOf(1,2,3,4), 0, 4))
+ assertEquals(BrowserAudioBridge.AUDIO_FLAG_MEDIA,
+ ByteBuffer.wrap(packets[0]).order(ByteOrder.LITTLE_ENDIAN).getShort(14).toInt())
+ assertEquals(0, ByteBuffer.wrap(packets[1]).order(ByteOrder.LITTLE_ENDIAN).getShort(14).toInt())
+ bridge.configure(true, false, 500)
+ assertEquals(500, org.json.JSONObject(controls.last()).getInt("mediaBufferMillis"))
+ bridge.detach(owner)
+ bridge.attach(owner, false, { _, _ -> }, controls::add)
+ assertEquals(500, org.json.JSONObject(controls.last()).getInt("mediaBufferMillis"))
+ bridge.configure(true, false, 37)
+ assertEquals(300, org.json.JSONObject(controls.last()).getInt("mediaBufferMillis"))
+ } finally { route.close(); bridge.detach(owner) }
+ }
+
}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt
index 8ba25a9..4965902 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt
@@ -7,12 +7,10 @@ import com.shilapi.xcertplay.web.LanAddresses.Entry
class LanAddressesTest {
@Test fun customPortIsUsedByBothTeslaAndOtherLanEntries() {
- val owned = LanAddresses.entry("tun0", "100.96.0.1", Kind.VPN, false,
- "100.96.0.1", "car.example.com", 9090)
+ val owned = LanAddresses.sharedEntry("100.96.0.1", "car.example.com", 9090)
assertEquals("http://car.example.com:9090", owned.url)
assertEquals("http://100.96.0.1:9090", owned.ipUrl)
- val hotspot = LanAddresses.entry("ap0", "192.168.43.1", Kind.HOTSPOT, false,
- "100.96.0.1", "car.example.com", 9090)
+ val hotspot = Entry("ap0", "192.168.43.1", Kind.HOTSPOT, port = 9090)
assertEquals("http://192.168.43.1:9090", hotspot.url)
}
@@ -25,18 +23,15 @@ class LanAddressesTest {
assertFalse(LanAddresses.isDiscoverable(ip("169.254.1.1")))
}
- @Test fun onlyTheActiveOwnedVpnAliasGetsATeslaEntryAndOptionalDomain() {
- val owned = LanAddresses.entry("tun0", "100.96.0.1", Kind.VPN, false, "100.96.0.1", "car.example.com")
- assertEquals(Kind.TESLA_HTTP, owned.kind)
- assertEquals("http://car.example.com:8080", owned.url)
- assertEquals("http://100.96.0.1:8080", owned.ipUrl)
- val cellular = LanAddresses.entry("rmnet0", "100.96.0.1", Kind.CELLULAR, true, "100.96.0.1", "car.example.com")
- val otherVpn = LanAddresses.entry("tun1", "100.96.0.2", Kind.VPN, false, "100.96.0.1", "car.example.com")
- val disabled = LanAddresses.entry("tun0", "100.96.0.1", Kind.VPN, false, null, "car.example.com")
- assertEquals(Kind.CELLULAR, cellular.kind)
- assertEquals(Kind.VPN, otherVpn.kind)
- assertEquals("http://100.96.0.1:8080", disabled.url)
- assertEquals(owned, LanAddresses.ranked(listOf(cellular, otherVpn, owned)).first())
+ @Test fun rootSharedEntryRanksFirstWithoutPromotingOtherVpnAddresses() {
+ val shared = LanAddresses.sharedEntry("100.96.0.1", "car.example.com")
+ val vpn = Entry("tun0", "100.96.0.1", Kind.VPN)
+ val hotspot = Entry("ap0", "192.168.43.1", Kind.HOTSPOT)
+ assertEquals(Kind.TESLA_HTTP, shared.kind)
+ assertEquals("http://car.example.com:8080", shared.url)
+ assertEquals("http://100.96.0.1:8080", vpn.url)
+ assertEquals(shared, LanAddresses.ranked(listOf(vpn, hotspot, shared)).first())
+ assertEquals(hotspot, LanAddresses.ranked(listOf(vpn, hotspot)).first())
}
@Test fun lanBeatsPrivateCellularVpnAndCarPlayDirectAddresses() {
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanTlsEcTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanTlsEcTest.kt
new file mode 100644
index 0000000..20d798d
--- /dev/null
+++ b/common/src/test/java/com/shilapi/xcertplay/web/LanTlsEcTest.kt
@@ -0,0 +1,220 @@
+package com.shilapi.xcertplay.web
+
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.asn1.sec.SECNamedCurves
+import org.bouncycastle.asn1.sec.SECObjectIdentifiers
+import org.bouncycastle.asn1.x509.BasicConstraints
+import org.bouncycastle.asn1.x509.Extension
+import org.bouncycastle.asn1.x509.GeneralName
+import org.bouncycastle.asn1.x509.GeneralNames
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.openssl.PKCS8Generator
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter
+import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator
+import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8EncryptorBuilder
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import org.bouncycastle.util.io.pem.PemObject
+import org.bouncycastle.util.io.pem.PemWriter
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.io.ByteArrayOutputStream
+import java.io.StringWriter
+import java.math.BigInteger
+import java.security.KeyFactory
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.Provider
+import java.security.Security
+import java.security.cert.X509Certificate
+import java.security.spec.ECGenParameterSpec
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Date
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocket
+import javax.net.ssl.TrustManagerFactory
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class LanTlsEcTest {
+ private val bc = BouncyCastleProvider()
+ private data class Identity(val keys: KeyPair, val chain: List)
+ private fun identity(): Identity {
+ fun keys(curve: String) = KeyPairGenerator.getInstance("EC", bc).apply {
+ initialize(ECGenParameterSpec(curve))
+ }.generateKeyPair()
+ val root = keys("secp384r1")
+ val issuer = keys("secp384r1")
+ val server = keys("secp256r1")
+ fun certificate(subject: String, issuerName: String, public: KeyPair, signer: KeyPair, ca: Boolean): X509Certificate {
+ val now = System.currentTimeMillis()
+ val builder = JcaX509v3CertificateBuilder(X500Name("CN=$issuerName"), BigInteger(128, java.security.SecureRandom()),
+ Date(now - 60000), Date(now + 86400000), X500Name("CN=$subject"), public.public)
+ builder.addExtension(Extension.basicConstraints, true, BasicConstraints(ca))
+ if (!ca) builder.addExtension(Extension.subjectAlternativeName, false,
+ GeneralNames(GeneralName(GeneralName.iPAddress, "127.0.0.1")))
+ return JcaX509CertificateConverter().setProvider(bc).getCertificate(builder.build(
+ JcaContentSignerBuilder(if (ca) "SHA384withECDSA" else "SHA256withECDSA").setProvider(bc).build(signer.private)))
+ }
+ return Identity(server, listOf(
+ certificate("EC Server", "EC Issuer", server, issuer, false),
+ certificate("EC Issuer", "EC Root", issuer, root, true),
+ certificate("EC Root", "EC Root", root, root, true)))
+ }
+
+ private fun pem(vararg objects: Any) = StringWriter().also { output ->
+ JcaPEMWriter(output).use { writer -> objects.forEach(writer::writeObject) }
+ }.toString().toByteArray()
+
+ private fun ecParameters(encoded: ByteArray) = StringWriter().also { output ->
+ PemWriter(output).use { it.writeObject(PemObject("EC PARAMETERS", encoded)) }
+ }.toString().toByteArray()
+
+ private fun withAndroidKeyManager(action: () -> Unit) {
+ // Use Android's real Conscrypt key manager and a BC PKCS#12 store. SunPKCS12
+ // rewrites the private key as EC and hides Conscrypt's strict name check.
+ val factory = Class.forName("com.android.org.conscrypt.KeyManagerFactoryImpl")
+ val pkcs12 = bc.getService("KeyStore", "PKCS12")
+ val provider = object : Provider("WheelPlayAndroidTlsTest", 1.0, "Android key manager and BC PKCS12") {
+ init {
+ putService(object : Service(this, "KeyManagerFactory", "WheelPlayAndroidX509", factory.name, emptyList(), emptyMap()) {
+ override fun newInstance(parameter: Any?) = factory.getConstructor().newInstance()
+ })
+ putService(object : Service(this, "KeyStore", "PKCS12", pkcs12.className, emptyList(), emptyMap()) {
+ override fun newInstance(parameter: Any?) = pkcs12.newInstance(parameter)
+ })
+ }
+ }
+ val original = Security.getProperty("ssl.KeyManagerFactory.algorithm")
+ Security.insertProviderAt(provider, 1)
+ Security.setProperty("ssl.KeyManagerFactory.algorithm", "WheelPlayAndroidX509")
+ try { action() } finally {
+ Security.setProperty("ssl.KeyManagerFactory.algorithm", original)
+ Security.removeProvider(provider.name)
+ }
+ }
+
+ private fun assertTrustedHandshake(endpoint: LanTls.Endpoint) {
+ val root = java.security.cert.CertificateFactory.getInstance("X.509")
+ .generateCertificate(endpoint.certificate.inputStream())
+ val trust = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null); setCertificateEntry("root", root) }
+ val managers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(trust) }
+ val tls = SSLContext.getInstance("TLS").apply { init(null, managers.trustManagers, null) }
+ val server = endpoint.sockets.createServerSocket(0)
+ val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
+ val handshake = executor.submit {
+ (server.accept() as SSLSocket).use {
+ it.soTimeout = 3000
+ it.startHandshake()
+ assertEquals(42, it.inputStream.read())
+ it.outputStream.write(43)
+ }
+ }
+ try {
+ (tls.socketFactory.createSocket("127.0.0.1", server.localPort) as SSLSocket).use { client ->
+ client.soTimeout = 3000
+ client.sslParameters = client.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
+ client.startHandshake()
+ assertEquals(3, client.session.peerCertificates.size)
+ client.outputStream.write(42)
+ assertEquals(43, client.inputStream.read())
+ }
+ handshake.get(5, java.util.concurrent.TimeUnit.SECONDS)
+ } catch (error: Exception) {
+ try { handshake.get(5, java.util.concurrent.TimeUnit.SECONDS) }
+ catch (serverError: Exception) { error.addSuppressed(serverError) }
+ throw error
+ } finally { server.close(); executor.shutdownNow() }
+ }
+
+ @Test fun ecPemFormatsWorkWithAndroidKeyManagerAndSurviveReload() = withAndroidKeyManager {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ val chain = pem(*identity.chain.toTypedArray())
+ val password = "test-password".toCharArray()
+ val encryptor = JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.AES_256_CBC).setProvider(bc).setPassword(password).build()
+ val formats = listOf(
+ pem(JcaPKCS8Generator(identity.keys.private, null)) to CharArray(0),
+ pem(identity.keys.private) to CharArray(0),
+ pem(JcaPKCS8Generator(identity.keys.private, encryptor)) to password)
+ for ((key, secret) in formats) {
+ LanTls.preparePem(chain, key, secret).use { prepared ->
+ assertEquals("CN=EC Server", prepared.endpoint.subject)
+ assertTrustedHandshake(prepared.endpoint)
+ LanTls.saveCustom(context, prepared)
+ val restored = LanTls.create(context, emptyList())
+ assertEquals(prepared.endpoint.fingerprint, restored.fingerprint)
+ assertTrustedHandshake(restored)
+ }
+ }
+ }
+
+ @Test fun ecPkcs12WorksWithAndroidKeyManagerAndSurvivesReload() = withAndroidKeyManager {
+ val identity = identity()
+ val password = "test-password".toCharArray()
+ val key = KeyFactory.getInstance("ECDSA", bc).generatePrivate(PKCS8EncodedKeySpec(identity.keys.private.encoded))
+ val store = KeyStore.getInstance("PKCS12", bc).apply {
+ load(null, password); setKeyEntry("server", key, password, identity.chain.toTypedArray())
+ }
+ val encoded = ByteArrayOutputStream().use { store.store(it, password); it.toByteArray() }
+ LanTls.preparePkcs12(encoded, password).use { prepared ->
+ assertTrustedHandshake(prepared.endpoint)
+ val context = RuntimeEnvironment.getApplication()
+ LanTls.saveCustom(context, prepared)
+ assertTrustedHandshake(LanTls.create(context, emptyList()))
+ }
+ }
+
+ @Test fun ecParametersBeforeTraditionalPrivateKeyWorkWithAndroidKeyManagerAndSurviveReload() = withAndroidKeyManager {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ val chain = pem(*identity.chain.toTypedArray())
+ val key = pem(identity.keys.private)
+ assertTrue(key.toString(Charsets.UTF_8).startsWith("-----BEGIN EC PRIVATE KEY-----"))
+ for (parameters in listOf(SECObjectIdentifiers.secp256r1.encoded, SECNamedCurves.getByName("secp256r1").encoded)) {
+ LanTls.preparePem(chain, ecParameters(parameters) + key).use { prepared ->
+ assertTrustedHandshake(prepared.endpoint)
+ LanTls.saveCustom(context, prepared)
+ val restored = LanTls.create(context, emptyList())
+ assertEquals(prepared.endpoint.fingerprint, restored.fingerprint)
+ assertTrustedHandshake(restored)
+ }
+ }
+ }
+
+ @Test fun ecParametersDoNotAllowMissingOrMultiplePrivateKeysOrExtraObjects() = withAndroidKeyManager {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ val chain = pem(*identity.chain.toTypedArray())
+ val key = pem(identity.keys.private)
+ val parameters = ecParameters(SECObjectIdentifiers.secp256r1.encoded)
+ LanTls.preparePem(chain, key).use { LanTls.saveCustom(context, it) }
+ val old = LanTls.create(context, emptyList())
+ for (invalid in listOf(parameters, parameters + key + key, parameters + key + parameters, parameters + parameters + key)) {
+ val error = runCatching { LanTls.preparePem(chain, invalid).use { } }.exceptionOrNull()
+ assertNotNull(error)
+ }
+ val current = LanTls.create(context, emptyList())
+ assertEquals(old.fingerprint, current.fingerprint)
+ assertTrustedHandshake(current)
+ }
+
+ @Test fun aDifferentEcPrivateKeyIsRejectedAndKeepsTheExistingIdentity() = withAndroidKeyManager {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ val chain = pem(*identity.chain.toTypedArray())
+ LanTls.preparePem(chain, pem(identity.keys.private)).use { LanTls.saveCustom(context, it) }
+ val old = LanTls.create(context, emptyList())
+ val failure = runCatching { LanTls.preparePem(chain, pem(identity().keys.private)) }.exceptionOrNull()
+ assertEquals("服务器证书与私钥不匹配", failure?.message)
+ val current = LanTls.create(context, emptyList())
+ assertEquals(old.fingerprint, current.fingerprint)
+ assertTrustedHandshake(current)
+ }
+}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanTlsTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanTlsTest.kt
new file mode 100644
index 0000000..11df574
--- /dev/null
+++ b/common/src/test/java/com/shilapi/xcertplay/web/LanTlsTest.kt
@@ -0,0 +1,279 @@
+package com.shilapi.xcertplay.web
+
+import com.shilapi.xcertplay.MfiAssetStore
+import com.shilapi.xcertplay.SyntheticMfiIdentity
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.asn1.x509.BasicConstraints
+import org.bouncycastle.asn1.x509.Extension
+import org.bouncycastle.asn1.x509.GeneralName
+import org.bouncycastle.asn1.x509.GeneralNames
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter
+import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator
+import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8EncryptorBuilder
+import org.bouncycastle.openssl.PKCS8Generator
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.io.ByteArrayOutputStream
+import java.io.StringWriter
+import java.math.BigInteger
+import java.net.ServerSocket
+import java.net.URL
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.HttpsURLConnection
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class LanTlsTest {
+ @Before fun startWithoutRootAuthorization() {
+ val identity = SyntheticMfiIdentity.create()
+ MfiAssetStore(RuntimeEnvironment.getApplication().noBackupFilesDir)
+ .installFiles({ identity.key.inputStream() }, { identity.certificate.inputStream() })
+ val original = RootAccess.authorize
+ try {
+ RootAccess.authorize = { throw java.io.IOException("Permission denied") }
+ RootAccess.request()
+ } finally { RootAccess.authorize = original }
+ }
+
+ private val provider = BouncyCastleProvider()
+ private data class Identity(val keys: KeyPair, val chain: List)
+ private fun identity(): Identity {
+ val keys = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
+ val rootKeys = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair()
+ fun certificate(name: String, signer: KeyPair, public: KeyPair, ca: Boolean): X509Certificate {
+ val now = System.currentTimeMillis()
+ val builder = JcaX509v3CertificateBuilder(X500Name("CN=Test Root"), BigInteger.valueOf(if (ca) 1 else 2),
+ Date(now - 60000), Date(now + 86400000), X500Name("CN=$name"), public.public)
+ builder.addExtension(Extension.basicConstraints, true, BasicConstraints(ca))
+ if (!ca) builder.addExtension(Extension.subjectAlternativeName, false, GeneralNames(arrayOf(
+ GeneralName(GeneralName.iPAddress, "127.0.0.1"), GeneralName(GeneralName.dNSName, "car.example.com"))))
+ return JcaX509CertificateConverter().setProvider(provider).getCertificate(
+ builder.build(JcaContentSignerBuilder("SHA256withRSA").setProvider(provider).build(signer.private)))
+ }
+ return Identity(keys, listOf(certificate("Test Server", rootKeys, keys, false), certificate("Test Root", rootKeys, rootKeys, true)))
+ }
+ private fun pem(vararg objects: Any): ByteArray = StringWriter().also { output ->
+ JcaPEMWriter(output).use { writer -> objects.forEach(writer::writeObject) }
+ }.toString().toByteArray()
+ private fun pkcs12(identity: Identity, password: CharArray, extra: Boolean = false, keyPassword: CharArray = password): ByteArray {
+ val store = KeyStore.getInstance("PKCS12").apply {
+ load(null, password); setKeyEntry("car", identity.keys.private, keyPassword, identity.chain.toTypedArray())
+ if (extra) setKeyEntry("other", identity.keys.private, keyPassword, identity.chain.toTypedArray())
+ }
+ return ByteArrayOutputStream().use { store.store(it, password); it.toByteArray() }
+ }
+ private fun waitForHttps() {
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8)
+ while (WebSession.tls == null && WebSession.tlsError == null && System.nanoTime() < deadline) Thread.sleep(20)
+ assertNotNull("HTTPS failed: ${WebSession.tlsError}", WebSession.tls)
+ }
+ private fun httpsGet(port: Int): String {
+ val root = CertificateFactory.getInstance("X.509").generateCertificate(WebSession.tls!!.certificate.inputStream())
+ val store = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null); setCertificateEntry("root", root) }
+ val managers = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(store) }
+ val tls = SSLContext.getInstance("TLS").apply { init(null, managers.trustManagers, null) }
+ val connection = URL("https://127.0.0.1:$port/").openConnection() as HttpsURLConnection
+ connection.sslSocketFactory = tls.socketFactory
+ connection.connectTimeout = 3000; connection.readTimeout = 3000
+ return try { connection.inputStream.use { it.reader().readText() } } finally { connection.disconnect() }
+ }
+
+ @Test fun importedPemServesATrustedHandshakeAndSurvivesRestart() {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ WebSession.start(context)
+ try {
+ waitForHttps()
+ val code = WebSession.code
+ LanTls.preparePem(pem(*identity.chain.toTypedArray()), pem(identity.keys.private)).use { prepared ->
+ assertNull(WebSession.installCertificate(context, prepared))
+ assertTrue(WebSession.tls!!.custom)
+ assertEquals(code, WebSession.code)
+ assertTrue(httpsGet(WebSession.httpsPort).contains("设备上的六位配对码"))
+ assertTrue(LanTls.hasCustom(context))
+ val fingerprint = WebSession.tls!!.fingerprint
+ WebSession.stop(); WebSession.start(context); waitForHttps()
+ assertEquals(fingerprint, WebSession.tls!!.fingerprint)
+ assertTrue(httpsGet(WebSession.httpsPort).contains("WheelPlay"))
+ }
+ assertNull(WebSession.restoreDefaultCertificate(context))
+ assertFalse(LanTls.hasCustom(context))
+ assertFalse(WebSession.tls!!.custom)
+ assertTrue(httpsGet(WebSession.httpsPort).contains("WheelPlay"))
+ } finally { WebSession.stop() }
+ }
+
+ @Test fun pkcs12SupportsPasswordAliasSelectionAndDistinctKeyPassword() {
+ val identity = identity()
+ val password = "store-password".toCharArray()
+ val keyPassword = "key-password".toCharArray()
+ val bytes = pkcs12(identity, password, extra = true, keyPassword = keyPassword)
+ assertEquals(setOf("car", "other"), LanTls.pkcs12Aliases(bytes, password).toSet())
+ assertTrue(runCatching { LanTls.preparePkcs12(bytes, password) }.isFailure)
+ assertTrue(runCatching { LanTls.preparePkcs12(bytes, "incorrect".toCharArray(), "car") }.isFailure)
+ assertTrue(runCatching { LanTls.preparePkcs12(bytes, password, "car", "incorrect".toCharArray()) }.isFailure)
+ LanTls.preparePkcs12(bytes, password, "car", keyPassword).use {
+ assertTrue(it.endpoint.custom)
+ LanTls.saveCustom(RuntimeEnvironment.getApplication(), it)
+ }
+ assertTrue(LanTls.create(RuntimeEnvironment.getApplication(), listOf("127.0.0.1")).custom)
+ }
+
+ @Test fun invalidKeyAndFilesLeaveTheExistingCertificateUntouched() {
+ val context = RuntimeEnvironment.getApplication()
+ val valid = identity()
+ LanTls.preparePem(pem(*valid.chain.toTypedArray()), pem(valid.keys.private)).use { LanTls.saveCustom(context, it) }
+ val fingerprint = LanTls.create(context, emptyList()).fingerprint
+ val other = identity()
+ assertTrue(runCatching { LanTls.preparePem(pem(*valid.chain.toTypedArray()), pem(other.keys.private)) }.isFailure)
+ assertTrue(runCatching { LanTls.preparePem("invalid".toByteArray(), pem(valid.keys.private)) }.isFailure)
+ assertTrue(runCatching { LanTls.readImport(ByteArray(LanTls.MAX_IMPORT_BYTES + 1).inputStream()) }.isFailure)
+ assertEquals(fingerprint, LanTls.create(context, emptyList()).fingerprint)
+ }
+
+ @Test fun encryptedPemAndPkcs12BothServeTheSelectedServerIdentity() {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ val password = "fixture-password".toCharArray()
+ val encryptor = JceOpenSSLPKCS8EncryptorBuilder(PKCS8Generator.AES_256_CBC)
+ .setProvider(provider).setPassword(password).build()
+ val encrypted = pem(JcaPKCS8Generator(identity.keys.private, encryptor))
+ assertTrue(runCatching { LanTls.preparePem(pem(*identity.chain.toTypedArray()), encrypted, "wrong".toCharArray()) }.isFailure)
+ LanTls.preparePem(pem(*identity.chain.toTypedArray()), encrypted, password).use {
+ assertTrue(it.endpoint.custom)
+ }
+ WebSession.start(context)
+ try {
+ waitForHttps()
+ LanTls.preparePkcs12(pkcs12(identity, password), password).use {
+ assertNull(WebSession.installCertificate(context, it))
+ assertTrue(httpsGet(WebSession.httpsPort).contains("WheelPlay"))
+ assertEquals("CN=Test Server", WebSession.tls!!.subject)
+ }
+ } finally { WebSession.stop() }
+ }
+
+ @Test fun ordinaryListenersRejectPrivilegedPortsAndKeepBothExistingEndpoints() {
+ val context = RuntimeEnvironment.getApplication()
+ WebSession.start(context)
+ try {
+ waitForHttps()
+ val code = WebSession.code
+ val http = WebSession.httpPort
+ val https = WebSession.httpsPort
+ assertNotNull(WebSession.setHttpPort(context, 80))
+ assertNotNull(WebSession.setHttpsPort(context, 443))
+ assertEquals(http, WebSession.httpPort)
+ assertEquals(https, WebSession.httpsPort)
+ assertFalse(RootAccess.granted)
+ assertEquals(code, WebSession.code)
+ assertTrue(URL("http://127.0.0.1:$http/").readText().contains("WheelPlay"))
+ assertTrue(httpsGet(https).contains("WheelPlay"))
+ } finally { WebSession.stop() }
+ }
+
+ @Test fun disablingHttpsStopsItsListenerAndMappingWhileKeepingHttpPairingAndCertificate() {
+ val context = RuntimeEnvironment.getApplication()
+ val identity = identity()
+ WebSession.start(context)
+ try {
+ waitForHttps()
+ LanTls.preparePem(pem(*identity.chain.toTypedArray()), pem(identity.keys.private)).use {
+ assertNull(WebSession.installCertificate(context, it))
+ }
+ val fingerprint = WebSession.tls!!.fingerprint
+ val code = WebSession.code
+ val https = WebSession.httpsPort
+ val config = com.shilapi.xcertplay.network.TeslaHttpConfig(enabled = true)
+ val interfaces = listOf(HttpDownstream("ap0", 12, "192.168.43.1/24"))
+ val before = TeslaHttpCompatibility.plan(config, interfaces, WebSession.httpListenerPort, WebSession.httpsListenerPort, true)!!
+ assertEquals(443, before.httpsMappingPort)
+ assertNull(WebSession.setHttpsEnabled(context, false))
+ assertFalse(WebSession.httpsReady)
+ assertNull(WebSession.httpsListenerPort)
+ assertTrue(LanTls.hasCustom(context))
+ assertTrue(WebSession.running)
+ assertEquals(code, WebSession.code)
+ ServerSocket(https).use { assertEquals(https, it.localPort) }
+ val after = TeslaHttpCompatibility.plan(config, interfaces, WebSession.httpListenerPort, WebSession.httpsListenerPort, true)!!
+ assertEquals(80, after.httpMappingPort)
+ assertNull(after.httpsMappingPort)
+ val info = org.json.JSONObject(URL("http://127.0.0.1:${WebSession.httpPort}/tls.json").readText())
+ assertFalse(info.getBoolean("available")); assertFalse(info.getBoolean("enabled"))
+ WebSession.stop(); WebSession.start(context)
+ assertFalse(WebSession.httpsReady)
+ assertFalse(WebListenSettings.httpsEnabled(context))
+ assertNull(WebSession.setHttpsEnabled(context, true))
+ assertTrue(WebSession.httpsReady)
+ assertEquals(fingerprint, WebSession.tls!!.fingerprint)
+ assertEquals(https, WebSession.httpsPort)
+ assertTrue(httpsGet(https).contains("WheelPlay"))
+ } finally { WebSession.stop() }
+ }
+
+ @Test fun enablingHttpsOnAnOccupiedPortKeepsItDisabledAndHttpUsable() {
+ val context = RuntimeEnvironment.getApplication()
+ assertNull(WebSession.setHttpsEnabled(context, false))
+ WebSession.start(context)
+ try {
+ val code = WebSession.code
+ ServerSocket(0).use { occupied ->
+ assertNull(WebSession.setHttpsPort(context, occupied.localPort))
+ assertNotNull(WebSession.setHttpsEnabled(context, true))
+ assertFalse(WebListenSettings.httpsEnabled(context))
+ assertFalse(WebSession.httpsReady)
+ assertEquals(code, WebSession.code)
+ assertTrue(URL("http://127.0.0.1:${WebSession.httpPort}/").readText().contains("WheelPlay"))
+ }
+ assertNull(WebSession.setHttpsEnabled(context, true))
+ assertTrue(WebSession.httpsReady)
+ assertTrue(httpsGet(WebSession.httpsPort).contains("WheelPlay"))
+ } finally { WebSession.stop() }
+ }
+
+ @Test fun httpsPortBindingFailureKeepsListenerAndNewPortPersists() {
+ val context = RuntimeEnvironment.getApplication()
+ WebSession.start(context)
+ try {
+ waitForHttps()
+ val oldPort = WebSession.httpsPort
+ val code = WebSession.code
+ val fingerprint = WebSession.tls!!.fingerprint
+ ServerSocket(0).use { occupied ->
+ assertNotNull(WebSession.setHttpsPort(context, occupied.localPort))
+ }
+ assertEquals(oldPort, WebSession.httpsPort)
+ assertEquals(oldPort, WebListenSettings.httpsPort(context))
+ assertTrue(httpsGet(oldPort).contains("WheelPlay"))
+ assertNotNull(WebSession.setHttpsPort(context, WebSession.httpPort))
+ val port = ServerSocket(0).use { it.localPort }
+ assertNull(WebSession.setHttpsPort(context, port))
+ assertEquals(port, WebListenSettings.httpsPort(context))
+ assertEquals(code, WebSession.code)
+ assertEquals(fingerprint, WebSession.tls!!.fingerprint)
+ assertTrue(httpsGet(port).contains("WheelPlay"))
+ val info = org.json.JSONObject(URL("http://127.0.0.1:${WebSession.httpPort}/tls.json").readText())
+ assertEquals(port, info.getInt("port"))
+ WebSession.stop(); WebSession.start(context); waitForHttps()
+ assertEquals(port, WebSession.httpsPort)
+ assertTrue(httpsGet(port).contains("WheelPlay"))
+ } finally { WebSession.stop() }
+ }
+}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt
index de449d4..5801aa2 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt
@@ -38,7 +38,7 @@ class LanWebServerTest {
val port = java.net.ServerSocket(0).use { it.localPort }
assertNull(WebSession.setHttpPort(context, port))
assertEquals(port, WebSession.httpPort)
- assertEquals(port, TeslaHttpCompatibility.config(context).port)
+ assertEquals(port, WebListenSettings.httpPort(context))
assertFalse(TeslaHttpCompatibility.config(context).enabled)
assertEquals(oldCode, WebSession.code)
assertSame(oldFrame, WebSession.frame)
@@ -171,7 +171,7 @@ class LanWebServerTest {
assertEquals(3, received.int)
assertEquals(48_000, received.int)
assertEquals(1, received.short.toInt())
- received.short
+ assertEquals(BrowserAudioBridge.AUDIO_FLAG_MEDIA, received.short.toInt())
val sequence = received.int.toLong() and 0xffff_ffffL
val samples = ByteArray(4); received.get(samples)
assertArrayEquals(byteArrayOf(1,2,3,4), samples)
@@ -180,6 +180,37 @@ class LanWebServerTest {
}
} finally { WebSession.stop() }
}
+
+ @Test fun browserMediaBufferFollowsSavedSettingsLiveAndAfterServiceRestart() {
+ val context = org.robolectric.RuntimeEnvironment.getApplication()
+ com.shilapi.xcertplay.AirPlayPersistence.saveMediaBufferMillis(context, 1000)
+ com.shilapi.xcertplay.AirPlayPersistence.saveBrowserAudioPlayback(context, true)
+ WebSession.start(context)
+ try {
+ handshake(WebSession.code).use { client ->
+ client.soTimeout = 3000
+ assertTrue(readHeaders(client).startsWith("HTTP/1.1 101"))
+ val initial = org.json.JSONObject(frame(client).toString(Charsets.UTF_8))
+ assertEquals(1000, initial.getInt("mediaBufferMillis"))
+ assertTrue(initial.getBoolean("playback"))
+ WebSession.setMediaBufferMillis(context, 500)
+ assertEquals(500, org.json.JSONObject(frame(client).toString(Charsets.UTF_8)).getInt("mediaBufferMillis"))
+ assertEquals(500, com.shilapi.xcertplay.AirPlayPersistence.loadMediaBufferMillis(context))
+ WebSession.setBrowserAudioPlayback(context, false)
+ val disabled = org.json.JSONObject(frame(client).toString(Charsets.UTF_8))
+ assertFalse(disabled.getBoolean("playback"))
+ assertEquals(500, disabled.getInt("mediaBufferMillis"))
+ WebSession.setBrowserMicrophone(context, false)
+ assertEquals(500, org.json.JSONObject(frame(client).toString(Charsets.UTF_8)).getInt("mediaBufferMillis"))
+ }
+ WebSession.stop(); WebSession.start(context)
+ handshake(WebSession.code).use { client ->
+ client.soTimeout = 3000
+ assertTrue(readHeaders(client).startsWith("HTTP/1.1 101"))
+ assertEquals(500, org.json.JSONObject(frame(client).toString(Charsets.UTF_8)).getInt("mediaBufferMillis"))
+ }
+ } finally { WebSession.stop() }
+ }
@Test fun plainHttpRejectsMicrophoneCaptureEvenForAPairedViewer() {
WebSession.start(org.robolectric.RuntimeEnvironment.getApplication())
try {
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/RootHttpRoutingTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/RootHttpRoutingTest.kt
new file mode 100644
index 0000000..2a60f87
--- /dev/null
+++ b/common/src/test/java/com/shilapi/xcertplay/web/RootHttpRoutingTest.kt
@@ -0,0 +1,251 @@
+package com.shilapi.xcertplay.web
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.util.concurrent.TimeUnit
+
+class RootHttpRoutingTest {
+ private val plan = RootHttpPlan("100.96.0.1", 8080, listOf(HttpDownstream("ap0", 7, "192.168.43.1/24")))
+ private class Session : HttpRoutingSession {
+ override var alive = true
+ var closes = 0
+ override fun close() { alive = false; closes++ }
+ }
+
+ @Test fun publicAliasAndBothPortsStayScopedToTheHotspotAndLocalNetworkReturnTable() {
+ val commands = plan.copy(address = "3.3.3.3", httpsPort = 9443).commands().map { it.apply }
+ assertTrue(commands.any { it.contains("from 3.3.3.3/32 to 192.168.43.1/24 iif lo lookup 97") })
+ for (port in listOf(8080, 9443)) {
+ assertTrue(commands.any { it.contains("INPUT -i ap0 -d 3.3.3.3/32 -p tcp --dport $port") })
+ }
+ assertFalse(commands.any { it.contains("FORWARD") || it.contains("MASQUERADE") || it.contains("0.0.0.0/0") })
+ }
+
+ @Test fun failedAuthorizationIsNotAdvertisedOrRepeatedUntilAnExplicitRetry() {
+ var attempts = 0
+ val router = RootHttpRouting { attempts++; throw IOException("Root denied") }
+ assertNull(router.reconcile(plan).address)
+ assertEquals("Root denied", router.reconcile(plan).error)
+ assertEquals(1, attempts)
+ router.retry()
+ router.reconcile(plan)
+ assertEquals(2, attempts)
+ }
+
+ @Test fun unchangedPlanKeepsSessionWhilePortAndHotspotChangesCloseOldRules() {
+ val sessions = mutableListOf()
+ val router = RootHttpRouting { Session().also(sessions::add) }
+ assertEquals(plan.address, router.reconcile(plan).address)
+ assertEquals(listOf("ap0"), router.reconcile(plan).downstreams)
+ assertEquals(1, sessions.size)
+ val next = plan.copy(port = 9090)
+ assertEquals(plan.address, router.reconcile(next).address)
+ assertEquals(1, sessions.first().closes)
+ router.reconcile(null)
+ assertEquals(1, sessions.last().closes)
+ router.reconcile(plan)
+ assertEquals(3, sessions.size)
+ router.reconcile(null)
+ }
+
+ @Test fun unexpectedRootExitWithdrawsAddressAndRequiresRetry() {
+ val session = Session()
+ val router = RootHttpRouting { session }
+ router.reconcile(plan)
+ session.alive = false
+ assertNull(router.reconcile(plan).address)
+ assertNotNull(router.reconcile(plan).error)
+ assertEquals(1, session.closes)
+ }
+
+ @Test fun cleanupFailureNeverAdvertisesOrStartsAnotherSession() {
+ var attempts = 0
+ val router = RootHttpRouting {
+ attempts++
+ object : HttpRoutingSession {
+ override val alive = true
+ override fun close() { throw IOException("cleanup failed") }
+ }
+ }
+ router.reconcile(plan)
+ val result = router.reconcile(plan.copy(port = 9090))
+ assertNull(result.address)
+ assertEquals("cleanup failed", result.error)
+ assertEquals(1, attempts)
+ }
+
+ @Test fun commandInputsCannotInjectShellSyntax() {
+ for (name in listOf("ap0;id", "ap0\nreboot", "$(id)", "lo")) {
+ try { HttpDownstream(name, 7, "192.168.43.1/24"); fail(name) }
+ catch (_: IllegalArgumentException) { }
+ }
+ for (subnet in listOf("192.168.1.1/0", "192.168.1.999/24", "192.168.1.1/24;id", "1.2.3.4/32")) {
+ try { HttpDownstream("ap0", 7, subnet); fail(subnet) }
+ catch (_: IllegalArgumentException) { }
+ }
+ }
+
+ /** Execute the actual generated shell, replacing only kernel commands with recording binaries. */
+ private fun withShell(failAt: Int = 0, failRollbackAt: Int = 0, occupiedPort: Boolean = false, ownedPort: Boolean = false,
+ unsupportedAddrtype: Boolean = false, unavailableNat: Boolean = false,
+ block: (File, (String) -> Process) -> Unit) {
+ val directory = Files.createTempDirectory("wheelplay-root-test-").toFile()
+ try {
+ val log = File(directory, "commands")
+ val counter = File(directory, "counter").apply { writeText("0") }
+ val tcp = File(directory, "tcp").apply { writeText(if (occupiedPort) "0: 00000000:0050 00000000:0000 0A\n" else "") }
+ val tcp6 = File(directory, "tcp6").apply { writeText("") }
+ val executable = File(directory, "kernel").apply {
+ writeText("""#!/bin/sh
+ case "${'$'}*" in *'-t nat -S') ${if (unavailableNat) "echo 'Cannot initialize NAT table' >&2; exit 1" else if (ownedPort) "echo '--comment wheelplay-port-80'; exit 0" else "exit 0"};; esac
+ echo "${'$'}*" >> '${log.path}'
+ ${if (unsupportedAddrtype) "case \"\$*\" in *'-m addrtype'*) echo 'iptables: No chain/target/match by that name.' >&2; exit 1;; esac" else ":"}
+ n=${'$'}(cat '${counter.path}')
+ n=${'$'}((n + 1))
+ echo "${'$'}n" > '${counter.path}'
+ case "${'$'}n" in $failAt|$failRollbackAt) echo 'iptables: No chain/target/match by that name.' >&2; exit 1;; esac
+ exit 0
+ """.trimIndent())
+ setExecutable(true)
+ }
+ block(log) { script ->
+ val adapted = script.replace("/system/bin/ip", executable.path)
+ .replace("ip6tables", executable.path).replace("iptables", executable.path).replace("id -u", "printf 0")
+ .replace("/proc/net/tcp6", tcp6.path).replace("/proc/net/tcp", tcp.path)
+ ProcessBuilder("sh", "-c", adapted).redirectErrorStream(true).start()
+ }
+ } finally { directory.deleteRecursively() }
+ }
+
+ private fun recorded(command: String) = command.removePrefix("/system/bin/ip ").removePrefix("iptables ")
+
+ @Test fun rootShellRevertsEverySuccessfulCommandInReverseOrderOnStop() = withShell { log, launch ->
+ val shell = RootHttpShell.open(plan, launch)
+ assertTrue(shell.alive)
+ assertEquals(plan.commands().map { recorded(it.apply) }, log.readLines())
+ shell.close()
+ assertFalse(shell.alive)
+ assertEquals(plan.commands().map { recorded(it.apply) } +
+ plan.commands().asReversed().map { recorded(it.revert) }, log.readLines())
+ }
+
+ @Test fun partialStartupFailureRollsBackWithoutDeletingAnUnownedRule() = withShell(failAt = 4) { log, launch ->
+ try { RootHttpShell.open(plan, launch); fail("startup must fail") }
+ catch (_: IOException) { }
+ assertEquals(plan.commands().take(4).map { recorded(it.apply) } +
+ plan.commands().take(3).asReversed().map { recorded(it.revert) }, log.readLines())
+ }
+
+ @Test fun unconfirmedStartupRollbackBlocksRetriesPlanChangesAndStopStart() = withShell(failAt = 4, failRollbackAt = 5) { log, launch ->
+ var attempts = 0
+ val routing = RootHttpRouting { attempts++; RootHttpShell.open(it, launch) }
+ try {
+ val failed = routing.reconcile(plan)
+ assertNull(failed.address)
+ assertTrue(failed.error.orEmpty().contains("清理未确认"))
+ assertEquals(plan.commands().take(4).map { recorded(it.apply) } +
+ plan.commands().take(3).asReversed().map { recorded(it.revert) }, log.readLines())
+ val installed = log.readText()
+ for (changed in listOf(plan, plan.copy(address = "3.3.3.3"), plan.copy(port = 9090),
+ plan.copy(httpsPort = 9443), plan.copy(httpMappingPort = 80),
+ plan.copy(downstreams = listOf(HttpDownstream("ap1", 8, "192.168.44.1/24"))))) {
+ routing.retry()
+ val result = routing.reconcile(changed)
+ assertNull(result.address)
+ assertNull(result.httpMappingPort)
+ assertEquals(failed.error, result.error)
+ }
+ assertEquals(failed.error, routing.reconcile(null).error)
+ routing.retry()
+ assertEquals(failed.error, routing.reconcile(plan).error)
+ assertEquals(1, attempts)
+ assertEquals(installed, log.readText())
+ } finally { routing.reconcile(null) }
+ }
+
+ @Test fun closingStdinCleansRulesWhenTheAppOwnerDisappears() = withShell { log, launch ->
+ var process: Process? = null
+ val shell = RootHttpShell.open(plan) { launch(it).also { child -> process = child } }
+ process!!.outputStream.close()
+ assertTrue(process!!.waitFor(5, TimeUnit.SECONDS))
+ shell.close()
+ assertEquals(plan.commands().map { recorded(it.apply) } +
+ plan.commands().asReversed().map { recorded(it.revert) }, log.readLines())
+ }
+
+ @Test fun mappedPortsAreScopedToCustomIpWithoutRequiringTheAddrtypeModule() = withShell(unsupportedAddrtype = true) { log, launch ->
+ val mapped = plan.copy(httpsPort = 8443, httpMappingPort = 80, httpsMappingPort = 443)
+ val rules = mapped.commands().filter { it.apply.contains("-t nat") }
+ assertEquals(4, rules.size)
+ assertTrue(rules.all { it.apply.contains("-d 100.96.0.1/32") && !it.apply.contains("addrtype") })
+ assertTrue(rules.any { it.apply.contains("--dport 80") && it.apply.contains("--to-destination :8080") })
+ assertTrue(rules.any { it.apply.contains("--dport 443") && it.apply.contains("--to-destination :8443") })
+ val shell = RootHttpShell.open(mapped, launch)
+ shell.close()
+ assertEquals(mapped.commands().map { recorded(it.apply) } +
+ mapped.commands().asReversed().map { recorded(it.revert) }, log.readLines())
+ }
+
+ @Test fun startupErrorKeepsTheFailedCommandAndOriginalErrorAfterRollback() = withShell(failAt = 4) { _, launch ->
+ val error = runCatching { RootHttpShell.open(plan, launch) }.exceptionOrNull()
+ assertTrue(error?.message.orEmpty().contains("No chain/target/match by that name"))
+ assertTrue(error?.message.orEmpty().contains(recorded(plan.commands()[3].apply)))
+ assertFalse(error is RootCleanupException)
+ }
+
+ @Test fun failedNatPreflightReportsItsCommandWithoutMutatingRules() = withShell(unavailableNat = true) { log, launch ->
+ val error = runCatching { RootHttpShell.open(plan.copy(httpMappingPort = 80), launch) }.exceptionOrNull()
+ assertTrue(error?.message.orEmpty().contains("-t nat -S"))
+ assertTrue(error?.message.orEmpty().contains("Cannot initialize NAT table"))
+ assertFalse(log.exists())
+ assertFalse(error is RootCleanupException)
+ }
+
+ @Test fun mappingFailureRollsBackTheAliasAndEveryInstalledMapping() = withShell(failAt = 10) { log, launch ->
+ val mapped = plan.copy(httpsPort = 8443, httpMappingPort = 80, httpsMappingPort = 443)
+ assertTrue(runCatching { RootHttpShell.open(mapped, launch) }.isFailure)
+ assertEquals(mapped.commands().take(10).map { recorded(it.apply) } +
+ mapped.commands().take(9).asReversed().map { recorded(it.revert) }, log.readLines())
+ }
+
+ @Test fun occupiedLowPortIsRejectedBeforeAnyFirewallMutation() = withShell(occupiedPort = true) { log, launch ->
+ val error = runCatching { RootHttpShell.openScript(RootPortPlan(80, 42000, plan.address).script(), launch) }.exceptionOrNull()
+ assertTrue(error?.message.orEmpty().contains("already in use"))
+ assertFalse(log.exists())
+ }
+
+ @Test fun lowPortAlreadyForwardedByAnotherInstanceIsNotReplaced() = withShell(ownedPort = true) { log, launch ->
+ val error = runCatching { RootHttpShell.openScript(RootPortPlan(80, 42000, plan.address).script(), launch) }.exceptionOrNull()
+ assertTrue(error?.message.orEmpty().contains("already has a WheelPlay"))
+ assertFalse(log.exists())
+ }
+
+ @Test fun explicitPermissionProbeInstallsNoRulesAndDenialDoesNotClaimLeftoverRules() = withShell { log, launch ->
+ RootHttpShell.openScript(rootScript(emptyList()), launch).use { }
+ assertFalse(log.exists())
+ val error = runCatching { RootHttpShell.openScript(rootScript(emptyList())) {
+ ProcessBuilder("sh", "-c", "echo 'Permission denied'; exit 1").redirectErrorStream(true).start()
+ } }.exceptionOrNull()
+ assertTrue(error?.message.orEmpty().contains("Permission denied"))
+ assertFalse(error is RootCleanupException)
+ }
+
+ @Test fun failedMappingCleanupBlocksFurtherRedirects() {
+ var attempts = 0
+ val routing = RootHttpRouting {
+ attempts++
+ object : HttpRoutingSession {
+ override val alive = true
+ override fun close() { throw IOException("cannot remove rule") }
+ }
+ }
+ val mapped = plan.copy(httpMappingPort = 80)
+ assertEquals(80, routing.reconcile(mapped).httpMappingPort)
+ assertNotNull(routing.reconcile(null).error)
+ assertNull(routing.reconcile(mapped.copy(httpMappingPort = 81)).address)
+ assertEquals(1, attempts)
+ }
+}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt
index 46e8a06..ba07a5a 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt
@@ -3,7 +3,6 @@ package com.shilapi.xcertplay.web
import android.content.Intent
import android.os.ParcelFileDescriptor
import com.shilapi.xcertplay.network.CarPlayVpnService
-import com.shilapi.xcertplay.network.TeslaHttpConfig
import com.shilapi.xcertplay.airplay.AirPlayConfig
import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig
import com.shilapi.xcertplay.airplay.AirPlayIdentity
@@ -19,7 +18,6 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.RealObject
-import java.io.IOException
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [29])
@@ -57,27 +55,15 @@ class TeslaHttpLifecycleTest {
@Test
@Config(shadows = [AllocatingBuilderShadow::class])
- fun standaloneAndUsbPlansOnlyRouteTheAliasAndOriginalIpv6Link() {
+ fun wiredVpnOnlyRoutesItsOriginalIpv6Link() {
AllocatingBuilderShadow.plans.clear()
val controller = Robolectric.buildService(RoutingVpnService::class.java).create()
try {
- val service = controller.get()
- assertEquals(CarPlayVpnService.AttachResult.Started, service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS))
- service.allocateWiredTunnel().close()
- val plans = AllocatingBuilderShadow.plans
- assertEquals(2, plans.size)
- for ((index, plan) in plans.withIndex()) {
- assertEquals(if (index == 0) 1 else 2, plan.addresses.size)
- assertEquals(if (index == 0) 1 else 2, plan.routes.size)
- assertTrue(plan.addresses.contains(TeslaHttpConfig.DEFAULT_ADDRESS to 32))
- assertTrue(plan.routes.contains(TeslaHttpConfig.DEFAULT_ADDRESS to 32))
- assertFalse("Internet traffic must not be routed into this VPN", plan.routes.any { it.second == 0 })
- assertEquals(setOf(android.system.OsConstants.AF_INET, android.system.OsConstants.AF_INET6), plan.families)
- if (index == 1) {
- assertTrue(plan.addresses.contains("fe80::1" to 64))
- assertTrue(plan.routes.contains("fe80::" to 64))
- }
- }
+ controller.get().allocateWiredTunnel().close()
+ val plan = AllocatingBuilderShadow.plans.single()
+ assertEquals(listOf("fe80::1" to 64), plan.addresses)
+ assertEquals(listOf("fe80::" to 64), plan.routes)
+ assertEquals(setOf(android.system.OsConstants.AF_INET, android.system.OsConstants.AF_INET6), plan.families)
} finally {
controller.destroy()
AllocatingBuilderShadow.peers.forEach { it.close() }
@@ -86,28 +72,10 @@ class TeslaHttpLifecycleTest {
}
}
- // Replace only the kernel TUN allocation; exercise the real service lifecycle and resources.
- class TestVpnService : CarPlayVpnService() {
- val tunnels = mutableListOf()
- val peers = mutableListOf()
- var failAllocation = false
- override fun establishTunnel(linkLocal: String?): ParcelFileDescriptor {
- if (failAllocation) throw IOException("test allocation failure")
- val pipe = ParcelFileDescriptor.createPipe()
- tunnels.add(pipe[0]); peers.add(pipe[1])
- return pipe[0]
- }
- }
-
- @Test fun aliasSurvivesPhoneDetachButStopsWithItsWebOwner() {
- val controller = Robolectric.buildService(TestVpnService::class.java).create()
+ @Test fun wirelessAttachDetachAndVpnRevocationNeverAllocateATunnel() {
+ val controller = Robolectric.buildService(RoutingVpnService::class.java).create()
val service = controller.get()
try {
- assertEquals(CarPlayVpnService.AttachResult.Started, service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS))
- assertFalse(service.isAttached())
- assertEquals(TeslaHttpConfig.DEFAULT_ADDRESS, service.browserStatus.address)
- assertEquals(CarPlayVpnService.AttachResult.AlreadyStarted, service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS))
- assertEquals(1, service.tunnels.size)
assertEquals(CarPlayVpnService.AttachResult.Started, service.attachWireless(
java.net.InetAddress.getByName("127.0.0.1"),
AirPlayConfig("Test", "00:11:22:33:44:55", "00:11:22:33:44:55", "1.0",
@@ -115,52 +83,24 @@ class TeslaHttpLifecycleTest {
AirPlayIdentity.generate(), PairingStore(), null,
object : AirPlaySessionListener {}, object : AirPlayMediaHandler {}))
assertTrue(service.isAttached())
- assertEquals("Wireless attach must retain the existing alias", 1, service.tunnels.size)
+ service.onRevoke()
+ assertTrue("Wireless listener is independent of the wired VPN", service.isAttached())
service.detach()
assertFalse(service.isAttached())
- assertEquals(TeslaHttpConfig.DEFAULT_ADDRESS, service.browserStatus.address)
- assertEquals(2, service.tunnels.size)
- assertClosed(service.tunnels.first())
- service.clearBrowserAddress()
- assertNull(service.browserStatus.address)
- assertClosed(service.tunnels.last())
- service.detach()
- assertEquals("A later phone teardown must not resurrect a stopped alias", 2, service.tunnels.size)
- } finally { controller.destroy(); service.peers.forEach { it.close() } }
- }
-
- @Test fun failedAllocationIsNotAdvertisedAndCanBeRetried() {
- val controller = Robolectric.buildService(TestVpnService::class.java).create()
- val service = controller.get()
- try {
- service.failAllocation = true
- assertTrue(service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS) is CarPlayVpnService.AttachResult.Failed)
- assertNull(service.browserStatus.address)
- assertNotNull(service.browserStatus.error)
- service.failAllocation = false
- assertEquals(CarPlayVpnService.AttachResult.Started, service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS))
- assertNotNull(service.browserStatus.address)
- assertNull(service.browserStatus.error)
- } finally { controller.destroy(); service.peers.forEach { it.close() } }
+ } finally { controller.destroy() }
}
- @Test fun revocationWithdrawsTheAliasAndDoesNotReestablishOnDetach() {
- val controller = Robolectric.buildService(TestVpnService::class.java).create()
- val service = controller.get()
- try {
- service.setBrowserAddress(TeslaHttpConfig.DEFAULT_ADDRESS)
- service.onRevoke()
- assertNull(service.browserStatus.address)
- assertNotNull(service.browserStatus.error)
- assertClosed(service.tunnels.single())
- service.detach()
- assertEquals(1, service.tunnels.size)
- assertNotNull("Phone teardown must retain the revocation explanation", service.browserStatus.error)
- } finally { controller.destroy(); service.peers.forEach { it.close() } }
+ @Test fun tetheringUsesSystemReportedInterfacesAndRejectsShellInput() {
+ val intent = Intent("android.net.conn.TETHER_STATE_CHANGED")
+ .putStringArrayListExtra("tetherArray", arrayListOf("ap0", "wlan1", "ap0", "lo", "ap0;id"))
+ assertEquals(setOf("ap0", "wlan1"), TeslaHttpCompatibility.tetheredInterfaces(intent))
+ assertTrue(TeslaHttpCompatibility.tetheredInterfaces(Intent()).isEmpty())
+ assertEquals(setOf("ap0"), TeslaHttpCompatibility.tetheredInterfaces(
+ Intent().putExtra("tetherArray", arrayOf("ap0"))))
}
@Test fun frameworkVpnBindingUsesTheRevocationBinderAndLocalClientsUseTheLocalBinder() {
- val controller = Robolectric.buildService(TestVpnService::class.java).create()
+ val controller = Robolectric.buildService(RoutingVpnService::class.java).create()
try {
val service = controller.get()
assertTrue(service.onBind(Intent()) is CarPlayVpnService.LocalBinder)
@@ -169,8 +109,4 @@ class TeslaHttpLifecycleTest {
} finally { controller.destroy() }
}
- private fun assertClosed(fd: ParcelFileDescriptor) {
- try { fd.fd; fail("TUN descriptor must be closed") }
- catch (_: IllegalStateException) { }
- }
}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt
index 31f3bb3..353a5fe 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt
@@ -15,13 +15,13 @@ class TeslaHttpPreferencesTest {
@Test fun modeIsOptInAndSavedAddressAndDomainSurviveReload() {
val context = RuntimeEnvironment.getApplication()
assertEquals(TeslaHttpConfig(), TeslaHttpCompatibility.config(context))
- val value = TeslaHttpConfig(true, "100.96.0.2", "car.example.com", 9090)
+ val value = TeslaHttpConfig(true, "100.96.0.2", 81, 444)
TeslaHttpCompatibility.save(context, value)
assertEquals(value, TeslaHttpCompatibility.config(context))
TeslaHttpCompatibility.save(context, value.copy(enabled = false))
assertFalse(TeslaHttpCompatibility.config(context).enabled)
assertEquals("100.96.0.2", TeslaHttpCompatibility.config(context).address)
- assertEquals(9090, TeslaHttpCompatibility.config(context).port)
+ assertEquals(81, TeslaHttpCompatibility.config(context).httpMappingPort)
}
@Test fun olderPreferencesKeepDefaultPortAndInvalidPortsFailClosed() {
@@ -29,17 +29,17 @@ class TeslaHttpPreferencesTest {
val prefs = context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE)
prefs.edit().putBoolean("enabled", true).putString("address", "100.96.0.2").apply()
assertEquals(TeslaHttpConfig(true, "100.96.0.2"), TeslaHttpCompatibility.config(context))
- prefs.edit().putInt("port", 0).apply()
+ prefs.edit().putInt("http_mapping_port", 0).apply()
assertEquals(TeslaHttpConfig(), TeslaHttpCompatibility.config(context))
}
@Test fun invalidStoredConfigCannotStartOrAdvertiseAnArbitraryAddress() {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE)
- prefs.edit().putBoolean("enabled", true).putString("address", "3.3.3.3").apply()
+ prefs.edit().putBoolean("enabled", true).putString("address", "0.0.0.0").apply()
assertEquals(TeslaHttpConfig(), TeslaHttpCompatibility.config(context))
prefs.edit().putString("address", TeslaHttpConfig.DEFAULT_ADDRESS)
- .putString("hostname", "evil.invalid/stream").apply()
+ .putInt("https_mapping_port", 0).apply()
assertEquals(TeslaHttpConfig(), TeslaHttpCompatibility.config(context))
}
}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/WebListenSettingsTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/WebListenSettingsTest.kt
new file mode 100644
index 0000000..1982dd6
--- /dev/null
+++ b/common/src/test/java/com/shilapi/xcertplay/web/WebListenSettingsTest.kt
@@ -0,0 +1,106 @@
+package com.shilapi.xcertplay.web
+
+import android.content.Context
+import com.shilapi.xcertplay.network.TeslaHttpConfig
+import com.shilapi.xcertplay.network.TeslaHttpStatus
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29])
+class WebListenSettingsTest {
+ @Before fun startWithoutRootAuthorization() {
+ val original = RootAccess.authorize
+ try {
+ RootAccess.authorize = { throw java.io.IOException("Permission denied") }
+ RootAccess.request()
+ } finally { RootAccess.authorize = original }
+ }
+
+ @Test fun oldListenerDomainAndCertificateRemainAvailableWithoutRoot() {
+ val context = RuntimeEnvironment.getApplication()
+ context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE).edit().putBoolean("enabled", true)
+ .putInt("port", 9090).putString("hostname", "car.example.com").putString("address", "3.3.3.3").commit()
+ context.getSharedPreferences("web_https", Context.MODE_PRIVATE).edit().putInt("port", 9443).commit()
+ assertEquals(9090, WebListenSettings.httpPort(context))
+ assertEquals(9443, WebListenSettings.httpsPort(context))
+ assertEquals("car.example.com", WebListenSettings.hostname(context))
+ assertTrue(WebListenSettings.httpsEnabled(context))
+ assertEquals(TeslaHttpConfig(true, "3.3.3.3"), TeslaHttpCompatibility.config(context))
+ assertFalse(RootAccess.granted)
+ WebListenSettings.saveHttpPort(context, 9080)
+ assertEquals(80, TeslaHttpCompatibility.config(context).httpMappingPort)
+ assertEquals(9080, WebListenSettings.httpPort(context))
+ assertEquals("car.example.com", WebListenSettings.hostname(context))
+ }
+
+ @Test fun oldPrivilegedPortsBecomeMappingsAndListenersUseUnprivilegedDefaults() {
+ val context = RuntimeEnvironment.getApplication()
+ context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE).edit().putInt("port", 81).commit()
+ context.getSharedPreferences("web_https", Context.MODE_PRIVATE).edit().putInt("port", 444).commit()
+ assertEquals(8080, WebListenSettings.httpPort(context))
+ assertEquals(8443, WebListenSettings.httpsPort(context))
+ assertEquals(81, TeslaHttpCompatibility.config(context).httpMappingPort)
+ assertEquals(444, TeslaHttpCompatibility.config(context).httpsMappingPort)
+ assertFalse(RootAccess.granted)
+ }
+
+ @Test fun listenersRejectLowPortsAndDomainCannotInjectAnOrigin() {
+ val context = RuntimeEnvironment.getApplication()
+ for (port in listOf(0, 80, 443, 65536)) {
+ assertTrue(runCatching { WebListenSettings.saveHttpPort(context, port) }.isFailure)
+ assertTrue(runCatching { WebListenSettings.saveHttpsPort(context, port) }.isFailure)
+ }
+ assertTrue(runCatching { WebListenSettings.saveHostname(context, "https://car.example.com/") }.isFailure)
+ assertEquals("", WebListenSettings.hostname(context))
+ for (port in listOf(1024, 65535)) {
+ WebListenSettings.saveHttpPort(context, port)
+ assertEquals(port, WebListenSettings.httpPort(context))
+ }
+ }
+
+ @Test fun migrationKeepsListenerPortsDistinctWhenALowPortUsesTheOtherDefault() {
+ val context = RuntimeEnvironment.getApplication()
+ for ((http, https, expected) in listOf(
+ Triple(8443, 443, 8443 to 8444),
+ Triple(80, 8080, 8081 to 8080),
+ Triple(9443, 9443, 8080 to 9443)
+ )) {
+ context.getSharedPreferences("web_listen", Context.MODE_PRIVATE).edit().clear().commit()
+ context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE).edit().clear().putInt("port", http).commit()
+ context.getSharedPreferences("web_https", Context.MODE_PRIVATE).edit().clear().putInt("port", https).commit()
+ assertEquals(expected.first, WebListenSettings.httpPort(context))
+ assertEquals(expected.second, WebListenSettings.httpsPort(context))
+ }
+ }
+
+ @Test fun rootPlanRequiresAuthorizationAndEnabledHttpsForItsHttpsMapping() {
+ val interfaces = listOf(HttpDownstream("ap0", 7, "192.168.43.1/24"))
+ val config = TeslaHttpConfig(enabled = true)
+ assertNull(TeslaHttpCompatibility.plan(config, interfaces, 8080, 8443, false))
+ assertNull(TeslaHttpCompatibility.plan(config.copy(enabled = false), interfaces, 8080, 8443, true))
+ val full = TeslaHttpCompatibility.plan(config, interfaces, 8080, 8443, true)!!
+ val plain = TeslaHttpCompatibility.plan(config, interfaces, 8080, null, true)!!
+ assertEquals(443, full.httpsMappingPort)
+ assertEquals(80, plain.httpMappingPort)
+ assertNull(plain.httpsMappingPort)
+ assertFalse(plain.commands().any { it.apply.contains("--dport 443") })
+ }
+
+ @Test fun httpsLinksUseMappedPortOnlyForTheMappedHttpOrigin() {
+ val context = RuntimeEnvironment.getApplication()
+ WebListenSettings.saveHostname(context, "car.example.com")
+ val status = TeslaHttpStatus("3.3.3.3", httpMappingPort = 80, httpsMappingPort = 443)
+ assertEquals(443, WebSession.httpsPortFor(context, "3.3.3.3", status))
+ assertEquals(443, WebSession.httpsPortFor(context, "car.example.com:80", status))
+ assertEquals(WebSession.httpsPort, WebSession.httpsPortFor(context, "192.168.1.1:8080", status))
+ assertEquals(WebSession.httpsPort, WebSession.httpsPortFor(context, "car.example.com:8080", status))
+ assertEquals(WebSession.httpsPort, WebSession.httpsPortFor(context, "example.invalid:80", status))
+ assertEquals(WebSession.httpsPort, WebSession.httpsPortFor(context, "3.3.3.3:80", status.copy(address = null)))
+ }
+}
diff --git a/common/src/test/java/com/shilapi/xcertplay/web/WebStreamSenderTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/WebStreamSenderTest.kt
index 8d5a657..ecc14f6 100644
--- a/common/src/test/java/com/shilapi/xcertplay/web/WebStreamSenderTest.kt
+++ b/common/src/test/java/com/shilapi/xcertplay/web/WebStreamSenderTest.kt
@@ -25,6 +25,10 @@ class WebStreamSenderTest {
}
private fun frame(id: Long) = WebSession.Frame(byteArrayOf(id.toByte()), 0, id)
private fun text(value: String) = WebSocketFrame(WebSocketFrame.OpCode.Text, true, value)
+ private fun pcm(sequence: Int, media: Boolean = true) = java.nio.ByteBuffer.allocate(20 + 960 * 2)
+ .order(java.nio.ByteOrder.LITTLE_ENDIAN).putInt(BrowserAudioBridge.AUDIO_MAGIC).putInt(7)
+ .putInt(48_000).putShort(1).putShort(if (media) BrowserAudioBridge.AUDIO_FLAG_MEDIA.toShort() else 0)
+ .putInt(sequence).array()
@Test fun publicationAndAckWakeTheWriterAndSkipSupersededFrames() {
val executor = ManualExecutor()
@@ -149,6 +153,49 @@ class WebStreamSenderTest {
} finally { sender.close() }
}
+ @Test fun mediaBurstSurvivesFourHundredMillisecondWriterDelayWithAOneSecondBuffer() {
+ val executor = ManualExecutor(); val sent = mutableListOf(); var time = 0L
+ val sender = WebStreamSender({ null }, {
+ sent.add(java.nio.ByteBuffer.wrap(it.binaryPayload).order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(16))
+ }, { fail() }, executor, { time })
+ try {
+ sender.configureAudioBuffer(1000)
+ for (id in 1..30) sender.audio(pcm(id), id.toLong())
+ time = 400
+ executor.runNext()
+ assertEquals(listOf(1, 2, 3, 4), sent)
+ while (sent.size < 30) {
+ sender.acknowledgeAudio(sent.last().toLong()); executor.runNext()
+ }
+ assertEquals((1..30).toList(), sent)
+ sender.acknowledgeAudio(30)
+ for (id in 31..36) sender.audio(pcm(id), id.toLong())
+ time = 1601
+ executor.runNext()
+ assertEquals(30, sent.size)
+ sender.audio(pcm(37, false), 37)
+ time = 1802
+ executor.runNext()
+ assertEquals("Calls retain the 100 ms expiration", 30, sent.size)
+ } finally { sender.close() }
+ }
+
+ @Test fun mediaQueueIsBoundedByPcmDurationForEachSelectedPreset() {
+ for (millis in listOf(300, 500, 1000)) {
+ val executor = ManualExecutor(); val sent = mutableListOf()
+ val sender = WebStreamSender({ null }, {
+ sent.add(java.nio.ByteBuffer.wrap(it.binaryPayload).order(java.nio.ByteOrder.LITTLE_ENDIAN).getInt(16))
+ }, { fail() }, executor, { 0 })
+ try {
+ sender.configureAudioBuffer(millis)
+ for (id in 1..80) sender.audio(pcm(id), id.toLong())
+ executor.runNext()
+ assertEquals(81 - (millis + 200) / 20, sent.first())
+ assertEquals(4, sent.size)
+ } finally { sender.close() }
+ }
+ }
+
@Test fun stopControlsForDistinctAudioStreamsAreBothDelivered() {
val executor = ManualExecutor()
val sent = mutableListOf()
diff --git a/docs/BROWSER_AUDIO.md b/docs/BROWSER_AUDIO.md
index d124139..4e60b6c 100644
--- a/docs/BROWSER_AUDIO.md
+++ b/docs/BROWSER_AUDIO.md
@@ -4,10 +4,34 @@
浏览器麦克风需要可信安全上下文。普通局域网 `http://设备地址:8080` 页面不能申请麦克风权限。配置步骤:
-1. 在 Android 应用的「设置 → 权限与连接帮助 → 浏览器 HTTPS 证书」查看本设备的 SHA-256 指纹。在车机配对页展开「浏览器麦克风需要 HTTPS」,等待页面显示指纹并逐字核对,然后下载本地证书。
-2. 在车机浏览器或系统中按其支持的方式信任此证书。随后打开页面提供的 `https://设备地址:8443/` 链接,重新配对。
+1. 在「设置 → Web 监听」打开「启用 HTTPS」。在「设置 → 权限与连接帮助 → 浏览器 HTTPS 证书」查看本设备的 SHA-256 指纹。在车机配对页展开「浏览器麦克风需要 HTTPS」,等待页面显示指纹并逐字核对,然后下载本地证书。
+2. 在车机浏览器或系统中按其支持的方式信任此证书。随后打开页面提供的 `https://设备地址:HTTPS端口/` 链接(默认 `8443`),重新配对。
3. 在 App 设置中打开「使用浏览器麦克风」,按浏览器提示授权。仅在 iPhone 发起通话或语音输入并要求麦克风数据时,浏览器采样才会发送到服务端。
-证书的私钥只保存在 Android 应用的私有、非备份存储中。HTTPS 服务使用启动时检测到的局域网地址;网络地址更换后,重新启动服务再打开 HTTPS 入口。如果车机浏览器不能信任本地证书、禁用了麦克风权限或不支持 AudioWorklet,请使用 Android 麦克风。请勿通过浏览器的证书警告强行继续访问。
+证书的私钥只保存在 Android 应用的私有、非备份存储中。默认 HTTPS 证书包含启动时的局域网地址,以及配置的共享 IP/域名。共享 IP/域名修改后自动刷新默认服务器证书;其他局域网地址变化后请重新启动服务。如果车机浏览器不能信任本地证书、禁用了麦克风权限或不支持 AudioWorklet,请使用 Android 麦克风。请勿通过浏览器的证书警告强行继续访问。
-浏览器播放采用有界缓冲:服务端最多保留 16 帧待发音频、四帧等待确认,超过 100 ms 的待发帧被丢弃以避免不断增加延迟。浏览器麦克风发送 20 ms PCM 帧,并在服务端沿用 CarPlay 加密音频上行链路。实际车机对证书安装、音频焦点、回声消除与延迟的支持仍需分别验证。
+## Web 监听与 HTTPS 证书
+
+「设置 → Web 监听」包含 HTTP 端口(默认 `8080`)、Web 域名和 HTTPS 开关,无需 Root。Web 域名同时用于 HTTP 与 HTTPS,DNS 需解析到实际访问 IP,自定义 HTTPS 证书需覆盖该域名。HTTPS 默认开启;关闭后停止 TLS 监听,隐藏 HTTPS 端口和证书设置,保留证书与端口配置,HTTP 和配对状态继续可用。
+
+开启 HTTPS 后可修改「Web HTTPS 端口」(默认 `8443`)。HTTP / HTTPS 实际监听端口范围均为 `1024–65535`,两者不能相同。运行中先绑定新端口;占用或绑定失败会保留原监听器和保存值。成功后浏览器需重新连接,配对令牌和 iPhone 媒体会话保留;网页的「打开 HTTPS 页面」同步使用新端口。设置重启后仍有效。
+
+使用 `80` / `443` 等低位端口时,在「设置 → Tesla 兼容(实验)」第一行请求 Root 权限。授权成功后显示映射端口与访问 IP 设置;开启系统热点及此模式后,通过 Root 将访问 IP 的低位端口转发到 Web 监听高位端口。映射规则与热点路由统一安装和清理,失败不影响普通 Web 服务。普通 App 直接绑定低位端口可能得到 `EACCES`,仅对路由 shell 授权不会改变 App 进程的绑定权限。详见 [Tesla 兼容说明](TESLA_HTTP.md)。
+
+证书类型选择器直接显示在 Web 监听页面上:
+
+- **PEM**:先通过系统文件选择器选证书文件,再选私钥文件。证书链按服务器证书、签发者顺序排列;支持 PKCS#8、RSA、EC PEM 私钥及加密 PEM,未加密时密码留空。传统 EC 私钥可带一个前置 `EC PARAMETERS` 块(命名曲线或显式参数);仍须仅包含一把私钥,不接受额外尾随对象。
+- **PKCS#12**:选择 `.p12` 或 `.pfx`,输入文件密码。若有多个私钥条目,会列出别名供选择;可输入不同的私钥密码,留空表示使用文件密码。空密码容器也可导入。
+- **默认本地证书**:选择后撤销自定义身份,重新使用本安装的本地 CA 签发服务器证书。
+
+选择 PEM 或 PKCS#12 后显示对应文件选择按钮,导入成功前继续使用当前证书。每个文件最多 2 MiB。导入会验证有效期、服务器用途、证书链顺序/签名和证书与私钥匹配。无效文件、密码错误或私钥不匹配时保留原证书。有效身份规范化后原子保存到 `noBackupFilesDir`;不持久保存导入密码,也不向网页暴露私钥。文件选择取消不会更换证书。更换同端口证书会短暂重启 HTTPS 监听器,失败时尝试恢复原监听器;恢复也失败时保留原配置并提示重启服务。
+
+导入的证书必须包含浏览器实际访问的 IP 或域名(SAN)。受浏览器信任的公共 CA 证书可直接通过相应域名访问;自签名证书仍需按车机支持的方式建立信任。域名解析自行配置,导入不会签发证书或修改 DNS。
+
+## 音频行为
+
+浏览器媒体播放跟随 App 的「音频缓冲」设置,支持 300 / 500 / 1000 ms,默认 300 ms。首次播放和缓冲耗尽后的恢复都采用所选延迟。修改设置会同步给已连接的浏览器,重建媒体播放缓冲;原有 iPhone 重新连接行为保留。通话、导航和其他非媒体音频继续采用短缓冲,麦克风采样不受此设置影响。
+
+服务端按各音频流的 PCM 时长限制待发队列:媒体最多保留所选缓冲加 200 ms,待发超过此时间的帧过期;非媒体音频保留 100 ms。队列还有 128 帧总上限,四帧等待确认。浏览器允许媒体突发包在所选缓冲上方保留最多约一秒余量;超过积压限制时丢弃新到音频,不再清空已经排队的声音。单次 JPEG 写入仍与音频共用连接,持续网络拥塞或超过缓冲时长的停顿仍可能造成断续。
+
+浏览器麦克风发送 20 ms PCM 帧,并在服务端沿用 CarPlay 加密音频上行链路。实际车机对证书安装、音频焦点、回声消除与延迟的支持仍需分别验证。
diff --git a/docs/TESLA_HTTP.md b/docs/TESLA_HTTP.md
index f21fd03..f1bed16 100644
--- a/docs/TESLA_HTTP.md
+++ b/docs/TESLA_HTTP.md
@@ -1,51 +1,39 @@
-# Tesla HTTP 兼容模式(实验)
+# Tesla 兼容模式(实验)
-此模式尝试让车机通过 Android 上的本地虚拟 IPv4 地址访问 WheelPlay。无需 Root、HTTPS 证书、证书签发服务或云端视频代理;不会修改系统热点的 IP 或 DHCP 网段。它不保证所有 Tesla 车机版本都允许 HTTP。
+此模式通过内置的 [VPN Hotspot](https://github.com/Mygod/VPNHotspot) Root 路由适配,让车机从 Android 热点访问 WheelPlay 的本地共享 IPv4 地址。需要已 Root 的设备以及可用的 `su`、`ip`、`iptables`;无需另外安装 VPN Hotspot。系统热点的 IP 和 DHCP 网段保持不变。
## 使用
-1. 在运行 WheelPlay 的 Android 设备上开启系统热点,让车机连接这个热点。车机连接其他路由器时,通常没有到虚拟地址的路由。
-2. 在 WheelPlay 设置中打开「Tesla HTTP 兼容(实验)」→「Tesla HTTP 模式」,完成系统本地 VPN 授权。先断开 iPhone,再切换模式或修改虚拟 IP。
-3. 默认虚拟 IP 是 `100.96.0.1`,可修改为 `100.64.0.0–100.127.255.255` 内的其他地址。避免与移动网络、其他 VPN 或现有局域网地址冲突。
- 「HTTP 端口」默认 `8080`,支持 `1–65535`,作用于全部 HTTP IP、域名和本机预览入口,即使 Tesla 模式关闭也生效。运行中修改会先尝试绑定新端口,成功后断开旧浏览器连接;绑定失败则保留原端口和配置。端口会保存,重启服务后继续使用。部分系统限制低于 `1024` 的端口;内置 HTTPS 正在监听 `8443` 时,该端口不可用于 HTTP。
-4. 等待「虚拟地址已创建」,在车机浏览器输入完整的 `http://100.96.0.1:8080/`,按网页提示配对。
-5. 如需域名,先自行配置域名的 DNS A 记录,使其解析到虚拟 IP,再填写设置中的「HTTP 域名(可选)」。车机入口为 `http://你的域名:8080/`;不需要证书。App 不配置 DNS,也不验证解析结果。DNS 服务可能过滤共享地址,解析通常需要联网。
- 使用自定义端口时,将上述 URL 中的 `8080` 换成设置值,例如 `http://100.96.0.1:9090/` 或 `http://你的域名:9090/`。
-6. 地址或域名变更后,浏览器可能需要重新配对,因为设备记忆按网页 origin 保存。
+1. 在运行 WheelPlay 的 Android 设备上开启系统热点,让车机连接这个热点。
+2. App 启动时在后台检查一次 Root,在 Magisk 等 Root 管理器中授予 **WheelPlay** 权限。检查成功后才显示其余设置,再打开「启用 Tesla 兼容」。拒绝授权后不会自动重复请求,可在「设置 → Tesla 兼容(实验)」第一行点击「请求 Root 权限」重试。按钮可独立于热点和 Web 服务使用;此模式不申请 Android VPN 授权。
+3. 默认共享 IP 是 `100.96.0.1`,可改为一般 IPv4 单播地址,包括私有或公网地址;回环、多播、链路本地及未指定地址不可用。避免与移动网络、其他 VPN 或局域网冲突。
+4. 映射 HTTP 端口默认 `80`,映射 HTTPS 端口默认 `443`,有效范围为 `1–1023`,两者不能相同。它们通过 Root 映射到「Web 监听」中的实际端口(默认 `8080` / `8443`),只作用于配置的访问 IP。HTTPS 映射仅在 Web HTTPS 开启且监听成功时安装。
+5. 等待「Root 热点路由已启用」,在车机浏览器输入 `http://100.96.0.1/` 并配对;HTTPS 默认入口是 `https://100.96.0.1/`,仍需车机信任证书。拒绝 Root、缺少命令、端口占用或规则安装失败时,App 不展示已就绪的共享入口。修复后点击「重新启动 Root 路由」;不会持续重复申请 Root。
+6. 普通 HTTP 端口、Web 域名及可选 HTTPS 在「设置 → Web 监听」中配置,无需 Root。自备域名需配置 DNS A 记录,解析到实际访问 IP;填写域名不自动配置 DNS。映射端口不是 `80` 时使用 `http://你的域名:映射端口/`。地址、域名或端口变化后,设备记忆因 origin 改变可能需要重新配对。
-模式默认关闭,配置会保存。重开 Web 服务时,只有已获 VPN 授权才自动创建接口;缺少授权时在设置中点「重新授权并启动」。被其他 VPN 替换或授权撤销后,不会反复抢占 VPN。
+模式默认关闭,配置会保存。Root 检查结果仅在本次进程中有效,重新启动进程后自动重新检查;检查过程不阻塞普通 Web 监听。旧版本的模式与地址偏好保留;Web 域名和高位端口迁移到 Web 监听,旧低位端口迁移为映射端口,实际监听改用高位端口。Web 域名同时用于 HTTP 与 HTTPS,自定义证书需覆盖该域名。无 Root 时仍可使用普通局域网 HTTP/HTTPS 入口。
-「虚拟地址已创建」仅表示 Android VPN 接口分配成功,不代表车机已可达。系统热点的本地投递、回程路由、客户端隔离和车机浏览器策略,都可能使访问失败。域名不能保证绕过车机对目标 IP 或 HTTP 的限制。
+## 路由和生命周期
-## 功能和生命周期
+- 适配 VPN Hotspot 的下游策略路由、成对规则和逆序回滚机制,源码版本及修改见 [UPSTREAM.md](UPSTREAM.md)。共享范围是 **WheelPlay 的本地 HTTP/HTTPS 服务**,不提供手机第三方 VPN 的 Internet 转发。
+- 用 Root 将共享地址作为 `/32` 添加到 loopback,通过 `lookup local` 投递;按系统实际报告的热点接口安装 HTTP 和 HTTPS 端口的 INPUT/OUTPUT 放行及源地址限定的回程路由。回程使用 Android 的 `local_network`(97)路由表,不依赖 Internet 默认路由。只展示完整安装成功的入口。
+- 只识别系统 `TETHER_STATE_CHANGED` 的 `tetherArray`,定期检查接口及 IPv4 地址;不根据 `wlan` 名称猜测热点。热点关闭时撤销规则,重开或接口/地址变化时重新创建。系统未提供热点状态时显示未检测到接口。
+- 关闭模式、停止 Web 服务、端口或地址变更时撤销旧规则。Root shell 持有事务,App 退出导致 stdin 关闭时执行清理;不清空系统/其他应用的规则,不修改持久系统设置。清理失败会撤下入口并提示检查或重启设备。
+- HTTP/HTTPS 的低位端口转发与共享 IPv4 地址、热点投递和回程规则在同一 Root 事务中管理。仅对发往此 `/32` 地址的 TCP 连接做端口 DNAT,保留目标 IP;普通局域网地址和本机预览继续使用 Web 监听端口。安装前检查现有监听器,已占用的低位端口会拒绝转发。任一步骤失败都会回滚;清理未确认时阻止继续安装新规则。启动回滚未确认时,本次进程内的手动重试、配置变化及 Web 服务停止/重启均不会解除保护,需检查残留规则或重启设备。Root 路由失败不影响普通 Web 监听。
+- 精确的访问 IP 匹配不依赖 `addrtype` 内核模块,兼容缺少该模块的 ROM。规则安装失败时提示具体命令及原始错误;仍需内核支持 IPv4 NAT/DNAT,以及规则使用的 TCP、comment、conntrack 匹配模块。
+- Tesla 模式与 CarPlay 会话独立;连接/断开 iPhone 不重建 HTTP 地址。有线 CarPlay 仍使用原有 `fe80::/64` IPv6 VPN,其 Android 授权和同一用户仅能有一个 VPN 的限制仍然存在。
+- 复用现有 HTTP/WebSocket 配对、设备撤销、画面、触控和浏览器音频。浏览器麦克风仍要求可信 HTTPS,可继续使用 Android 麦克风。
-- 复用现有 HTTP 服务(默认 `8080`)、同源 WebSocket、配对和设备撤销机制。切换 HTTP 端口保留当前配对令牌和 iPhone 媒体会话;网页 origin 改变后,浏览器记忆的设备仍可能需要重新配对。
-- Android 同一用户只能有一个活动 VPN。此模式与 WheelPlay 有线 CarPlay 共用 `CarPlayVpnService`,可能替换第三方 VPN;其他 VPN 启动也可能使此模式失效。
-- VPN 仅添加虚拟地址的 `/32` 路由;有线 CarPlay 连接时还包含原有 `fe80::/64` 路由。不添加 Internet 默认路由或更改 DNS。虚拟地址的 HTTP 连接由本机网络栈处理,不向 iPhone USB 链路发送 IPv4 数据包。
-- 连接或断开有线 iPhone 时,需要重建 VPN 接口,浏览器连接可能短暂中断;断开后虚拟入口继续存在。停止 Web 服务或关闭模式会释放入口。
-- HTTP 支持现有画面、触控和浏览器音频路径;浏览器麦克风仍要求可信 HTTPS。可以继续使用 Android 麦克风。
-- 仅用于可信热点网络。HTTP 配对、控制与 JPEG 流量未加密,不应映射到公网。
+## 验证
-## 自动验证
+自动测试覆盖配置持久化、热点广播解析、命令输入校验、Root 拒绝后不自动重复申请、热点/端口变化和停止清理。真实 shell 测试以记录命令的替身代替内核操作,验证成功步骤逆序撤销、部分安装失败回滚及 stdin 关闭清理。有线 VPN 与无线 AirPlay 的独立性、HTTP 端口绑定失败回退和 WebSocket 仍由回归测试覆盖。
-- Android 单元测试:覆盖配置校验、偏好保存、接口路由、VPN 生命周期、HTTP/WebSocket 配对与同源检查,以及端口切换、占用回退和重启后的监听端口。
-- 网页端:`npm test` 的 7 组测试和 `npm run check` 全部通过。
-- JVM 测试使用 JDK 21;Gradle 构建使用项目要求的 JDK 25。VPN 测试替代了内核 TUN 分配,不证明真实热点的路由或车机可达性。
+这些测试不执行真实 Android Root 路由,尚需在 Root Android 热点及 Tesla 实机上验证:
-## 实机验证顺序
+1. 先让另一台电脑/手机连接热点,访问完整的 HTTP IP 地址并配对,检查双向连接与服务端日志。
+2. 再用 Tesla 测试同一地址及可选域名,记录是否被改为 HTTPS或被浏览器策略拒绝。路由就绪不保证所有 Tesla 版本允许 HTTP。
+3. 验证 JPEG 连续画面、触控、音频;WebRTC 单独检查 ICE/UDP 可达性,失败时使用 JPEG。
+4. 测试热点关闭/重开、端口与 IP 变化、前后台切换、USB/无线 iPhone 连接及断开、关闭模式、停止服务和强制停止 App;核对 `/32` 地址及规则清理。
+5. 拒绝/撤销 Root,确认失败时不展示共享地址、不反复弹授权;恢复权限后手动重试。
-当前实现尚未在 Android 热点 + Tesla 实机上验证。按以下顺序记录 Android 型号/版本和车机版本:
-
-1. **热点到虚拟地址**:先用另一台电脑或手机连接该 Android 热点,访问完整 HTTP IP 地址。失败时先排查系统路由、地址冲突和热点隔离,不要归因于 Tesla。
-2. **车机 HTTP IP**:用相同地址在 Tesla 上打开网页并配对,记录是否被改为 HTTPS、是否到达服务端。
-3. **可选 HTTP 域名**:确认域名解析到虚拟 IP 后测试;不能把 IP 或域名入口的单次失败当作另一入口必然可用。
-4. **WebSocket / JPEG**:先选择 JPEG 模式,检查配对、状态、连续画面和触控。首次验证无需先接通 WebRTC UDP。
-5. **WebRTC / 音频**:检查 ICE 可达性、UDP 和浏览器解码能力;WebRTC 失败时应回退 JPEG。
-6. **生命周期**:测试连接/断开 USB iPhone、无线 CarPlay、热点关闭/重开、App 前后台切换、VPN 被替换/撤销、模式关闭及「停止服务」。网页入口应与 Web 服务生命周期一致。
-
-若目标 Tesla 拒绝 HTTP IP 和 HTTP 域名,此模式无法承诺解决;修改监听地址或继续换共享地址也不一定有效。此分支不引入 HTTPS 域名和证书签发。
-
-## 参考案例
-
-- [Tesla Android 2022.18.1](https://github.com/tesla-android/tesla-android.github.io/blob/main/install-guide-2022-18-1.md#ip-address-range):历史版本记录 RFC1918 地址限制和修改实际网络地址段的方案。
-- [TeslaMirror FAQ](https://teslamirror.com/faq-android):开发者记录本地 VPN/虚拟地址的方案,但最新文档要求 HTTPS,并说明旧虚拟 IP 不能直接作为浏览器入口。这提供了实现方向,不能证明本分支的 HTTP 方案兼容当前所有车机版本。
+在「设置 → Web 监听」打开「启用 HTTPS」后,可修改 Web HTTPS 端口及证书;关闭后停止 HTTPS 监听并撤下 HTTPS 映射,HTTP 继续可用。默认本地证书包含配置的共享 IP 与可选域名,修改地址后会刷新。导入自定义证书时需由证书本身覆盖实际访问的 IP/域名。详见 [BROWSER_AUDIO.md](BROWSER_AUDIO.md)。
diff --git a/docs/UPSTREAM.md b/docs/UPSTREAM.md
index f64af2d..88735fb 100644
--- a/docs/UPSTREAM.md
+++ b/docs/UPSTREAM.md
@@ -59,3 +59,19 @@ Material Components for Android 1.13.0(Apache-2.0),使用官方 Material 3
| 连接通知停止服务 · `ic_media_stop` | [stop](https://github.com/google/material-design-icons/blob/master/src/av/stop/materialicons/24px.svg) |
启动使用播放圆形图标,车机访问及服务通知使用投屏图标。通知采用白色遮罩;页面图标跟随 Material 主题、卡片颜色和导航选中状态。方向箭头保留展开旋转动画,设置选择器支持 RTL 镜像。NIO 返回入口与原有应用品牌位图不属于自绘功能图标;二维码矩阵、裁剪网格及安全区域辅助线是功能内容,不作图标替换。
+
+## VPN Hotspot Root 路由适配
+
+来源:[Mygod/VPNHotspot](https://github.com/Mygod/VPNHotspot),Apache-2.0,许可全文见 [VPNHOTSPOT-LICENSE.txt](VPNHOTSPOT-LICENSE.txt)。适配基线为 v2.17.1,提交 `58ef509708a84f4da84fc54d8008895d02adef34`:
+
+- `mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt`:按下游接口安装策略路由、源地址限定的回程查询、成对增删 iptables 规则。
+- `mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt`:成功安装后保存逆序撤销命令、异常回滚及服务停止后撤销。
+- `mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt`:系统热点状态广播及 `tetherArray` 字段。
+
+修改集中于 `RootHttpRouting.kt` 和 `TeslaHttpCompatibility.kt`:将上游的通用 VPN 上网共享收窄为 WheelPlay 本地 HTTP 入口,通过 Root 分配 loopback `/32` 共享地址、安装局部投递及回程路由;Root shell 持有规则事务,App 管道关闭后撤销。未复制上游 App、UI、DNS 代理、客户端数据库或 Rust daemon,不提供通用 VPN Internet 转发。Tesla 模式不占用 VPN;有线 CarPlay 继续使用原有 IPv6 VPN。
+
+热点下游采用 [AOSP RouteController](https://android.googlesource.com/platform/system/netd/+/refs/heads/main/server/RouteController.cpp) 的 `local_network`(97)路由表;上游 `1000 + ifindex` 的物理上网接口表不适用于热点下游。
+
+`RootWebPorts.kt` 生成低位端口转发规则及占用检查,由 `RootHttpRouting.kt` 与共享 IPv4 地址、热点投递和回程规则在同一事务 shell 中管理。端口权限依据 [Linux 内核 ip_unprivileged_port_start 文档](https://docs.kernel.org/networking/ip-sysctl.html);按 [Netfilter DNAT 文档](https://ipset.netfilter.org/iptables-extensions.man.html) 只修改 TCP 目标端口,限定到共享地址 `/32` 并保留目标 IP。普通 Web 监听使用无需 Root 的高位端口。此端口转发是 WheelPlay 的本地服务扩展。
+
+目标地址已限定为成功安装的本地 `/32`,端口规则无需额外的 `addrtype --dst-type LOCAL` 匹配。移除此依赖以兼容缺少 `addrtype` 内核模块的 ROM;TCP、comment、conntrack 及 DNAT 行为保留。事务 shell 记录具体失败命令和原始错误,回滚后仍保留首次失败的诊断。
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
index 52ac9e4..e5496a1 100644
--- a/docs/VALIDATION.md
+++ b/docs/VALIDATION.md
@@ -259,14 +259,83 @@ APK 构建成功,7 项 UI 测试通过,其中新增双方向测试验证图
- 八项 MediaSession bridge 测试全部通过。已有服务测试检查播放与暂停期间的三项操作,以及恢复连接通知后的停止服务 Intent。
- `:mobile:assembleProvisionedDebug`、独立认证资产 ZIP 比较和 `git diff --check` 通过,Java 25 配置已恢复。未安装设备或验证通知截图。
+## Root 热点路由与自定义 HTTPS(2026-10-10)
+
+Tesla HTTP 模式改为内置 VPN Hotspot 的 Root 路由适配,按实际热点接口共享 WheelPlay 的 HTTP/HTTPS 服务。共享地址支持一般 IPv4 单播地址;Web HTTPS 增加持久端口设置、系统文件选择器导入 PEM 证书/私钥及 PKCS#12,支持加密私钥、多个私钥条目和不同的私钥密码。
+
+- `:common:testDebugUnitTest` 146 项、`:shared:testDebugUnitTest` 239 项通过,共 385 项,0 失败 / 错误 / 跳过。Gradle 守护进程使用项目指定的 Java 25;本轮测试进程通过仓库外临时初始化脚本使用本机 JDK 21,仓库工具链配置未修改。
+- Root 测试覆盖输入校验、拒绝授权后不重复申请、热点 / 端口变更、异常退出和清理失败;真实 shell 配合命令替身验证成功步骤逆序撤销、部分失败回滚和 stdin EOF 清理。未执行真实 Android 内核路由操作。
+- HTTPS 测试包含可信客户端的真实 TLS 握手、PEM / 加密 PEM / PKCS#12、错误密码、私钥不匹配、导入大小限制、持久保存与恢复默认。端口占用时保留原监听器及配对状态;新端口与重启后的保存值验证通过。文件选择器验证 `ACTION_OPEN_DOCUMENT`、`CATEGORY_OPENABLE` 与可选文件类型。
+- `npm test` 56 项通过,JavaScript 语法检查通过;新增覆盖 HTTPS 自定义端口、IPv6 地址及失效入口撤下。`git diff --check` 通过。
+- `:mobile:lintDebug` 通过,0 错误、18 项警告。`:mobile:assembleProvisionedDebug` 通过,并验证本地认证资产与 APK 条目逐字节一致;APK 包含网页资源、VPN Hotspot 许可证,以及 arm64-v8a、armeabi-v7a、x86、x86_64 原生库。
+- 本轮未安装到设备。Root Android 热点客户端与 Tesla 的双向访问、真实规则清理、第三方 VPN 并存以及车机证书信任仍需真机验证;本适配不提供第三方 VPN 的 Internet 转发。
+
+## 显式 Root 授权与低位端口(2026-10-10)
+
+普通 App 绑定 `80` / `443` 时可收到 `EACCES`;路由 shell 获得 Root 不改变 App 的绑定权限。新增「设置 → Root 权限 → 请求 Root 权限」,独立执行授权检查并展示结果。`1–1023` 通过 Root 将发往本机的 IPv4 / IPv6 TCP 连接转发到动态高位监听器,继续由原 HTTP / TLS 服务处理;公开端口与浏览器 origin 保持用户配置。
+
+- Common 156 项、Shared 239 项完整测试通过,共 395 项,0 失败 / 错误 / 跳过;Lint 0 错误、18 项警告。
+- 新增 10 项回归覆盖:按钮独立授权、无规则权限探测、拒绝授权的错误处理、低位端口占用及已有转发保护、IPv4/IPv6 规则与失败回滚、清理失败阻止新转发、80 的高位监听及配置回退、443 的真实 TLS 握手 / 证书更换 / 重启,以及转发进程退出后的入口撤下和恢复。内核命令使用记录替身,未执行真实设备 NAT。
+- arm64 发行版构建通过,包名 `moe.feng.wheelplay`,版本 `0.2.0` / 20,沿用现有发行密钥。`apksigner verify`、签名 SHA-256 指纹对照、仅 `arm64-v8a` 检查及 ZIP 对齐检查通过;认证资产与网页资源逐字节对照通过,APK 包含新的 Root 授权与端口转发类。
+- 本轮未安装到设备;Magisk 授权弹窗、实际 80/443 NAT、热点共享及规则清理仍待用户真机复测。
+
+## EC 证书导入算法名称兼容修复(2026-10-10)
+
+未加密的 PKCS#8 PEM EC 私钥原本可完成签名匹配校验,但 PEM 转换器将其算法标为 `ECDSA`。Android Conscrypt 从 PKCS#12 取得 `PrivateKeyEntry` 时,要求私钥算法名称与服务器证书公钥的 `EC` 完全一致,因此导入时误报算法不匹配。导入准备阶段统一转换为 `EC`,保留原私钥、曲线及完整证书链;PEM 与 PKCS#12 共用此处理。
+
+- 使用用户提供的原始文件及空密码,在 Android Conscrypt 密钥管理器与 Bouncy Castle PKCS#12 存储组合下复现相同异常;修复后导入、PKCS#12 规范化及按证书域名验证的真实 HTTPS 请求通过。个人文件及临时验证源码保存在 Git 忽略的本地目录中,未加入应用源码或资产。
+- 新增生成式 EC 回归夹具,覆盖 P-256 服务器证书与 P-384 签发链、PKCS#8 / SEC1 / 加密 PEM、PKCS#12、持久保存后重新加载及错误私钥拒绝。测试调用 Android Conscrypt 的实际密钥管理器;桌面默认 SunPKCS12 会重写算法名称,单独使用它无法捕获原始错误。
+- Common 159 项完整回归通过,0 失败 / 错误 / 跳过;Lint 0 错误、18 项警告。新夹具使用直接 TLS 握手与双向加密数据交换,检查完整证书链及主机名;用户文件的临时检查另行验证了真实 HTTP 服务上的 HTTPS 请求。
+- 新版 arm64 发行包的签名、现有签名指纹、ZIP 对齐及认证资产 / 网页资源逐字节检查通过,确认个人 HTTPS 证书、私钥与临时验证测试没有进入 APK。本轮未安装到设备,导入界面的真机复测仍待完成。
+
+## Web 监听与 Tesla 兼容设置重组(2026-10-10)
+
+「Web 监听」集中管理无需 Root 的 HTTP 端口、HTTP 域名和可选 HTTPS。HTTPS 开关停止或恢复 TLS 监听,保留证书、端口与 HTTP 配对;开启后显示 HTTPS 端口和页面内的默认 / PEM / PKCS#12 证书选择器。「Tesla 兼容(实验)」第一行请求 Root,仅本次检查成功后显示模式开关、低位端口映射与自定义 IPv4 地址。80 / 443 映射到普通高位监听,仅匹配配置的访问 IP,与热点路由统一安装和清理。
+
+- Common 168 项、Shared 239 项完整测试通过,共 407 项,0 失败 / 错误 / 跳过。测试进程继续使用本机 JDK 21 临时初始化脚本,项目守护进程与工具链配置保留 Java 25。
+- 新增和调整的回归覆盖:Root 授权前后控件、拒绝授权、页面内证书类型及文件选择、HTTPS 开关的监听与配对状态、启用时端口占用回退、映射条件、HTTPS 链接的映射端口选择,以及旧域名 / 高低位端口迁移与端口冲突处理。
+- 使用 Robolectric 原生渲染检查 360 dp 页面,核对授权前后排版;截图保存在忽略的 `common/build/reports/web-settings/`。Root shell 的事务安装、映射失败回滚与清理仍由记录命令替身验证,未操作真实 Android 内核。
+- 网页测试 57 项通过,JavaScript 语法检查通过;覆盖 HTTPS 关闭时撤下入口并提示在 Web 监听中开启。Lint 0 错误、18 项警告;`git diff --check` 通过。
+- arm64 发行版构建通过。`apksigner verify`、原发行证书 SHA-256 指纹、16 KiB ZIP 对齐和仅 `arm64-v8a` 检查通过;认证资产及网页资源逐字节一致,个人 HTTPS 证书 / 私钥与临时测试材料未进入 APK。
+- 本轮未安装到设备。Magisk 授权、热点客户端双向访问、低位端口实际映射、HTTPS 证书信任及 App 退出后的规则清理仍需真机验证。
+
+## 自动 Root 检查与 addrtype 兼容修复(2026-10-10)
+
+App 启动时后台检查一次 Root,检查成功自动更新设置控件并恢复已启用的热点路由;拒绝后等待手动重试,普通 Web 监听不等待 Root。HTTP 域名改名为「Web 域名(可选)」,说明其同时用于 HTTP / HTTPS,并要求自定义证书覆盖该域名。
+
+- Common 172 项完整回归通过,0 失败 / 错误 / 跳过;Lint 0 错误、18 项警告。新增覆盖启动后台检查、拒绝后不循环请求、手动重试,以及回滚后保留具体失败命令 / NAT 预检错误。Shared 未改动,沿用此前 239 项通过的结果。
+- 在用户的一加 7 Pro(GM1911,EvolutionX Android 16,Magisk,iptables 1.8.11 legacy)上通过 ADB 精确复现:`addrtype` 提示缺少内核模块并返回 `No chain/target/match by that name`;conntrack、state、comment 均可用。临时测试链不挂接真实流量并在退出后清理。去除冗余 addrtype 后,精确目的 IP 的 DNAT 规则可安装;保留原连接状态约束。
+- 同发行签名覆盖安装 arm64 修复版,保留设备数据。两个冷启动均在未点击 Root 按钮的情况下授权成功、添加访问 `/32` 并安装映射;从手机自身请求映射 HTTP 80 和 HTTPS 443 成功,HTTPS 使用配置域名并通过系统 CA / 主机名验证,未关闭 TLS 验证。
+- 覆盖安装与冷启动后的自定义证书指纹一致,HTTPS 元数据返回映射端口 443。手机设置页面确认「Web 域名」文案及 Root 路由已启用;未更改用户域名、访问 IP、端口、HTTPS 开关或证书配置。
+- 强制停止 App 后,其 NAT / 策略路由与访问 IP 已清理,系统热点规则保留;重新启动后映射和 HTTPS 恢复。发行包签名、原签名指纹、16 KiB 对齐、仅 arm64 ABI、认证资产及网页资源校验通过,个人 HTTPS 私钥未进入 APK。
+- 上述访问测试来自手机自身,未验证另一台热点客户端的入站连接及 Tesla 浏览器;热点关闭 / 重开和第三方 VPN 并存仍待复测。
+
+## 同步上游 main 后的回归(2026-10-10)
+
+将单笔功能提交重放到上游 `1dc11aa`,保留认证资源导入、认证校验和浏览器全屏启动功能。合并启动流程时,缺少有效认证资源仍不能开启监听器或获取唤醒锁;TLS 测试复用上游合成认证资源,不读取个人私钥。
+
+- Common 194 项、Shared 239 项完整回归通过,共 433 项,0 失败 / 错误 / 跳过;网页测试 64 项及 JavaScript 语法检查通过。
+- `:mobile:lintDebug`:0 错误、18 项警告;`:mobile:assembleDebug` 构建通过。Debug APK 中的两份认证资源与本机已有配置一致,未包含额外凭据,网页资源与当前源码一致;本地认证资源及 APK 未提交。
+- Robolectric 原生渲染检查授权后的 Web 监听及 Tesla 兼容设置;上游未配置资源时的启动限制、认证导入和全屏启动均由合并后的完整回归覆盖。
+- 此轮未重新安装到手机;此前真机验证对应同步上游前的版本。另一台热点客户端和 Tesla 浏览器仍待验证。
+
+## PR Review:启动清理保护与 EC 参数块(2026-10-10)
+
+- 启动失败且回滚清理未确认时保存独立阻塞状态,本次进程内的手动重试、地址/端口/热点配置变化及 Web 服务停止/重启均不会安装新规则,也不会发布共享入口。正常拒绝授权或已确认回滚的失败仍可重试。
+- 传统 EC PEM 私钥支持一个前置 `EC PARAMETERS` 块,兼容命名曲线及显式参数;保持单把私钥与无额外尾随对象的校验。
+- 新增 3 项回归:生成的真实事务 shell 同时模拟启动失败与回滚失败;参数块前置的 EC 私钥经 Android Conscrypt KeyManager 完成可信 TLS 握手、保存与重载;缺失/多把私钥或额外对象被拒绝且原身份保持有效。前两项在旧代码上失败,修复后通过。
+- Common 197 项、Shared 239 项完整回归通过,共 436 项,0 失败 / 错误 / 跳过;Lint 0 错误、18 项警告;Debug 构建通过。网页代码未变,沿用此前 64 项网页测试及语法检查结果;此轮未重新安装到手机。
+
## 产物
-`mobile/build/outputs/apk/debug/mobile-debug.apk`
+以下发行包为同步上游前用于真机验证的本地产物,本轮仅重新构建 Debug。
+
+`mobile/release/wheelplay-0.2.0-arm64-root-fix-092bba0b.apk`(53.9 MiB)
SHA-256:
```text
-f38c2eb4af206aed00441594200beb0da4201a3d132ade198d5e6d0553e9149d
+092bba0b333bd17e3d045a41eaee512a6c05b3708aadd8a8ed656bdf877aca56
```
-包名 `moe.feng.wheelplay.debug`,调试签名,此本地产物包含上述 CarPlay 认证资产。
+包名 `moe.feng.wheelplay`,发行签名,此本地产物包含上述 CarPlay 认证资产。
diff --git a/docs/VPNHOTSPOT-LICENSE.txt b/docs/VPNHOTSPOT-LICENSE.txt
new file mode 100644
index 0000000..b0e73fd
--- /dev/null
+++ b/docs/VPNHOTSPOT-LICENSE.txt
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2018 Mygod
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts
index 9b3d62e..8c5ecb2 100644
--- a/mobile/build.gradle.kts
+++ b/mobile/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "moe.feng.wheelplay"
minSdk = 28
targetSdk = 36
- versionCode = 20
- versionName = "0.2.0"
+ versionCode = 21
+ versionName = "0.3.0"
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt
index 0b029da..bb45169 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt
@@ -60,63 +60,16 @@ open class CarPlayVpnService : VpnService() {
private var bridge: Ipv6NcmBridge? = null
private var tun: ParcelFileDescriptor? = null
private var attachGeneration = 0
- private var browserAddress: String? = null
- @Volatile var browserStatus = TeslaHttpStatus()
- private set
-
- /** Changes are deferred by the UI while CarPlay is connected; never replace a live NCM tunnel. */
- @Synchronized
- fun setBrowserAddress(address: String?): AttachResult {
- if (address != null && !TeslaHttpConfig.isSharedAddress(address)) {
- return AttachResult.Failed("虚拟 IP 必须位于 100.64.0.0–100.127.255.255")
- }
- if (address == browserAddress && (address == null || tun != null)) {
- if (address == null) browserStatus = TeslaHttpStatus()
- return AttachResult.AlreadyStarted
- }
- if (bridge != null) return AttachResult.Failed("请先断开 iPhone,再修改 Tesla HTTP 模式")
- closeTunnelLocked()
- browserAddress = address
- browserStatus = TeslaHttpStatus()
- return restoreBrowserTunnelLocked()
- }
-
- /** Used only when the owning web service is stopping; keep a live USB tunnel intact. */
- @Synchronized
- fun clearBrowserAddress() {
- browserAddress = null
- browserStatus = TeslaHttpStatus()
- // An established VPN's addresses are immutable. Remove the alias when the USB
- // attachment closes, without disturbing it during the asynchronous service teardown.
- if (bridge == null) closeTunnelLocked()
- }
-
- /** No default route, DNS override or interception of the phone's Internet traffic. */
- protected open fun establishTunnel(linkLocal: String? = null): ParcelFileDescriptor {
- val builder = Builder()
- .allowFamily(OsConstants.AF_INET)
- .allowFamily(OsConstants.AF_INET6)
- .setSession(SESSION_NAME)
- .setMtu(TUN_MTU)
- .setBlocking(true)
- if (linkLocal != null) {
- builder.addAddress(linkLocal, LINK_PREFIX).addRoute(LINK_LOCAL_ROUTE, LINK_PREFIX)
- }
- browserAddress?.let { builder.addAddress(it, 32).addRoute(it, 32) }
- return builder.establish() ?: throw IOException("本地 VPN 未获授权,请重新开启 Tesla HTTP 模式")
- }
-
- private fun restoreBrowserTunnelLocked(): AttachResult {
- val address = browserAddress ?: return AttachResult.Started
- return try {
- if (tun == null) tun = establishTunnel()
- browserStatus = TeslaHttpStatus(address)
- AttachResult.Started
- } catch (error: Exception) {
- browserStatus = TeslaHttpStatus(error = "虚拟地址启动失败:${error.javaClass.simpleName}")
- AttachResult.Failed(browserStatus.error!!)
- }
- }
+ /** Only the original wired CarPlay IPv6 link uses Android's VPN API. */
+ protected open fun establishTunnel(linkLocal: String): ParcelFileDescriptor = Builder()
+ .allowFamily(OsConstants.AF_INET)
+ .allowFamily(OsConstants.AF_INET6)
+ .setSession(SESSION_NAME)
+ .setMtu(TUN_MTU)
+ .setBlocking(true)
+ .addAddress(linkLocal, LINK_PREFIX)
+ .addRoute(LINK_LOCAL_ROUTE, LINK_PREFIX)
+ .establish() ?: throw IOException("CarPlay VPN 未获授权")
override fun onBind(intent: Intent?): IBinder? =
if (intent?.action == SERVICE_INTERFACE) super.onBind(intent) else binder
@@ -133,7 +86,6 @@ open class CarPlayVpnService : VpnService() {
listener: AirPlaySessionListener,
media: AirPlayMediaHandler,
): AttachResult {
- // The replacement TUN carries both the USB IPv6 link and the optional browser alias.
releaseLocked()
active.set(true)
val generation = ++attachGeneration
@@ -146,7 +98,6 @@ open class CarPlayVpnService : VpnService() {
val tunFd = establishTunnel(linkLocal)
tun = tunFd
- browserStatus = TeslaHttpStatus(browserAddress)
val ipv6Bridge = Ipv6NcmBridge(ncm, tunFd, hostMac) { error ->
onTransportError(generation, listener, error)
@@ -161,7 +112,6 @@ open class CarPlayVpnService : VpnService() {
AttachResult.Started
} catch (error: Exception) {
releaseLocked()
- restoreBrowserTunnelLocked()
AttachResult.Failed(error.message ?: error.javaClass.simpleName)
}
}
@@ -184,7 +134,6 @@ open class CarPlayVpnService : VpnService() {
Log.i(TAG, "replacing stale local-only Wi-Fi attachment")
releaseLocked()
}
- restoreBrowserTunnelLocked()
active.set(true)
val generation = ++attachGeneration
return try {
@@ -195,7 +144,6 @@ open class CarPlayVpnService : VpnService() {
AttachResult.Started
} catch (error: Exception) {
releaseLocked()
- restoreBrowserTunnelLocked()
AttachResult.Failed(error.message ?: error.javaClass.simpleName)
}
}
@@ -204,7 +152,6 @@ open class CarPlayVpnService : VpnService() {
@Synchronized
fun detach() {
releaseLocked()
- restoreBrowserTunnelLocked()
}
fun isAttached(): Boolean = active.get() && attachment != null
@@ -212,20 +159,15 @@ open class CarPlayVpnService : VpnService() {
@Synchronized
override fun onRevoke() {
val listener = if (bridge != null) attachment?.listener else null
- browserAddress = null
- // Wireless AirPlay does not depend on the VPN; withdrawing the browser alias
- // must not close its independent TCP listener.
+ // Wireless AirPlay does not depend on the wired VPN.
if (bridge != null) releaseLocked() else closeTunnelLocked()
- browserStatus = TeslaHttpStatus(error = "本地 VPN 授权已撤销,请重新开启 Tesla HTTP 模式")
listener?.onTransportError("CarPlay VPN permission revoked")
super.onRevoke()
}
@Synchronized
override fun onDestroy() {
- browserAddress = null
releaseLocked()
- browserStatus = TeslaHttpStatus()
super.onDestroy()
}
@@ -325,7 +267,6 @@ open class CarPlayVpnService : VpnService() {
synchronized(this) {
if (generation != attachGeneration) return@Thread
releaseLocked()
- restoreBrowserTunnelLocked()
}
listener.onTransportError(message)
},
@@ -352,7 +293,6 @@ open class CarPlayVpnService : VpnService() {
bridge = null
tun?.close()
tun = null
- browserStatus = TeslaHttpStatus(error = browserStatus.error)
}
companion object {
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt
index de8dae1..18851e2 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt
@@ -1,26 +1,33 @@
package com.shilapi.xcertplay.network
-/** A local virtual address, not a replacement for the system hotspot's DHCP subnet. */
+/** A root-managed local HTTP address, independent of the system hotspot's DHCP subnet. */
data class TeslaHttpConfig(
val enabled: Boolean = false,
val address: String = DEFAULT_ADDRESS,
- val hostname: String = "",
- val port: Int = DEFAULT_PORT,
+ val httpMappingPort: Int = 80,
+ val httpsMappingPort: Int = 443,
) {
init {
- require(isSharedAddress(address)) { "虚拟 IP 必须位于 100.64.0.0–100.127.255.255" }
- require(isHostname(hostname)) { "请输入域名,不要包含协议、端口或路径" }
- require(port in 1..65535) { "HTTP 端口必须为 1–65535" }
+ require(isHttpAddress(address)) { "请输入可供热点访问的 IPv4 单播地址" }
+ require(httpMappingPort in 1..1023 && httpsMappingPort in 1..1023) { "映射端口必须为 1–1023" }
+ require(httpMappingPort != httpsMappingPort) { "HTTP 与 HTTPS 映射端口不能相同" }
}
- val ipUrl get() = "http://$address:$port/"
- val hostnameUrl get() = hostname.takeIf { it.isNotEmpty() }?.let { "http://$it:$port/" }
+ val ipUrl get() = "http://$address:$httpMappingPort/"
companion object {
const val DEFAULT_ADDRESS = "100.96.0.1"
- const val DEFAULT_PORT = 8080
// Parse literals without DNS, accepting only canonical dotted decimal notation.
+ fun isHttpAddress(value: String): Boolean {
+ val parts = value.split('.')
+ if (parts.size != 4 || parts.any { !Regex("0|[1-9][0-9]{0,2}").matches(it) }) return false
+ val bytes = parts.map { it.toInt() }
+ return bytes.all { it in 0..255 } && bytes[0] in 1..223 && bytes[0] != 127 &&
+ !(bytes[0] == 169 && bytes[1] == 254)
+ }
+
+ // Ordinary interface discovery includes RFC6598 but does not list arbitrary WAN addresses.
fun isSharedAddress(value: String): Boolean {
val parts = value.split('.')
if (parts.size != 4 || parts.any { !Regex("0|[1-9][0-9]{0,2}").matches(it) }) return false
@@ -35,5 +42,6 @@ data class TeslaHttpConfig(
}
}
-/** Ready means that the local interface exists; hotspot/browser reachability still needs testing. */
-data class TeslaHttpStatus(val address: String? = null, val error: String? = null)
+/** Ready means scoped hotspot routing is installed; Tesla's HTTP policy still needs device testing. */
+data class TeslaHttpStatus(val address: String? = null, val error: String? = null, val downstreams: List = emptyList(),
+ val httpMappingPort: Int? = null, val httpsMappingPort: Int? = null)
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/TeslaHttpConfigTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/TeslaHttpConfigTest.kt
index a107984..8ab27ea 100644
--- a/shared/src/test/java/com/shilapi/xcertplay/network/TeslaHttpConfigTest.kt
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/TeslaHttpConfigTest.kt
@@ -4,25 +4,26 @@ import org.junit.Assert.*
import org.junit.Test
class TeslaHttpConfigTest {
- @Test fun customPortsIncludeBoundariesAndRejectInvalidValues() {
- for (port in listOf(1, 80, 9090, 65535)) {
- val config = TeslaHttpConfig(hostname = "car.example.com", port = port)
+ @Test fun mappedPortsArePrivilegedDistinctPorts() {
+ for (port in listOf(1, 80, 1023)) {
+ val config = TeslaHttpConfig(httpMappingPort = port)
assertEquals("http://100.96.0.1:$port/", config.ipUrl)
- assertEquals("http://car.example.com:$port/", config.hostnameUrl)
}
- for (port in listOf(-1, 0, 65536, Int.MAX_VALUE)) {
- try { TeslaHttpConfig(port = port); fail("Invalid port $port must be rejected") }
- catch (_: IllegalArgumentException) { }
+ for (port in listOf(-1, 0, 1024, 65535, Int.MAX_VALUE)) {
+ assertTrue(runCatching { TeslaHttpConfig(httpMappingPort = port) }.isFailure)
+ assertTrue(runCatching { TeslaHttpConfig(httpsMappingPort = port) }.isFailure)
}
+ assertTrue(runCatching { TeslaHttpConfig(httpMappingPort = 80, httpsMappingPort = 80) }.isFailure)
}
- @Test fun onlySharedSpaceLiteralsAreAcceptedWithoutDns() {
- for (address in listOf("100.64.0.0", "100.96.0.1", "100.127.255.255")) {
- assertTrue(address, TeslaHttpConfig.isSharedAddress(address))
+ @Test fun rootModeAcceptsGeneralUnicastAddressesWithoutDns() {
+ for (address in listOf("100.64.0.0", "100.96.0.1", "100.127.255.255", "192.168.1.2", "10.0.0.1", "3.3.3.3")) {
+ assertTrue(address, TeslaHttpConfig.isHttpAddress(address))
+ assertEquals(address, TeslaHttpConfig(address = address).address)
}
- for (address in listOf("100.63.255.255", "100.128.0.0", "192.168.1.1", "3.3.3.3",
+ for (address in listOf("0.0.0.0", "0.1.2.3", "127.0.0.1", "169.254.1.1", "224.0.0.1", "255.255.255.255",
"100.96.0.256", "100.096.0.1", "100.96.0", "100.96.0.1/32", "localhost", "::1", " 100.96.0.1")) {
- assertFalse(address, TeslaHttpConfig.isSharedAddress(address))
+ assertFalse(address, TeslaHttpConfig.isHttpAddress(address))
}
}
@@ -35,8 +36,6 @@ class TeslaHttpConfigTest {
"100.96.0.1", "localhost", "car.example.com\n", "a".repeat(64) + ".example.com")) {
assertFalse(hostname, TeslaHttpConfig.isHostname(hostname))
}
- assertEquals("http://100.96.0.1:8080/", TeslaHttpConfig().ipUrl)
- assertNull(TeslaHttpConfig().hostnameUrl)
- assertEquals("http://car.example.com:8080/", TeslaHttpConfig(hostname = "car.example.com").hostnameUrl)
+ assertEquals("http://100.96.0.1:80/", TeslaHttpConfig().ipUrl)
}
}
diff --git a/test/browser-audio.test.js b/test/browser-audio.test.js
index 1d17f2c..ee598fa 100644
--- a/test/browser-audio.test.js
+++ b/test/browser-audio.test.js
@@ -2,10 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createBrowserAudio, createMicResampler, decodeAudioPacket } from '../common/src/main/assets/web/browser-audio.js';
-function packet(samples, sequence = 1) {
+function packet(samples, sequence = 1, { media = false, stream = 7 } = {}) {
const bytes = new ArrayBuffer(20 + samples.length * 2), view = new DataView(bytes);
- view.setUint32(0, 0x31415057, true); view.setInt32(4, 7, true);
- view.setUint32(8, 48000, true); view.setUint16(12, 1, true); view.setUint32(16, sequence, true);
+ view.setUint32(0, 0x31415057, true); view.setInt32(4, stream, true);
+ view.setUint32(8, 48000, true); view.setUint16(12, 1, true);
+ view.setUint16(14, media ? 1 : 0, true); view.setUint32(16, sequence, true);
samples.forEach((sample, i) => view.setInt16(20 + 2 * i, sample, true));
return bytes;
}
@@ -14,11 +15,93 @@ test('PCM frames validate format and preserve stream, rate, channels and sequenc
const decoded = decodeAudioPacket(packet([16384, -16384], 9));
assert.deepEqual([decoded.stream, decoded.rate, decoded.channels, decoded.sequence], [7, 48000, 1, 9]);
assert.deepEqual([...decoded.pcm], [16384, -16384]);
+ assert.equal(decoded.media, false);
+ assert.equal(decodeAudioPacket(packet([1], 2, { media: true })).media, true);
const malformed = packet([1]); new DataView(malformed).setUint16(12, 3, true);
assert.equal(decodeAudioPacket(malformed), null);
assert.equal(decodeAudioPacket(new Uint8Array([1]).buffer), null);
});
+async function playbackPlayer(mediaBufferMillis) {
+ const sources = [], controls = [];
+ const context = {
+ state: 'running', currentTime: 0, destination: {}, async resume() {}, async close() {},
+ createBuffer(channels, count, rate) {
+ const data = Array.from({ length: channels }, () => new Float32Array(count));
+ return { duration: count / rate, getChannelData(channel) { return data[channel]; } };
+ },
+ createBufferSource() {
+ const node = { connect() {}, disconnect() {}, start(at) { this.at = at; }, stop() { this.stopped = true; } };
+ sources.push(node); return node;
+ },
+ };
+ const audio = createBrowserAudio({ send: value => controls.push(value), createContext: () => context });
+ audio.attach(); audio.control({ type: 'audio-route', playback: true, microphone: false, mediaBufferMillis });
+ await new Promise(resolve => setImmediate(resolve));
+ function advance(time) {
+ context.currentTime = time;
+ for (const node of sources) {
+ if (!node.ended && !node.stopped && node.at + node.buffer.duration <= time) {
+ node.ended = true; node.onended();
+ }
+ }
+ }
+ return { audio, context, sources, controls, advance };
+}
+
+test('all media buffer presets preserve continuous playback across repeated 400 ms delivery bursts', async () => {
+ for (const millis of [300, 500, 1000]) {
+ const { audio, sources, advance } = await playbackPlayer(millis);
+ let sequence = 0, peakNodes = 0;
+ for (let burst = 0; burst < 6; burst++) {
+ advance(burst * .4);
+ for (let i = 0; i < 20; i++) audio.receive(packet(new Int16Array(960).fill(123), ++sequence, { media: true }));
+ peakNodes = Math.max(peakNodes, sources.filter(node => !node.ended && !node.stopped).length);
+ }
+ assert.equal(sources.length, 120);
+ assert.equal(sources[0].at, millis / 1000);
+ assert.equal(sources.filter(node => node.stopped).length, 0);
+ for (let i = 1; i < sources.length; i++) {
+ assert.ok(Math.abs(sources[i].at - sources[i - 1].at - .02) < 1e-9, `Gap in ${millis} ms playback`);
+ }
+ if (millis === 1000) assert.ok(peakNodes > 64, 'A one-second buffer must survive the former 64-node cap');
+ audio.close();
+ }
+});
+
+test('changing media buffering rebuilds only media playback while calls keep the short delay', async () => {
+ const { audio, sources, advance } = await playbackPlayer(500);
+ audio.receive(packet(new Int16Array(960), 1, { media: true }));
+ audio.receive(packet(new Int16Array(960), 2, { stream: 8 }));
+ assert.equal(sources[0].at, .5); assert.equal(sources[1].at, .06);
+ advance(.02);
+ audio.control({ type: 'audio-route', playback: true, microphone: false, mediaBufferMillis: 1000 });
+ assert.equal(sources[0].stopped, true); assert.equal(sources[1].stopped, undefined);
+ audio.receive(packet(new Int16Array(960), 3, { media: true }));
+ assert.equal(sources[2].at, 1.02);
+ audio.control({ type: 'audio-route', playback: true, microphone: false, mediaBufferMillis: 37 });
+ audio.receive(packet(new Int16Array(960), 4, { media: true }));
+ assert.equal(sources[3].at, .32);
+ audio.close();
+});
+
+test('media resumes with the selected buffer after starvation and keeps queued audio on overflow', async () => {
+ const { audio, sources, advance, controls } = await playbackPlayer(1000);
+ audio.receive(packet(new Int16Array(960), 1, { media: true }));
+ advance(1.5);
+ audio.receive(packet(new Int16Array(960), 2, { media: true }));
+ assert.equal(sources[1].at, 2.5);
+ for (let sequence = 3; sequence < 153; sequence++) audio.receive(packet(new Int16Array(960), sequence, { media: true }));
+ assert.ok(sources.length < 152, 'Excess audio must remain bounded');
+ assert.equal(sources.filter(node => node.stopped).length, 0, 'Overflow must not cancel pending sound');
+ assert.equal(controls.at(-1).sequence, 152, 'Dropped incoming packets still release the server ACK window');
+ const previousCount = sources.length;
+ advance(1.7);
+ audio.receive(packet(new Int16Array(960), 153, { media: true }));
+ assert.equal(sources.length, previousCount + 1);
+ audio.close();
+});
+
test('continuous 48-to-16 kHz microphone resampling emits 20 ms bounded PCM frames', () => {
const frames = [], resample = createMicResampler(48000, 16000, 1, frame => frames.push(new Int16Array(frame)));
for (let i = 0; i < 15; i++) resample(new Float32Array(64).fill(.5));
diff --git a/test/pairing.test.js b/test/pairing.test.js
index 4608deb..7b725f5 100644
--- a/test/pairing.test.js
+++ b/test/pairing.test.js
@@ -10,14 +10,14 @@ const source = readFileSync(new URL('../common/src/main/assets/web/browser-audio
const flush = () => new Promise(resolve => setImmediate(resolve));
const savedDevice = { id: 'd'.repeat(32), secret: 's'.repeat(32), name: 'Chrome · Android' };
const ok = data => ({ ok: true, json: async () => data });
-function fixture({ status = () => ok({ state: 'pending' }), stored = null, resume = () => ok({ token: 'resumed-token', name: savedDevice.name }), storageDisabled = false, search = '' } = {}) {
+function fixture({ status = () => ok({ state: 'pending' }), stored = null, resume = () => ok({ token: 'resumed-token', name: savedDevice.name }), storageDisabled = false, search = '', tls = { available: true, port: 8443 }, href = 'http://server:8080/?code=123456#old' } = {}) {
const elements = new Map(), timers = new Map(), sockets = [], requests = [], windowEvents = {};
const preferences = new Map(stored ? [['wheelplay.browserDevice', JSON.stringify(stored)]] : []);
let nextTimer = 0;
const element = id => {
if (!elements.has(id)) elements.set(id, { hidden: false, value: '123456', handlers: {}, checked: false,
set src(value) { this.source = value; if (this.onload) this.onload(); },
- classList: { toggle() {} }, removeAttribute() {}, setAttribute() {}, addEventListener(name, fn) { this.handlers[name] = fn; } });
+ classList: { toggle() {} }, removeAttribute(name) { if (name === "href") delete this.href; }, setAttribute() {}, addEventListener(name, fn) { this.handlers[name] = fn; } });
return elements.get(id);
};
class Socket {
@@ -33,11 +33,12 @@ function fixture({ status = () => ok({ state: 'pending' }), stored = null, resum
setItem(key, value) { if (storageDisabled) throw new Error('denied'); preferences.set(key, value); },
removeItem(key) { preferences.delete(key); },
},
- }, location: { host: 'server:8080' }, URL, Date, WebSocket: Socket,
+ }, location: { host: 'server:8080', href }, URL, Date, WebSocket: Socket,
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); }, clearInterval() {},
fetch: async (url, options) => {
requests.push({ url, options });
+ if (url === '/tls.json') return ok(tls);
if (url.endsWith('/status')) return status();
if (url.endsWith('/resume')) return resume();
if (url.endsWith('/remember')) return ok(savedDevice);
@@ -160,3 +161,35 @@ test('revocation between restore and launch forces fresh pairing instead of usin
f.submit(); await flush();
assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token');
});
+
+test('HTTPS setup uses the configured port and strips pairing data, including IPv6 hosts', async () => {
+ for (const [href, expected] of [
+ ['http://server:8080/?code=123456#old', 'https://server:9443/'],
+ ['http://[fd00::1]:8080/?code=123456', 'https://[fd00::1]:9443/'],
+ ]) {
+ const f = fixture({ href, tls: { available: true, port: 9443, fingerprint: 'verified' } });
+ f.element('secure-setup').open = true;
+ await f.element('secure-setup').handlers.toggle();
+ assert.equal(f.element('https-entry').href, expected);
+ assert.equal(f.element('tls-fingerprint').textContent, 'verified');
+ }
+});
+
+test('unavailable HTTPS or invalid port withdraws a previously displayed link', async () => {
+ for (const tls of [{ available: false, port: 9443 }, { available: true, port: 0 }, { available: true, port: '9443' }]) {
+ const f = fixture({ tls });
+ f.element('https-entry').href = 'https://server:8443/';
+ f.element('secure-setup').open = true;
+ await f.element('secure-setup').handlers.toggle();
+ assert.equal(f.element('https-entry').href, undefined);
+ }
+});
+
+test('disabled HTTPS removes the link and explains the app setting', async () => {
+ const f = fixture({ tls: { available: false, enabled: false, port: 8443 } });
+ f.element('https-entry').href = 'https://server:8443/';
+ f.element('secure-setup').open = true;
+ await f.element('secure-setup').handlers.toggle();
+ assert.equal(f.element('https-entry').href, undefined);
+ assert.match(f.element('tls-fingerprint').textContent, /HTTPS 已关闭.*Web 监听/);
+});