diff --git a/README.md b/README.md index 6c5e4d3..77369cf 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/README.zh-CN.md b/README.zh-CN.md index 4d43e6d..c7dc861 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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)。 ## 构建 diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index e72ee25..2af8ad5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -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 @@ -65,6 +67,15 @@ class DiPlayActivity : AppCompatActivity() { private var teslaStatus: TextView? = null private var renderedRootState: Triple? = 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) { @@ -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() @@ -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() } @@ -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 @@ -525,6 +541,38 @@ 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() } } @@ -532,8 +580,8 @@ class DiPlayActivity : AppCompatActivity() { 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) } @@ -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() } @@ -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}") diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt index 89cb272..e79a62c 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -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 -> 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 955e6e0..dc177de 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/LanAddresses.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import com.shilapi.xcertplay.network.TeslaHttpConfig +import com.shilapi.xcertplay.network.TeslaHttpMode import java.net.Inet4Address import java.net.NetworkInterface @@ -15,11 +16,12 @@ internal object LanAddresses { 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 = WebListenSettings.DEFAULT_HTTP_PORT) { + val hostname: String = "", val port: Int = WebListenSettings.DEFAULT_HTTP_PORT, + val teslaMode: TeslaHttpMode = TeslaHttpMode.ROOT) { 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 -> " · Root 热点共享;HTTP 可用性需实测" + Kind.TESLA_HTTP -> " · ${teslaMode.label};热点与车机访问需实测" Kind.P2P -> " · 通常用于 iPhone 连接" Kind.VPN, Kind.CELLULAR -> " · 通常无法从局域网访问" else -> "" @@ -46,11 +48,13 @@ internal object LanAddresses { fun isDiscoverable(address: Inet4Address): Boolean = address.isSiteLocalAddress || TeslaHttpConfig.isSharedAddress(address.hostAddress ?: "") - fun sharedEntry(address: String, hostname: String, port: Int = WebListenSettings.DEFAULT_HTTP_PORT): Entry = - Entry("Root 热点路由", address, Kind.TESLA_HTTP, hostname = hostname, port = port) + fun sharedEntry(address: String, hostname: String, port: Int = WebListenSettings.DEFAULT_HTTP_PORT, + mode: TeslaHttpMode = TeslaHttpMode.ROOT): Entry = + Entry(mode.label, address, Kind.TESLA_HTTP, hostname = hostname, port = port, teslaMode = mode) fun discover(context: Context, teslaAddress: String? = null, hostname: String = "", - port: Int = WebListenSettings.DEFAULT_HTTP_PORT, mappedPort: Int? = null): List { + port: Int = WebListenSettings.DEFAULT_HTTP_PORT, mappedPort: Int? = null, + mode: TeslaHttpMode = TeslaHttpMode.ROOT): List { val transports = mutableMapOf() var activeInterface: String? = null // Framework metadata is authoritative; interface-name heuristics cover tethering/P2P. @@ -80,9 +84,9 @@ internal object LanAddresses { }.getOrDefault(emptyList()) } }.getOrDefault(emptyList()) - // 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() + // Aliases on loopback or a retained DOWN TUN are excluded from ordinary discovery. + // Only the owning routing/VPN manager may publish them; reachability still needs testing. + val shared = teslaAddress?.let { listOf(sharedEntry(it, hostname, mappedPort ?: port, mode)) }.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 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 b607d36..6f7a54e 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/TeslaHttpCompatibility.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import com.shilapi.xcertplay.network.TeslaHttpConfig +import com.shilapi.xcertplay.network.TeslaHttpMode import com.shilapi.xcertplay.network.TeslaHttpStatus import java.net.Inet4Address import java.net.NetworkInterface @@ -15,13 +16,14 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong -/** Web-service-owned root routing. Never binds or obtains consent for the USB CarPlay VPN. */ +/** Web-service-owned routing. Only the selected VPN mode binds the shared CarPlay VpnService. */ internal object TeslaHttpCompatibility { private val main = Handler(Looper.getMainLooper()) private val worker = Executors.newSingleThreadScheduledExecutor { work -> Thread(work, "tesla-http-routing").apply { isDaemon = true } } private val routing = RootHttpRouting() + private val vpn = TeslaVpnBinding() private val revision = AtomicLong() @Volatile private var owner: Context? = null @Volatile private var tethered = emptySet() @@ -51,7 +53,9 @@ internal object TeslaHttpCompatibility { return runCatching { TeslaHttpConfig(prefs.getBoolean("enabled", false), prefs.getString("address", TeslaHttpConfig.DEFAULT_ADDRESS)!!, - prefs.getInt("http_mapping_port", 80), prefs.getInt("https_mapping_port", 443)) + prefs.getInt("http_mapping_port", 80), prefs.getInt("https_mapping_port", 443), + TeslaHttpMode.valueOf(prefs.getString("mode", TeslaHttpMode.ROOT.name)!!), + prefs.getString("vpn_address", TeslaHttpConfig.DEFAULT_VPN_ADDRESS)!!) }.getOrDefault(TeslaHttpConfig()) } @@ -59,9 +63,10 @@ internal object TeslaHttpCompatibility { val previous = config(context) preferences(context).edit().putBoolean("enabled", config.enabled) .putString("address", config.address).putInt("http_mapping_port", config.httpMappingPort) - .putInt("https_mapping_port", config.httpsMappingPort).apply() + .putInt("https_mapping_port", config.httpsMappingPort).putString("mode", config.mode.name) + .putString("vpn_address", config.vpnAddress).apply() refresh(retry = true) - if (previous.address != config.address) + if (previous.accessAddress != config.accessAddress) WebSession.refreshGeneratedCertificate(context) } @@ -85,6 +90,7 @@ internal object TeslaHttpCompatibility { main.post { owner?.let { runCatching { it.unregisterReceiver(receiver) } } owner = null; tethered = emptySet(); status = TeslaHttpStatus() + vpn.stop() refresh() } } @@ -93,13 +99,16 @@ internal object TeslaHttpCompatibility { internal fun plan(config: TeslaHttpConfig, downstreams: List, httpPort: Int?, httpsPort: Int?, authorized: Boolean): RootHttpPlan? = - if (!authorized || !config.enabled || downstreams.isEmpty() || httpPort == null) null + if (!authorized || !config.enabled || config.mode != TeslaHttpMode.ROOT || 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 热点路由…" }) + val config = owner?.let(::config) + status = TeslaHttpStatus(error = config?.takeIf { it.enabled }?.let { "正在准备${it.mode.label}…" }, + mode = config?.mode ?: TeslaHttpMode.ROOT) + if (retry) main.post { vpn.retry() } worker.execute { if (retry) routing.retry(); updateRouting() } } @@ -108,6 +117,23 @@ internal object TeslaHttpCompatibility { val context = owner val config = context?.let(::config) try { + if (config?.enabled == true && config.mode == TeslaHttpMode.VPN) { + // Do not create a competing alias until the former Root transaction is cleaned up. + val cleanup = routing.reconcile(null) + main.post { + if (revision.get() != version || owner !== context || config(context!!) != config) return@post + val http = WebSession.httpListenerPort + if (cleanup.error != null || http == null) { + vpn.stop() + status = TeslaHttpStatus(error = cleanup.error ?: "请先启动 Web 监听", mode = TeslaHttpMode.VPN) + } else { + vpn.reconcile(context, config.vpnAddress) + status = vpn.status.copy(httpMappingPort = http, httpsMappingPort = WebSession.httpsListenerPort) + } + } + return + } + main.post { if (revision.get() == version) vpn.stop() } 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 diff --git a/common/src/main/java/com/shilapi/xcertplay/web/TeslaVpnBinding.kt b/common/src/main/java/com/shilapi/xcertplay/web/TeslaVpnBinding.kt new file mode 100644 index 0000000..02ffcd9 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/web/TeslaVpnBinding.kt @@ -0,0 +1,96 @@ +package com.shilapi.xcertplay.web + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.network.TeslaHttpMode +import com.shilapi.xcertplay.network.TeslaHttpStatus + +/** Main-thread Web owner. Its binding keeps the VPN alive independently of CarPlayController. */ +internal class TeslaVpnBinding { + private var context: Context? = null + private var bound = false + private var service: CarPlayVpnService? = null + private var address: String? = null + private var failure: String? = null + private var retryPending = false + var status = TeslaHttpStatus(mode = TeslaHttpMode.VPN) + private set + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + if (!bound) return + service = (binder as? CarPlayVpnService.LocalBinder)?.service + if (service == null) failure = "无法连接免 Root VPN 服务" + updateService() + } + override fun onServiceDisconnected(name: ComponentName) { + service = null + status = TeslaHttpStatus(error = "VPN 服务已断开,正在等待系统恢复;也可手动重试", mode = TeslaHttpMode.VPN) + } + override fun onBindingDied(name: ComponentName) { + val requestedAddress = address + stop() + address = requestedAddress + failure = "VPN 服务绑定失效,请手动重试" + status = TeslaHttpStatus(error = failure, mode = TeslaHttpMode.VPN) + } + override fun onNullBinding(name: ComponentName) = onBindingDied(name) + } + + fun retry() { + retryPending = true + failure = null + } + + fun reconcile(owner: Context?, requestedAddress: String?) { + if (owner == null || requestedAddress == null) { stop(); return } + if (address != requestedAddress) failure = null + address = requestedAddress + try { + failure?.let { status = TeslaHttpStatus(error = it, mode = TeslaHttpMode.VPN); return } + if (!bound) { + // prepare() can switch VPN ownership when previously authorized. Check only + // on an initial start or explicit retry, never on every reconciliation tick. + if (CarPlayVpnService.prepare(owner) != null) { + failure = "请在设置中授权 Tesla VPN 后重试" + status = TeslaHttpStatus(error = failure, mode = TeslaHttpMode.VPN) + return + } + context = owner.applicationContext + bound = true + if (!context!!.bindService(Intent(context, CarPlayVpnService::class.java), connection, Context.BIND_AUTO_CREATE)) { + bound = false + failure = "无法绑定免 Root VPN 服务,请手动重试" + } + } + updateService() + } catch (error: Exception) { + stop() + address = requestedAddress + failure = error.message ?: "免 Root VPN 启动失败" + status = TeslaHttpStatus(error = failure, mode = TeslaHttpMode.VPN) + } + } + + private fun updateService() { + val current = service + status = if (current == null) TeslaHttpStatus(error = failure ?: "正在连接免 Root VPN 服务…", mode = TeslaHttpMode.VPN) + else current.setTeslaAddress(address, retryPending).also { retryPending = false } + } + + fun stop() { + service?.setTeslaAddress(null) + service = null + if (bound) context?.let { runCatching { it.unbindService(connection) } } + bound = false + context = null + address = null + failure = null + retryPending = false + status = TeslaHttpStatus(mode = TeslaHttpMode.VPN) + } +} 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 036ef2b..efe1833 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -267,13 +267,13 @@ internal object WebSession { 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)) + val endpoint = LanTls.createDefault(context, (addresses(context).map { it.address } + config.accessAddress).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)) + return LanTls.create(context, (addresses(context).map { it.address } + config.accessAddress).distinct(), WebListenSettings.hostname(context)) } /** A changed port binds first. Certificate replacement on the same port rolls back on failure. */ @@ -415,7 +415,7 @@ internal object WebSession { fun addresses(context: Context): List { val status = TeslaHttpCompatibility.status - return LanAddresses.discover(context, status.address, WebListenSettings.hostname(context), httpPort, status.httpMappingPort) + return LanAddresses.discover(context, status.address, WebListenSettings.hostname(context), httpPort, status.httpMappingPort, status.mode) } fun httpsPortFor(context: Context, authority: String?, status: com.shilapi.xcertplay.network.TeslaHttpStatus = TeslaHttpCompatibility.status): Int { diff --git a/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt index b01c0d1..b97b819 100644 --- a/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/HttpsSettingsUiTest.kt @@ -71,6 +71,8 @@ class HttpsSettingsUiTest { entered.countDown() check(release.await(8, TimeUnit.SECONDS)) } + com.shilapi.xcertplay.web.TeslaHttpCompatibility.save(org.robolectric.RuntimeEnvironment.getApplication(), + com.shilapi.xcertplay.network.TeslaHttpConfig(enabled = true)) val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() try { @@ -100,6 +102,8 @@ class HttpsSettingsUiTest { val requests = AtomicInteger() RootAccess.startupChecked = false RootAccess.authorize = { requests.incrementAndGet(); throw java.io.IOException("Permission denied") } + com.shilapi.xcertplay.web.TeslaHttpCompatibility.save(org.robolectric.RuntimeEnvironment.getApplication(), + com.shilapi.xcertplay.network.TeslaHttpConfig(enabled = true)) val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() try { @@ -118,7 +122,7 @@ class HttpsSettingsUiTest { } finally { RootAccess.authorize = original; controller.pause().stop().destroy(); WebSession.stop() } } - @Test fun rootRequestIsFirstAndMappingControlsAppearOnlyAfterAccessIsGranted() { + @Test fun modeSelectorPrecedesRootRequestAndMappingControlsRequireAccess() { val original = RootAccess.authorize var requests = 0 RootAccess.authorize = { requests++ } @@ -132,7 +136,8 @@ class HttpsSettingsUiTest { val button = rootButton(activity) val row = button.parent as ViewGroup val section = row.parent as ViewGroup - assertSame(row, section.getChildAt(0)) + assertSame(row, section.getChildAt(1)) + assertTrue(hasRow(activity, "连接方案")) button.performClick() waitForUi(activity) { RootAccess.granted } assertEquals(1, requests) diff --git a/common/src/test/java/com/shilapi/xcertplay/TeslaVpnSettingsUiTest.kt b/common/src/test/java/com/shilapi/xcertplay/TeslaVpnSettingsUiTest.kt new file mode 100644 index 0000000..5976e72 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/TeslaVpnSettingsUiTest.kt @@ -0,0 +1,96 @@ +package com.shilapi.xcertplay + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.button.MaterialButton +import com.google.android.material.materialswitch.MaterialSwitch +import com.shilapi.xcertplay.network.TeslaHttpConfig +import com.shilapi.xcertplay.network.TeslaHttpMode +import com.shilapi.xcertplay.web.RootAccess +import com.shilapi.xcertplay.web.TeslaHttpCompatibility +import com.shilapi.xcertplay.web.WebSession +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowDialog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp", shadows = [TeslaVpnSettingsUiTest.ConsentShadow::class]) +class TeslaVpnSettingsUiTest { + @Implements(VpnService::class) + class ConsentShadow : org.robolectric.shadows.ShadowService() { + companion object { + @JvmStatic @Implementation fun prepare(context: Context): Intent = Intent("wheelplay.test.VPN_CONSENT") + } + } + private fun views(view: View): List = listOf(view) + + if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } else emptyList() + private fun all(activity: DiPlayActivity) = views(activity.window.decorView) + private fun openSettings(activity: DiPlayActivity) { + all(activity).filterIsInstance().single().selectedItemId = 3 + } + private fun enable(activity: DiPlayActivity) = all(activity).filterIsInstance() + .single { it.contentDescription?.toString() == "启用免 Root Tesla 兼容" } + + @Test fun nonRootUserCanSelectVpnAndDeniedConsentKeepsItDisabled() { + val original = RootAccess.authorize + var rootRequests = 0 + RootAccess.startupChecked = false + RootAccess.authorize = { rootRequests++; throw java.io.IOException("No root") } + val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() + val activity = controller.get() + try { + openSettings(activity) + assertFalse(TeslaHttpCompatibility.config(activity).enabled) + all(activity).single { it.contentDescription?.toString()?.startsWith("连接方案,") == true }.performClick() + val dialog = ShadowDialog.getLatestDialog() as AlertDialog + dialog.listView.performItemClick(dialog.listView.getChildAt(1), 1, 1) + assertEquals(TeslaHttpMode.VPN, TeslaHttpCompatibility.config(activity).mode) + assertFalse(all(activity).filterIsInstance().any { it.text.toString() == "请求 Root 权限" }) + enable(activity).isChecked = true + val consent = shadowOf(activity).nextStartedActivity + assertEquals("wheelplay.test.VPN_CONSENT", consent.action) + assertFalse(TeslaHttpCompatibility.config(activity).enabled) + shadowOf(activity).receiveResult(consent, Activity.RESULT_CANCELED, null) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertFalse(TeslaHttpCompatibility.config(activity).enabled) + assertEquals(0, rootRequests) + } finally { + RootAccess.authorize = original + controller.pause().stop().destroy(); WebSession.stop() + } + } + + @Test fun acceptedConsentEnablesVpnAndSwitchingModesTurnsItOff() { + val context = RuntimeEnvironment.getApplication() + TeslaHttpCompatibility.save(context, TeslaHttpConfig(mode = TeslaHttpMode.VPN)) + val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() + val activity = controller.get() + try { + openSettings(activity) + enable(activity).isChecked = true + val consent = shadowOf(activity).nextStartedActivity + shadowOf(activity).receiveResult(consent, Activity.RESULT_OK, null) + shadowOf(android.os.Looper.getMainLooper()).idle() + assertTrue(TeslaHttpCompatibility.config(activity).enabled) + all(activity).single { it.contentDescription?.toString()?.startsWith("连接方案,") == true }.performClick() + val dialog = ShadowDialog.getLatestDialog() as AlertDialog + dialog.listView.performItemClick(dialog.listView.getChildAt(0), 0, 0) + assertEquals(TeslaHttpMode.ROOT, TeslaHttpCompatibility.config(activity).mode) + assertFalse(TeslaHttpCompatibility.config(activity).enabled) + } finally { controller.pause().stop().destroy(); WebSession.stop() } + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt index 4965902..24abc4f 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/LanAddressesTest.kt @@ -6,6 +6,12 @@ import com.shilapi.xcertplay.web.LanAddresses.Kind import com.shilapi.xcertplay.web.LanAddresses.Entry class LanAddressesTest { + @Test fun vpnEntryUsesTheWebPortAndDoesNotClaimRootRouting() { + val entry = LanAddresses.sharedEntry("6.6.6.6", "", 9090, com.shilapi.xcertplay.network.TeslaHttpMode.VPN) + assertEquals("http://6.6.6.6:9090", entry.url) + assertTrue(entry.description.contains("免 Root")) + assertFalse(entry.description.contains("Root 热点路由")) + } @Test fun customPortIsUsedByBothTeslaAndOtherLanEntries() { val owned = LanAddresses.sharedEntry("100.96.0.1", "car.example.com", 9090) assertEquals("http://car.example.com:9090", owned.url) diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt index 4748576..a2e3931 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpLifecycleTest.kt @@ -24,6 +24,7 @@ import org.robolectric.annotation.RealObject class TeslaHttpLifecycleTest { class RoutingVpnService : CarPlayVpnService() { fun allocateWiredTunnel() = establishTunnel("fe80::1") + fun allocateTeslaTunnel(address: String) = establishTeslaTunnel(address) } @Implements(android.net.VpnService.Builder::class) @@ -32,6 +33,7 @@ class TeslaHttpLifecycleTest { val addresses = mutableListOf>() val routes = mutableListOf>() val families = mutableSetOf() + var bypass = false val allowedApplications = mutableListOf() @Implementation fun addAddress(address: String, prefix: Int): android.net.VpnService.Builder { addresses.add(address to prefix); return builder @@ -42,6 +44,7 @@ class TeslaHttpLifecycleTest { @Implementation fun allowFamily(family: Int): android.net.VpnService.Builder { families.add(family); return builder } + @Implementation fun allowBypass(): android.net.VpnService.Builder { bypass = true; return builder } @Implementation fun addAllowedApplication(packageName: String): android.net.VpnService.Builder { allowedApplications.add(packageName); return builder } @@ -57,6 +60,29 @@ class TeslaHttpLifecycleTest { } } + @Test + @Config(shadows = [AllocatingBuilderShadow::class]) + fun teslaBuildersUseOnlyHostRoutesAndAllowBothAddressFamiliesAndBypass() { + AllocatingBuilderShadow.plans.clear() + val controller = Robolectric.buildService(RoutingVpnService::class.java).create() + try { + val service = controller.get() + for (address in listOf("6.6.6.6", "192.168.229.229")) { + service.allocateTeslaTunnel(address).close() + val plan = AllocatingBuilderShadow.plans.last() + assertEquals(listOf(address to 32), plan.addresses) + assertEquals(listOf(address to 32), plan.routes) + assertEquals(setOf(android.system.OsConstants.AF_INET, android.system.OsConstants.AF_INET6), plan.families) + assertTrue(plan.bypass) + } + } finally { + controller.destroy() + AllocatingBuilderShadow.peers.forEach { it.close() } + AllocatingBuilderShadow.peers.clear() + AllocatingBuilderShadow.plans.clear() + } + } + @Test @Config(shadows = [AllocatingBuilderShadow::class]) fun wiredVpnOnlyRoutesItsOriginalIpv6Link() { diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt index 353a5fe..8854d2a 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaHttpPreferencesTest.kt @@ -2,6 +2,7 @@ package com.shilapi.xcertplay.web import android.content.Context import com.shilapi.xcertplay.network.TeslaHttpConfig +import com.shilapi.xcertplay.network.TeslaHttpMode import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -12,6 +13,27 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [29]) class TeslaHttpPreferencesTest { + @Test fun vpnModePreservesRootSettingsAndUsesASeparateSavedAddress() { + val context = RuntimeEnvironment.getApplication() + val root = TeslaHttpConfig(true, "100.96.0.2", 81, 444) + TeslaHttpCompatibility.save(context, root) + val vpn = root.copy(mode = TeslaHttpMode.VPN, vpnAddress = "6.6.6.7") + TeslaHttpCompatibility.save(context, vpn) + assertEquals(vpn, TeslaHttpCompatibility.config(context)) + assertEquals("6.6.6.7", vpn.accessAddress) + assertEquals("100.96.0.2", vpn.copy(mode = TeslaHttpMode.ROOT).accessAddress) + assertNull(TeslaHttpCompatibility.plan(vpn, listOf(HttpDownstream("ap0", 7, "192.168.43.1/24")), 8080, 8443, true)) + } + + @Test fun invalidModeOrReservedPlaceholderFailsClosed() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("tesla_http", Context.MODE_PRIVATE) + prefs.edit().putBoolean("enabled", true).putString("mode", "UNKNOWN").apply() + assertFalse(TeslaHttpCompatibility.config(context).enabled) + prefs.edit().putString("mode", "VPN").putString("vpn_address", TeslaHttpConfig.VPN_PLACEHOLDER_ADDRESS).apply() + assertFalse(TeslaHttpCompatibility.config(context).enabled) + } + @Test fun modeIsOptInAndSavedAddressAndDomainSurviveReload() { val context = RuntimeEnvironment.getApplication() assertEquals(TeslaHttpConfig(), TeslaHttpCompatibility.config(context)) diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnBindingTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnBindingTest.kt new file mode 100644 index 0000000..f634121 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnBindingTest.kt @@ -0,0 +1,96 @@ +package com.shilapi.xcertplay.web + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.VpnService +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], shadows = [TeslaVpnBindingTest.ConsentShadow::class]) +class TeslaVpnBindingTest { + @Implements(VpnService::class) + class ConsentShadow : org.robolectric.shadows.ShadowService() { + companion object { + var granted = true + var preparations = 0 + @JvmStatic @Implementation fun prepare(context: Context): Intent? { + preparations++ + return if (granted) null else Intent("consent") + } + } + } + + @Test fun webBindingKeepsAliasAfterCarPlayDetachAndReleasesItWhenWebStops() { + ConsentShadow.granted = true + ConsentShadow.preparations = 0 + val context = RuntimeEnvironment.getApplication() + val app = shadowOf(context) + val controller = Robolectric.buildService(TeslaVpnLifecycleTest.TestService::class.java).create() + val service = controller.get() + app.setComponentNameAndServiceForBindService(ComponentName(context, com.shilapi.xcertplay.network.CarPlayVpnService::class.java), service.onBind(Intent())) + app.setBindServiceCallsOnServiceConnectedDirectly(true) + val binding = TeslaVpnBinding() + try { + binding.reconcile(context, "6.6.6.6") + assertEquals("6.6.6.6", binding.status.address) + assertEquals(1, app.boundServiceConnections.size) + service.detach() + assertEquals("6.6.6.6", service.teslaVpnStatus.address) + binding.reconcile(context, "6.6.6.6") + assertEquals(2, service.allocations.size) + service.onRevoke() + repeat(3) { binding.reconcile(context, "6.6.6.6") } + assertNull(binding.status.address) + assertTrue(binding.status.error!!.contains("撤销")) + assertEquals("Polling must not prepare the VPN again after revocation", 1, ConsentShadow.preparations) + assertEquals(2, service.allocations.size) + binding.stop() + assertNull(service.teslaVpnStatus.address) + assertEquals(1, app.unboundServiceConnections.size) + service.allocations.forEach { assertTrue(runCatching { it.second.fd }.isFailure) } + } finally { + binding.stop(); controller.destroy(); service.peers.forEach { it.close() } + } + } + + @Test fun missingConsentNeverBindsOrLaunchesAnActivityFromTheBackground() { + ConsentShadow.granted = false + ConsentShadow.preparations = 0 + val context = RuntimeEnvironment.getApplication() + val binding = TeslaVpnBinding() + repeat(3) { binding.reconcile(context, "6.6.6.6") } + assertNull(binding.status.address) + assertTrue(binding.status.error!!.contains("授权")) + assertTrue(shadowOf(context).boundServiceConnections.isEmpty()) + assertNull(shadowOf(context).nextStartedActivity) + assertEquals(1, ConsentShadow.preparations) + binding.stop() + } + + @Test fun bindingFailuresWaitForManualRetry() { + ConsentShadow.granted = true + val context = RuntimeEnvironment.getApplication() + val app = shadowOf(context) + app.setThrowInBindService(SecurityException("blocked")) + val binding = TeslaVpnBinding() + binding.reconcile(context, "6.6.6.6") + assertEquals("blocked", binding.status.error) + app.setThrowInBindService(null) + repeat(3) { binding.reconcile(context, "6.6.6.6") } + assertEquals("blocked", binding.status.error) + binding.retry() + binding.reconcile(context, "6.6.6.6") + assertNotEquals("blocked", binding.status.error) + binding.stop() + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnLifecycleTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnLifecycleTest.kt new file mode 100644 index 0000000..b612532 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/web/TeslaVpnLifecycleTest.kt @@ -0,0 +1,196 @@ +package com.shilapi.xcertplay.web + +import android.os.ParcelFileDescriptor +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.network.TeslaHttpConfig +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.android.controller.ServiceController +import org.robolectric.annotation.Config +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class TeslaVpnLifecycleTest { + class TestService : CarPlayVpnService() { + val allocations = mutableListOf>() + val duplicates = mutableListOf() + val peers = mutableListOf() + var consent = true + var addressPresent = true + var failAddress: String? = null + var failDup = false + override fun isTeslaAddressPresent(address: String) = addressPresent + private fun allocate(address: String): ParcelFileDescriptor { + if (address == failAddress) throw IOException("allocation failed") + return ParcelFileDescriptor.createPipe().let { pipe -> + peers.add(pipe[1]); allocations.add(address to pipe[0]); pipe[0] + } + } + override fun establishTeslaTunnel(address: String): ParcelFileDescriptor { + if (!consent) throw IOException("VPN 未获授权") + return allocate(address) + } + override fun establishTunnel(linkLocal: String) = allocate(linkLocal) + override fun duplicateTeslaTunnel(fd: ParcelFileDescriptor): ParcelFileDescriptor { + if (failDup) throw IOException("dup failed") + return super.duplicateTeslaTunnel(fd).also(duplicates::add) + } + fun wire(): ParcelFileDescriptor = activateWiredTunnel("fe80::1") + } + + private lateinit var controller: ServiceController + private lateinit var service: TestService + private val address = TeslaHttpConfig.DEFAULT_VPN_ADDRESS + private val placeholder = TeslaHttpConfig.VPN_PLACEHOLDER_ADDRESS + @Before fun create() { + controller = Robolectric.buildService(TestService::class.java).create() + service = controller.get() + } + @After fun destroy() { + controller.destroy() + service.allocations.map { it.second }.plus(service.duplicates).plus(service.peers).forEach { runCatching { it.close() } } + } + private fun assertClosed(fd: ParcelFileDescriptor) { + assertTrue("FD must be closed", runCatching { fd.fd }.isFailure) + } + private fun assertOpen(fd: ParcelFileDescriptor) { assertTrue(fd.fd >= 0) } + + @Test fun retainsBothAnchorReferencesAndReusesTheUnchangedConfiguration() { + assertEquals(address, service.setTeslaAddress(address).address) + assertEquals(listOf(address, placeholder), service.allocations.map { it.first }) + assertEquals(1, service.duplicates.size) + repeat(3) { service.setTeslaAddress(address); service.detach() } + assertEquals(2, service.allocations.size) + service.allocations.forEach { assertOpen(it.second) } + service.duplicates.forEach(::assertOpen) + } + + @Test fun wiredTunnelIsLastAndDetachingRestoresPlaceholderWithoutReplacingAnchor() { + service.setTeslaAddress(address) + val anchor = service.allocations[0].second + val oldPlaceholder = service.allocations[1].second + val wired = service.wire() + assertEquals(listOf(address, placeholder, "fe80::1"), service.allocations.map { it.first }) + assertClosed(oldPlaceholder) + repeat(3) { assertEquals(address, service.setTeslaAddress(address).address) } + assertEquals(3, service.allocations.size) + service.detach() + assertClosed(wired) + assertOpen(anchor) + assertOpen(service.duplicates.single()) + assertEquals(placeholder, service.allocations.last().first) + assertEquals(address, service.teslaVpnStatus.address) + } + + @Test fun enableAndAddressChangesWaitForWiredDisconnect() { + val wired = service.wire() + assertNull(service.setTeslaAddress(address).address) + assertTrue(service.teslaVpnStatus.error!!.contains("断开")) + assertEquals(listOf("fe80::1"), service.allocations.map { it.first }) + assertOpen(wired) + service.detach() + assertEquals(address, service.teslaVpnStatus.address) + val wiredAgain = service.wire() + val count = service.allocations.size + assertNull(service.setTeslaAddress("6.6.6.7").address) + assertEquals(count, service.allocations.size) + assertOpen(wiredAgain) + service.detach() + assertEquals("6.6.6.7", service.teslaVpnStatus.address) + assertEquals(listOf("6.6.6.7", placeholder), service.allocations.takeLast(2).map { it.first }) + } + + @Test fun disablingTeslaClosesAllItsFdsAndKeepsWiredCarPlayRunning() { + service.setTeslaAddress(address) + val owned = service.allocations.map { it.second } + service.duplicates + val wired = service.wire() + assertNull(service.setTeslaAddress(null).address) + owned.forEach(::assertClosed) + assertOpen(wired) + val count = service.allocations.size + service.detach() + assertEquals(count, service.allocations.size) + } + + @Test fun partialEstablishmentFailureClosesOriginalAndDuplicateAndDoesNotAutomaticallyRetry() { + service.failAddress = placeholder + assertNull(service.setTeslaAddress(address).address) + service.allocations.forEach { assertClosed(it.second) } + service.duplicates.forEach(::assertClosed) + service.failAddress = null + repeat(3) { service.setTeslaAddress(address); service.detach() } + assertEquals(1, service.allocations.size) + assertEquals(address, service.setTeslaAddress(address, retry = true).address) + } + + @Test fun duplicateFailureClosesTheOriginalAndNeverCreatesPlaceholder() { + service.failDup = true + assertNull(service.setTeslaAddress(address).address) + assertEquals(listOf(address), service.allocations.map { it.first }) + assertClosed(service.allocations.single().second) + } + + @Test fun failedWiredEstablishmentKeepsTheExistingTeslaPlaceholderAndAnchor() { + service.setTeslaAddress(address) + service.failAddress = "fe80::1" + assertTrue(runCatching { service.wire() }.exceptionOrNull() is IOException) + service.detach() + assertEquals(2, service.allocations.size) + service.allocations.forEach { assertOpen(it.second) } + assertEquals(address, service.teslaVpnStatus.address) + } + + @Test fun placeholderRestoreFailureWithdrawsEntryAndClosesAnchorReferences() { + service.setTeslaAddress(address) + service.wire() + service.failAddress = placeholder + service.detach() + assertNull(service.teslaVpnStatus.address) + assertEquals("allocation failed", service.teslaVpnStatus.error) + service.allocations.forEach { assertClosed(it.second) } + service.duplicates.forEach(::assertClosed) + } + + @Test fun addressDisappearingWithdrawsEntryWithoutDeactivatingWiredTunnel() { + service.setTeslaAddress(address) + val wired = service.wire() + service.addressPresent = false + assertNull(service.setTeslaAddress(address).address) + assertTrue(service.teslaVpnStatus.error!!.contains("系统未保留")) + assertOpen(wired) + assertClosed(service.allocations.first().second) + service.duplicates.forEach(::assertClosed) + } + + @Test fun revokeClosesAllReferencesAndNeverReestablishesUntilExplicitAuthorizedRetry() { + service.setTeslaAddress(address) + service.onRevoke() + service.allocations.forEach { assertClosed(it.second) } + service.duplicates.forEach(::assertClosed) + val count = service.allocations.size + service.setTeslaAddress(address) + service.detach() + assertEquals(count, service.allocations.size) + service.consent = false + assertNull(service.setTeslaAddress(address, retry = true).address) + assertEquals(count, service.allocations.size) + service.consent = true + assertEquals(address, service.setTeslaAddress(address, retry = true).address) + } + + @Test fun destroyClosesAllReferencesAndDoesNotRestorePlaceholder() { + service.setTeslaAddress(address) + service.wire() + val count = service.allocations.size + controller.destroy() + assertEquals(count, service.allocations.size) + service.allocations.forEach { assertClosed(it.second) } + service.duplicates.forEach(::assertClosed) + } +} diff --git a/docs/TESLA_HTTP.md b/docs/TESLA_HTTP.md index f1bed16..be6cb41 100644 --- a/docs/TESLA_HTTP.md +++ b/docs/TESLA_HTTP.md @@ -1,19 +1,54 @@ # Tesla 兼容模式(实验) -此模式通过内置的 [VPN Hotspot](https://github.com/Mygod/VPNHotspot) Root 路由适配,让车机从 Android 热点访问 WheelPlay 的本地共享 IPv4 地址。需要已 Root 的设备以及可用的 `su`、`ip`、`iptables`;无需另外安装 VPN Hotspot。系统热点的 IP 和 DHCP 网段保持不变。 +此模式主要用于帮助**屏蔽私有网段访问的车机浏览器**绕过地址限制,通过兼容访问地址连接手机上的本地服务。功能名称沿用「Tesla 兼容」,也适用于有同类限制的其他品牌车机;具体可用性仍需在目标车机上验证。 -## 使用 +提供 **免 Root VPN(实验)** 与 **Root 热点路由** 两种可选方案,都默认关闭。切换方案会关闭当前模式,需重新主动启用;各自的地址设置独立保存。系统热点的 IP 和 DHCP 网段保持不变。 + +## 免 Root VPN 使用 + +1. 先导入有效认证资源并启动 Web 服务,在 Android 上开启**系统热点**,让 Tesla 连接该热点。 +2. 在「设置 → Tesla 兼容(实验)→ 连接方案」选择「免 Root VPN(实验)」,开启模式并接受 Android VPN 授权。无需 Root 或 Shizuku,不执行 `su`、`ip`、`iptables`。后台启动和轮询不会弹授权;拒绝授权后保持关闭,撤销授权后请点击「授权或重试 VPN」。 +3. 等待「免 Root 地址已配置」,访问 `http://6.6.6.6:8080`,并完成现有浏览器配对。实际端口取自「Web 监听」,无 `80` / `443` 映射。访问 IP 可修改,不能等于兼容接口地址 `192.168.229.229`。 +4. 有线 CarPlay 已连接时启用模式、修改 IP 或需要重新创建地址,先断开 iPhone。App 会等待断开后应用,避免停用正在使用的 NCM 通道。关闭免 Root 模式只清理其 IPv4 资源,不停止有线 CarPlay。 +5. 可选 HTTPS 使用实际 HTTPS 监听端口,证书需覆盖 IP / 域名并由车机信任。IP 或域名变化会刷新默认本地证书;域名仍需自行配置 DNS。Origin 改变后需重新配对。 + +### 实现与生命周期 + +- 通过公开的 `VpnService.Builder` 创建 `6.6.6.6/32` 地址及同目标 `/32` 路由,允许 IPv4 / IPv6 与 bypass;同时持有原 FD 和 `dup()` 的副本。再创建 `192.168.229.229/32` 兼容 TUN,使第一个接口不再是活动 VPN。两者都不启动包读写线程,也不提供互联网 VPN 转发。 +- 与有线 CarPlay 使用**同一个** `CarPlayVpnService`。NCM 的 `fe80::/64` TUN 最后建立并交给现有 IPv6 桥处理;之后关闭已停用的兼容 TUN FD,保留 IPv4 地址接口的两个 FD。断开 CarPlay 后重新建立兼容 TUN,复用原地址接口。 +- Web 服务独立绑定 VPN 服务,因此无需连接 iPhone 即可保活地址;CarPlay 的解绑不会销毁仍被 Web 持有的服务。关闭模式、停止 Web 服务、销毁 VPN 服务或收到 `onRevoke()` 时关闭相关 FD。撤销授权不会自动重建;创建失败、复制 FD 失败或地址消失后撤下入口并等待手动重试。失败不阻止普通 Web 监听或有线 CarPlay 启动。 +- 本机检测会包含 DOWN 接口,但**不证明热点请求和回包可达**。长期保留旧 TUN 超出了 Android 文档所保证的切换生命周期,依赖系统保留 IPv4 地址及本地路由的实现行为;不同 ROM、热点防火墙和系统更新可能影响结果。占用同一用户的 VPN 槽位,不能承诺与第三方 VPN 共存。服务由 Web / CarPlay 管理,不支持系统 Always-on VPN。 +- `6.6.6.6` 是公网地址形式。在正常本地投递路径下,请求和回包留在热点局域网,无公网服务器;地址或路由失效时客户端可能将请求发往上游,不能据地址配置状态承诺任何情况下都留在局域网。 +- 读取 TUN 本身不会自动建立转发或改变路由。此实现不给 IPv4 TUN 配置读写桥;NCM 读写仅使用最后建立的 IPv6 TUN。 + +### 免 Root 验证范围 + +自动回归测试覆盖地址接口和兼容接口的建立顺序、两份 FD 保留、NCM 最后建立、断开后恢复、连接时延后变更、停用/停止/撤销时清理、部分创建失败回滚、手动重试、Web 独立绑定,以及设置中的授权接受/拒绝和方案切换。测试使用模拟 TUN,不验证 Android 内核的实际地址和路由保留行为。 + +仍需在 Android 手机与 Tesla 上验证: + +1. 另一台热点客户端能打开页面,完成配对并持续使用 WebSocket、JPEG、触控与音频。 +2. Tesla 可访问相同完整 URL;记录手机型号、Android / ROM 版本、Tesla 固件版本及是否被强制 HTTPS。 +3. 多次连接/断开/重连有线 CarPlay,核对页面和 NCM 均正常;连接时启用或修改 IP 不停用当前 NCM,断开后生效。 +4. 热点关闭/重开、前后台、Web 端口变更、停止服务、关闭模式、强制停止 App、拒绝/撤销 VPN 授权后,确认入口状态和 FD / 地址清理。 +5. WebRTC 单独检查 ICE / UDP 可达性;页面能打开不代表视频直通可用,失败时使用已有 JPEG 回退。 + +## Root 热点路由 + +此方案通过内置的 [VPN Hotspot](https://github.com/Mygod/VPNHotspot) Root 路由适配,让车机从 Android 热点访问 WheelPlay 的本地共享 IPv4 地址。需要已 Root 的设备以及可用的 `su`、`ip`、`iptables`;无需另外安装 VPN Hotspot。 + +## Root 热点路由使用 1. 在运行 WheelPlay 的 Android 设备上开启系统热点,让车机连接这个热点。 -2. App 启动时在后台检查一次 Root,在 Magisk 等 Root 管理器中授予 **WheelPlay** 权限。检查成功后才显示其余设置,再打开「启用 Tesla 兼容」。拒绝授权后不会自动重复请求,可在「设置 → Tesla 兼容(实验)」第一行点击「请求 Root 权限」重试。按钮可独立于热点和 Web 服务使用;此模式不申请 Android VPN 授权。 +2. 在「连接方案」选择 Root 热点路由,点击「请求 Root 权限」,在 Magisk 等 Root 管理器中授予 **WheelPlay** 权限。已启用 Root 模式时,App 启动会在后台检查一次 Root;默认关闭或免 Root 模式不会请求 Root。检查成功后才显示其余设置,再打开「启用 Tesla 兼容」。拒绝授权后不会自动重复请求,可在「设置 → Tesla 兼容(实验)」中点击「请求 Root 权限」重试。按钮可独立于热点和 Web 服务使用;此模式不申请 Android VPN 授权。 3. 默认共享 IP 是 `100.96.0.1`,可改为一般 IPv4 单播地址,包括私有或公网地址;回环、多播、链路本地及未指定地址不可用。避免与移动网络、其他 VPN 或局域网冲突。 4. 映射 HTTP 端口默认 `80`,映射 HTTPS 端口默认 `443`,有效范围为 `1–1023`,两者不能相同。它们通过 Root 映射到「Web 监听」中的实际端口(默认 `8080` / `8443`),只作用于配置的访问 IP。HTTPS 映射仅在 Web HTTPS 开启且监听成功时安装。 5. 等待「Root 热点路由已启用」,在车机浏览器输入 `http://100.96.0.1/` 并配对;HTTPS 默认入口是 `https://100.96.0.1/`,仍需车机信任证书。拒绝 Root、缺少命令、端口占用或规则安装失败时,App 不展示已就绪的共享入口。修复后点击「重新启动 Root 路由」;不会持续重复申请 Root。 6. 普通 HTTP 端口、Web 域名及可选 HTTPS 在「设置 → Web 监听」中配置,无需 Root。自备域名需配置 DNS A 记录,解析到实际访问 IP;填写域名不自动配置 DNS。映射端口不是 `80` 时使用 `http://你的域名:映射端口/`。地址、域名或端口变化后,设备记忆因 origin 改变可能需要重新配对。 -模式默认关闭,配置会保存。Root 检查结果仅在本次进程中有效,重新启动进程后自动重新检查;检查过程不阻塞普通 Web 监听。旧版本的模式与地址偏好保留;Web 域名和高位端口迁移到 Web 监听,旧低位端口迁移为映射端口,实际监听改用高位端口。Web 域名同时用于 HTTP 与 HTTPS,自定义证书需覆盖该域名。无 Root 时仍可使用普通局域网 HTTP/HTTPS 入口。 +模式默认关闭,配置会保存。Root 检查结果仅在本次进程中有效,已启用 Root 方案时重新启动进程会自动重新检查;检查过程不阻塞普通 Web 监听。旧版本的模式与地址偏好保留;Web 域名和高位端口迁移到 Web 监听,旧低位端口迁移为映射端口,实际监听改用高位端口。Web 域名同时用于 HTTP 与 HTTPS,自定义证书需覆盖该域名。无 Root 时仍可使用普通局域网 HTTP/HTTPS 入口。 -## 路由和生命周期 +## Root 路由和生命周期 - 适配 VPN Hotspot 的下游策略路由、成对规则和逆序回滚机制,源码版本及修改见 [UPSTREAM.md](UPSTREAM.md)。共享范围是 **WheelPlay 的本地 HTTP/HTTPS 服务**,不提供手机第三方 VPN 的 Internet 转发。 - 用 Root 将共享地址作为 `/32` 添加到 loopback,通过 `lookup local` 投递;按系统实际报告的热点接口安装 HTTP 和 HTTPS 端口的 INPUT/OUTPUT 放行及源地址限定的回程路由。回程使用 Android 的 `local_network`(97)路由表,不依赖 Internet 默认路由。只展示完整安装成功的入口。 @@ -24,7 +59,7 @@ - Tesla 模式与 CarPlay 会话独立;连接/断开 iPhone 不重建 HTTP 地址。有线 CarPlay 仍使用原有 `fe80::/64` IPv6 VPN,其 Android 授权和同一用户仅能有一个 VPN 的限制仍然存在。 - 复用现有 HTTP/WebSocket 配对、设备撤销、画面、触控和浏览器音频。浏览器麦克风仍要求可信 HTTPS,可继续使用 Android 麦克风。 -## 验证 +## Root 验证 自动测试覆盖配置持久化、热点广播解析、命令输入校验、Root 拒绝后不自动重复申请、热点/端口变化和停止清理。真实 shell 测试以记录命令的替身代替内核操作,验证成功步骤逆序撤销、部分安装失败回滚及 stdin 关闭清理。有线 VPN 与无线 AirPlay 的独立性、HTTP 端口绑定失败回退和 WebSocket 仍由回归测试覆盖。 diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml index aff8a0c..a658024 100644 --- a/shared/src/main/AndroidManifest.xml +++ b/shared/src/main/AndroidManifest.xml @@ -49,6 +49,8 @@ android:name="com.shilapi.xcertplay.network.CarPlayVpnService" android:permission="android.permission.BIND_VPN_SERVICE" android:exported="false"> + + diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt index ad34646..4ff2c0a 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt @@ -20,6 +20,7 @@ import java.io.IOException import java.net.Inet6Address import java.net.InetAddress import java.net.InetSocketAddress +import java.net.NetworkInterface import java.net.ServerSocket import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean @@ -60,6 +61,99 @@ open class CarPlayVpnService : VpnService() { private var bridge: Ipv6NcmBridge? = null private var tun: ParcelFileDescriptor? = null private var attachGeneration = 0 + private var teslaAddress: String? = null + private var installedTeslaAddress: String? = null + private var teslaAnchor: ParcelFileDescriptor? = null + private var teslaAnchorDuplicate: ParcelFileDescriptor? = null + private var teslaPlaceholder: ParcelFileDescriptor? = null + private var teslaFailure: String? = null + private var destroying = false + @Volatile var teslaVpnStatus = TeslaHttpStatus(mode = TeslaHttpMode.VPN) + private set + + /** No packet reader is attached to these IPv4 TUNs. Only the NCM TUN exchanges packets. */ + protected open fun establishTeslaTunnel(address: String): ParcelFileDescriptor = Builder() + .setSession("WheelPlay Tesla (experimental)") + .allowFamily(OsConstants.AF_INET) + .allowFamily(OsConstants.AF_INET6) + .allowBypass() + .addAddress(address, 32) + .addRoute(address, 32) + .establish() ?: throw IOException("Tesla VPN 未获授权,请在设置中授权后重试") + + protected open fun duplicateTeslaTunnel(fd: ParcelFileDescriptor): ParcelFileDescriptor = + fd.dup() + + protected open fun isTeslaAddressPresent(address: String): Boolean = + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { iface -> + // The retained anchor is deliberately DOWN; do not filter by isUp. + iface.inetAddresses.toList().any { it.hostAddress == address } + } + + /** Called by the independent Web owner, including while no iPhone is attached. */ + @Synchronized + fun setTeslaAddress(address: String?, retry: Boolean = false): TeslaHttpStatus { + require(address == null || TeslaHttpConfig.isHttpAddress(address) && + address != TeslaHttpConfig.VPN_PLACEHOLDER_ADDRESS) + if (address != teslaAddress || retry) teslaFailure = null + teslaAddress = address + reconcileTeslaLocked() + return teslaVpnStatus + } + + private fun reconcileTeslaLocked() { + val address = teslaAddress + if (address == null || destroying) { + closeTeslaLocked() + teslaVpnStatus = TeslaHttpStatus(mode = TeslaHttpMode.VPN) + return + } + try { + teslaFailure?.let { throw IOException(it) } + // Do not call prepare() from reconciliation: a pre-consented app can take VPN + // ownership back from another app. establish() itself fails if no longer prepared. + if (installedTeslaAddress != address) { + if (tun != null) { + teslaVpnStatus = TeslaHttpStatus(error = "请先断开有线 CarPlay,再应用免 Root 模式或新访问 IP", mode = TeslaHttpMode.VPN) + return + } + closeTeslaLocked() + teslaAnchor = establishTeslaTunnel(address) + teslaAnchorDuplicate = duplicateTeslaTunnel(teslaAnchor!!) + installedTeslaAddress = address + } + if (tun == null && teslaPlaceholder == null) { + teslaPlaceholder = establishTeslaTunnel(TeslaHttpConfig.VPN_PLACEHOLDER_ADDRESS) + } + if (!isTeslaAddressPresent(address)) throw IOException("系统未保留访问 IP;此设备可能不支持免 Root 模式,请关闭或手动重试") + teslaVpnStatus = TeslaHttpStatus(address = address, mode = TeslaHttpMode.VPN) + } catch (error: Exception) { + teslaFailure = error.message ?: "免 Root VPN 配置失败" + closeTeslaLocked() + teslaVpnStatus = TeslaHttpStatus(error = teslaFailure, mode = TeslaHttpMode.VPN) + } + } + + private fun closeTeslaLocked() { + // Close every reference: the anchor disappears only after its last FD is closed. + listOf(teslaPlaceholder, teslaAnchorDuplicate, teslaAnchor).forEach { fd -> + runCatching { fd?.close() }.onFailure { Log.w(TAG, "Tesla TUN close failed", it) } + } + teslaPlaceholder = null + teslaAnchorDuplicate = null + teslaAnchor = null + installedTeslaAddress = null + } + + /** Establish last; creating an IPv4 placeholder after this would deactivate the NCM link. */ + @Synchronized + protected fun activateWiredTunnel(linkLocal: String): ParcelFileDescriptor { + val wired = establishTunnel(linkLocal) + tun = wired + teslaPlaceholder?.close() + teslaPlaceholder = null + return wired + } /** Only the original wired CarPlay IPv6 link uses Android's VPN API. */ protected open fun establishTunnel(linkLocal: String): ParcelFileDescriptor = Builder() .allowFamily(OsConstants.AF_INET) @@ -98,8 +192,7 @@ open class CarPlayVpnService : VpnService() { } require(hostMac.size == 6) { "hostMac must be 6 bytes" } - val tunFd = establishTunnel(linkLocal) - tun = tunFd + val tunFd = activateWiredTunnel(linkLocal) val ipv6Bridge = Ipv6NcmBridge(ncm, tunFd, hostMac) { error -> onTransportError(generation, listener, error) @@ -160,15 +253,19 @@ open class CarPlayVpnService : VpnService() { @Synchronized override fun onRevoke() { + teslaFailure = "VPN 授权已撤销,请在设置中重新授权并重试" val listener = if (bridge != null) attachment?.listener else null // Wireless AirPlay does not depend on the wired VPN. if (bridge != null) releaseLocked() else closeTunnelLocked() + closeTeslaLocked() + teslaVpnStatus = TeslaHttpStatus(error = teslaFailure, mode = TeslaHttpMode.VPN) listener?.onTransportError("CarPlay VPN permission revoked") super.onRevoke() } @Synchronized override fun onDestroy() { + destroying = true releaseLocked() super.onDestroy() } @@ -288,6 +385,7 @@ open class CarPlayVpnService : VpnService() { serverSocket = null closeSessionsLocked() closeTunnelLocked() + reconcileTeslaLocked() } private fun closeTunnelLocked() { diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt index 18851e2..22cc395 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/network/TeslaHttpConfig.kt @@ -1,22 +1,34 @@ package com.shilapi.xcertplay.network -/** A root-managed local HTTP address, independent of the system hotspot's DHCP subnet. */ +enum class TeslaHttpMode(val label: String) { + ROOT("Root 热点路由"), VPN("免 Root VPN(实验)") +} + +/** Two opt-in ways to expose local HTTP, without changing the hotspot's DHCP subnet. */ data class TeslaHttpConfig( val enabled: Boolean = false, val address: String = DEFAULT_ADDRESS, val httpMappingPort: Int = 80, val httpsMappingPort: Int = 443, + val mode: TeslaHttpMode = TeslaHttpMode.ROOT, + val vpnAddress: String = DEFAULT_VPN_ADDRESS, ) { init { require(isHttpAddress(address)) { "请输入可供热点访问的 IPv4 单播地址" } require(httpMappingPort in 1..1023 && httpsMappingPort in 1..1023) { "映射端口必须为 1–1023" } require(httpMappingPort != httpsMappingPort) { "HTTP 与 HTTPS 映射端口不能相同" } + require(isHttpAddress(vpnAddress) && vpnAddress != VPN_PLACEHOLDER_ADDRESS) { + "请输入有效 IPv4 地址,且不能使用兼容接口地址 $VPN_PLACEHOLDER_ADDRESS" + } } val ipUrl get() = "http://$address:$httpMappingPort/" + val accessAddress get() = if (mode == TeslaHttpMode.VPN) vpnAddress else address companion object { const val DEFAULT_ADDRESS = "100.96.0.1" + const val DEFAULT_VPN_ADDRESS = "6.6.6.6" + const val VPN_PLACEHOLDER_ADDRESS = "192.168.229.229" // Parse literals without DNS, accepting only canonical dotted decimal notation. fun isHttpAddress(value: String): Boolean { @@ -42,6 +54,7 @@ data class TeslaHttpConfig( } } -/** Ready means scoped hotspot routing is installed; Tesla's HTTP policy still needs device testing. */ +/** Configured is not a promise of hotspot reachability or Tesla browser compatibility. */ data class TeslaHttpStatus(val address: String? = null, val error: String? = null, val downstreams: List = emptyList(), - val httpMappingPort: Int? = null, val httpsMappingPort: Int? = null) + val httpMappingPort: Int? = null, val httpsMappingPort: Int? = null, + val mode: TeslaHttpMode = TeslaHttpMode.ROOT)