Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ Select Audio output and Microphone source under Settings → Display & Audio. Ou

## Requirements and limitations

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.
Tesla compatibility helps car browsers that block private IP ranges reach the phone's local service through a compatible access address. It also applies to other vehicle brands with the same restriction. Choose **No-root VPN (experimental)** or **Root hotspot routing** under Settings → Tesla compatibility → Connection method. Both are opt-in; switching methods disables the mode until explicitly enabled again, retaining each method's saved address.

- **No-root VPN** requires Android VPN consent, with no Root or Shizuku. Connect the Tesla to this phone's system hotspot and try `http://6.6.6.6:8080`, using the actual Web listener port; no low-port mapping is provided. The shared VPN service retains the IPv4 alias TUN and establishes wired CarPlay's IPv6/NCM TUN last. Disconnecting the iPhone restores the placeholder VPN. Enabling the alias or changing its IP during wired CarPlay waits until disconnect. Long-lived old TUN retention is not guaranteed by Android's API contract and may fail across devices or system updates. “Address configured” confirms the address exists on the phone; hotspot access, Tesla browser behavior, and WebRTC ICE/UDP require device testing.
- **Root hotspot routing** embeds the [VPN Hotspot](https://github.com/Mygod/VPNHotspot) adaptation and requires `su`, `ip`, and `iptables`. Grant Root manually and enable it; the default entry is `http://100.96.0.1/`. Ports `1–1023` (defaults `80` / `443`) map to the Web listener ports. Startup checks Root only for an enabled Root mode. This method does not request VPN consent and keeps wired CarPlay's IPv6 VPN independent.

Both methods clean up when disabled or when the Web service stops. Ordinary LAN Web listeners remain independent; optional HTTPS still requires a trusted certificate. See the [Tesla compatibility guide](docs/TESLA_HTTP.md) (Chinese).

Settings → Web listeners (Web 监听) contains the HTTP port (default `8080`), a self-managed Web hostname shared by HTTP and HTTPS, and an optional HTTPS switch. No Root access is required; listener ports range from `1024–65535`. Enabling HTTPS reveals its port (default `8443`) and inline certificate types: local default, PEM, and PKCS#12. Android's file picker imports PEM chains plus private keys (including encrypted keys) or PKCS#12 (`.p12` / `.pfx`) with passwords and key-alias selection. Identities are validated and saved in private non-backup storage; failed imports retain the current certificate, and occupied ports retain the current listener. Disabling HTTPS stops TLS while retaining its settings and certificate. See [Web listener and certificate setup](docs/BROWSER_AUDIO.md).

Expand Down
9 changes: 8 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素

## 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)。
此模式主要帮助屏蔽私有网段访问的车机浏览器,通过兼容访问地址绕过地址限制,访问手机上的本地服务。功能名称沿用「Tesla 兼容」,也适用于有同类限制的其他品牌车机。

在「设置 → Tesla 兼容(实验)→ 连接方案」选择 **免 Root VPN(实验)** 或 **Root 热点路由**。两种方案都默认关闭,切换方案会关闭模式,需重新主动启用;已有 Root 设置保留。

- **免 Root VPN**:无需 Root 或 Shizuku,需要 Android VPN 授权。开启手机系统热点,让车机连接后尝试 `http://6.6.6.6:8080`;直接使用 Web 监听端口,不映射 `80` / `443`。与有线 CarPlay 共用一个 VPN 服务,保留 IPv4 地址接口,再让 NCM IPv6 接口最后成为活动 VPN;断开 iPhone 后恢复兼容接口。连接有线 CarPlay 时启用或修改 IP,会等待断开后生效。此方案利用公开 API 之外未保证的旧 TUN 保留行为,可能不兼容部分设备,系统更新后也可能失效。「地址已配置」仅表示本机检测到该地址,热点和 Tesla 访问仍需实测;WebRTC 的 ICE / UDP 通道需单独验证。
- **Root 热点路由**:内置适配 [VPN Hotspot](https://github.com/Mygod/VPNHotspot),需要 `su`、`ip`、`iptables`。手动请求 Root 后启用,默认入口 `http://100.96.0.1/`;映射端口范围 `1–1023`,默认 `80` / `443`,转发到 Web 的高位监听端口。仅已启用的 Root 方案会在启动时后台检查 Root;免 Root 模式不请求 Root。Root 方案不申请 VPN 授权,有线 CarPlay 的 IPv6 VPN 独立保留。

两种方案都在关闭模式或停止 Web 服务时清理资源;普通局域网 Web 监听可独立使用。可选 HTTPS 仍要求可信证书。详见 [使用与验证说明](docs/TESLA_HTTP.md)。

## 构建

Expand Down
81 changes: 75 additions & 6 deletions common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import com.shilapi.xcertplay.web.TeslaHttpCompatibility
import com.shilapi.xcertplay.web.LanTls
import com.shilapi.xcertplay.web.WebListenSettings
import com.shilapi.xcertplay.web.BrowserExperienceLink
import com.shilapi.xcertplay.network.CarPlayVpnService
import com.shilapi.xcertplay.network.TeslaHttpMode
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
import android.bluetooth.BluetoothManager
Expand Down Expand Up @@ -65,6 +67,15 @@ class DiPlayActivity : AppCompatActivity() {
private var teslaStatus: TextView? = null
private var renderedRootState: Triple<Boolean, Boolean, String>? = null
private var webBusy = false
private var awaitingTeslaVpnConsent = false
private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val requested = awaitingTeslaVpnConsent
awaitingTeslaVpnConsent = false
if (requested && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) {
applyTeslaHttpEnabled(result.resultCode == RESULT_OK)
if (result.resultCode != RESULT_OK) toast("VPN 授权已取消,免 Root 模式保持关闭")
}
}
private var pendingPemCertificate: ByteArray? = null
private val pemCertificatePicker = registerForActivityResult(CertificateDocument()) { uri ->
if (uri != null) {
Expand Down Expand Up @@ -208,11 +219,12 @@ class DiPlayActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
RootAccess.checkOnStartup()
if (TeslaHttpCompatibility.config(this).let { it.enabled && it.mode == TeslaHttpMode.ROOT }) RootAccess.checkOnStartup()
com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext)
ServerWindow.showSystemBars(window)
refreshAuthentication()
selectedPrivateKey = savedInstanceState?.getString("selectedPrivateKey")?.let(Uri::parse)
awaitingTeslaVpnConsent = savedInstanceState?.getBoolean("awaitingTeslaVpnConsent") ?: false
page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page")
?: if (setupError == null) "service" else "settings"
createShell()
Expand Down Expand Up @@ -241,6 +253,7 @@ class DiPlayActivity : AppCompatActivity() {
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("page", page)
outState.putString("selectedPrivateKey", selectedPrivateKey?.toString())
outState.putBoolean("awaitingTeslaVpnConsent", awaitingTeslaVpnConsent)
super.onSaveInstanceState(outState)
}
override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); createShell() }
Expand Down Expand Up @@ -370,10 +383,13 @@ class DiPlayActivity : AppCompatActivity() {
if (webAddress?.text?.toString() != addressText) webAddress?.text = addressText
webInterface?.text = primary?.let { it.description + if (it.hostname.isNotEmpty()) "\n直接 IP:${it.ipUrl}" else "" }.orEmpty()
val compatibility = TeslaHttpCompatibility.status
teslaStatus?.text = if (!TeslaHttpCompatibility.config(this).enabled) "已关闭"
val teslaConfig = TeslaHttpCompatibility.config(this)
teslaStatus?.text = if (!teslaConfig.enabled) "已关闭"
else if (!WebSession.running && setupError != null) "导入认证资源后启动"
else compatibility.error ?: compatibility.address?.let { "Root 热点路由已启用:$it(${compatibility.downstreams.joinToString()});请用车机测试访问" }
?: "正在准备 Root 热点路由…"
else compatibility.error ?: compatibility.address?.let {
if (compatibility.mode == TeslaHttpMode.VPN) "免 Root 地址已配置:$it;热点与车机访问需实测"
else "Root 热点路由已启用:$it(${compatibility.downstreams.joinToString()});请用车机测试访问"
} ?: "正在准备${teslaConfig.mode.label}…"
webInterface?.visibility = if (primary == null) View.GONE else View.VISIBLE
if (displayedAddresses != addresses) {
displayedAddresses = addresses
Expand Down Expand Up @@ -525,15 +541,47 @@ class DiPlayActivity : AppCompatActivity() {
}
section(content, "Tesla 兼容(实验)") { card ->
renderedRootState = rootState()
val config = TeslaHttpCompatibility.config(this)
val compatibilityDescription = label("主要用于帮助屏蔽私有网段访问的车机绕过地址限制,访问手机上的本地服务。除 Tesla 外,有同类限制的其他品牌车机也可尝试。", 14, MUTED)
card.addView(ui.preference("连接方案", config.mode.label) {
MaterialAlertDialogBuilder(this).setTitle("Tesla 连接方案")
.setSingleChoiceItems(TeslaHttpMode.entries.map { it.label }.toTypedArray(), config.mode.ordinal) { dialog, index ->
val selected = TeslaHttpMode.entries[index]
if (selected != config.mode) {
TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(mode = selected, enabled = false))
render(); refreshService()
}
dialog.dismiss()
}.setNegativeButton("取消", null).show()
}.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent })
if (config.mode == TeslaHttpMode.VPN) {
card.addView(compatibilityDescription)
toggle(card, "启用免 Root Tesla 兼容", "需要 Android VPN 授权,无需 Root 或 Shizuku;可能与其他 VPN 冲突。", config.enabled,
enabled = !webBusy && !awaitingTeslaVpnConsent) { setTeslaHttpEnabled(it) }
teslaStatus = label("", 14, MUTED)
card.addView(teslaStatus)
card.addView(ui.preference("访问 IP 地址", config.vpnAddress) {
textInput("免 Root 访问 IP 地址", config.vpnAddress, false) { value ->
runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(vpnAddress = value)) }
.onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") }
}
}.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent })
card.addView(label("请让车机连接此手机的系统热点,使用 http://${config.vpnAddress}:${WebListenSettings.httpPort(this)}。此方案可能不兼容部分手机或车机,系统更新后可能失效。有线 CarPlay 已连接时,启用或修改 IP 将等待断开后生效。", 14, MUTED))
card.addView(label("直接使用 Web 监听端口,无低位端口映射。可选 HTTPS 仍需车机信任证书;WebRTC 视频需单独验证。", 14, MUTED))
card.addView(button("授权或重试 VPN", false) { setTeslaHttpEnabled(true) }
.apply { isEnabled = !webBusy && !awaitingTeslaVpnConsent }, ui.secondaryButtonLayout(12))
card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12))
return@section
}
val rootRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
rootRow.addView(button("请求 Root 权限", false) {
configureWeb("Root 已授权") { RootAccess.request().also { TeslaHttpCompatibility.retry() } }
}.apply { isEnabled = !webBusy && !RootAccess.checking })
rootRow.addView(label(RootAccess.status, 14, MUTED).apply { maxLines = 3; ellipsize = android.text.TextUtils.TruncateAt.END },
LinearLayout.LayoutParams(0, -2, 1f).apply { marginStart = dp(12) })
card.addView(rootRow)
card.addView(compatibilityDescription)
if (RootAccess.granted) {
val config = TeslaHttpCompatibility.config(this)
toggle(card, "启用 Tesla 兼容", "将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。", config.enabled, enabled = !webBusy) {
setTeslaHttpEnabled(it)
}
Expand Down Expand Up @@ -936,6 +984,27 @@ class DiPlayActivity : AppCompatActivity() {
}

private fun setTeslaHttpEnabled(enabled: Boolean) {
if (enabled && TeslaHttpCompatibility.config(this).mode == TeslaHttpMode.VPN) {
if (awaitingTeslaVpnConsent) return
try {
val consent = CarPlayVpnService.prepare(this)
if (consent != null) {
awaitingTeslaVpnConsent = true
teslaVpnConsent.launch(consent)
render()
return
}
} catch (error: Exception) {
awaitingTeslaVpnConsent = false
toast("无法请求 VPN 授权:${error.message ?: error.javaClass.simpleName}")
render()
return
}
}
applyTeslaHttpEnabled(enabled)
}

private fun applyTeslaHttpEnabled(enabled: Boolean) {
TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(enabled = enabled))
if (!WebSession.running) startWebServiceIfReady()
handler.post { render(); refreshService() }
Expand Down Expand Up @@ -1333,7 +1402,7 @@ 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 compatibility: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; rootRoutingReady=${TeslaHttpCompatibility.status.address != null}; downstreams=${TeslaHttpCompatibility.status.downstreams.joinToString()}")
appendLine("Tesla compatibility: enabled=${TeslaHttpCompatibility.config(appContext).enabled}; mode=${TeslaHttpCompatibility.config(appContext).mode}; addressConfigured=${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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ class DiPlaySessionService : Service() {

override fun onCreate() {
super.onCreate()
RootAccess.checkOnStartup()
if (com.shilapi.xcertplay.web.TeslaHttpCompatibility.config(this).let {
it.enabled && it.mode == com.shilapi.xcertplay.network.TeslaHttpMode.ROOT
}) RootAccess.checkOnStartup()
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "CarPlay 服务", NotificationManager.IMPORTANCE_LOW))
media = CarPlayMediaSessionBridge(this) { state, session, artwork ->
Expand Down
Loading
Loading