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 车机屏幕

-
浏览器麦克风需要 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