diff --git a/README.md b/README.md index 77369cf..8233fa4 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ Install the app on an Android device and open the displayed address in your car' - **Background service**: Background operation and automatic connection. - **Display and audio settings**: Adjustable display size, resolution, frame rate, and audio buffering. - **CarPlay preferences**: Follow Android's day/night mode or force day/night; select Android/browser audio output and Android/browser/disabled microphone input. Preview custom 0–40% safe margins on each edge; microphone and safe-area changes reconnect the iPhone. Keep CarPlay when browsers go offline, or disconnect the iPhone after 1/5 minutes; browser reconnection cancels the timer. -- **Custom car branding**: In **Settings → OEM car name and icon**, rename CarPlay's return-to-car entry, select and crop a square image, or restore the default NIO name and icon. Changes apply on the next iPhone connection. Images are stored privately in the app, so the original file is no longer needed. -- **Location source**: **Settings → Location → Location source** defaults to the iPhone's own location, with no external location advertised or reported by WheelPlay. Select the Android device to provide its position when requested by the iPhone, after reconnecting and granting precise location permission. iOS and the navigation app determine location fusion; wireless connections on older Android versions can still require location permission. +- **Custom car branding**: In **Settings → Vehicle & location → OEM car name and icon**, rename CarPlay's return-to-car entry, select and crop a square image, or restore the default NIO name and icon. Changes apply on the next iPhone connection. Images are stored privately in the app, so the original file is no longer needed. +- **Location source**: **Settings → Vehicle & location → Location source** defaults to the iPhone's own location, with no external location advertised or reported by WheelPlay. Select the Android device to provide its position when requested by the iPhone, after reconnecting and granting precise location permission. iOS and the navigation app determine location fusion; wireless connections on older Android versions can still require location permission. - **Diagnostic export**: Save diagnostic reports locally for troubleshooting. ## Getting started @@ -33,7 +33,7 @@ Manage remembered browsers under Settings → 浏览器设备 (Browser devices) Device memory is scoped to the current browser and origin (scheme, host and port). Clearing or disabling browser storage, switching HTTP / HTTPS, or changing the address requires fresh pairing. Android persists only hashes of random device secrets; the six-digit code is never stored as a remembered credential, and browser credentials are excluded from app backups. -For 60 fps, select WebRTC video passthrough under Settings → Display & Audio and set the negotiated frame rate to 60. Apply the settings and reconnect for the changes to take effect. Actual frame rate depends on the iPhone's output, network conditions, and browser decoding capabilities; static scenes may run at a lower frame rate. WebRTC uses local-network UDP without external STUN/TURN servers. It automatically falls back to JPEG if UDP is unavailable or the source video codec is incompatible. Allowing HEVC on the iPhone enables H.265 passthrough to browsers that support HEVC over WebRTC; H.264 is recommended for older browsers. +For 60 fps, select WebRTC video passthrough under Settings → Display & streaming and set the negotiated frame rate to 60. Apply the settings and reconnect for the changes to take effect. Actual frame rate depends on the iPhone's output, network conditions, and browser decoding capabilities; static scenes may run at a lower frame rate. WebRTC uses local-network UDP without external STUN/TURN servers. It automatically falls back to JPEG if UDP is unavailable or the source video codec is incompatible. Allowing HEVC on the iPhone enables H.265 passthrough to browsers that support HEVC over WebRTC; H.264 is recommended for older browsers. The WebRTC interruption tolerance setting offers 4, 10 (default), or 30 seconds and applies on the next browser connection. Recoverable interruptions keep the last video frame visible while waiting for recovery; persistent problems fall back to JPEG after the grace period. Unsupported codecs and terminal connection failures still fall back immediately. This does not enlarge the video buffer or change control and audio connection rules. Refresh the webpage after updating the App. See the [stability and troubleshooting notes](docs/WEBRTC_RECOVERY.md) (Chinese). @@ -41,7 +41,7 @@ If the video element renders incorrectly in your car's browser, enable “Use Ca Canvas mode requires testing on car browsers such as NIO's. Changing the rendering path may help if an overlay covers only the native video element; it cannot guarantee success if the system stops decoding or overlays the entire page. Test while the vehicle is parked. Drivers should use only essential navigation interfaces while driving. -Select Audio output and Microphone source under Settings → Display & Audio. Output changes apply immediately; microphone source changes reconnect the iPhone. Disabling microphone input stops capture/uplink and removes input capability advertisements. Current routes are shown; unavailable browsers fall back to Android, while disabled microphone input blocks all fallback capture. Browser microphone input requires a trusted local HTTPS certificate, pairing through HTTPS, and granting microphone permission. See the [browser audio guide](docs/BROWSER_AUDIO.md) (Chinese). If trusted HTTPS or AudioWorklet is unavailable, use the Android microphone. +Select Audio output and Microphone source under Settings → Audio & microphone. Output changes apply immediately; microphone source changes reconnect the iPhone. Disabling microphone input stops capture/uplink and removes input capability advertisements. Current routes are shown; unavailable browsers fall back to Android, while disabled microphone input blocks all fallback capture. Browser microphone input requires a trusted local HTTPS certificate, pairing through HTTPS, and granting microphone permission. See the [browser audio guide](docs/BROWSER_AUDIO.md) (Chinese). If trusted HTTPS or AudioWorklet is unavailable, use the Android microphone. ## Requirements and limitations @@ -52,7 +52,7 @@ Tesla compatibility helps car browsers that block private IP ranges reach the ph Both methods clean up when disabled or when the Web service stops. Ordinary LAN Web listeners remain independent; optional HTTPS still requires a trusted certificate. See the [Tesla compatibility guide](docs/TESLA_HTTP.md) (Chinese). -Settings → Web listeners (Web 监听) contains the HTTP port (default `8080`), a self-managed Web hostname shared by HTTP and HTTPS, and an optional HTTPS switch. No Root access is required; listener ports range from `1024–65535`. Enabling HTTPS reveals its port (default `8443`) and inline certificate types: local default, PEM, and PKCS#12. Android's file picker imports PEM chains plus private keys (including encrypted keys) or PKCS#12 (`.p12` / `.pfx`) with passwords and key-alias selection. Identities are validated and saved in private non-backup storage; failed imports retain the current certificate, and occupied ports retain the current listener. Disabling HTTPS stops TLS while retaining its settings and certificate. See [Web listener and certificate setup](docs/BROWSER_AUDIO.md). +Settings → Web service (Web 服务) contains the HTTP port (default `8080`), a self-managed Web hostname shared by HTTP and HTTPS, and an optional HTTPS switch. No Root access is required; listener ports range from `1024–65535`. Enabling HTTPS reveals its port (default `8443`) and inline certificate types: local default, PEM, and PKCS#12. Android's file picker imports PEM chains plus private keys (including encrypted keys) or PKCS#12 (`.p12` / `.pfx`) with passwords and key-alias selection. Identities are validated and saved in private non-backup storage; failed imports retain the current certificate, and occupied ports retain the current listener. Disabling HTTPS stops TLS while retaining its settings and certificate. See [Web listener and certificate setup](docs/BROWSER_AUDIO.md). - The server device requires Android 9 or later. The car's browser must support modern web features and touch interaction. - The server device and car must be able to reach each other over the local network. Wireless connection compatibility depends on the devices' networking capabilities. @@ -63,6 +63,8 @@ Settings → Web listeners (Web 监听) contains the HTTP port (default `8080`), ## Build +Android settings use Jetpack Compose. Category screens and shared controls live in `common/src/main/java/com/shilapi/xcertplay/settings/`; `SettingsViewModel` owns preferences and saved navigation state, while `DiPlayActivity` coordinates platform permissions, file pickers, and service changes. `SettingsScreen.kt` includes phone and large-text previews. Settings interaction tests use Compose semantics with Robolectric. + Use Android NDK 28.2.13676358 and CMake 3.22.1. The first build fetches pinned versions of libdatachannel and Mbed TLS from GitHub; subsequent builds reuse the local CMake cache. Run: ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index c7dc861..a2dd74b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,10 +17,12 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone - **后台服务**:支持后台运行及自动连接。 - **显示与音频设置**:可调整画面尺寸、分辨率、帧率和音频缓冲。 - **CarPlay 使用偏好**:可选择跟随 Android/始终日间/始终夜间、Android/浏览器音频输出,以及 Android/浏览器/禁用麦克风。自定义四边 0–40% 安全区域,并预览布局;修改麦克风或安全区域会重新连接。浏览器离线策略可选保持连接或等待 1/5 分钟后断开 iPhone,重连浏览器会取消计时。 -- **自定义车厂入口**:在「设置 → OEM 车厂名称与图标」修改 CarPlay「返回车机」入口的名称,选择图片并裁剪为正方形,或恢复默认 NIO 名称与图标;下次连接 iPhone 生效。图片保存在应用私有目录,无需保留原文件。 -- **定位来源**:「设置 → 定位 → 定位来源」默认选择 iPhone 自身定位,此时 WheelPlay 不声明或上报外部定位。可选择 Android 设备定位,在下次连接并取得精确定位权限后,按 iPhone 请求提供设备位置。具体定位融合由 iOS 和导航 App 决定;旧版 Android 的无线连接仍可能需要定位权限。 +- **自定义车厂入口**:在「设置 → 车厂与定位 → OEM 车厂名称与图标」修改 CarPlay「返回车机」入口的名称,选择图片并裁剪为正方形,或恢复默认 NIO 名称与图标;下次连接 iPhone 生效。图片保存在应用私有目录,无需保留原文件。 +- **定位来源**:「设置 → 车厂与定位 → 定位来源」默认选择 iPhone 自身定位,此时 WheelPlay 不声明或上报外部定位。可选择 Android 设备定位,在下次连接并取得精确定位权限后,按 iPhone 请求提供设备位置。具体定位融合由 iOS 和导航 App 决定;旧版 Android 的无线连接仍可能需要定位权限。 - **诊断导出**:支持将诊断报告保存到本地,便于排查问题。 +设置首页提供九个分类入口:iPhone 连接、画面与串流、音频与麦克风、车厂与定位、浏览器设备、Web 服务、Tesla 兼容、CarPlay 认证资源、帮助与关于。点击进入二级页,顶部返回按钮或系统返回键回到分类首页;切换底部标签和旋转屏幕后保留当前分类及滚动位置。 + ## 使用方式 1. 在 Android 服务设备上安装并启动 WheelPlay。 @@ -33,7 +35,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone 设备记忆保存于当前浏览器及访问地址(协议、主机、端口)的本地存储;清理浏览器数据、禁用存储、切换 HTTP / HTTPS 或访问地址后需要重新配对。Android 仅持久保存随机设备密钥的哈希,凭据不包含六位配对码,也不进入应用备份。 -追求 60 fps 时,在「设置 → 画面与音频」选择 WebRTC 视频直通,并将协商帧率设为 60;应用设置并重新连接后生效。实际帧率取决于 iPhone 输出、网络和浏览器解码能力,静态画面可能降低帧率。WebRTC 使用局域网 UDP,不依赖外部 STUN/TURN;UDP 不通或源视频编码不兼容时自动回退 JPEG。允许 iPhone 使用 HEVC 后,可向支持 HEVC / WebRTC 的浏览器直通 H.265;较旧浏览器建议保持 H.264。 +追求 60 fps 时,在「设置 → 画面与串流」选择 WebRTC 视频直通,并将协商帧率设为 60;应用设置并重新连接后生效。实际帧率取决于 iPhone 输出、网络和浏览器解码能力,静态画面可能降低帧率。WebRTC 使用局域网 UDP,不依赖外部 STUN/TURN;UDP 不通或源视频编码不兼容时自动回退 JPEG。允许 iPhone 使用 HEVC 后,可向支持 HEVC / WebRTC 的浏览器直通 H.265;较旧浏览器建议保持 H.264。 「WebRTC 卡顿容忍时间」可选 4 秒、10 秒(默认)或 30 秒,下次浏览器连接生效。短暂断流时保留最后一帧并等待恢复,持续异常超过阈值才回退 JPEG;不支持的编码或彻底失败的连接仍立即回退。该设置不会扩大视频缓冲,控制与音频保持各自的连接规则。更新 App 后请刷新网页。详见 [稳定性分析与排查](docs/WEBRTC_RECOVERY.md)。 @@ -41,7 +43,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素,改变显示路径可能有效;若系统停止解码或在整个页面上叠加遮罩,则无法保证有效。请在车辆停稳后验证,驾驶员行驶中只应使用必要的导航界面。 -在 App「设置 → 画面与音频」选择「音频输出」和「麦克风来源」。音频输出即时切换;麦克风来源修改后重新连接,禁用时停止采集与上传并取消输入能力声明。页面显示当前路由,浏览器未就绪时回退 Android;禁用麦克风时始终禁止回退采集。浏览器麦克风需要先配置该设备的本地 HTTPS 证书,再从 HTTPS 入口重新配对并授权;步骤见 [浏览器音频说明](docs/BROWSER_AUDIO.md)。若车机浏览器不支持可信 HTTPS 或 AudioWorklet,继续使用 Android 麦克风。 +在 App「设置 → 音频与麦克风」选择「音频输出」和「麦克风来源」。音频输出即时切换;麦克风来源修改后重新连接,禁用时停止采集与上传并取消输入能力声明。页面显示当前路由,浏览器未就绪时回退 Android;禁用麦克风时始终禁止回退采集。浏览器麦克风需要先配置该设备的本地 HTTPS 证书,再从 HTTPS 入口重新配对并授权;步骤见 [浏览器音频说明](docs/BROWSER_AUDIO.md)。若车机浏览器不支持可信 HTTPS 或 AudioWorklet,继续使用 Android 麦克风。 ## 使用要求与限制 @@ -54,7 +56,7 @@ Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素 ## Web 监听配置 -「设置 → Web 监听」集中配置 HTTP 端口(默认 `8080`)、HTTP / HTTPS 共用的自备 Web 域名和可选 HTTPS,无需 Root。实际监听端口范围为 `1024–65535`;打开 HTTPS 后显示 Web HTTPS 端口(默认 `8443`)及页面内的证书类型选择器:默认本地证书、PEM、PKCS#12。系统文件选择器支持 PEM 证书链+私钥(含加密私钥),或 PKCS#12(`.p12` / `.pfx`,可选择私钥条目)。导入前验证证书与私钥,保存到 App 私有且不备份的存储;失败保留当前证书,端口绑定失败保留原监听器。关闭 HTTPS 仅停止 TLS 监听,保留配置与证书。详见 [Web 监听与证书说明](docs/BROWSER_AUDIO.md)。 +「设置 → Web 服务」集中配置 HTTP 端口(默认 `8080`)、HTTP / HTTPS 共用的自备 Web 域名和可选 HTTPS,无需 Root。实际监听端口范围为 `1024–65535`;打开 HTTPS 后显示 Web HTTPS 端口(默认 `8443`)及页面内的证书类型选择器:默认本地证书、PEM、PKCS#12。系统文件选择器支持 PEM 证书链+私钥(含加密私钥),或 PKCS#12(`.p12` / `.pfx`,可选择私钥条目)。导入前验证证书与私钥,保存到 App 私有且不备份的存储;失败保留当前证书,端口绑定失败保留原监听器。关闭 HTTPS 仅停止 TLS 监听,保留配置与证书。详见 [Web 监听与证书说明](docs/BROWSER_AUDIO.md)。 ## Tesla 兼容模式(实验) @@ -69,6 +71,8 @@ Canvas 模式需要蔚来等车机实测:若遮罩只覆盖原生视频元素 ## 构建 +Android 设置界面使用 Jetpack Compose。分类页面与共用控件位于 `common/src/main/java/com/shilapi/xcertplay/settings/`;`SettingsViewModel` 管理偏好与可恢复的导航状态,`DiPlayActivity` 协调系统权限、文件选择和服务操作。`SettingsScreen.kt` 提供手机及大字体预览,设置交互测试通过 Robolectric 和 Compose 语义节点执行。 + 使用 Android NDK 28.2.13676358 和 CMake 3.22.1。首次构建会从 GitHub 获取固定版本的 libdatachannel 及 Mbed TLS;后续复用本地 CMake 缓存。运行 `./gradlew :mobile:assembleDebug`。 源码构建默认不包含 CarPlay 认证资源。未配置有效资源时不会启动 Web 服务或 CarPlay;导入校验通过后自动启动 Web 服务。包内自带有效资源的安装包可直接使用,仍允许用户导入替换,且用户资源优先。在 **设置 → CarPlay 认证资源** 中手动选择 `identity.pk8` 和 `certificate.p7b`,或通过下载入口获取 DiPlay 官网 APK,再选择“从 DiPlay APK 导入”;也可在文件管理器中将该 APK 分享给 WheelPlay,无需安装 DiPlay。导入在本机完成,校验私钥与证书匹配后才替换原有资源;资源保存在应用私有目录,不上传、不参与备份。详见 [认证资源导入说明](docs/AUTHENTICATION_IMPORT.md)。 diff --git a/common/build.gradle.kts b/common/build.gradle.kts index e449789..7aa2dec 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -49,7 +49,12 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1") testImplementation(libs.junit) + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + debugImplementation(libs.androidx.compose.ui.tooling) testImplementation("org.robolectric:robolectric:4.17") } diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 868c160..1383fd4 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -4,15 +4,11 @@ package com.shilapi.xcertplay import android.Manifest import com.google.android.material.dialog.MaterialAlertDialogBuilder -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.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 @@ -21,30 +17,22 @@ import io.github.g00fy2.quickie.ScanQRCode import android.bluetooth.BluetoothManager import android.content.Intent import android.content.pm.PackageManager -import android.content.res.ColorStateList import android.content.res.Configuration -import android.graphics.Color -import android.graphics.BitmapFactory -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.Settings -import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.compose.ui.platform.ComposeView +import com.shilapi.xcertplay.settings.* import androidx.lifecycle.ViewModelProvider import androidx.appcompat.app.AppCompatActivity import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.widget.doAfterTextChanged import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.orchestration.WirelessHotspotMode import java.io.File @@ -61,11 +49,12 @@ class DiPlayActivity : AppCompatActivity() { private val ui by lazy { ServerUi(this) } private val handler = Handler(Looper.getMainLooper()) private var page = "service" + private val settingsModel by lazy { ViewModelProvider(this)[SettingsViewModel::class.java] } + private var settingsView: ComposeView? = null + private val settingsDialogs = SettingsDialogHost() private lateinit var shell: ServerUi.Shell private var webAddress: TextView? = null private var webInterface: TextView? = null - 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 -> @@ -130,14 +119,11 @@ class DiPlayActivity : AppCompatActivity() { private var initialLaunch = true private var notificationTransport = true private var exportInProgress = false - private var exportButton: Button? = null - private var audioOutputStatus: TextView? = null - private var microphoneSourceStatus: TextView? = null private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { connect(notificationTransport) } private val tick = object : Runnable { - override fun run() { refreshStatus(); refreshService(); refreshRootSettings(); refreshAudioRoutes(); handler.postDelayed(this, 1000) } + override fun run() { refreshStatus(); refreshService(); refreshSettingsRuntime(); handler.postDelayed(this, 1000) } } private val bluetoothPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) choosePhone() else permissionHelp("附近设备", "请允许访问附近设备,以连接已配对的 iPhone。") @@ -227,6 +213,9 @@ class DiPlayActivity : AppCompatActivity() { awaitingTeslaVpnConsent = savedInstanceState?.getBoolean("awaitingTeslaVpnConsent") ?: false page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page") ?: if (setupError == null) "service" else "settings" + if (savedInstanceState == null && page == "settings" && setupError != null) { + settingsModel.open(SettingsCategory.AUTHENTICATION) + } createShell() startWebServiceIfReady() handleWirelessRecovery() @@ -241,7 +230,11 @@ class DiPlayActivity : AppCompatActivity() { if (savedInstanceState == null) handleAuthenticationIntent(intent) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (page != "service") navigate("service") else moveTaskToBack(true) + when { + page == "settings" && settingsModel.state.value.category != null -> settingsModel.open(null) + page != "service" -> navigate("service") + else -> moveTaskToBack(true) + } } }) } @@ -256,7 +249,11 @@ class DiPlayActivity : AppCompatActivity() { outState.putBoolean("awaitingTeslaVpnConsent", awaitingTeslaVpnConsent) super.onSaveInstanceState(outState) } - override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); createShell() } + override fun onConfigurationChanged(newConfig: Configuration) { + settingsView?.disposeComposition() + super.onConfigurationChanged(newConfig) + createShell() + } override fun onResume() { super.onResume(); refreshAuthentication(); render(); ServerWindow.showSystemBars(window); handler.removeCallbacks(tick); handler.post(tick) WebSession.setPhoneConnectHandler { @@ -279,7 +276,7 @@ class DiPlayActivity : AppCompatActivity() { } } override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } - override fun onDestroy() { pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } + override fun onDestroy() { settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } private fun normalizedPage(value: String) = when (value) { "home", "wireless-recovery" -> "phone" @@ -290,6 +287,9 @@ class DiPlayActivity : AppCompatActivity() { private fun navigate(destination: String) { val target = normalizedPage(destination) + if (target == "settings") { + settingsModel.open(if (setupError != null) SettingsCategory.AUTHENTICATION else null) + } shell.select(target) page = target if (destination == "wireless-recovery") confirmWirelessReset() @@ -301,7 +301,11 @@ class DiPlayActivity : AppCompatActivity() { page = initial shell.put("service", ui.content(::service)) refreshPhone() - shell.put("settings", ui.content(::settings)) + settingsView = ComposeView(this).apply { + id = R.id.settings_compose + setContent { SettingsRoute(settingsModel, settingsActions, settingsDialogs) { shell.setPageScroll("settings", it) } } + }.also { shell.put("settings", it) } + refreshSettings() setContentView(shell.root) refreshService() } @@ -314,9 +318,102 @@ class DiPlayActivity : AppCompatActivity() { private fun render() { refreshPhone() - exportButton = null - teslaStatus = null - shell.put("settings", ui.content(::settings)) + refreshSettings() + } + + private fun refreshSettings() { + settingsModel.reload() + refreshSettingsRuntime() + } + + private fun refreshSettingsRuntime() { + val session = CarPlayBackgroundSession.snapshot() + val audioStatus = when { + session == null -> "当前:未连接 iPhone" + AirPlayPersistence.loadBrowserAudioPlayback(this) && WebSession.audio.playbackReady && session.sink.isRemoteMediaAvailable() -> "当前输出:车机浏览器" + AirPlayPersistence.loadBrowserAudioPlayback(this) -> "当前输出:Android(浏览器尚未就绪或已离线)" + else -> "当前输出:Android" + } + val source = AirPlayPersistence.loadMicrophoneSource(this) + val microphoneStatus = when { + source == CarPlayMicrophoneSource.DISABLED -> "当前:麦克风已禁用" + session == null -> "当前:未连接 iPhone" + source == CarPlayMicrophoneSource.BROWSER && WebSession.audio.microphoneReady -> "输入来源:浏览器(按需采集)" + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED -> "Android 麦克风未授权;浏览器未就绪时无法输入语音" + source == CarPlayMicrophoneSource.BROWSER -> "输入来源:Android(浏览器麦克风尚未就绪)" + else -> "输入来源:Android(按需采集)" + } + val compatibility = TeslaHttpCompatibility.status + val teslaConfig = TeslaHttpCompatibility.config(this) + val teslaMessage = if (!teslaConfig.enabled) "已关闭" + else if (!WebSession.running && setupError != null) "导入认证资源后启动" + else compatibility.error ?: compatibility.address?.let { + if (compatibility.mode == TeslaHttpMode.VPN) "免 Root 地址已配置:$it;热点与车机访问需实测" + else "Root 热点路由已启用:$it(${compatibility.downstreams.joinToString()});请用车机测试访问" + } ?: "正在准备${teslaConfig.mode.label}…" + settingsModel.updateRuntime(SettingsRuntime( + authenticationError = setupError, importing = DiPlayBootstrap.importing, webBusy = webBusy, + awaitingVpnConsent = awaitingTeslaVpnConsent, exporting = exportInProgress, + rootGranted = RootAccess.granted, rootChecking = RootAccess.checking, rootStatus = RootAccess.status, + teslaStatus = teslaMessage, audioStatus = audioStatus, microphoneStatus = microphoneStatus, + version = version(), fingerprint = WebSession.tls?.fingerprint + )) + } + + private val settingsActions by lazy { + SettingsActions( + choice = { setting, index -> changeSettings { applySettingsEffect(settingsModel.choose(setting, index)) }?.let(::toast) }, + toggle = { setting, checked -> changeSettings { applySettingsEffect(settingsModel.toggle(setting, checked)) }?.let(::toast) }, + text = { setting, value -> changeSettings { applySettingsEffect(settingsModel.edit(setting, value)) } }, + credentials = { ssid, password -> changeSettings { settingsModel.saveCredentials(ssid, password) } }, + margins = { margins -> changeSettings { applySettingsEffect(settingsModel.saveMargins(margins)) }?.let(::toast) }, + resetBranding = { includeName -> changeSettings { settingsModel.resetBranding(includeName) }?.let(::toast) }, + command = { command -> changeSettings { settingsCommand(command) }?.let(::toast) }, + renameBrowser = { id, name -> changeSettings { settingsModel.renameBrowser(id, name) }?.let(::toast) }, + removeBrowser = { id -> changeSettings { settingsModel.removeBrowser(id) }?.let(::toast) } + ) + } + + private fun changeSettings(action: () -> Unit): String? = runCatching { + action(); refreshSettings(); refreshService() + }.exceptionOrNull()?.let { it.message ?: "设置保存失败" } + + private fun applySettingsEffect(effect: SettingsEffect) { + when (effect) { + SettingsEffect.None -> Unit + is SettingsEffect.Reconnect -> if (CarPlayBackgroundSession.hasSession()) { + val wireless = effect.wireless ?: AirPlayPersistence.loadWirelessEnabled(this) + if (effect.stopBeforeConnect) { + // Release the old backend before checking the new hotspot: its P2P group + // can prevent the user from enabling the system hotspot. + CarPlayBackgroundSession.stop { runOnUiThread { connect(wireless) } } + } else connect(wireless) + } + is SettingsEffect.WebUpdate -> configureWeb(effect.message, effect.operation) + is SettingsEffect.TeslaEnabled -> setTeslaHttpEnabled(effect.enabled) + } + } + + private fun settingsCommand(command: SettingsCommand) { + when (command) { + SettingsCommand.SELECT_PHONE -> choosePhone() + SettingsCommand.HOTSPOT_SETTINGS -> openCarWifiSettings() + SettingsCommand.REQUEST_ROOT -> configureWeb("Root 已授权") { RootAccess.request().also { TeslaHttpCompatibility.retry() } } + SettingsCommand.RETRY_ROOT -> TeslaHttpCompatibility.retry() + SettingsCommand.ENABLE_VPN -> setTeslaHttpEnabled(true) + SettingsCommand.SELECT_ICON -> oemImagePicker.launch("image/*") + SettingsCommand.IMPORT_APK -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } + SettingsCommand.IMPORT_FILES -> if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } + SettingsCommand.SELECT_PEM -> pemCertificatePicker.launch(arrayOf("*/*")) + SettingsCommand.SELECT_PKCS12 -> pkcs12Picker.launch(arrayOf("*/*")) + SettingsCommand.APP_PERMISSIONS -> openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + SettingsCommand.BLUETOOTH_SETTINGS -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + SettingsCommand.WIRELESS_HELP -> wirelessHelp() + SettingsCommand.EXPORT_REPORT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() else chooseReportDestination() + SettingsCommand.DIPLAY -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) + SettingsCommand.SOURCE -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/fython/wheelplay"))) + SettingsCommand.DOWNLOAD -> openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) + } } private fun service(content: LinearLayout) { @@ -382,14 +479,6 @@ class DiPlayActivity : AppCompatActivity() { ?: "未找到局域网地址,请连接 Wi-Fi" else "正在启动服务…" 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 - val teslaConfig = TeslaHttpCompatibility.config(this) - teslaStatus?.text = if (!teslaConfig.enabled) "已关闭" - else if (!WebSession.running && setupError != null) "导入认证资源后启动" - 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 @@ -459,438 +548,6 @@ class DiPlayActivity : AppCompatActivity() { setupError?.let { content.addView(label(it, 16, WARNING)) } } - private fun settings(content: LinearLayout) { - content.addView(label("设置", 30, TEXT, true)) - content.addView(label("昼夜模式、音频输出和浏览器离线策略立即生效。修改画面、麦克风来源或串流参数会重新连接;其他设置在下次连接生效。", 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) - ui.card(content, "本项目基于 DiPlay 修改", R.drawable.ic_server_launch) { body -> - body.addView(label("如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。", 15, MUTED)) - body.contentDescription = "本项目基于 DiPlay 修改。点击打开 DiPlay GitHub 项目" - body.setOnClickListener { - runCatching { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/shihabal3amri/DiPlay"))) - }.onFailure { toast("未找到可打开网页的浏览器") } - } - } - section(content, "CarPlay 认证资源") { card -> - card.addView(label(if (DiPlayBootstrap.importing) "正在本机导入…" else if (setupError == null) - "资源已就绪 · 私钥与证书校验通过" else "尚未配置有效资源", 16, if (setupError == null) TEXT else WARNING)) - card.addView(label("开源安装包不包含认证资源。可选择现有的两份文件,或下载 DiPlay 官网 APK 后在此导入,无需安装 DiPlay。", 14, MUTED)) - card.addView(button("从 DiPlay APK 导入", false) { - if (canImportAuthentication()) launchAuthenticationPicker { selectAuthenticationApk.launch(arrayOf("*/*")) } - }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) - card.addView(button("手动选择认证资源", false) { - if (canImportAuthentication()) MaterialAlertDialogBuilder(this) - .setTitle("选择两份匹配的资源") - .setMessage("先选择 identity.pk8(PKCS#8 私钥),再选择 certificate.p7b(P-256 证书)。两份文件必须来自同一套资源;取消或校验失败不会替换原有资源。") - .setNegativeButton("取消", null) - .setPositiveButton("选择私钥") { _, _ -> launchAuthenticationPicker { selectAuthenticationKey.launch(arrayOf("*/*")) } }.show() - }.apply { isEnabled = !DiPlayBootstrap.importing }, ui.secondaryButtonLayout(12)) - card.addView(ui.preference("DiPlay 官网下载", "下载 APK 后返回导入,或分享给 WheelPlay") { - openSystem(Intent(Intent.ACTION_VIEW, Uri.parse("https://shihabal3amri.github.io/DiPlay/"))) - }) - card.addView(label("仅提取认证资源并保存在此设备的应用私有目录,不上传。导入结束后删除临时 APK;卸载 WheelPlay 将删除已导入资源。", 14, MUTED)) - } - section(content, "自动连接") { card -> - toggle(card, "打开应用时自动连接", "使用上次的连接方式和已选择的 iPhone。", DiPlayPreferences.autoConnect(this)) { DiPlayPreferences.saveAutoConnect(this, it) } - toggle(card, "设备启动后打开", "需要系统允许应用自启动。", AirPlayPersistence.loadAutoStartOnBoot(this)) { AirPlayPersistence.saveAutoStartOnBoot(this, it) } - card.addView(ui.preference("iPhone", selectedPhoneName()) { choosePhone() }, matchButton()) - } - section(content, "无线连接") { card -> wirelessLinkControls(card) } - section(content, "OEM 车厂名称与图标") { card -> oemControls(card) } - section(content, "定位") { card -> - locationSourceControl(card) - card.addView(label("默认由 iPhone 和导航 App 定位。选择 Android 后,在 iPhone 请求时提供这台设备的定位数据,需要 Android 精确定位权限。下次连接 iPhone 生效。", 14, MUTED)) - } - section(content, "浏览器设备") { card -> - card.addView(label("配对后的浏览器会被记住,下次打开时自动恢复配对;选择启动选项后,点击网页上的「启动显示」。", 16, MUTED)) - card.addView(ui.preference("连接过的设备", "查看、重命名或移除浏览器") { showBrowserDevices() }) - val policies = BrowserOfflinePolicy.entries - experienceChoice(card, "浏览器离线后的处理", policies.map { it.label }, - policies.indexOf(AirPlayPersistence.loadBrowserOfflinePolicy(this))) { - WebSession.setBrowserOfflinePolicy(this, policies[it]) - } - card.addView(label("计时从 CarPlay 会话启动且无浏览器连接时开始;浏览器重连会取消计时。超时只断开 iPhone,Web 服务保持运行。", 14, MUTED)) - } - 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 1024..65535) toast("HTTP 监听端口必须为 1024–65535") - else configureWeb("HTTP 端口已保存,浏览器请使用新地址重新连接") { WebSession.setHttpPort(this, port) } - } - }.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)) } - } - }.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 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) { - toggle(card, "启用 Tesla 兼容", "将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。", config.enabled, enabled = !webBusy) { - setTeslaHttpEnabled(it) - } - teslaStatus = label("", 14, MUTED) - card.addView(teslaStatus) - card.addView(ui.preference("映射 HTTP 端口", "${config.httpMappingPort} → ${WebListenSettings.httpPort(this)}") { - textInput("映射 HTTP 端口(1–1023)", config.httpMappingPort.toString(), false) { value -> - saveTeslaMapping(value, https = false) - } - }.apply { isEnabled = !webBusy }) - card.addView(ui.preference("映射 HTTPS 端口", "${config.httpsMappingPort} → " + - if (WebListenSettings.httpsEnabled(this)) WebListenSettings.httpsPort(this).toString() else "HTTPS 已关闭") { - textInput("映射 HTTPS 端口(1–1023)", config.httpsMappingPort.toString(), false) { value -> - saveTeslaMapping(value, https = true) - } - }.apply { isEnabled = !webBusy }) - card.addView(ui.preference("访问 IP 地址", config.address) { - textInput("访问 IP 地址", config.address, false) { value -> - runCatching { TeslaHttpCompatibility.save(this, TeslaHttpCompatibility.config(this).copy(address = value)) } - .onSuccess { render(); refreshService() }.onFailure { toast(it.message ?: "访问 IP 无效") } - } - }.apply { isEnabled = !webBusy }) - card.addView(label("映射只作用于此访问 IP,不改变 Web 监听端口。HTTPS 映射仅在 HTTPS 开启时生效;请开启系统热点,自定义证书需覆盖访问 IP 或域名。", 14, MUTED)) - card.addView(button("重新启动 Root 路由", false) { TeslaHttpCompatibility.retry() }, ui.secondaryButtonLayout(12)) - card.addView(button("打开热点设置", false) { openCarWifiSettings() }, ui.secondaryButtonLayout(12)) - } - } - section(content, "画面与音频") { card -> - val appearances = CarPlayAppearance.entries - experienceChoice(card, "CarPlay 昼夜模式", appearances.map { it.label }, - appearances.indexOf(AirPlayPersistence.loadAppearance(this))) { - WebSession.setAppearance(this, appearances[it]) - } - card.addView(label("即时发送给 iPhone;跟随 Android 时使用此服务设备的系统昼夜模式。", 14, MUTED)) - carPlaySizeControl(card) - safeAreaControl(card) - toggle(card, "自适应浏览器尺寸", "按车机浏览器的实际画面比例协商 CarPlay 分辨率;浏览器尺寸变化时会重新连接。", AirPlayPersistence.loadAdaptiveBrowserSize(this)) { - AirPlayPersistence.saveAdaptiveBrowserSize(this, it) - WebSession.setAdaptiveBrowserSize(it) - } - choice(card, "分辨率", listOf("原始分辨率", "80% · 降低负载", "60% · 最低负载"), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } - val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets - choice(card, "音频缓冲", listOf("300 ms · 默认", "500 ms", "1000 ms · 更稳定"), - bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) { - WebSession.setMediaBufferMillis(this, bufferPresets[it]) - } - val outputs = CarPlayAudioOutput.entries - experienceChoice(card, "音频输出", outputs.map { it.label }, if (AirPlayPersistence.loadBrowserAudioPlayback(this)) 1 else 0) { - WebSession.setBrowserAudioPlayback(this, outputs[it] == CarPlayAudioOutput.BROWSER) - refreshAudioRoutes() - } - audioOutputStatus = label("", 14, MUTED).also(card::addView) - card.addView(label("即时切换。浏览器未就绪或离线时回退 Android;浏览器可能需要点击页面才能播放声音。", 14, MUTED)) - val microphones = CarPlayMicrophoneSource.entries - experienceChoice(card, "麦克风来源", microphones.map { it.label }, - microphones.indexOf(AirPlayPersistence.loadMicrophoneSource(this)), reconnect = true) { - WebSession.setMicrophoneSource(this, microphones[it]) - refreshAudioRoutes() - } - microphoneSourceStatus = label("", 14, MUTED).also(card::addView) - card.addView(label("修改后重新连接。浏览器麦克风需要可信 HTTPS 和授权,未就绪时回退 Android;禁用会停止上传并取消麦克风能力声明。", 14, MUTED)) - refreshAudioRoutes() - choice(card, "协商帧率", listOf("30 fps · 较低负载", "60 fps · 更流畅"), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } - val technologies = com.shilapi.xcertplay.web.StreamTechnology.entries - choice(card, "串流技术", technologies.map { it.label }, - technologies.indexOf(AirPlayPersistence.loadStreamTechnology(this))) { - AirPlayPersistence.saveStreamTechnology(this, technologies[it]) - } - card.addView(label("WebRTC 支持 H.264 / HEVC 直通,浏览器不支持时自动回退 JPEG(约 15 fps)。追求 60 fps 请将协商帧率设为 60;实际帧率以网页显示为准。", 14, MUTED)) - val recoveryPolicies = com.shilapi.xcertplay.web.RtcRecoveryPolicy.entries - choice(card, "WebRTC 卡顿容忍时间", recoveryPolicies.map { it.label }, - recoveryPolicies.indexOf(AirPlayPersistence.loadRtcRecoveryPolicy(this))) { - AirPlayPersistence.saveRtcRecoveryPolicy(this, recoveryPolicies[it]) - } - card.addView(label("短暂断流时保留最后一帧并等待恢复,持续异常超过所选时间才回退 JPEG。不增加视频缓冲;不支持的编码或彻底失败的连接仍立即回退。下次浏览器连接生效。", 14, MUTED)) - toggle(card, "允许 iPhone 使用 HEVC", "下次连接生效。需要浏览器支持 HEVC / WebRTC,否则回退 JPEG;关闭时使用 H.264。", AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } - toggle(card, "右舵布局", "将 CarPlay 控件移到右侧。", AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } - - } - val simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) - if (com.shilapi.xcertplay.hud.BydOutputSettings.available(this)) section(content, "$simulatedManufacturerName 导航输出") { card -> - toggle(card, "抬头显示与仪表导航", - "在兼容的 $simulatedManufacturerName 设备上显示导航箭头、距离与路名,支持情况取决于车型。", - com.shilapi.xcertplay.hud.BydOutputSettings.enabled(this)) { com.shilapi.xcertplay.hud.BydOutputSettings.setEnabled(this, it) } - } - section(content, "权限与连接帮助") { card -> - card.addView(label("附近设备权限用于连接 iPhone,麦克风用于 Siri 和通话。旧版 Android 的无线连接还需要定位权限,USB 模式可能请求本地 VPN 授权。", 16, MUTED)) - card.addView(ui.preference("浏览器 HTTPS 证书", "查看本设备证书指纹") { - val fingerprint = WebSession.tls?.fingerprint - MaterialAlertDialogBuilder(this) - .setTitle("浏览器 HTTPS 证书指纹") - .setMessage(if (!WebListenSettings.httpsEnabled(this)) "HTTPS 已关闭,请在 Web 监听中开启。" - else if (fingerprint == null) "HTTPS 服务正在准备或启动失败,请稍后重试。" - else "请在车机安装证书前,与浏览器页面显示的 SHA-256 指纹逐字核对:\n\n$fingerprint") - .setPositiveButton("关闭", null) - .show() - }) - card.addView(button("应用权限", false) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) }, ui.secondaryButtonLayout(16)) - card.addView(button("系统蓝牙设置", false) { openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }, ui.secondaryButtonLayout(12)) - card.addView(button("无线连接帮助", false) { wirelessHelp() }, ui.secondaryButtonLayout(12)) - } - section(content, "关于与诊断") { card -> - card.addView(label("${getString(R.string.app_name)} · ${version()}", 17, TEXT, true)) - exportButton = button(if (exportInProgress) "正在保存报告…" else "保存诊断报告", false) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() - else chooseReportDestination() - }.apply { isEnabled = !exportInProgress } - card.addView(exportButton, ui.secondaryButtonLayout(12)) - val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "报告保存到 Downloads/WheelPlay。" else "请选择报告保存位置。" - card.addView(label(destination + "不会自动上传,报告排除协议载荷和凭据。", 14, MUTED).apply { setPadding(0, dp(12), 0, 0) }) - card.addView(label("基于 xcertplay / DiPlay,保留 GPL-3.0 与 AGPL-3.0 许可声明。CarPlay 与 NIO 图标属于各自权利人。", 13, MUTED).apply { setPadding(0, dp(20), 0, 0) }) - } - section(content, "开源") { card -> - card.addView(ui.preference("源码地址", "github.com/fython/wheelplay") { - runCatching { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/fython/wheelplay"))) - }.onFailure { toast("未找到可打开网页的浏览器") } - }) - } - } - - private fun oemControls(card: LinearLayout) { - val name = AirPlayPersistence.loadSimulatedManufacturerName(this) - val custom = AirPlayPersistence.loadCustomAirPlayIconFile(this)?.let { - BitmapFactory.decodeFile(it.absolutePath) - } - val preview = ImageView(this).apply { - contentDescription = "CarPlay 车厂图标预览" - scaleType = ImageView.ScaleType.FIT_CENTER - setImageBitmap(custom ?: BitmapFactory.decodeResource(resources, R.raw.ic_car_home)) - } - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView(preview, LinearLayout.LayoutParams(dp(72), dp(72))) - addView(column().apply { - addView(label(name, 20, TEXT, true)) - addView(label(if (custom != null) "自定义图标" else "默认 NIO 图标", 14, MUTED)) - }, LinearLayout.LayoutParams(0, -2, 1f).apply { marginStart = dp(16) }) - } - card.addView(row) - card.addView(label("显示在 CarPlay 应用列表的「返回车机」入口。名称与图标可分别修改,下次连接 iPhone 生效。", 14, MUTED).apply { - setPadding(0, dp(12), 0, dp(8)) - }) - card.addView(ui.preference("车厂名称", name) { - textInput("车厂名称(留空恢复 NIO)", name, false) { - AirPlayPersistence.saveSimulatedManufacturerName(this, it) - render() - toast("车厂名称已保存,下次连接 iPhone 生效") - } - }) - card.addView(button("选择车厂图标", false) { - runCatching { oemImagePicker.launch("image/*") } - .onFailure { toast("此设备没有可用的图片选择器,请先安装文件管理器后重试") } - }, ui.secondaryButtonLayout(12)) - card.addView(label("选择图片后可拖动、缩放并裁剪为正方形。图标保存在本机,原图片删除后仍可使用。", 14, MUTED)) - card.addView(button("恢复默认图标", false) { - runCatching { AirPlayPersistence.clearCustomAirPlayIcon(this) } - .onSuccess { render(); toast("已恢复默认图标,下次连接 iPhone 生效") } - .onFailure { toast("无法恢复默认图标,请重试") } - }.apply { isEnabled = AirPlayPersistence.loadCustomAirPlayIconFile(this@DiPlayActivity) != null }, ui.secondaryButtonLayout(12)) - card.addView(button("恢复默认名称与图标", false) { - MaterialAlertDialogBuilder(this).setTitle("恢复默认车厂设置?") - .setMessage("车厂名称和图标将恢复为 NIO。下次连接 iPhone 生效。") - .setNegativeButton("取消", null) - .setPositiveButton("恢复默认") { _, _ -> - runCatching { - AirPlayPersistence.clearCustomAirPlayIcon(this) - AirPlayPersistence.saveSimulatedManufacturerName(this, AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME) - }.onSuccess { render(); toast("已恢复默认车厂设置") } - .onFailure { toast("无法恢复默认车厂设置,请重试") } - }.show() - }, ui.secondaryButtonLayout(12)) - } - - private fun locationSourceControl(card: LinearLayout) { - val options = listOf("iPhone 自身定位(默认)", "Android 设备定位") - val control = ui.preference("定位来源", options[if (AirPlayPersistence.loadLocationReportingEnabled(this)) 1 else 0]) {} - control.setOnClickListener { - var selected = if (AirPlayPersistence.loadLocationReportingEnabled(this)) 1 else 0 - MaterialAlertDialogBuilder(this).setTitle("定位来源") - .setSingleChoiceItems(options.toTypedArray(), selected) { _, index -> selected = index } - .setPositiveButton("保存") { _, _ -> - AirPlayPersistence.saveLocationReportingEnabled(this, selected == 1) - control.setValue(options[selected]) - toast("定位来源已保存,下次连接 iPhone 生效") - }.setNegativeButton("取消", null).show() - } - card.addView(control, matchButton()) - } - - private fun experienceChoice(card: LinearLayout, title: String, options: List, initial: Int, - reconnect: Boolean = false, save: (Int) -> Unit) { - var selection = initial - val control = ui.preference(title, options[selection]) {} - control.setOnClickListener { - var pending = selection - MaterialAlertDialogBuilder(this).setTitle(title) - .setSingleChoiceItems(options.toTypedArray(), selection) { _, index -> pending = index } - .setPositiveButton("保存") { _, _ -> - if (pending != selection) { - save(pending) - selection = pending - control.setValue(options[selection]) - if (reconnect) reconnectForPreference() - } - }.setNegativeButton("取消", null).show() - } - card.addView(control, matchButton()) - } - - private fun reconnectForPreference() { - if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) - else toast("已保存,下次连接 iPhone 生效") - } - - private fun refreshAudioRoutes() { - val session = CarPlayBackgroundSession.snapshot() - audioOutputStatus?.text = when { - session == null -> "当前:未连接 iPhone" - AirPlayPersistence.loadBrowserAudioPlayback(this) && WebSession.audio.playbackReady && session.sink.isRemoteMediaAvailable() -> "当前输出:车机浏览器" - AirPlayPersistence.loadBrowserAudioPlayback(this) -> "当前输出:Android(浏览器尚未就绪或已离线)" - else -> "当前输出:Android" - } - val source = AirPlayPersistence.loadMicrophoneSource(this) - microphoneSourceStatus?.text = when { - source == CarPlayMicrophoneSource.DISABLED -> "当前:麦克风已禁用" - session == null -> "当前:未连接 iPhone" - source == CarPlayMicrophoneSource.BROWSER && WebSession.audio.microphoneReady -> "输入来源:浏览器(按需采集)" - checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED -> "Android 麦克风未授权;浏览器未就绪时无法输入语音" - source == CarPlayMicrophoneSource.BROWSER -> "输入来源:Android(浏览器麦克风尚未就绪)" - else -> "输入来源:Android(按需采集)" - } - } - - private fun safeAreaControl(card: LinearLayout) { - val margins = AirPlayPersistence.loadSafeMargins(this) - val summary = when { - margins == null -> { - val size = WebSession.currentBrowserViewport ?: (1280 to 720) - if (AirPlayPersistence.loadSafeAreaRect(this, size.first, size.second) != null) "保留已有安全区域" else "全屏" - } - margins.isFullScreen -> "全屏" - else -> "上 ${margins.top}% · 下 ${margins.bottom}% · 左 ${margins.left}% · 右 ${margins.right}%" - } - card.addView(ui.preference("画面安全区域", summary) { editSafeArea() }) - card.addView(label("为被车机控件遮挡的边缘设置留白,按画面比例适配分辨率。保存后重新连接,让 iPhone 在安全区域内布局;触控坐标仍覆盖完整画面。", 14, MUTED)) - } - - private fun editSafeArea() { - val current = AirPlayPersistence.loadSafeMargins(this) ?: CarPlaySafeMargins() - val values = listOf(current.top, current.bottom, current.left, current.right) - val names = listOf("上边留白", "下边留白", "左边留白", "右边留白") - val inputs = mutableListOf() - val content = column().apply { setPadding(dp(24), dp(8), dp(24), 0) } - val validation = label("每边 0–40%,0 表示不留白。", 14, MUTED) - var previewMargins = current - val preview = object : View(this) { - override fun onDraw(canvas: android.graphics.Canvas) { - val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) - val border = dp(8).toFloat() - val w = width - 2 * border; val h = height - 2 * border - paint.color = SURFACE - canvas.drawRect(border, border, border + w, border + h, paint) - paint.color = ACCENT - paint.alpha = 100 - canvas.drawRect(border + w * previewMargins.left / 100, border + h * previewMargins.top / 100, - border + w * (100 - previewMargins.right) / 100, border + h * (100 - previewMargins.bottom) / 100, paint) - } - }.apply { contentDescription = "画面安全区域预览" } - content.addView(preview, LinearLayout.LayoutParams(-1, dp(112))) - content.addView(label("高亮区域为 CarPlay 布局区域", 14, MUTED)) - for (index in names.indices) { - val field = TextInputLayout(this).apply { - hint = names[index]; suffixText = "%"; boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE - } - val input = TextInputEditText(field.context).apply { - setSingleLine(); inputType = android.text.InputType.TYPE_CLASS_NUMBER; setText(values[index].toString()) - } - inputs.add(input); field.addView(input, LinearLayout.LayoutParams(-1, -2)) - input.doAfterTextChanged { - runCatching { - val parts = inputs.map { it.text.toString().toInt() } - if (parts.size == 4) CarPlaySafeMargins(parts[0], parts[1], parts[2], parts[3]) else null - }.getOrNull()?.let { previewMargins = it; preview.invalidate() } - } - content.addView(field, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(8) }) - } - content.addView(validation) - val dialog = MaterialAlertDialogBuilder(this).setTitle("画面安全区域").setView(ScrollView(this).apply { addView(content) }) - .setNegativeButton("取消", null).setNeutralButton("恢复全屏") { _, _ -> - AirPlayPersistence.saveSafeMargins(this, CarPlaySafeMargins()) - render(); reconnectForPreference() - }.setPositiveButton("保存", null).create() - dialog.setOnShowListener { - dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { - val result = runCatching { - val parts = inputs.map { it.text.toString().toIntOrNull() ?: error("请输入 0–40 的整数") } - CarPlaySafeMargins(parts[0], parts[1], parts[2], parts[3]) - } - result.onSuccess { - AirPlayPersistence.saveSafeMargins(this, it) - dialog.dismiss(); render(); reconnectForPreference() - }.onFailure { validation.text = it.message ?: "请输入有效的留白比例"; validation.setTextColor(WARNING) } - } - } - dialog.show() - } - private fun refreshAuthentication() { setupError = if (DiPlayBootstrap.importing) "正在导入认证资源,请稍候" else runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { @@ -919,68 +576,16 @@ class DiPlayActivity : AppCompatActivity() { private fun handleAuthenticationIntent(incoming: Intent): Boolean { if (incoming.action != Intent.ACTION_SEND && incoming.action != Intent.ACTION_VIEW) return false - navigate("settings") + settingsModel.open(SettingsCategory.AUTHENTICATION) + shell.select("settings") + page = "settings" val uri = AuthenticationImportIntents.apk(incoming) if (uri == null) { toast("无法读取 APK,请在设置中通过文件选择器导入"); return true } if (!canImportAuthentication()) return true - MaterialAlertDialogBuilder(this).setTitle("从此 APK 导入认证资源?") - .setMessage("WheelPlay 只在本机读取 DiPlay APK 内的两份认证资源,不安装 APK。校验通过后将替换原有资源。") - .setNegativeButton("取消", null) - .setPositiveButton("导入") { _, _ -> authenticationImport.import(apk = uri) }.show() - return true - } - - 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)) + settingsDialogs.confirm("从此 APK 导入认证资源?", "WheelPlay 只在本机读取 DiPlay APK 内的两份认证资源,不安装 APK。校验通过后将替换原有资源。") { + authenticationImport.import(apk = uri) } - 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) } - } - } else { - WebListenSettings.saveCertificateType(this, type) - render() - } - } - 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 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 ?: "端口映射保存失败") } + return true } private fun setTeslaHttpEnabled(enabled: Boolean) { @@ -1040,124 +645,8 @@ class DiPlayActivity : AppCompatActivity() { openSystem(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - // Wi-Fi Direct is the default link. The car's own hotspot is an alternative when Wi-Fi Direct is unstable. - // The runtime config rejects manual mode without valid credentials, so it is only saved together with them. - private fun wirelessLinkControls(parent: LinearLayout) { - val carHotspot = AirPlayPersistence.loadWirelessHotspotMode(this) == WirelessHotspotMode.MANUAL - val options = arrayOf("Wi-Fi Direct · 默认", "设备热点") - val control = ui.preference("连接网络", if (carHotspot) "设备热点" else "Wi-Fi Direct") {} - control.setOnClickListener { - var selection = if (carHotspot) 1 else 0 - MaterialAlertDialogBuilder(this).setTitle("连接网络") - .setSingleChoiceItems(options, selection) { _, index -> selection = index } - .setPositiveButton(if (CarPlayBackgroundSession.hasSession()) "应用并重新连接" else "保存") { _, _ -> - when { - (selection == 1) == carHotspot -> Unit - selection == 0 -> applyWirelessLink(WirelessHotspotMode.WIFI_P2P) - hotspotError(storedSsid(), storedPassword()) == null -> applyWirelessLink(WirelessHotspotMode.MANUAL) - else -> askHotspotCredentials { ssid, password -> - saveHotspotCredentials(ssid, password) - applyWirelessLink(WirelessHotspotMode.MANUAL) - } - } - }.setNegativeButton("取消", null).show() - } - parent.addView(control, matchButton(0)) - if (!carHotspot) { - parent.addView(label("由此设备创建 Wi-Fi Direct 网络供 iPhone 连接。", 14, MUTED)) - return - } - val ssid = storedSsid() - val password = storedPassword() - parent.addView(ui.preference("热点名称", ssid) { - textInput("热点名称", ssid, secret = false, preserveWhitespace = true) { value -> - hotspotError(value, password)?.let { toast(it); return@textInput } - saveHotspotCredentials(value, password) - render() - } - }, matchButton(0)) - parent.addView(ui.preference("热点密码", if (password.isEmpty()) "未设置" else "•".repeat(8)) { - textInput("热点密码", password, secret = true) { value -> - hotspotError(ssid, value)?.let { toast(it); return@textInput } - saveHotspotCredentials(ssid, value) - render() - } - }, matchButton(0)) - parent.addView(label("先在系统设置中开启热点,在此填写相同名称与密码。iPhone 会连接该网络,修改在下次连接生效。", 14, MUTED).apply { - setPadding(0, dp(8), 0, 0) - }) - } - private fun selectedPhoneName() = if (DiPlayPreferences.phoneAddress(this) == null) "未选择" else DiPlayPreferences.phoneName(this) - private fun storedSsid() = AirPlayPersistence.loadManualHotspotSsid(this) - private fun storedPassword() = AirPlayPersistence.loadManualHotspotPassphrase(this) - private fun hotspotError(ssid: String, password: String) = - com.shilapi.xcertplay.orchestration.ManualHotspotValidation.validate(ssid, password) - - private fun saveHotspotCredentials(ssid: String, password: String) { - AirPlayPersistence.saveManualHotspotSsid(this, ssid) - AirPlayPersistence.saveManualHotspotPassphrase(this, password) - AirPlayPersistence.saveManualHotspotSecurity(this, - com.shilapi.xcertplay.orchestration.ManualHotspotValidation.securityFor(password)) - AirPlayPersistence.saveManualHotspotBand(this, com.shilapi.xcertplay.orchestration.ManualHotspotBand.AUTO) - AirPlayPersistence.saveManualHotspotChannel(this, 0) - } - - private fun askHotspotCredentials(done: (String, String) -> Unit) { - textInput("热点名称", storedSsid(), secret = false, preserveWhitespace = true) { ssid -> - textInput("热点密码", storedPassword(), secret = true) { password -> - val error = hotspotError(ssid, password) - if (error != null) toast(error) else done(ssid, password) - } - } - } - - private fun applyWirelessLink(mode: WirelessHotspotMode) { - AirPlayPersistence.saveWirelessHotspotMode(this, mode) - render() - if (CarPlayBackgroundSession.hasSession()) { - // Release the previous backend before checking whether the new hotspot is on. - // Otherwise its P2P group can prevent the user from enabling the system hotspot. - CarPlayBackgroundSession.stop { runOnUiThread { connect(true) } } - } - } - - private fun showBrowserDevices() { - val devices = WebSession.rememberedBrowsers(this).list() - if (devices.isEmpty()) { - MaterialAlertDialogBuilder(this).setTitle("连接过的浏览器") - .setMessage("还没有记住的浏览器。首次在网页扫码或输入配对码后会自动保存。") - .setPositiveButton("关闭", null).show() - return - } - val date = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - val items = devices.map { "${it.name}\n最近连接:${date.format(Date(it.lastUsedAt))} · ${it.peer}" } + "移除全部设备" - MaterialAlertDialogBuilder(this).setTitle("连接过的浏览器(${devices.size})") - .setItems(items.toTypedArray()) { _, index -> - if (index == devices.size) { - MaterialAlertDialogBuilder(this).setTitle("移除全部浏览器?") - .setMessage("当前浏览器连接会断开,所有浏览器下次使用时需要重新扫码或输入配对码。") - .setNegativeButton("取消", null) - .setPositiveButton("移除全部") { _, _ -> WebSession.forgetAllBrowsers(this); showBrowserDevices() }.show() - } else { - val device = devices[index] - MaterialAlertDialogBuilder(this).setTitle(device.name) - .setMessage("首次配对:${date.format(Date(device.createdAt))}\n最近连接:${date.format(Date(device.lastUsedAt))}\n最近地址:${device.peer}") - .setNeutralButton("重命名") { _, _ -> - textInput("浏览器名称", device.name, secret = false) { - WebSession.rememberedBrowsers(this).rename(device.id, it) - showBrowserDevices() - } - } - .setNegativeButton("取消", null) - .setPositiveButton("移除设备") { _, _ -> - WebSession.forgetBrowser(this, device.id); showBrowserDevices() - }.show() - } - }.setNegativeButton("关闭", null).show() - } - private fun readCertificateFile(uri: Uri): ByteArray = contentResolver.openInputStream(uri)?.use(LanTls::readImport) ?: error("无法打开所选文件") @@ -1198,9 +687,7 @@ class DiPlayActivity : AppCompatActivity() { } } 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() + else settingsDialogs.select("选择 PKCS#12 私钥条目", aliases, ::clear) { index -> choose(aliases[index]) } } } }, "https-pkcs12-read").start() @@ -1208,39 +695,7 @@ class DiPlayActivity : AppCompatActivity() { private fun textInput(title: String, current: String, secret: Boolean, preserveWhitespace: Boolean = false, onCancel: () -> Unit = {}, save: (String) -> Unit) { - val field = TextInputLayout(this).apply { - hint = title - boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE - if (secret) endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - } - 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 { - android.text.InputType.TYPE_CLASS_TEXT - } - } - field.addView(input, LinearLayout.LayoutParams(-1, -2)) - 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 || preserveWhitespace) it else it.trim() }) - } - .setNegativeButton("取消") { _, _ -> onCancel() }.setOnCancelListener { onCancel() }.show() - } - - private fun carPlaySizeControl(parent: LinearLayout) { - val sizes = com.shilapi.xcertplay.airplay.CarPlaySize.entries - val current = com.shilapi.xcertplay.airplay.CarPlaySize.fromWidthMillimeters(AirPlayPersistence.loadWidthPhysicalMm(this)) - choice(parent, "CarPlay 显示尺寸", sizes.map { it.label }, sizes.indexOf(current)) { - AirPlayPersistence.saveWidthPhysicalMm(this, sizes[it].widthMillimeters) - } - parent.addView(label("调整 CarPlay 图标和文字大小,应用后会重新连接。", 14, MUTED).apply { - setPadding(0, 0, 0, dp(16)) - }) + settingsDialogs.input(title, current, secret, preserveWhitespace, onCancel, save) } private fun connect(wireless: Boolean) { @@ -1392,7 +847,7 @@ class DiPlayActivity : AppCompatActivity() { private fun exportDiagnostics(uri: Uri? = null) { if (exportInProgress) return exportInProgress = true - exportButton?.apply { isEnabled = false; text = "正在保存报告…" } + refreshSettingsRuntime() val appContext = applicationContext val fileName = reportFileName() Thread({ @@ -1430,7 +885,7 @@ class DiPlayActivity : AppCompatActivity() { runOnUiThread { exportInProgress = false if (isFinishing || isDestroyed) return@runOnUiThread - exportButton?.apply { isEnabled = true; text = "保存诊断报告" } + refreshSettingsRuntime() if (result.isSuccess) { MaterialAlertDialogBuilder(this).setTitle("诊断报告已保存") .setMessage(if (uri == null) "Downloads/WheelPlay/$fileName" else "报告已保存到所选位置。") @@ -1452,46 +907,13 @@ class DiPlayActivity : AppCompatActivity() { private fun openSystem(intent: Intent) { runCatching { startActivity(intent) }.onFailure { toast("请从此设备的系统设置中打开该选项。") } } private fun toast(message: String) { com.google.android.material.snackbar.Snackbar.make(shell.root, message, com.google.android.material.snackbar.Snackbar.LENGTH_LONG).show() } 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, 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; 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) { - var selection = current - val control = ui.preference(title, options[selection].substringBefore(" · ")) {} - control.setOnClickListener { - var pendingSelection = selection - MaterialAlertDialogBuilder(this).setTitle(title) - .setSingleChoiceItems(options.toTypedArray(), selection) { _, index -> pendingSelection = index } - .setPositiveButton(if (CarPlayBackgroundSession.hasSession()) "应用并重新连接" else "保存") { _, _ -> - if (pendingSelection != selection) { - selection = pendingSelection - save(selection) - control.setValue(options[selection].substringBefore(" · ")) - if (CarPlayBackgroundSession.hasSession()) { - connect(AirPlayPersistence.loadWirelessEnabled(this)) - } - } - }.setNegativeButton("取消", null).show() - } - parent.addView(control, matchButton(0)) - } private fun column() = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } - private fun row() = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } private fun label(value: String, size: Int, color: Int, bold: Boolean = false) = ui.text(value, size, color, bold) private fun button(title: String, primary: Boolean, @androidx.annotation.DrawableRes iconRes: Int = 0, click: () -> Unit) = ui.button(title, primary, iconRes, click) private fun matchButton(top: Int = 0) = LinearLayout.LayoutParams(-1, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(top) } private fun space(height: Int) = View(this).apply { layoutParams = LinearLayout.LayoutParams(1, dp(height)) } private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() companion object { - private val BG = ServerUi.BG - private val SURFACE = ServerUi.SURFACE - private val BORDER = ServerUi.BORDER private val ACCENT = ServerUi.ACCENT private val TEXT = ServerUi.TEXT private val MUTED = ServerUi.MUTED diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt index 1b876a6..33c95e5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt +++ b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt @@ -257,9 +257,9 @@ internal class ServerUi(private val context: Context) { tabs.setOnItemSelectedListener { item -> select(ids[item.itemId - 1]); true } } - fun put(id: String, view: View) { + fun put(id: String, view: View, scrollPosition: Int? = null) { val old = pages.put(id, view) - val scrollY = old?.scrollY ?: 0 + val scrollY = scrollPosition ?: old?.scrollY ?: 0 if (old != null) container.removeView(old) container.addView(view, FrameLayout.LayoutParams(-1, -1)) view.visibility = if (id == selected) View.VISIBLE else View.GONE @@ -275,7 +275,12 @@ internal class ServerUi(private val context: Context) { view.setPadding(view.paddingLeft, topInset, view.paddingRight, view.paddingBottom) } private fun updateStatusScrim() { - statusScrim.alpha = ((pages[selected]?.scrollY ?: 0).toFloat() / dp(24)).coerceIn(0f, 1f) + statusScrim.alpha = ((pageScroll[selected] ?: pages[selected]?.scrollY ?: 0).toFloat() / dp(24)).coerceIn(0f, 1f) + } + private val pageScroll = mutableMapOf() + fun setPageScroll(id: String, offset: Int) { + pageScroll[id] = offset + if (selected == id) updateStatusScrim() } fun select(id: String) { if (id !in ids || selected == id) return diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt new file mode 100644 index 0000000..d33ab21 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/AudioSettingsScreen.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.shilapi.xcertplay.* +import com.shilapi.xcertplay.host.R + +@Composable +internal fun AudioSettingsScreen(state: SettingsState, actions: SettingsActions) { + SettingsChoice( + state, + actions, + ChoiceSetting.AUDIO_BUFFER, + R.string.settings_audio_buffer, + listOf( + settingsString(R.string.settings_buffer_default), + "500 ms", + settingsString(R.string.settings_buffer_stable), + ), + ) + SettingsChoice( + state, + actions, + ChoiceSetting.AUDIO_OUTPUT, + R.string.settings_audio_output, + CarPlayAudioOutput.entries.map { it.label }, + ) + Text(state.runtime.audioStatus, style = MaterialTheme.typography.bodyMedium) + SettingsDescription(R.string.settings_audio_output_description) + SettingsChoice( + state, + actions, + ChoiceSetting.MICROPHONE, + R.string.settings_microphone, + CarPlayMicrophoneSource.entries.map { it.label }, + ) + Text(state.runtime.microphoneStatus, style = MaterialTheme.typography.bodyMedium) + SettingsDescription(R.string.settings_microphone_description) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt new file mode 100644 index 0000000..33bdd13 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/AuthenticationSettingsScreen.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import com.shilapi.xcertplay.host.R + +@Composable +internal fun AuthenticationSettingsScreen(state: SettingsState, actions: SettingsActions) { + var manualImport by remember { mutableStateOf(false) } + val ready = state.runtime.authenticationError == null + Text( + settingsString( + when { + state.runtime.importing -> R.string.settings_importing + ready -> R.string.settings_auth_ready + else -> R.string.settings_auth_missing + } + ), + color = if (ready) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, + ) + SettingsDescription(R.string.settings_auth_description) + SettingsButton(R.string.settings_import_apk, !state.runtime.importing) { + actions.command(SettingsCommand.IMPORT_APK) + } + SettingsButton(R.string.settings_import_files, !state.runtime.importing) { manualImport = true } + SettingsRow( + settingsString(R.string.settings_diplay_download), + settingsString(R.string.settings_diplay_download_description), + "settings:download", + ) { + actions.command(SettingsCommand.DOWNLOAD) + } + SettingsDescription(R.string.settings_auth_storage) + if (manualImport) + SettingsConfirmation( + R.string.settings_import_files_title, + R.string.settings_import_files_message, + { manualImport = false }, + ) { + actions.command(SettingsCommand.IMPORT_FILES) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt new file mode 100644 index 0000000..e840ac9 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/BrowserSettingsScreen.kt @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.BrowserOfflinePolicy +import com.shilapi.xcertplay.host.R +import java.text.DateFormat +import java.util.Date + +@Composable +internal fun BrowserSettingsScreen(state: SettingsState, actions: SettingsActions) { + var manage by remember { mutableStateOf(false) } + SettingsDescription(R.string.settings_browser_description) + SettingsRow( + settingsString(R.string.settings_devices), + settingsString(R.string.settings_devices_description), + "settings:devices", + ) { + manage = true + } + SettingsChoice( + state, + actions, + ChoiceSetting.OFFLINE, + R.string.settings_offline, + BrowserOfflinePolicy.entries.map { it.label }, + ) + SettingsDescription(R.string.settings_offline_description) + if (manage) BrowserDevicesDialog(state, actions) { manage = false } +} + +@Composable +private fun BrowserDevicesDialog( + state: SettingsState, + actions: SettingsActions, + onDismiss: () -> Unit, +) { + var selectedId by remember { mutableStateOf(null) } + var rename by remember { mutableStateOf(false) } + var removeAll by remember { mutableStateOf(false) } + val selected = state.browsers.firstOrNull { it.id == selectedId } + val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) } + if (selected == null && !removeAll) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(settingsString(R.string.settings_browser_devices)) }, + text = { + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + if (state.browsers.isEmpty()) + SettingsDescription(R.string.settings_devices_empty) + state.browsers.forEach { device -> + SettingsRow( + device.name, + dateFormat.format(Date(device.lastUsedAt)), + "browser:${device.id}", + ) { + selectedId = device.id + } + } + } + }, + confirmButton = { + if (state.browsers.isNotEmpty()) + TextButton(onClick = { removeAll = true }) { + Text(settingsString(R.string.settings_remove_all)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_close)) } + }, + ) + if (selected != null) { + if (rename) + SettingsTextDialog( + settingsString(R.string.settings_browser_name), + selected.name, + onDismiss = { rename = false }, + ) { + actions.renameBrowser(selected.id, it) + selectedId = null + null + } + else + AlertDialog( + onDismissRequest = { selectedId = null }, + title = { Text(selected.name) }, + text = { + Text( + settingsString( + R.string.settings_device_details, + dateFormat.format(Date(selected.createdAt)), + dateFormat.format(Date(selected.lastUsedAt)), + selected.peer, + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + actions.removeBrowser(selected.id) + selectedId = null + } + ) { + Text(settingsString(R.string.settings_remove_device)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { rename = true }) { + Text(settingsString(R.string.settings_rename)) + } + TextButton(onClick = { selectedId = null }) { + Text(settingsString(R.string.settings_cancel)) + } + } + }, + ) + } + if (removeAll) + SettingsConfirmation( + R.string.settings_remove_all_title, + R.string.settings_remove_all_message, + { removeAll = false }, + ) { + actions.removeBrowser(null) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt new file mode 100644 index 0000000..2a7d754 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/ConnectionSettingsScreen.kt @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.orchestration.ManualHotspotValidation + +@Composable +internal fun ConnectionSettingsScreen(state: SettingsState, actions: SettingsActions) { + var credentials by remember { mutableStateOf(false) } + SettingsGroup(R.string.settings_auto_connection) { + SettingsToggle( + state, + actions, + ToggleSetting.AUTO_CONNECT, + R.string.settings_auto_connect, + R.string.settings_auto_connect_description, + ) + SettingsToggle( + state, + actions, + ToggleSetting.AUTO_START, + R.string.settings_auto_start, + R.string.settings_auto_start_description, + ) + SettingsRow(settingsString(R.string.settings_iphone), state.phoneName, "settings:phone") { + actions.command(SettingsCommand.SELECT_PHONE) + } + } + SettingsGroup(R.string.settings_wireless_connection) { + SettingsChoice( + state, + actions, + ChoiceSetting.WIRELESS, + R.string.settings_network, + listOf( + settingsString(R.string.settings_wifi_direct), + settingsString(R.string.settings_hotspot), + ), + ) { index -> + if (index == 1 && ManualHotspotValidation.validate(state.ssid, state.password) != null) + credentials = true + else actions.choice(ChoiceSetting.WIRELESS, index) + } + if (state.selected(ChoiceSetting.WIRELESS) == 0) + SettingsDescription(R.string.settings_wifi_direct_description) + else { + SettingsTextPreference(actions, TextSetting.SSID, R.string.settings_ssid, state.ssid) + SettingsTextPreference( + actions, + TextSetting.PASSWORD, + R.string.settings_password, + state.password, + secret = true, + displayValue = + if (state.password.isEmpty()) settingsString(R.string.settings_not_set) + else "••••••••", + ) + SettingsDescription(R.string.settings_hotspot_description) + SettingsButton(R.string.settings_open_hotspot) { + actions.command(SettingsCommand.HOTSPOT_SETTINGS) + } + } + } + if (credentials) + HotspotCredentialsDialog(state, { credentials = false }) { ssid, password -> + actions.credentials(ssid, password)?.let { + return@HotspotCredentialsDialog it + } + actions.choice(ChoiceSetting.WIRELESS, 1) + null + } +} + +@Composable +private fun HotspotCredentialsDialog( + state: SettingsState, + onDismiss: () -> Unit, + onSave: (String, String) -> String?, +) { + var ssid by remember { mutableStateOf(state.ssid) } + var password by remember { mutableStateOf(state.password) } + var error by remember { mutableStateOf(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(settingsString(R.string.settings_hotspot)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + ssid, + { + ssid = it + error = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(settingsString(R.string.settings_ssid)) }, + singleLine = true, + ) + OutlinedTextField( + password, + { + password = it + error = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(settingsString(R.string.settings_password)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = PasswordVisualTransformation(), + ) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton( + onClick = { + error = onSave(ssid, password) + if (error == null) onDismiss() + } + ) { + Text(settingsString(R.string.settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + }, + ) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt new file mode 100644 index 0000000..2e24d66 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/DisplaySettingsScreen.kt @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.* +import com.shilapi.xcertplay.airplay.CarPlaySize +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.web.RtcRecoveryPolicy +import com.shilapi.xcertplay.web.StreamTechnology + +@Composable +internal fun DisplaySettingsScreen(state: SettingsState, actions: SettingsActions) { + var editMargins by rememberSaveable { mutableStateOf(false) } + SettingsChoice( + state, + actions, + ChoiceSetting.APPEARANCE, + R.string.settings_appearance, + CarPlayAppearance.entries.map { it.label }, + ) + SettingsDescription(R.string.settings_appearance_description) + SettingsChoice( + state, + actions, + ChoiceSetting.DISPLAY_SIZE, + R.string.settings_display_size, + CarPlaySize.entries.map { it.label }, + ) + SettingsDescription(R.string.settings_display_size_description) + val margins = state.margins + val summary = + if (margins == null && state.legacySafeArea) + settingsString(R.string.settings_legacy_safe_area) + else if (margins == null || margins.isFullScreen) + settingsString(R.string.settings_fullscreen) + else + settingsString( + R.string.settings_safe_area_value, + margins.top, + margins.bottom, + margins.left, + margins.right, + ) + SettingsRow(settingsString(R.string.settings_safe_area), summary, "settings:safe-area") { + editMargins = true + } + SettingsDescription(R.string.settings_safe_area_description) + SettingsToggle( + state, + actions, + ToggleSetting.ADAPTIVE_SIZE, + R.string.settings_adaptive_size, + R.string.settings_adaptive_size_description, + ) + SettingsChoice( + state, + actions, + ChoiceSetting.RESOLUTION, + R.string.settings_resolution, + listOf( + settingsString(R.string.settings_original_resolution), + settingsString(R.string.settings_resolution_lower), + settingsString(R.string.settings_resolution_lowest), + ), + ) + SettingsChoice( + state, + actions, + ChoiceSetting.FPS, + R.string.settings_fps, + listOf(settingsString(R.string.settings_fps_30), settingsString(R.string.settings_fps_60)), + ) + SettingsChoice( + state, + actions, + ChoiceSetting.STREAM, + R.string.settings_stream, + StreamTechnology.entries.map { it.label }, + ) + SettingsDescription(R.string.settings_stream_description) + SettingsChoice( + state, + actions, + ChoiceSetting.RECOVERY, + R.string.settings_recovery, + RtcRecoveryPolicy.entries.map { it.label }, + ) + SettingsDescription(R.string.settings_recovery_description) + SettingsToggle( + state, + actions, + ToggleSetting.HEVC, + R.string.settings_hevc, + R.string.settings_hevc_description, + ) + SettingsToggle( + state, + actions, + ToggleSetting.RIGHT_HAND, + R.string.settings_right_hand, + R.string.settings_right_hand_description, + ) + if (editMargins) + SafeAreaDialog(margins ?: CarPlaySafeMargins(), { editMargins = false }) { + actions.margins(it) + } +} + +@Composable +private fun SafeAreaDialog( + initial: CarPlaySafeMargins, + onDismiss: () -> Unit, + onSave: (CarPlaySafeMargins) -> Unit, +) { + var top by rememberSaveable { mutableStateOf(initial.top.toString()) } + var bottom by rememberSaveable { mutableStateOf(initial.bottom.toString()) } + var left by rememberSaveable { mutableStateOf(initial.left.toString()) } + var right by rememberSaveable { mutableStateOf(initial.right.toString()) } + var error by remember { mutableStateOf(null) } + val fields = listOf(top, bottom, left, right) + val parsed = + runCatching { + fields.map { it.toInt() }.let { CarPlaySafeMargins(it[0], it[1], it[2], it[3]) } + } + .getOrNull() + val preview = parsed ?: initial + val background = MaterialTheme.colorScheme.surfaceContainer + val highlight = MaterialTheme.colorScheme.primary.copy(alpha = .4f) + val previewDescription = settingsString(R.string.settings_safe_area_preview) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(settingsString(R.string.settings_safe_area)) }, + text = { + Column( + Modifier.heightIn(max = 440.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Canvas( + Modifier.fillMaxWidth().height(112.dp).semantics { + contentDescription = previewDescription + } + ) { + drawRect(background) + drawRect( + highlight, + Offset(size.width * preview.left / 100, size.height * preview.top / 100), + Size( + size.width * (100 - preview.left - preview.right) / 100, + size.height * (100 - preview.top - preview.bottom) / 100, + ), + ) + } + SettingsDescription(R.string.settings_safe_area_hint) + val labels = + listOf( + R.string.settings_margin_top, + R.string.settings_margin_bottom, + R.string.settings_margin_left, + R.string.settings_margin_right, + ) + val changes: List<(String) -> Unit> = + listOf({ top = it }, { bottom = it }, { left = it }, { right = it }) + fields.forEachIndexed { index, value -> + OutlinedTextField( + value, + { + changes[index](it) + error = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(settingsString(labels[index])) }, + suffix = { Text("%") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = error != null, + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + val invalidMessage = settingsString(R.string.settings_safe_area_invalid) + TextButton( + onClick = { + if (parsed == null) error = invalidMessage + else { + onSave(parsed) + onDismiss() + } + } + ) { + Text(settingsString(R.string.settings_save)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + onSave(CarPlaySafeMargins()) + onDismiss() + } + ) { + Text(settingsString(R.string.settings_restore_fullscreen)) + } + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + } + }, + ) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt new file mode 100644 index 0000000..2395185 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/HelpSettingsScreen.kt @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import android.os.Build +import androidx.compose.material3.* +import androidx.compose.runtime.* +import com.shilapi.xcertplay.host.R + +@Composable +internal fun HelpSettingsScreen(state: SettingsState, actions: SettingsActions) { + var fingerprint by remember { mutableStateOf(false) } + SettingsGroup(R.string.settings_permissions_help) { + SettingsDescription(R.string.settings_permissions_description) + SettingsRow( + settingsString(R.string.settings_fingerprint), + settingsString(R.string.settings_fingerprint_description), + "settings:fingerprint", + ) { + fingerprint = true + } + SettingsButton(R.string.settings_app_permissions) { + actions.command(SettingsCommand.APP_PERMISSIONS) + } + SettingsButton(R.string.settings_bluetooth_settings) { + actions.command(SettingsCommand.BLUETOOTH_SETTINGS) + } + SettingsButton(R.string.settings_wireless_help) { + actions.command(SettingsCommand.WIRELESS_HELP) + } + } + SettingsGroup(R.string.settings_about_diagnostics) { + Text( + "${settingsString(R.string.app_name)} · ${state.runtime.version}", + style = MaterialTheme.typography.titleMedium, + ) + SettingsButton( + if (state.runtime.exporting) R.string.settings_exporting else R.string.settings_export, + !state.runtime.exporting, + ) { + actions.command(SettingsCommand.EXPORT_REPORT) + } + SettingsDescription( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) R.string.settings_export_downloads + else R.string.settings_export_document + ) + SettingsDescription(R.string.settings_licenses) + } + SettingsGroup(R.string.settings_open_source) { + SettingsRow( + settingsString(R.string.settings_source), + "github.com/fython/wheelplay", + "settings:source", + ) { + actions.command(SettingsCommand.SOURCE) + } + } + if (fingerprint) + AlertDialog( + onDismissRequest = { fingerprint = false }, + title = { Text(settingsString(R.string.settings_fingerprint)) }, + text = { + Text( + if (!state.checked(ToggleSetting.HTTPS)) + settingsString(R.string.settings_fingerprint_disabled) + else + state.runtime.fingerprint?.let { + settingsString(R.string.settings_fingerprint_value, it) + } ?: settingsString(R.string.settings_fingerprint_pending) + ) + }, + confirmButton = { + TextButton(onClick = { fingerprint = false }) { + Text(settingsString(R.string.settings_close)) + } + }, + ) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt new file mode 100644 index 0000000..8a6f0b8 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsComponents.kt @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R + +@Composable +internal fun settingsString(@StringRes id: Int, vararg args: Any) = + if (args.isEmpty()) stringResource(id) else stringResource(id, *args) + +@Composable +internal fun SettingsDescription(@StringRes text: Int, vararg args: Any) { + Text( + settingsString(text, *args), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) +} + +@Composable +internal fun SettingsGroup(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) { + Column(Modifier.fillMaxWidth()) { + Text( + settingsString(title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 20.dp, bottom = 8.dp), + ) + content() + } +} + +@Composable +internal fun SettingsRow( + title: String, + value: String, + tag: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Row( + Modifier.fillMaxWidth() + .testTag(tag) + .semantics(mergeDescendants = true) {} + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .heightIn(min = 72.dp) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + Text( + value, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = + if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.outline, + ) + Icon( + painterResource(R.drawable.ic_settings_chevron), + null, + Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +internal fun SettingsToggle( + state: SettingsState, + actions: SettingsActions, + setting: ToggleSetting, + @StringRes title: Int, + @StringRes description: Int, + enabled: Boolean = true, +) { + Row( + Modifier.fillMaxWidth() + .testTag("toggle:${setting.name}") + .toggleable(state.checked(setting), enabled = enabled, role = Role.Switch) { + actions.toggle(setting, it) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(Modifier.weight(1f)) { + Text(settingsString(title), style = MaterialTheme.typography.titleMedium) + Text( + settingsString(description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = state.checked(setting), onCheckedChange = null, enabled = enabled) + } +} + +@Composable +internal fun SettingsChoice( + state: SettingsState, + actions: SettingsActions, + setting: ChoiceSetting, + @StringRes title: Int, + options: List, + enabled: Boolean = true, + onSelected: (Int) -> Unit = { actions.choice(setting, it) }, +) { + var open by rememberSaveable { mutableStateOf(false) } + SettingsRow( + settingsString(title), + options[state.selected(setting)].substringBefore(" · "), + "choice:${setting.name}", + enabled, + ) { + open = true + } + if (open) + SettingsChoiceDialog( + settingsString(title), + options, + state.selected(setting), + { open = false }, + ) { + open = false + onSelected(it) + } +} + +@Composable +internal fun SettingsChoiceDialog( + title: String, + options: List, + selected: Int, + onDismiss: () -> Unit, + onSave: (Int) -> Unit, +) { + var pending by rememberSaveable(title) { mutableIntStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + options.forEachIndexed { index, option -> + Row( + Modifier.fillMaxWidth() + .testTag("option:$index") + .selectable(pending == index, role = Role.RadioButton) { + pending = index + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(pending == index, onClick = null) + Text(option, Modifier.padding(start = 12.dp)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(pending) }) { + Text(settingsString(R.string.settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + }, + ) +} + +@Composable +internal fun SettingsTextPreference( + actions: SettingsActions, + setting: TextSetting, + @StringRes title: Int, + value: String, + enabled: Boolean = true, + secret: Boolean = false, + numeric: Boolean = false, + displayValue: String = value, +) { + var open by remember { mutableStateOf(false) } + SettingsRow(settingsString(title), displayValue, "text:${setting.name}", enabled) { + open = true + } + if (open) + SettingsTextDialog( + settingsString(title), + value, + secret, + numeric, + onDismiss = { open = false }, + ) { + actions.text(setting, it) + } +} + +@Composable +internal fun SettingsTextDialog( + title: String, + initial: String, + secret: Boolean = false, + numeric: Boolean = false, + onDismiss: () -> Unit, + onSave: (String) -> String?, +) { + // Passwords stay in memory and are never written into saved-instance-state. + var value by remember(title) { mutableStateOf(initial) } + var error by remember { mutableStateOf(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value, + { + value = it + error = null + }, + modifier = Modifier.fillMaxWidth().testTag("settings:text-input"), + singleLine = true, + isError = error != null, + supportingText = error?.let { message -> { Text(message) } }, + keyboardOptions = + KeyboardOptions( + keyboardType = + if (secret) KeyboardType.Password + else if (numeric) KeyboardType.Number else KeyboardType.Text + ), + visualTransformation = + if (secret) PasswordVisualTransformation() else VisualTransformation.None, + ) + }, + confirmButton = { + TextButton( + onClick = { + error = onSave(value) + if (error == null) onDismiss() + } + ) { + Text(settingsString(R.string.settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + }, + ) +} + +@Composable +internal fun SettingsButton(@StringRes title: Int, enabled: Boolean = true, onClick: () -> Unit) { + OutlinedButton(onClick, Modifier.padding(vertical = 4.dp), enabled = enabled) { + Text(settingsString(title)) + } +} + +@Composable +internal fun SettingsConfirmation( + @StringRes title: Int, + @StringRes message: Int, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(settingsString(title)) }, + text = { Text(settingsString(message)) }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + } + ) { + Text(settingsString(R.string.settings_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(settingsString(R.string.settings_cancel)) } + }, + ) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt new file mode 100644 index 0000000..de6f5b1 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsDialogHost.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.material3.* +import androidx.compose.runtime.* +import com.shilapi.xcertplay.host.R + +/** + * Transient platform flows, including certificate passwords, are never saved or held by a + * ViewModel. + */ +internal class SettingsDialogHost { + internal sealed interface Request { + val onCancel: () -> Unit + + data class Input( + val title: String, + val initial: String, + val secret: Boolean, + val preserveWhitespace: Boolean, + val onSave: (String) -> Unit, + override val onCancel: () -> Unit, + ) : Request + + data class Selection( + val title: String, + val options: List, + val onSelect: (Int) -> Unit, + override val onCancel: () -> Unit, + ) : Request + + data class Confirmation( + val title: String, + val message: String, + val onConfirm: () -> Unit, + override val onCancel: () -> Unit = {}, + ) : Request + } + + var request by mutableStateOf(null) + private set + + fun input( + title: String, + initial: String, + secret: Boolean, + preserveWhitespace: Boolean, + onCancel: () -> Unit, + onSave: (String) -> Unit, + ) { + request = Request.Input(title, initial, secret, preserveWhitespace, onSave, onCancel) + } + + fun select( + title: String, + options: List, + onCancel: () -> Unit, + onSelect: (Int) -> Unit, + ) { + request = Request.Selection(title, options, onSelect, onCancel) + } + + fun confirm(title: String, message: String, onConfirm: () -> Unit) { + request = Request.Confirmation(title, message, onConfirm) + } + + fun complete(current: Request, action: () -> Unit) { + if (request !== current) return + request = null + action() + } + + fun cancel(current: Request? = request) { + if (request !== current) return + request = null + current?.onCancel?.invoke() + } +} + +@Composable +internal fun SettingsPlatformDialog(host: SettingsDialogHost) { + val current = host.request ?: return + key(current) { + when (current) { + is SettingsDialogHost.Request.Input -> + SettingsTextDialog( + current.title, + current.initial, + current.secret, + onDismiss = { host.cancel(current) }, + ) { raw -> + host.complete(current) { + current.onSave( + if (current.secret || current.preserveWhitespace) raw else raw.trim() + ) + } + null + } + is SettingsDialogHost.Request.Selection -> + SettingsChoiceDialog(current.title, current.options, 0, { host.cancel(current) }) { + index -> + host.complete(current) { current.onSelect(index) } + } + is SettingsDialogHost.Request.Confirmation -> + AlertDialog( + onDismissRequest = { host.cancel(current) }, + title = { Text(current.title) }, + text = { Text(current.message) }, + confirmButton = { + TextButton(onClick = { host.complete(current, current.onConfirm) }) { + Text(settingsString(R.string.settings_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { host.cancel(current) }) { + Text(settingsString(R.string.settings_cancel)) + } + }, + ) + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt new file mode 100644 index 0000000..b7af2eb --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsScreen.kt @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.shilapi.xcertplay.host.R +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +internal fun SettingsTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = + darkColorScheme( + primary = Color(0xffb4d9b1), + onPrimary = Color(0xff20391f), + background = Color(0xff11161a), + surface = Color(0xff11161a), + surfaceContainer = Color(0xff1c2428), + onSurface = Color(0xffe2e8e3), + onSurfaceVariant = Color(0xffc1c9be), + outline = Color(0xff8b9389), + ), + content = content, + ) +} + +@Composable +internal fun SettingsRoute( + model: SettingsViewModel, + actions: SettingsActions, + dialogs: SettingsDialogHost, + onScroll: (Int) -> Unit, +) { + val state by model.state.collectAsStateWithLifecycle() + SettingsTheme { + SettingsScreen(state, actions, model::open, model::scrollPosition) { category, offset -> + model.rememberScroll(category, offset) + onScroll(offset) + } + SettingsPlatformDialog(dialogs) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SettingsScreen( + state: SettingsState, + actions: SettingsActions, + onCategory: (SettingsCategory?) -> Unit, + initialScroll: (SettingsCategory?) -> Int, + onScroll: (SettingsCategory?, Int) -> Unit, +) { + Surface(Modifier.fillMaxSize()) { + BoxWithConstraints { + val gutter = maxOf(if (maxWidth < 360.dp) 16.dp else 24.dp, (maxWidth - 760.dp) / 2) + key(state.category) { + val category = state.category + val scroll = rememberScrollState(initialScroll(category)) + LaunchedEffect(scroll) { + snapshotFlow { scroll.value } + .distinctUntilChanged() + .collect { onScroll(category, it) } + } + DisposableEffect(scroll) { onDispose { onScroll(category, scroll.value) } } + Column(Modifier.fillMaxSize()) { + if (category != null) + TopAppBar( + title = { Text(settingsString(category.title)) }, + navigationIcon = { + IconButton( + onClick = { onCategory(null) }, + modifier = Modifier.testTag("settings:back"), + ) { + Icon( + painterResource(R.drawable.ic_settings_back), + settingsString(R.string.settings_back), + ) + } + }, + windowInsets = WindowInsets(0, 0, 0, 0), + ) + Column( + Modifier.weight(1f) + .fillMaxWidth() + .testTag("settings:page") + .verticalScroll(scroll) + .padding(horizontal = gutter) + .padding(top = 24.dp, bottom = 32.dp) + ) { + if (category == null) { + Text( + settingsString(R.string.settings_title), + style = MaterialTheme.typography.headlineSmall, + ) + SettingsDescription(R.string.settings_index_summary) + SettingsIndexScreen(state, actions, onCategory) + } else { + SettingsDescription( + when (category) { + SettingsCategory.DISPLAY -> R.string.settings_display_effect + SettingsCategory.AUDIO -> R.string.settings_audio_effect + SettingsCategory.CONNECTION, + SettingsCategory.VEHICLE -> R.string.settings_next_connection + else -> category.summary + } + ) + when (category) { + SettingsCategory.CONNECTION -> + ConnectionSettingsScreen(state, actions) + SettingsCategory.DISPLAY -> DisplaySettingsScreen(state, actions) + SettingsCategory.AUDIO -> AudioSettingsScreen(state, actions) + SettingsCategory.VEHICLE -> VehicleSettingsScreen(state, actions) + SettingsCategory.BROWSER -> BrowserSettingsScreen(state, actions) + SettingsCategory.WEB -> WebSettingsScreen(state, actions) + SettingsCategory.TESLA -> TeslaSettingsScreen(state, actions) + SettingsCategory.AUTHENTICATION -> + AuthenticationSettingsScreen(state, actions) + SettingsCategory.HELP -> HelpSettingsScreen(state, actions) + } + } + } + } + } + } + } +} + +@Composable +private fun SettingsIndexScreen( + state: SettingsState, + actions: SettingsActions, + onCategory: (SettingsCategory?) -> Unit, +) { + Card( + onClick = { actions.command(SettingsCommand.DIPLAY) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(20.dp)) { + Text( + settingsString(R.string.settings_diplay_title), + style = MaterialTheme.typography.titleLarge, + ) + SettingsDescription(R.string.settings_diplay_description) + } + } + SettingsCategory.entries.forEach { category -> + Row( + Modifier.fillMaxWidth() + .testTag("category:${category.name}") + .clickable(role = Role.Button) { onCategory(category) } + .heightIn(min = 80.dp) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f).padding(end = 16.dp)) { + Text(settingsString(category.title), style = MaterialTheme.typography.titleMedium) + Text( + settingsString( + if ( + category == SettingsCategory.AUTHENTICATION && + state.runtime.authenticationError != null + ) + R.string.settings_auth_missing_summary + else category.summary + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + painterResource(R.drawable.ic_settings_chevron), + null, + Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(name = "Settings", locale = "zh", widthDp = 360, heightDp = 800) +@Preview( + name = "Settings large text", + locale = "zh", + widthDp = 320, + heightDp = 640, + fontScale = 1.3f, +) +@Composable +private fun SettingsIndexPreview() { + SettingsTheme { SettingsScreen(SettingsState(), previewActions, {}, { 0 }, { _, _ -> }) } +} + +@Preview(name = "Audio", locale = "zh", widthDp = 360, heightDp = 800) +@Composable +private fun AudioSettingsPreview() { + SettingsTheme { + SettingsScreen( + SettingsState( + category = SettingsCategory.AUDIO, + choices = ChoiceSetting.entries.associateWith { 0 }, + toggles = ToggleSetting.entries.associateWith { false }, + ), + previewActions, + {}, + { 0 }, + { _, _ -> }, + ) + } +} + +private val previewActions = + SettingsActions( + { _, _ -> }, + { _, _ -> }, + { _, _ -> null }, + { _, _ -> null }, + {}, + {}, + {}, + { _, _ -> }, + {}, + ) diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt new file mode 100644 index 0000000..8394fc5 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsState.kt @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.annotation.StringRes +import com.shilapi.xcertplay.CarPlaySafeMargins +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.network.TeslaHttpConfig +import com.shilapi.xcertplay.web.RememberedBrowsers +import com.shilapi.xcertplay.web.WebListenSettings + +internal enum class SettingsCategory( + @param:StringRes val title: Int, + @param:StringRes val summary: Int, +) { + CONNECTION(R.string.settings_connection, R.string.settings_connection_summary), + DISPLAY(R.string.settings_display, R.string.settings_display_summary), + AUDIO(R.string.settings_audio, R.string.settings_audio_summary), + VEHICLE(R.string.settings_vehicle, R.string.settings_vehicle_summary), + BROWSER(R.string.settings_browser, R.string.settings_browser_summary), + WEB(R.string.settings_web, R.string.settings_web_summary), + TESLA(R.string.settings_tesla, R.string.settings_tesla_summary), + AUTHENTICATION(R.string.settings_authentication, R.string.settings_authentication_summary), + HELP(R.string.settings_help, R.string.settings_help_summary), +} + +internal enum class ChoiceSetting { + APPEARANCE, + DISPLAY_SIZE, + RESOLUTION, + FPS, + STREAM, + RECOVERY, + AUDIO_BUFFER, + AUDIO_OUTPUT, + MICROPHONE, + LOCATION, + OFFLINE, + WIRELESS, + TESLA_MODE, + CERTIFICATE, +} + +internal enum class ToggleSetting { + AUTO_CONNECT, + AUTO_START, + ADAPTIVE_SIZE, + HEVC, + RIGHT_HAND, + NAVIGATION, + HTTPS, + TESLA, +} + +internal enum class TextSetting { + OEM_NAME, + SSID, + PASSWORD, + HTTP_PORT, + HTTPS_PORT, + HOSTNAME, + TESLA_ADDRESS, + HTTP_MAPPING, + HTTPS_MAPPING, +} + +internal enum class SettingsCommand { + SELECT_PHONE, + HOTSPOT_SETTINGS, + REQUEST_ROOT, + RETRY_ROOT, + ENABLE_VPN, + SELECT_ICON, + IMPORT_APK, + IMPORT_FILES, + SELECT_PEM, + SELECT_PKCS12, + APP_PERMISSIONS, + BLUETOOTH_SETTINGS, + WIRELESS_HELP, + EXPORT_REPORT, + DIPLAY, + SOURCE, + DOWNLOAD, +} + +/** Immutable preferences and live service status. Credentials never enter SavedStateHandle. */ +internal data class SettingsState( + val category: SettingsCategory? = null, + val choices: Map = emptyMap(), + val toggles: Map = emptyMap(), + val phoneName: String = "", + val manufacturer: String = "", + val iconPath: String? = null, + val iconVersion: Long = 0, + val margins: CarPlaySafeMargins? = null, + val legacySafeArea: Boolean = false, + val ssid: String = "", + val password: String = "", + val httpPort: Int = 8080, + val httpsPort: Int = 8443, + val hostname: String = "", + val certificateType: WebListenSettings.CertificateType = + WebListenSettings.CertificateType.DEFAULT, + val customCertificate: Boolean = false, + val navigationAvailable: Boolean = false, + val tesla: TeslaHttpConfig = TeslaHttpConfig(), + val browsers: List = emptyList(), + val runtime: SettingsRuntime = SettingsRuntime(), +) { + fun selected(setting: ChoiceSetting) = choices.getValue(setting) + + fun checked(setting: ToggleSetting) = toggles.getValue(setting) +} + +internal data class SettingsRuntime( + val authenticationError: String? = null, + val importing: Boolean = false, + val webBusy: Boolean = false, + val awaitingVpnConsent: Boolean = false, + val exporting: Boolean = false, + val rootGranted: Boolean = false, + val rootChecking: Boolean = false, + val rootStatus: String = "", + val teslaStatus: String = "", + val audioStatus: String = "", + val microphoneStatus: String = "", + val version: String = "", + val fingerprint: String? = null, +) + +/** The Activity executes platform operations; composables only describe UI and emit actions. */ +internal class SettingsActions( + val choice: (ChoiceSetting, Int) -> Unit, + val toggle: (ToggleSetting, Boolean) -> Unit, + val text: (TextSetting, String) -> String?, + val credentials: (String, String) -> String?, + val margins: (CarPlaySafeMargins) -> Unit, + val resetBranding: (Boolean) -> Unit, + val command: (SettingsCommand) -> Unit, + val renameBrowser: (String, String) -> Unit, + val removeBrowser: (String?) -> Unit, +) diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt new file mode 100644 index 0000000..bc08d10 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/SettingsViewModel.kt @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import com.shilapi.xcertplay.* +import com.shilapi.xcertplay.airplay.CarPlaySize +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.hud.BydOutputSettings +import com.shilapi.xcertplay.media.MediaAudioBuffer +import com.shilapi.xcertplay.network.TeslaHttpMode +import com.shilapi.xcertplay.orchestration.* +import com.shilapi.xcertplay.web.* +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal sealed interface SettingsEffect { + data object None : SettingsEffect + + data class Reconnect(val wireless: Boolean? = null, val stopBeforeConnect: Boolean = false) : + SettingsEffect + + data class WebUpdate(val message: String, val operation: () -> String?) : SettingsEffect + + data class TeslaEnabled(val enabled: Boolean) : SettingsEffect +} + +/** Owns preference persistence and navigation. It never retains an Activity or a View. */ +internal class SettingsViewModel( + application: Application, + private val savedState: SavedStateHandle, +) : AndroidViewModel(application) { + private val app = application + private val mutableState = + MutableStateFlow( + readPreferences( + SettingsState( + category = + savedState.get("settingsCategory")?.let { name -> + SettingsCategory.entries.firstOrNull { it.name == name } + } + ) + ) + ) + val state = mutableState.asStateFlow() + + fun open(category: SettingsCategory?) { + savedState["settingsCategory"] = category?.name + mutableState.value = mutableState.value.copy(category = category) + } + + fun scrollPosition(category: SettingsCategory?) = + savedState.get("scroll:${category?.name ?: "index"}") ?: 0 + + fun rememberScroll(category: SettingsCategory?, position: Int) { + savedState["scroll:${category?.name ?: "index"}"] = position + } + + fun reload() { + mutableState.value = readPreferences(mutableState.value) + } + + fun updateRuntime(runtime: SettingsRuntime) { + mutableState.value = mutableState.value.copy(runtime = runtime) + } + + private fun readPreferences(previous: SettingsState): SettingsState = + previous.copy( + choices = + mapOf( + ChoiceSetting.APPEARANCE to AirPlayPersistence.loadAppearance(app).ordinal, + ChoiceSetting.DISPLAY_SIZE to + CarPlaySize.fromWidthMillimeters( + AirPlayPersistence.loadWidthPhysicalMm(app) + ) + .ordinal, + ChoiceSetting.RESOLUTION to + listOf(10, 8, 6) + .indexOf(AirPlayPersistence.loadDisplayScaleTenths(app)) + .coerceAtLeast(0), + ChoiceSetting.FPS to if (AirPlayPersistence.loadFps(app) == 60) 1 else 0, + ChoiceSetting.STREAM to AirPlayPersistence.loadStreamTechnology(app).ordinal, + ChoiceSetting.RECOVERY to AirPlayPersistence.loadRtcRecoveryPolicy(app).ordinal, + ChoiceSetting.AUDIO_BUFFER to + MediaAudioBuffer.presets + .indexOf(AirPlayPersistence.loadMediaBufferMillis(app)) + .coerceAtLeast(0), + ChoiceSetting.AUDIO_OUTPUT to + if (AirPlayPersistence.loadBrowserAudioPlayback(app)) 1 else 0, + ChoiceSetting.MICROPHONE to + AirPlayPersistence.loadMicrophoneSource(app).ordinal, + ChoiceSetting.LOCATION to + if (AirPlayPersistence.loadLocationReportingEnabled(app)) 1 else 0, + ChoiceSetting.OFFLINE to + AirPlayPersistence.loadBrowserOfflinePolicy(app).ordinal, + ChoiceSetting.WIRELESS to + if ( + AirPlayPersistence.loadWirelessHotspotMode(app) == + WirelessHotspotMode.MANUAL + ) + 1 + else 0, + ChoiceSetting.TESLA_MODE to TeslaHttpCompatibility.config(app).mode.ordinal, + ChoiceSetting.CERTIFICATE to WebListenSettings.certificateType(app).ordinal, + ), + toggles = + mapOf( + ToggleSetting.AUTO_CONNECT to DiPlayPreferences.autoConnect(app), + ToggleSetting.AUTO_START to AirPlayPersistence.loadAutoStartOnBoot(app), + ToggleSetting.ADAPTIVE_SIZE to AirPlayPersistence.loadAdaptiveBrowserSize(app), + ToggleSetting.HEVC to AirPlayPersistence.loadHevcEnabled(app), + ToggleSetting.RIGHT_HAND to AirPlayPersistence.loadRightHandDrive(app), + ToggleSetting.NAVIGATION to BydOutputSettings.enabled(app), + ToggleSetting.HTTPS to WebListenSettings.httpsEnabled(app), + ToggleSetting.TESLA to TeslaHttpCompatibility.config(app).enabled, + ), + phoneName = + if (DiPlayPreferences.phoneAddress(app) == null) + app.getString(R.string.settings_unselected) + else DiPlayPreferences.phoneName(app), + manufacturer = AirPlayPersistence.loadSimulatedManufacturerName(app), + iconPath = AirPlayPersistence.loadCustomAirPlayIconFile(app)?.absolutePath, + iconVersion = AirPlayPersistence.loadCustomAirPlayIconFile(app)?.lastModified() ?: 0, + margins = AirPlayPersistence.loadSafeMargins(app), + legacySafeArea = + (WebSession.currentBrowserViewport ?: (1280 to 720)).let { (w, h) -> + AirPlayPersistence.loadSafeAreaRect(app, w, h) != null + }, + ssid = AirPlayPersistence.loadManualHotspotSsid(app), + password = AirPlayPersistence.loadManualHotspotPassphrase(app), + httpPort = WebListenSettings.httpPort(app), + httpsPort = WebListenSettings.httpsPort(app), + hostname = WebListenSettings.hostname(app), + certificateType = WebListenSettings.certificateType(app), + customCertificate = LanTls.hasCustom(app), + navigationAvailable = BydOutputSettings.available(app), + tesla = TeslaHttpCompatibility.config(app), + browsers = WebSession.rememberedBrowsers(app).list(), + ) + + fun choose(setting: ChoiceSetting, index: Int): SettingsEffect { + if (state.value.selected(setting) == index) return SettingsEffect.None + val effect = + when (setting) { + ChoiceSetting.APPEARANCE -> { + WebSession.setAppearance(app, CarPlayAppearance.entries[index]) + SettingsEffect.None + } + ChoiceSetting.DISPLAY_SIZE -> { + AirPlayPersistence.saveWidthPhysicalMm( + app, + CarPlaySize.entries[index].widthMillimeters, + ) + SettingsEffect.Reconnect() + } + ChoiceSetting.RESOLUTION -> { + AirPlayPersistence.saveDisplayScaleTenths(app, listOf(10, 8, 6)[index]) + SettingsEffect.Reconnect() + } + ChoiceSetting.FPS -> { + AirPlayPersistence.saveFps(app, if (index == 1) 60 else 30) + SettingsEffect.Reconnect() + } + ChoiceSetting.STREAM -> { + AirPlayPersistence.saveStreamTechnology(app, StreamTechnology.entries[index]) + SettingsEffect.Reconnect() + } + ChoiceSetting.RECOVERY -> { + AirPlayPersistence.saveRtcRecoveryPolicy(app, RtcRecoveryPolicy.entries[index]) + SettingsEffect.Reconnect() + } + ChoiceSetting.AUDIO_BUFFER -> { + WebSession.setMediaBufferMillis(app, MediaAudioBuffer.presets[index]) + SettingsEffect.Reconnect() + } + ChoiceSetting.AUDIO_OUTPUT -> { + WebSession.setBrowserAudioPlayback(app, index == 1) + SettingsEffect.None + } + ChoiceSetting.MICROPHONE -> { + WebSession.setMicrophoneSource(app, CarPlayMicrophoneSource.entries[index]) + SettingsEffect.Reconnect() + } + ChoiceSetting.LOCATION -> { + AirPlayPersistence.saveLocationReportingEnabled(app, index == 1) + SettingsEffect.None + } + ChoiceSetting.OFFLINE -> { + WebSession.setBrowserOfflinePolicy(app, BrowserOfflinePolicy.entries[index]) + SettingsEffect.None + } + ChoiceSetting.WIRELESS -> { + if (index == 1) + require( + ManualHotspotValidation.validate( + state.value.ssid, + state.value.password, + ) == null + ) + AirPlayPersistence.saveWirelessHotspotMode( + app, + if (index == 1) WirelessHotspotMode.MANUAL else WirelessHotspotMode.WIFI_P2P, + ) + SettingsEffect.Reconnect(wireless = true, stopBeforeConnect = true) + } + ChoiceSetting.TESLA_MODE -> { + TeslaHttpCompatibility.save( + app, + TeslaHttpCompatibility.config(app) + .copy(mode = TeslaHttpMode.entries[index], enabled = false), + ) + SettingsEffect.None + } + ChoiceSetting.CERTIFICATE -> { + val type = WebListenSettings.CertificateType.entries[index] + if (type == WebListenSettings.CertificateType.DEFAULT) + SettingsEffect.WebUpdate( + app.getString(R.string.settings_default_certificate_restored) + ) { + WebSession.restoreDefaultCertificate(app).also { + if (it == null) WebListenSettings.saveCertificateType(app, type) + } + } + else { + WebListenSettings.saveCertificateType(app, type) + SettingsEffect.None + } + } + } + reload() + return effect + } + + fun toggle(setting: ToggleSetting, checked: Boolean): SettingsEffect { + val effect = + when (setting) { + ToggleSetting.AUTO_CONNECT -> { + DiPlayPreferences.saveAutoConnect(app, checked) + SettingsEffect.None + } + ToggleSetting.AUTO_START -> { + AirPlayPersistence.saveAutoStartOnBoot(app, checked) + SettingsEffect.None + } + ToggleSetting.ADAPTIVE_SIZE -> { + AirPlayPersistence.saveAdaptiveBrowserSize(app, checked) + WebSession.setAdaptiveBrowserSize(checked) + SettingsEffect.None + } + ToggleSetting.HEVC -> { + AirPlayPersistence.saveHevcEnabled(app, checked) + SettingsEffect.None + } + ToggleSetting.RIGHT_HAND -> { + AirPlayPersistence.saveRightHandDrive(app, checked) + SettingsEffect.None + } + ToggleSetting.NAVIGATION -> { + BydOutputSettings.setEnabled(app, checked) + SettingsEffect.None + } + ToggleSetting.HTTPS -> + SettingsEffect.WebUpdate( + if (checked) app.getString(R.string.settings_https_enabled) + else app.getString(R.string.settings_https_disabled) + ) { + WebSession.setHttpsEnabled(app, checked) + } + ToggleSetting.TESLA -> SettingsEffect.TeslaEnabled(checked) + } + reload() + return effect + } + + fun edit(setting: TextSetting, value: String): SettingsEffect { + val effect = + when (setting) { + TextSetting.OEM_NAME -> { + AirPlayPersistence.saveSimulatedManufacturerName(app, value.trim()) + SettingsEffect.None + } + TextSetting.SSID -> { + saveCredentials(value, state.value.password) + SettingsEffect.None + } + TextSetting.PASSWORD -> { + saveCredentials(state.value.ssid, value) + SettingsEffect.None + } + TextSetting.HTTP_PORT, + TextSetting.HTTPS_PORT -> { + val port = value.trim().toIntOrNull() + require(port != null && port in 1024..65535) { + app.getString(R.string.settings_listener_port_invalid) + } + SettingsEffect.WebUpdate(app.getString(R.string.settings_listener_port_saved)) { + if (setting == TextSetting.HTTP_PORT) WebSession.setHttpPort(app, port) + else WebSession.setHttpsPort(app, port) + } + } + TextSetting.HOSTNAME -> + SettingsEffect.WebUpdate(app.getString(R.string.settings_hostname_saved)) { + WebSession.setHostname(app, value.trim().lowercase(Locale.ROOT)) + } + TextSetting.TESLA_ADDRESS -> { + val config = TeslaHttpCompatibility.config(app) + TeslaHttpCompatibility.save( + app, + if (config.mode == TeslaHttpMode.VPN) config.copy(vpnAddress = value.trim()) + else config.copy(address = value.trim()), + ) + SettingsEffect.None + } + TextSetting.HTTP_MAPPING, + TextSetting.HTTPS_MAPPING -> { + val port = value.trim().toIntOrNull() + require(port != null && port in 1..1023) { + app.getString(R.string.settings_mapping_port_invalid) + } + val config = TeslaHttpCompatibility.config(app) + TeslaHttpCompatibility.save( + app, + if (setting == TextSetting.HTTP_MAPPING) config.copy(httpMappingPort = port) + else config.copy(httpsMappingPort = port), + ) + SettingsEffect.None + } + } + reload() + return effect + } + + /** Validate both fields before writing either, preserving SSID/password whitespace. */ + fun saveCredentials(ssid: String, password: String) { + ManualHotspotValidation.validate(ssid, password)?.let { error(it) } + AirPlayPersistence.saveManualHotspotSsid(app, ssid) + AirPlayPersistence.saveManualHotspotPassphrase(app, password) + AirPlayPersistence.saveManualHotspotSecurity( + app, + ManualHotspotValidation.securityFor(password), + ) + AirPlayPersistence.saveManualHotspotBand(app, ManualHotspotBand.AUTO) + AirPlayPersistence.saveManualHotspotChannel(app, 0) + reload() + } + + fun saveMargins(margins: CarPlaySafeMargins): SettingsEffect { + AirPlayPersistence.saveSafeMargins(app, margins) + reload() + return SettingsEffect.Reconnect() + } + + fun resetBranding(includeName: Boolean) { + AirPlayPersistence.clearCustomAirPlayIcon(app) + if (includeName) + AirPlayPersistence.saveSimulatedManufacturerName( + app, + AirPlayPersistence.DEFAULT_SIMULATED_MANUFACTURER_NAME, + ) + reload() + } + + fun renameBrowser(id: String, name: String) { + WebSession.rememberedBrowsers(app).rename(id, name.trim()) + reload() + } + + fun removeBrowser(id: String?) { + if (id == null) WebSession.forgetAllBrowsers(app) else WebSession.forgetBrowser(app, id) + reload() + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt new file mode 100644 index 0000000..b2b127f --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/TeslaSettingsScreen.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.network.TeslaHttpMode + +@Composable +internal fun TeslaSettingsScreen(state: SettingsState, actions: SettingsActions) { + val config = state.tesla + val enabled = !state.runtime.webBusy && !state.runtime.awaitingVpnConsent + SettingsChoice( + state, + actions, + ChoiceSetting.TESLA_MODE, + R.string.settings_connection_mode, + TeslaHttpMode.entries.map { it.label }, + enabled, + ) + SettingsDescription(R.string.settings_tesla_description) + if (config.mode == TeslaHttpMode.VPN) { + SettingsToggle( + state, + actions, + ToggleSetting.TESLA, + R.string.settings_enable_vpn, + R.string.settings_enable_vpn_description, + enabled, + ) + Text(state.runtime.teslaStatus, style = MaterialTheme.typography.bodyMedium) + SettingsTextPreference( + actions, + TextSetting.TESLA_ADDRESS, + R.string.settings_access_ip, + config.vpnAddress, + enabled, + ) + SettingsDescription(R.string.settings_vpn_description, config.vpnAddress, state.httpPort) + SettingsDescription(R.string.settings_vpn_ports_description) + SettingsButton(R.string.settings_retry_vpn, enabled) { + actions.command(SettingsCommand.ENABLE_VPN) + } + } else { + SettingsButton(R.string.settings_request_root, enabled && !state.runtime.rootChecking) { + actions.command(SettingsCommand.REQUEST_ROOT) + } + Text(state.runtime.rootStatus, style = MaterialTheme.typography.bodyMedium) + if (state.runtime.rootGranted) { + SettingsToggle( + state, + actions, + ToggleSetting.TESLA, + R.string.settings_enable_root, + R.string.settings_enable_root_description, + enabled, + ) + Text(state.runtime.teslaStatus, style = MaterialTheme.typography.bodyMedium) + SettingsTextPreference( + actions, + TextSetting.HTTP_MAPPING, + R.string.settings_http_mapping, + config.httpMappingPort.toString(), + enabled, + numeric = true, + displayValue = "${config.httpMappingPort} → ${state.httpPort}", + ) + SettingsTextPreference( + actions, + TextSetting.HTTPS_MAPPING, + R.string.settings_https_mapping, + config.httpsMappingPort.toString(), + enabled, + numeric = true, + displayValue = + "${config.httpsMappingPort} → " + + if (state.checked(ToggleSetting.HTTPS)) state.httpsPort.toString() + else settingsString(R.string.settings_https_disabled), + ) + SettingsTextPreference( + actions, + TextSetting.TESLA_ADDRESS, + R.string.settings_access_ip, + config.address, + enabled, + ) + SettingsDescription(R.string.settings_root_description) + SettingsButton(R.string.settings_retry_root, enabled) { + actions.command(SettingsCommand.RETRY_ROOT) + } + } + } + SettingsButton(R.string.settings_open_hotspot) { + actions.command(SettingsCommand.HOTSPOT_SETTINGS) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt new file mode 100644 index 0000000..d9428b0 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/VehicleSettingsScreen.kt @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun VehicleSettingsScreen(state: SettingsState, actions: SettingsActions) { + var reset by remember { mutableStateOf(false) } + val context = LocalContext.current.applicationContext + val icon by + produceState(null, state.iconPath, state.iconVersion) { + value = + withContext(Dispatchers.IO) { + (state.iconPath?.let { BitmapFactory.decodeFile(it) } + ?: BitmapFactory.decodeResource(context.resources, R.raw.ic_car_home)) + ?.asImageBitmap() + } + } + SettingsGroup(R.string.settings_oem) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + icon?.let { + Image(it, settingsString(R.string.settings_icon_preview), Modifier.size(72.dp)) + } + Column { + Text(state.manufacturer, style = MaterialTheme.typography.titleLarge) + SettingsDescription( + if (state.iconPath == null) R.string.settings_default_icon + else R.string.settings_custom_icon + ) + } + } + SettingsDescription(R.string.settings_oem_description) + SettingsTextPreference( + actions, + TextSetting.OEM_NAME, + R.string.settings_oem_name, + state.manufacturer, + ) + SettingsButton(R.string.settings_select_icon) { + actions.command(SettingsCommand.SELECT_ICON) + } + SettingsDescription(R.string.settings_icon_description) + SettingsButton(R.string.settings_reset_icon, enabled = state.iconPath != null) { + actions.resetBranding(false) + } + SettingsButton(R.string.settings_reset_branding) { reset = true } + } + SettingsGroup(R.string.settings_location) { + SettingsChoice( + state, + actions, + ChoiceSetting.LOCATION, + R.string.settings_location_source, + listOf( + settingsString(R.string.settings_location_iphone), + settingsString(R.string.settings_location_android), + ), + ) + SettingsDescription(R.string.settings_location_description) + } + if (state.navigationAvailable) + SettingsGroup(R.string.settings_navigation) { + Text( + settingsString(R.string.settings_navigation_manufacturer, state.manufacturer), + style = MaterialTheme.typography.bodyMedium, + ) + SettingsToggle( + state, + actions, + ToggleSetting.NAVIGATION, + R.string.settings_navigation_output, + R.string.settings_navigation_description, + ) + } + if (reset) + SettingsConfirmation( + R.string.settings_reset_branding_title, + R.string.settings_reset_branding_message, + { reset = false }, + ) { + actions.resetBranding(true) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt b/common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt new file mode 100644 index 0000000..a26f259 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/settings/WebSettingsScreen.kt @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +package com.shilapi.xcertplay.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.web.WebListenSettings + +@Composable +internal fun WebSettingsScreen(state: SettingsState, actions: SettingsActions) { + val enabled = !state.runtime.webBusy + SettingsGroup(R.string.settings_web_listeners) { + SettingsTextPreference( + actions, + TextSetting.HTTP_PORT, + R.string.settings_http_port, + state.httpPort.toString(), + enabled, + numeric = true, + ) + SettingsTextPreference( + actions, + TextSetting.HOSTNAME, + R.string.settings_hostname, + state.hostname, + enabled, + displayValue = state.hostname.ifEmpty { settingsString(R.string.settings_use_ip) }, + ) + SettingsDescription(R.string.settings_web_description) + SettingsToggle( + state, + actions, + ToggleSetting.HTTPS, + R.string.settings_https, + R.string.settings_https_description, + enabled, + ) + if (state.checked(ToggleSetting.HTTPS)) { + SettingsTextPreference( + actions, + TextSetting.HTTPS_PORT, + R.string.settings_https_port, + state.httpsPort.toString(), + enabled, + numeric = true, + ) + Text( + settingsString(R.string.settings_certificate_type), + style = MaterialTheme.typography.titleMedium, + ) + WebListenSettings.CertificateType.entries.forEach { type -> + Row( + Modifier.fillMaxWidth() + .testTag("certificate:${type.name}") + .selectable( + state.certificateType == type, + enabled = enabled, + role = Role.RadioButton, + ) { + actions.choice(ChoiceSetting.CERTIFICATE, type.ordinal) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(state.certificateType == type, onClick = null, enabled = enabled) + Text(type.label, Modifier.padding(start = 12.dp)) + } + } + when (state.certificateType) { + WebListenSettings.CertificateType.DEFAULT -> Unit + WebListenSettings.CertificateType.PEM -> + SettingsButton(R.string.settings_select_pem, enabled) { + actions.command(SettingsCommand.SELECT_PEM) + } + WebListenSettings.CertificateType.PKCS12 -> + SettingsButton(R.string.settings_select_pkcs12, enabled) { + actions.command(SettingsCommand.SELECT_PKCS12) + } + } + SettingsDescription( + if (state.customCertificate) R.string.settings_certificate_custom + else R.string.settings_certificate_default + ) + SettingsDescription(R.string.settings_certificate_description) + } + } +} diff --git a/common/src/main/res/drawable/ic_settings_back.xml b/common/src/main/res/drawable/ic_settings_back.xml new file mode 100644 index 0000000..beffdb6 --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_back.xml @@ -0,0 +1,7 @@ + + + diff --git a/common/src/main/res/values/settings_ids.xml b/common/src/main/res/values/settings_ids.xml new file mode 100644 index 0000000..03e31ca --- /dev/null +++ b/common/src/main/res/values/settings_ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/src/main/res/values/settings_strings.xml b/common/src/main/res/values/settings_strings.xml new file mode 100644 index 0000000..3f96a85 --- /dev/null +++ b/common/src/main/res/values/settings_strings.xml @@ -0,0 +1,193 @@ + + + + 设置 + 选择分类以调整 CarPlay 和浏览器体验。 + 返回设置 + iPhone 连接 + 自动连接、已配对的 iPhone、无线连接 + 画面与串流 + 昼夜模式、尺寸、安全区域、视频编码 + 音频与麦克风 + 音频输出、麦克风来源、音频缓冲 + 车厂与定位 + OEM 名称和图标、定位来源、导航输出 + 浏览器设备 + 设备管理、浏览器离线后的处理 + Web 服务 + HTTP / HTTPS 端口、域名、证书 + Tesla 兼容(实验) + 免 Root VPN、Root 路由、热点访问 IP + CarPlay 认证资源 + 从 DiPlay APK 导入或选择自定义资源 + 帮助与关于 + 应用权限、连接帮助、诊断报告、源码 + 昼夜模式立即生效;修改画面或串流参数会重新连接。 + 音频输出立即生效;修改麦克风来源会重新连接。 + 修改后在下次连接 iPhone 生效。 + 本项目基于 DiPlay 修改 + 如果车机可以直接安装任意 Android 应用,可以尝试直接使用 DiPlay 以获得更佳的体验。本项目旨在通过浏览器提供无侵入 CarPlay 支持。 + 尚未配置有效资源 · 导入后可启动 CarPlay + 保存 + 取消 + 确认 + 关闭 + 自动连接 + 打开应用时自动连接 + 使用上次的连接方式和已选择的 iPhone。 + 设备启动后打开 + 需要系统允许应用自启动。 + iPhone + 未选择 + 无线连接 + 连接网络 + Wi-Fi Direct · 默认 + 设备热点 + 由此设备创建 Wi-Fi Direct 网络供 iPhone 连接。 + 热点名称 + 热点密码 + 未设置 + 先在系统设置中开启热点,在此填写相同名称与密码。iPhone 会连接该网络,修改在下次连接生效。 + 打开热点设置 + 音频缓冲 + 300 ms · 默认 + 1000 ms · 更稳定 + 音频输出 + 即时切换。浏览器未就绪或离线时回退 Android;浏览器可能需要点击页面才能播放声音。 + 麦克风来源 + 修改后重新连接。浏览器麦克风需要可信 HTTPS 和授权,未就绪时回退 Android;禁用会停止上传并取消麦克风能力声明。 + CarPlay 昼夜模式 + 即时发送给 iPhone;跟随 Android 时使用此服务设备的系统昼夜模式。 + CarPlay 显示尺寸 + 调整 CarPlay 图标和文字大小,应用后会重新连接。 + 保留已有安全区域 + 全屏 + 上 %1$d%% · 下 %2$d%% · 左 %3$d%% · 右 %4$d%% + 画面安全区域 + 为被车机控件遮挡的边缘设置留白,按画面比例适配分辨率。保存后重新连接,让 iPhone 在安全区域内布局;触控坐标仍覆盖完整画面。 + 自适应浏览器尺寸 + 按车机浏览器的实际画面比例协商 CarPlay 分辨率;浏览器尺寸变化时会重新连接。 + 分辨率 + 原始分辨率 + 80% · 降低负载 + 60% · 最低负载 + 协商帧率 + 30 fps · 较低负载 + 60 fps · 更流畅 + 串流技术 + WebRTC 支持 H.264 / HEVC 直通,浏览器不支持时自动回退 JPEG(约 15 fps)。追求 60 fps 请将协商帧率设为 60;实际帧率以网页显示为准。 + WebRTC 卡顿容忍时间 + 短暂断流时保留最后一帧并等待恢复,持续异常超过所选时间才回退 JPEG。不增加视频缓冲;不支持的编码或彻底失败的连接仍立即回退。下次浏览器连接生效。 + 允许 iPhone 使用 HEVC + 下次连接生效。需要浏览器支持 HEVC / WebRTC,否则回退 JPEG;关闭时使用 H.264。 + 右舵布局 + 将 CarPlay 控件移到右侧。 + 画面安全区域预览 + 高亮区域为 CarPlay 布局区域。每边 0–40%,0 表示不留白。 + 上边留白 + 下边留白 + 左边留白 + 右边留白 + 每边留白须为 0–40% 的整数 + 恢复全屏 + OEM 车厂名称与图标 + CarPlay 车厂图标预览 + 默认 NIO 图标 + 自定义图标 + 显示在 CarPlay 应用列表的「返回车机」入口。名称与图标可分别修改,下次连接 iPhone 生效。 + 车厂名称 + 选择车厂图标 + 选择图片后可拖动、缩放并裁剪为正方形。图标保存在本机,原图片删除后仍可使用。 + 恢复默认图标 + 恢复默认名称与图标 + 恢复默认车厂设置? + 车厂名称和图标将恢复为 NIO。下次连接 iPhone 生效。 + 定位 + 定位来源 + iPhone 自身定位(默认) + Android 设备定位 + 默认由 iPhone 和导航 App 定位。选择 Android 后,在 iPhone 请求时提供这台设备的定位数据,需要 Android 精确定位权限。下次连接 iPhone 生效。 + 导航输出 + 适用于兼容的 %1$s 设备。 + 抬头显示与仪表导航 + 显示导航箭头、距离与路名,支持情况取决于车型。 + Web 监听 + HTTP 端口 + Web HTTPS 端口 + Web 域名(可选) + 使用 IP 地址 + 监听设置无需 Root。Web 域名同时用于 HTTP 和 HTTPS,需解析到实际访问的 IP;填写不会自动配置 DNS,自定义 HTTPS 证书需覆盖此域名。 + 启用 HTTPS + 为浏览器提供 HTTPS 访问;浏览器麦克风需要可信证书。 + 证书类型 + 选择 PEM 证书与私钥 + 选择 PKCS#12 文件 + 当前使用自定义证书 + 当前使用默认本地证书 + 证书和私钥保存在 App 私有存储。PEM 依次选择证书链、私钥;PKCS#12 可选择私钥条目。 + 连接方案 + 主要用于帮助屏蔽私有网段访问的车机绕过地址限制,访问手机上的本地服务。除 Tesla 外,有同类限制的其他品牌车机也可尝试。 + 启用免 Root Tesla 兼容 + 需要 Android VPN 授权,无需 Root 或 Shizuku;可能与其他 VPN 冲突。 + 访问 IP 地址 + 请让车机连接此手机的系统热点,使用 http://%1$s:%2$d。此方案可能不兼容部分手机或车机,系统更新后可能失效。有线 CarPlay 已连接时,启用或修改 IP 将等待断开后生效。 + 直接使用 Web 监听端口,无低位端口映射。可选 HTTPS 仍需车机信任证书;WebRTC 视频需单独验证。 + 授权或重试 VPN + 请求 Root 权限 + 启用 Tesla 兼容 + 将 HTTP / HTTPS 低位端口及自定义 IP 共享给热点设备。 + 映射 HTTP 端口 + 映射 HTTPS 端口 + HTTPS 已关闭 + 映射只作用于此访问 IP,不改变 Web 监听端口。HTTPS 映射仅在 HTTPS 开启时生效;请开启系统热点,自定义证书需覆盖访问 IP 或域名。 + 重新启动 Root 路由 + 正在本机导入… + 资源已就绪 · 私钥与证书校验通过 + 尚未配置有效资源 + 开源安装包不包含认证资源。可选择现有的两份文件,或下载 DiPlay 官网 APK 后在此导入,无需安装 DiPlay。 + 从 DiPlay APK 导入 + 手动选择认证资源 + DiPlay 官网下载 + 下载 APK 后返回导入,或分享给 WheelPlay + 仅提取认证资源并保存在此设备的应用私有目录,不上传。导入结束后删除临时 APK;卸载 WheelPlay 将删除已导入资源。 + 选择两份匹配的资源 + 先选择 identity.pk8(PKCS#8 私钥),再选择 certificate.p7b(P-256 证书)。两份文件必须来自同一套资源;取消或校验失败不会替换原有资源。 + 权限与连接帮助 + 附近设备权限用于连接 iPhone,麦克风用于 Siri 和通话。旧版 Android 的无线连接还需要定位权限,USB 模式可能请求本地 VPN 授权。 + 浏览器 HTTPS 证书 + 查看本设备证书指纹 + 应用权限 + 系统蓝牙设置 + 无线连接帮助 + 关于与诊断 + 正在保存报告… + 保存诊断报告 + 报告保存到 Downloads/WheelPlay。不会自动上传,报告排除协议载荷和凭据。 + 请选择报告保存位置。不会自动上传,报告排除协议载荷和凭据。 + 基于 xcertplay / DiPlay,保留 GPL-3.0 与 AGPL-3.0 许可声明。CarPlay 与 NIO 图标属于各自权利人。 + 开源 + 源码地址 + HTTPS 已关闭,请在 Web 服务中开启。 + HTTPS 服务正在准备或启动失败,请稍后重试。 + 请在车机安装证书前,与浏览器页面显示的 SHA-256 指纹逐字核对:\n\n%1$s + 配对后的浏览器会被记住,下次打开时自动恢复配对;选择启动选项后,点击网页上的「启动显示」。 + 连接过的设备 + 查看、重命名或移除浏览器 + 浏览器离线后的处理 + 计时从 CarPlay 会话启动且无浏览器连接时开始;浏览器重连会取消计时。超时只断开 iPhone,Web 服务保持运行。 + 连接过的浏览器 + 还没有记住的浏览器。首次在网页扫码或输入配对码后会自动保存。 + 移除全部设备 + 浏览器名称 + 首次配对:%1$s\n最近连接:%2$s\n最近地址:%3$s + 移除设备 + 重命名 + 移除全部浏览器? + 当前浏览器连接会断开,所有浏览器下次使用时需要重新扫码或输入配对码。 + 已恢复默认本地证书 + HTTPS 已开启 + 监听端口必须为 1024–65535 + 监听端口已保存,浏览器请使用新地址重新连接 + Web 域名已保存 + 映射端口必须为 1–1023 + diff --git a/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt b/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt index ff3b7ef..7cc1cc1 100644 --- a/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt @@ -6,9 +6,12 @@ import android.os.Looper import android.view.View import android.view.ViewGroup import android.widget.Button -import android.widget.TextView +import androidx.compose.ui.test.* import com.google.android.material.bottomnavigation.BottomNavigationView import com.shilapi.xcertplay.web.WebSession +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import org.junit.After import org.junit.Assert.* import org.junit.Test @@ -19,24 +22,27 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.util.ReflectionHelpers -import java.io.File -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream @RunWith(RobolectricTestRunner::class) @Config(sdk = [29]) -class AuthenticationActivationTest { - private val app get() = RuntimeEnvironment.getApplication() +@org.robolectric.annotation.GraphicsMode(org.robolectric.annotation.GraphicsMode.Mode.NATIVE) +class AuthenticationActivationTest : ComposeSettingsTest() { + private val app + get() = RuntimeEnvironment.getApplication() - @After fun cleanup() { + @After + fun cleanup() { WebSession.stop() CarPlayBackgroundSession.clear() } - 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 views(view: View): List = + listOf(view) + + if (view is ViewGroup) (0 until view.childCount).flatMap { views(view.getChildAt(it)) } + else emptyList() - @Test fun missingResourcesGuideToSettingsWithoutStartingWebOrCarPlay() { + @Test + fun missingResourcesGuideToSettingsWithoutStartingWebOrCarPlay() { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() val ui = views(activity.window.decorView) @@ -44,7 +50,7 @@ class AuthenticationActivationTest { assertNull(shadowOf(activity).nextStartedService) assertFalse(WebSession.running) assertFalse(CarPlayBackgroundSession.hasSession()) - assertTrue(ui.filterIsInstance().any { it.text.toString().contains("激活 Web 服务") }) + compose.onNodeWithText("从 DiPlay APK 导入").assertExists() assertFalse(ui.filterIsInstance