diff --git a/README.md b/README.md index d50dd96b..1ec58264 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The Astro product website lives in [`website/`](website/README.md), separately f ## Features -- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Android Auto and ICCOA adapters are follow-up work. See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). +- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Experimental Android Auto USB AOA and explicit Head Unit Server TCP support is available, including a guided same-phone shortcut; ICCOA is blocked on its proprietary SDK. See the [Android Auto setup and limitations](docs/ANDROID_AUTO.md). See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). - **Browser display and touch control**: CarPlay streaming, touch input, and fullscreen display. - **Selectable streaming modes**: Choose WebRTC video passthrough (H.264 / HEVC) or image compatibility mode in settings, with JPEG / WebP format and image quality from 1 to 100. Image streaming retains JPEG compatibility fallback and overlaps up to two frames while audio and touch keep their existing routes. - **Optional browser audio**: After pairing, the browser can play CarPlay audio. The browser microphone can provide CarPlay voice input through a trusted HTTPS page; Android handles audio and microphone input by default. diff --git a/README.zh-CN.md b/README.zh-CN.md index 61c570be..b926365d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,6 +12,8 @@ Astro 官网位于 [`website/`](website/README.md),与 Android 内置的局域 ## 功能 +- **多输入源与实验性 Android Auto**:支持 CarPlay、三类诊断源,以及 Android Auto USB AOA/显式 Head Unit Server TCP 接入,提供带首次设置引导的本机快捷入口。AA 使用 720p H.264、浏览器触控和宿主音频/麦克风,仍需真机验收;ICCOA 因缺少专有 SDK 暂不可用。详见 [Android Auto 使用与限制](docs/ANDROID_AUTO.md)。 + - **浏览器显示与触控**:支持 CarPlay 画面串流、触摸操作和全屏显示。 - **可选串流技术**:设置中可选 WebRTC 视频直通(H.264 / HEVC)或图片兼容模式,并调整 JPEG / WebP 格式和 1–100 的图片质量。图片串流保留 JPEG 兼容回退,最多两帧并行传输,音频与触控保持原有路径。详见[图片串流与多缓冲](docs/IMAGE_STREAM.md)。 - **可选浏览器音频**:配对后可改由浏览器播放 CarPlay 音频;浏览器麦克风可通过可信 HTTPS 页面接入 CarPlay 语音输入,默认仍由 Android 处理。 diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml index 4c6aec49..bba77960 100644 --- a/common/src/main/AndroidManifest.xml +++ b/common/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 4444156d..07396324 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -4,6 +4,7 @@ package com.shilapi.xcertplay import com.shilapi.xcertplay.projection.projectionSessions import com.shilapi.xcertplay.projection.registerProjectionSources +import com.shilapi.xcertplay.projection.androidauto.LocalAndroidAuto import com.shilapi.xcertplay.shared.R as UiR import com.shilapi.xcertplay.i18n.* @@ -55,6 +56,9 @@ class DiPlayActivity : AppCompatActivity() { private val ui by lazy { ServerUi(this) } private val handler = Handler(Looper.getMainLooper()) private var page = "service" + private val sourceDetailPages = setOf("source-carplay", "source-android-auto", "source-iccoa", "source-diagnostics") + private val sourceStatuses = mutableMapOf() + private val sourceStopButtons = mutableMapOf() private val settingsModel by lazy { ViewModelProvider(this)[SettingsViewModel::class.java] } private var settingsView: ComposeView? = null private val settingsDialogs = SettingsDialogHost() @@ -63,6 +67,10 @@ class DiPlayActivity : AppCompatActivity() { private var webInterface: TextView? = null private var webBusy = false private var awaitingTeslaVpnConsent = false + private var localAndroidAutoDialog: android.app.AlertDialog? = null + private val localAndroidAutoSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (!isFinishing && !isDestroyed) showLocalAndroidAutoSetup() + } private val teslaVpnConsent = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val requested = awaitingTeslaVpnConsent awaitingTeslaVpnConsent = false @@ -238,6 +246,7 @@ class DiPlayActivity : AppCompatActivity() { override fun handleOnBackPressed() { when { page == "settings" && settingsModel.state.value.category != null -> settingsModel.open(null) + page in sourceDetailPages -> navigate("phone") page != "service" -> navigate("service") else -> moveTaskToBack(true) } @@ -282,12 +291,14 @@ class DiPlayActivity : AppCompatActivity() { } } override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } - override fun onDestroy() { settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } + override fun onDestroy() { localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } private fun normalizedPage(value: String) = when (value) { - "home", "wireless-recovery" -> "phone" + "home" -> "phone" + "wireless-recovery" -> "source-carplay" "about" -> "settings" "phone", "settings" -> value + in sourceDetailPages -> value else -> "service" } @@ -303,7 +314,7 @@ class DiPlayActivity : AppCompatActivity() { private fun createShell() { val initial = normalizedPage(page) - shell = ui.Shell(initial) { page = it } + shell = ui.Shell(initial, { page = it }, sourceDetailPages.associateWith { "phone" }) page = initial shell.put("service", ui.content(::service)) refreshPhone() @@ -318,7 +329,12 @@ class DiPlayActivity : AppCompatActivity() { private fun refreshPhone() { status = null; connectButton = null; disconnectButton = null; lastRunning = null - shell.put("phone", ui.content(::home)) + sourceStatuses.clear(); sourceStopButtons.clear() + shell.put("phone", ui.content(::sourceIndex)) + shell.put("source-carplay", ui.content(::carPlaySource)) + shell.put("source-android-auto", ui.content(::androidAutoSource)) + shell.put("source-iccoa", ui.content(::iccoaSource)) + shell.put("source-diagnostics", ui.content(::diagnosticSources)) refreshStatus() } @@ -468,7 +484,7 @@ class DiPlayActivity : AppCompatActivity() { if (!CarPlayBackgroundSession.hasSession()) connect(true) }.apply { visibility = View.GONE } body.addView(sessionConnectButton, ui.secondaryButtonLayout(12)) - body.addView(button(getString(UiR.string.ui_manage_iphone_connection), false) { navigate("phone") }, ui.secondaryButtonLayout(16)) + body.addView(button(getString(UiR.string.ui_manage_iphone_connection), false) { navigate("source-carplay") }, ui.secondaryButtonLayout(16)) } content.addView(label(getString(UiR.string.ui_audio_and_microphone_use_this_device_by_default_enable), 14, MUTED)) content.addView(button(getString(UiR.string.ui_stop_service), false) { @@ -521,18 +537,22 @@ class DiPlayActivity : AppCompatActivity() { otherAddresses?.visibility = if (count > 0 && addressesExpanded) View.VISIBLE else View.GONE } - private fun home(content: LinearLayout) { - content.addView(label(getString(UiR.string.projection_sources), 30, TEXT, true)) - ui.card(content, getString(UiR.string.projection_sources), R.drawable.ic_server_phone) { body -> - body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) - body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseProjectionSource() }, matchButton(12)) - body.addView(button(getString(UiR.string.projection_stop), false) { - projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) - refreshStatus(); refreshService() - } } - }, ui.secondaryButtonLayout(12)) - } + private fun sourceIndex(content: LinearLayout) { + content.addView(label(getString(UiR.string.source_tab), 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) + ui.sourceCard(content, getString(UiR.string.source_carplay), getString(UiR.string.source_carplay_summary), R.drawable.ic_server_phone) { navigate("source-carplay") } + ui.sourceCard(content, "Android Auto", getString(UiR.string.source_aa_summary), R.drawable.ic_server_launch) { navigate("source-android-auto") } + ui.sourceCard(content, "ICCOA CarLink", getString(UiR.string.source_iccoa_summary), R.drawable.ic_server_wifi) { navigate("source-iccoa") } + ui.sourceCard(content, getString(UiR.string.source_diagnostics), getString(UiR.string.source_diagnostics_summary), R.drawable.ic_server_server) { navigate("source-diagnostics") } + } + + private fun sourceHeading(content: LinearLayout, title: String) { + content.addView(button(getString(UiR.string.source_back), false, R.drawable.ic_settings_back) { navigate("phone") }, + LinearLayout.LayoutParams(-2, -2).apply { bottomMargin = dp(16) }) + content.addView(label(title, 30, TEXT, true).apply { setPadding(0, 0, 0, dp(24)) }) + } + + private fun carPlaySource(content: LinearLayout) { + sourceHeading(content, getString(UiR.string.source_carplay)) content.addView(label(getString(UiR.string.ui_this_device_receives_carplay_the_head_unit_browser_provides), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) status = label(getString(UiR.string.ui_waiting_for_connection), 20, ACCENT, true) ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { it.addView(status) } @@ -562,19 +582,137 @@ class DiPlayActivity : AppCompatActivity() { setupError?.let { content.addView(label(it, 16, WARNING)) } } - private fun chooseProjectionSource() { - registerProjectionSources(this) - val sources = projectionSessions.sources() - android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) - .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> - startWebServiceIfReady() - projectionSessions.switchTo(sources[index].descriptor.id).whenComplete { _, failure -> runOnUiThread { + private fun androidAutoSource(content: LinearLayout) { + sourceHeading(content, "Android Auto") + sourceSessionControls(content, "android-auto") + ui.card(content, getString(UiR.string.aa_local_title), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.aa_local_summary), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_local_connect), true) { connectLocalAndroidAuto() }, matchButton(12)) + body.addView(button(getString(UiR.string.aa_local_help), false) { showLocalAndroidAutoSetup() }, ui.secondaryButtonLayout(12)) + } + ui.card(content, getString(UiR.string.ui_usb_connection), R.drawable.ic_server_usb) { body -> + body.addView(label(getString(UiR.string.aa_usb_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_usb_connect), false) { startProjectionSource("android-auto-usb") }, matchButton(12)) + } + ui.card(content, getString(UiR.string.aa_remote_title), R.drawable.ic_server_wifi) { body -> + body.addView(label(getString(UiR.string.aa_tcp_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.aa_remote_connect), false) { configureAndroidAutoTcp() }, matchButton(12)) + } + } + + private fun iccoaSource(content: LinearLayout) { + sourceHeading(content, "ICCOA CarLink") + ui.card(content, getString(UiR.string.source_unavailable), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.iccoa_sdk_unavailable), 16, MUTED)) + } + } + + private fun diagnosticSources(content: LinearLayout) { + sourceHeading(content, getString(UiR.string.source_diagnostics)) + sourceSessionControls(content, "demo") + ui.card(content, getString(UiR.string.source_diagnostics), R.drawable.ic_server_server) { body -> + body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseDiagnosticSource() }, matchButton(12)) + } + } + + private fun belongsToSource(id: String, group: String) = id.startsWith("$group-") + + private fun sourceSessionControls(content: LinearLayout, group: String) { + ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { body -> + sourceStatuses[group] = label(getString(UiR.string.projection_waiting), 16, TEXT).also { body.addView(it) } + sourceStopButtons[group] = button(getString(UiR.string.projection_stop), false) { + val session = projectionSessions.snapshot()?.takeIf { belongsToSource(it.descriptor.id, group) } + if (session != null) projectionSessions.stop(session).whenComplete { _, failure -> runOnUiThread { if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) refreshStatus(); refreshService() } } + }.also { body.addView(it, ui.secondaryButtonLayout(12)) } + } + } + + private fun chooseDiagnosticSource() { + registerProjectionSources(this) + val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, "demo") } + android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) + .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> + startProjectionSource(sources[index].descriptor.id) }.setNegativeButton(android.R.string.cancel, null).show() } + private fun configureAndroidAutoTcp() { + val host = android.widget.EditText(this).apply { + setSingleLine(true) + hint = "192.168.1.2" + setText(getSharedPreferences("projection", 0).getString("aa_host", "")) + } + android.app.AlertDialog.Builder(this) + .setTitle(getString(UiR.string.aa_tcp_title)) + .setMessage(getString(UiR.string.aa_tcp_hint)) + .setView(host) + .setPositiveButton(android.R.string.ok) { _, _ -> + val address = host.text.toString().trim() + if (!com.shilapi.xcertplay.projection.androidauto.AapTcpConnection.validAddress(address)) { + toast(getString(UiR.string.aa_invalid_address)) + } else { + getSharedPreferences("projection", 0).edit().putString("aa_host", address).putInt("aa_port", 5277).apply() + startProjectionSource("android-auto-tcp") + } + }.setNegativeButton(android.R.string.cancel, null).show() + } + + private fun connectLocalAndroidAuto() { + projectionSessions.snapshot()?.takeIf { it.descriptor.id == LocalAndroidAuto.SOURCE_ID }?.let { + toast(it.statusText ?: getString(UiR.string.projection_active, it.descriptor.displayName)) + return + } + if (!LocalAndroidAuto.isInstalledAndEnabled(this) || + !getSharedPreferences("projection", 0).getBoolean(LocalAndroidAuto.SETUP_SEEN, false)) { + showLocalAndroidAutoSetup() + return + } + registerProjectionSources(this) + startProjectionSource(LocalAndroidAuto.SOURCE_ID) + } + + private fun showLocalAndroidAutoSetup() { + localAndroidAutoDialog?.dismiss() + val available = LocalAndroidAuto.isInstalledAndEnabled(this) + val builder = android.app.AlertDialog.Builder(this) + .setTitle(getString(UiR.string.aa_local_title)) + .setMessage(getString(if (available) UiR.string.aa_local_setup else UiR.string.aa_local_unavailable)) + .setNeutralButton(UiR.string.aa_local_settings) { _, _ -> openLocalAndroidAutoSettings() } + .setNegativeButton(android.R.string.cancel, null) + if (available) builder.setPositiveButton(UiR.string.aa_local_connect) { _, _ -> + getSharedPreferences("projection", 0).edit().putBoolean(LocalAndroidAuto.SETUP_SEEN, true).apply() + connectLocalAndroidAuto() + } + localAndroidAutoDialog = builder.show() + } + + private fun openLocalAndroidAutoSettings() { + for (intent in LocalAndroidAuto.settingsIntents()) { + try { + localAndroidAutoSettings.launch(intent) + return + } catch (_: android.content.ActivityNotFoundException) { + // AA versions expose different settings entry points. + } catch (_: SecurityException) { + // Fall back when an AA activity is not exported. + } + } + toast(getString(UiR.string.aa_local_settings_unavailable)) + } + + private fun startProjectionSource(id: String) { + registerProjectionSources(this) + startWebServiceIfReady() + projectionSessions.switchTo(id).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + refreshStatus(); refreshService() + } } + } + private fun refreshAuthentication() { setupError = if (DiPlayBootstrap.importing) getString(UiR.string.ui_importing_authentication_resources_please_wait) else runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { @@ -796,7 +934,7 @@ class DiPlayActivity : AppCompatActivity() { private fun handleWirelessRecovery() { if (intent.getStringExtra("page") != "wireless-recovery") return intent.removeExtra("page") - navigate("phone") + navigate("source-carplay") confirmWirelessReset() } @@ -840,6 +978,13 @@ class DiPlayActivity : AppCompatActivity() { } private fun refreshStatus() { + val projection = projectionSessions.snapshot() + sourceStatuses.forEach { (group, label) -> + val session = projection?.takeIf { belongsToSource(it.descriptor.id, group) } + label.text = if (session == null) getString(UiR.string.projection_waiting) + else session.statusText ?: getString(UiR.string.projection_active, session.descriptor.displayName) + sourceStopButtons[group]?.visibility = if (session != null) View.VISIBLE else View.GONE + } val running = CarPlayBackgroundSession.hasSession() status?.text = when { projectionSessions.snapshot()?.descriptor?.id?.let { it != "carplay" } == true -> diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt index 7b73eda5..316614bd 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -239,7 +239,8 @@ class DiPlaySessionService : Service() { val notification = builder.build() if (Build.VERSION.SDK_INT >= 29) { var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - if (currentState.available && currentState.playing) + if (currentState.available && currentState.playing || + projectionSessions.snapshot()?.let { it.capabilities.audioPlayback && !it.capabilities.browserAudio } == true) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK if ( Build.VERSION.SDK_INT >= 30 && diff --git a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt index 99dc78ce..df342b3d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt +++ b/common/src/main/java/com/shilapi/xcertplay/ServerUi.kt @@ -177,6 +177,35 @@ internal class ServerUi(private val context: Context) { addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) } + + /** One accessible navigation target, with no nested settings or connection actions. */ + fun sourceCard(parent: LinearLayout, title: String, summary: String, icon: Int, action: () -> Unit) { + val row = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = android.view.Gravity.CENTER_VERTICAL + minimumHeight = dp(132) + setPadding(dp(cardPadding), dp(24), dp(cardPadding), dp(24)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + row.addView(android.widget.ImageView(context).apply { setImageResource(icon) }, + LinearLayout.LayoutParams(dp(40), dp(40)).apply { marginEnd = dp(16) }) + row.addView(column().apply { + addView(text(title, 22, TEXT, true)) + addView(text(summary, 14, MUTED).apply { setPadding(0, dp(8), 0, 0) }) + }, LinearLayout.LayoutParams(0, -2, 1f)) + row.addView(android.widget.ImageView(context).apply { setImageResource(R.drawable.ic_settings_chevron) }, + LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginStart = dp(12) }) + parent.addView(object : MaterialCardView(context) { + override fun getAccessibilityClassName(): CharSequence = android.widget.Button::class.java.name + }.apply { + radius = dp(24).toFloat(); cardElevation = 0f; strokeWidth = 0 + setCardBackgroundColor(SURFACE) + contentDescription = "$title, $summary" + isFocusable = true + setOnClickListener { action() } + addView(row, android.view.ViewGroup.LayoutParams(-1, -2)) + }, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(16) }) + } fun content(build: (LinearLayout) -> Unit): ScrollView { val gutter = pageGutter val body = column().apply { setPadding(dp(gutter), dp(24), dp(gutter), dp(32)) } @@ -193,7 +222,8 @@ internal class ServerUi(private val context: Context) { addView(body, android.view.ViewGroup.LayoutParams(-1, -2)) } } - inner class Shell(initialPage: String, private val onPageChanged: (String) -> Unit) { + inner class Shell(initialPage: String, private val onPageChanged: (String) -> Unit, + private val parentPages: Map = emptyMap()) { val root = FrameLayout(context).apply { setBackgroundColor(BG) } private val layout = column() private var topInset = 0 @@ -225,6 +255,8 @@ internal class ServerUi(private val context: Context) { private val container = FrameLayout(context) private val pages = linkedMapOf() private val ids = listOf("service", "phone", "settings") + private var syncingTabs = false + private fun tabId(page: String) = ids.indexOf(parentPages[page] ?: page).coerceAtLeast(0) + 1 var selected = initialPage private set init { @@ -237,7 +269,7 @@ internal class ServerUi(private val context: Context) { updateStatusScrim() } val icons = listOf(R.drawable.ic_server_launch, R.drawable.ic_server_phone, R.drawable.ic_server_settings) - listOf(context.getString(UiR.string.ui_start), "iPhone", context.getString(UiR.string.ui_settings)).forEachIndexed { index, title -> + listOf(context.getString(UiR.string.ui_start), context.getString(UiR.string.source_tab), context.getString(UiR.string.ui_settings)).forEachIndexed { index, title -> tabs.menu.add(0, index + 1, index, title).setIcon(icons[index]) } // CJK fallback metrics otherwise enlarge only Chinese labels, shifting their baseline. @@ -253,10 +285,11 @@ internal class ServerUi(private val context: Context) { } } } - tabs.selectedItemId = ids.indexOf(initialPage).coerceAtLeast(0) + 1 + tabs.selectedItemId = tabId(initialPage) layout.addView(container, LinearLayout.LayoutParams(-1, 0, 1f)) layout.addView(navigationSurface, LinearLayout.LayoutParams(-1, -2)) - tabs.setOnItemSelectedListener { item -> select(ids[item.itemId - 1]); true } + tabs.setOnItemSelectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]); true } + tabs.setOnItemReselectedListener { item -> if (!syncingTabs) select(ids[item.itemId - 1]) } } fun put(id: String, view: View, scrollPosition: Int? = null) { @@ -285,11 +318,14 @@ internal class ServerUi(private val context: Context) { if (selected == id) updateStatusScrim() } fun select(id: String) { - if (id !in ids || selected == id) return + if ((id !in ids && id !in parentPages) || selected == id) return selected = id pages.forEach { (page, view) -> view.visibility = if (page == id) View.VISIBLE else View.GONE } // Only the stock Material indicator animates; no Activity or whole-window transition. - if (tabs.selectedItemId != ids.indexOf(id) + 1) tabs.selectedItemId = ids.indexOf(id) + 1 + if (tabs.selectedItemId != tabId(id)) { + syncingTabs = true + try { tabs.selectedItemId = tabId(id) } finally { syncingTabs = false } + } updateStatusScrim() onPageChanged(id) } diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt new file mode 100644 index 00000000..5df0d85c --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay.projection + +import android.content.Context +import java.util.concurrent.CompletableFuture + +/** No proprietary SDK is bundled. Do not start BLE/P2P or claim a usable CarLink session. */ +internal class IccoaSource(private val context: Context) : ProjectionSourceProvider { + override val descriptor = SourceDescriptor("iccoa-carlink", "ICCOA CarLink") + override val resources = setOf(ProjectionResource.USB, ProjectionResource.WIRELESS, + ProjectionResource.DISPLAY, ProjectionResource.MICROPHONE) + override fun probe(): SourceAvailability = SourceAvailability.Unavailable( + context.getString(com.shilapi.xcertplay.shared.R.string.iccoa_sdk_unavailable)) + override fun open(): CompletableFuture = CompletableFuture().also { + it.completeExceptionally(IllegalStateException((probe() as SourceAvailability.Unavailable).reason)) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt index 0857fc63..7e68a9b6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt @@ -6,6 +6,12 @@ internal val projectionSessions = ProjectionRuntime() @Synchronized internal fun registerProjectionSources(context: android.content.Context) { if (projectionSessions.sources().isNotEmpty()) return val app = context.applicationContext + com.shilapi.xcertplay.projection.androidauto.AndroidAutoMode.entries.forEach { mode -> + projectionSessions.register(com.shilapi.xcertplay.projection.androidauto.AndroidAutoSource(app, mode) { + BrowserProjectionVideo(app, 1280, 720, VideoIngressKind.ENCODED) + }) + } + projectionSessions.register(IccoaSource(app)) VideoIngressKind.entries.forEach { kind -> projectionSessions.register(SyntheticSource(kind, { ingress, width, height -> BrowserProjectionVideo(app, width, height, ingress) }, diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt new file mode 100644 index 00000000..8c403423 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapAudio.kt @@ -0,0 +1,126 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.Context +import android.media.* +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** PCM endpoints are bounded independently of AAP acknowledgements and browser video. */ +internal class AapAudio(context: Context, private val sendMic: (ByteArray) -> Boolean, + private val focusChanged: (Int) -> Unit, private val failed: (Throwable) -> Unit, + private val microphoneAllowed: () -> Boolean) : AutoCloseable { + private val manager = context.getSystemService(AudioManager::class.java) + private val closed = AtomicBoolean() + private val queue = ArrayBlockingQueue>(32) + private val tracks = mutableMapOf() + @Volatile private var focusState = 1 + @Volatile private var focus: AudioFocusRequest? = null + private val focusListener = AudioManager.OnAudioFocusChangeListener { value -> + focusState = when (value) { + AudioManager.AUDIOFOCUS_GAIN -> 1 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> 4 + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> 5 + else -> 3 + } + if (focusState == 3 || focusState == 5) queue.clear() + focusChanged(focusState) + } + private val stoppingChannels = java.util.concurrent.ConcurrentLinkedQueue() + private val player = Thread({ + try { + while (!closed.get()) { + while (true) { + val stop = stoppingChannels.poll() ?: break + tracks.remove(stop)?.release() + } + val (channel, bytes) = queue.poll(100, TimeUnit.MILLISECONDS) ?: continue + if (focusState == 3 || focusState == 5) continue + val track = tracks.getOrPut(channel) { + val rate = if (channel == 6) 48000 else 16000 + val mask = if (channel == 6) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO + val minimum = AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_PCM_16BIT) + check(minimum > 0) + AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder() + .setUsage(if (channel == 6) AudioAttributes.USAGE_MEDIA else AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE) + .setContentType(if (channel == 6) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_SPEECH).build()) + .setAudioFormat(AudioFormat.Builder().setSampleRate(rate).setChannelMask(mask).setEncoding(AudioFormat.ENCODING_PCM_16BIT).build()) + .setBufferSizeInBytes(maxOf(minimum, rate / 5 * if (channel == 6) 4 else 2)).build().apply { play() } + } + // Retry partial writes on this worker only; the protocol reader remains independent. + AapPcmWriter.write(bytes.size, + active = { !closed.get() && focusState != 3 && focusState != 5 && !stoppingChannels.contains(channel) }, + write = { offset, length -> + track.setVolume(if (focusState == 4) 0.2f else 1f) + track.write(bytes, offset, length, AudioTrack.WRITE_NON_BLOCKING) + }) + } + } catch (error: Throwable) { if (!closed.get()) failed(error) } + finally { tracks.values.forEach { it.release() }; tracks.clear() } + }, "aa-pcm").apply { start() } + @Volatile private var recorder: AudioRecord? = null + private var micThread: Thread? = null + fun offer(channel: Int, bytes: ByteArray) { + if (closed.get()) return + if (!queue.offer(channel to bytes)) { queue.poll(); queue.offer(channel to bytes) } + } + fun stopChannel(channel: Int) { + queue.removeIf { it.first == channel } + stoppingChannels.offer(channel) + } + @Synchronized fun requestFocus(request: Int): Int { + focus?.let { manager.abandonAudioFocusRequest(it) } + focus = null + if (request == 4 || closed.get()) { focusState = 3; queue.clear(); return 3 } + val gain = when (request) { + 2 -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + 3 -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + else -> AudioManager.AUDIOFOCUS_GAIN + } + val next = AudioFocusRequest.Builder(gain) + .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()) + .setOnAudioFocusChangeListener(focusListener).build() + focus = next + val state = if (manager.requestAudioFocus(next) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + if (request == 1) 1 else 2 + } else 3 + focusState = state + return state + } + @android.annotation.SuppressLint("MissingPermission") // Provider gates microphone capability on RECORD_AUDIO. + @Synchronized fun microphone(open: Boolean) { + stopMicrophone() + if (!open || closed.get() || !microphoneAllowed()) return + val minimum = AudioRecord.getMinBufferSize(16000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + check(minimum > 0) + val record = AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION, 16000, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, maxOf(minimum, 6400)) + try { check(record.state == AudioRecord.STATE_INITIALIZED); record.startRecording() } + catch (t: Throwable) { record.release(); throw t } + recorder = record + micThread = Thread({ + val buffer = ByteArray(640) + try { + while (recorder === record && !closed.get() && microphoneAllowed()) { + val count = record.read(buffer, 0, buffer.size) + if (count <= 0) break + if (!sendMic(buffer.copyOf(count))) break + } + } catch (error: Throwable) { if (!closed.get()) failed(error) } + finally { if (recorder === record) recorder = null; record.release() } + }, "aa-mic").apply { start() } + } + private fun stopMicrophone() { + val record = recorder; recorder = null + if (record != null) runCatching { record.stop() } + micThread?.join(3000) + check(micThread?.isAlive != true) { "AA microphone did not stop" } + micThread = null + } + @Synchronized override fun close() { + closed.set(true); stopMicrophone(); player.join(3000) + check(!player.isAlive) { "AA audio did not stop" } + queue.clear(); focus?.let { manager.abandonAudioFocusRequest(it) }; focus = null + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt new file mode 100644 index 00000000..b70fb04d --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapConnection.kt @@ -0,0 +1,132 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.app.PendingIntent +import android.content.* +import android.hardware.usb.* +import androidx.core.content.ContextCompat +import java.io.* +import java.net.InetSocketAddress +import java.net.Socket +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal interface AapConnection : AutoCloseable { + fun connect() + val input: InputStream + val output: OutputStream +} +internal class AapTcpConnection(private val host: String, private val port: Int) : AapConnection { + private val socket = Socket() + override fun connect() { require(validAddress(host)) { "Enter a numeric IP address" }; socket.connect(InetSocketAddress(host, port), 10000); socket.tcpNoDelay = true; socket.soTimeout = 15000 } + override val input get() = socket.getInputStream() + override val output get() = socket.getOutputStream() + override fun close() = socket.close() + companion object { + fun validAddress(host: String): Boolean = host.split('.').let { parts -> + parts.size == 4 && parts.all { it.isNotEmpty() && it.length <= 3 && it.all(Char::isDigit) && (it.toIntOrNull() ?: -1) in 0..255 } + } + } +} + +/** Android USB host -> phone in Android Open Accessory mode; no USB accessory permission on the host. */ +internal class AapUsbConnection(private val context: Context, private val device: UsbDevice) : AapConnection { + private val manager = context.getSystemService(UsbManager::class.java) + private val closed = AtomicBoolean() + @Volatile private var connection: UsbDeviceConnection? = null + @Volatile private var permissionWait: CountDownLatch? = null + private lateinit var incoming: UsbEndpoint + private lateinit var outgoing: UsbEndpoint + companion object { + fun isAccessory(device: UsbDevice) = device.vendorId == 0x18d1 && device.productId in setOf(0x2d00, 0x2d01, 0x2d04, 0x2d05) + fun candidates(context: Context) = context.getSystemService(UsbManager::class.java).deviceList.values.filter { + it.vendorId != 0x05ac && (isAccessory(it) || it.deviceClass == 0 || it.deviceClass == 0xef) + }.sortedBy { it.deviceName } + } + private fun checkOpen() { check(!closed.get()) { "Android Auto stopped" } } + private fun open(device: UsbDevice): UsbDeviceConnection { + checkOpen() + if (!manager.hasPermission(device)) { + val action = context.packageName + ".AA_USB." + UUID.randomUUID() + val wait = CountDownLatch(1); permissionWait = wait + try { + // close() either sees this latch, or this check observes the earlier close. + checkOpen() + val receiver = object : BroadcastReceiver() { override fun onReceive(c: Context, i: Intent) { if (i.action == action) wait.countDown() } } + ContextCompat.registerReceiver(context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED) + try { + val pending = PendingIntent.getBroadcast(context, 0, Intent(action).setPackage(context.packageName), PendingIntent.FLAG_IMMUTABLE) + try { + checkOpen() + manager.requestPermission(device, pending) + check(wait.await(45, TimeUnit.SECONDS)) { "USB permission timed out" } + } finally { pending.cancel() } + } finally { context.unregisterReceiver(receiver) } + } finally { permissionWait = null } + checkOpen(); check(manager.hasPermission(device)) { "USB permission denied" } + } + return checkNotNull(manager.openDevice(device)) { "Cannot open USB phone" }.also { + connection = it + if (closed.get()) { it.close(); error("Android Auto stopped") } + } + } + override fun connect() { + var selected = device + var link = open(selected) + if (!isAccessory(selected)) { + val version = ByteArray(2) + check(link.controlTransfer(0xc0, 51, 0, 0, version, 2, 2000) == 2 && version[0].toInt() > 0) { "Phone does not support Android Open Accessory" } + listOf("Android", "Android Auto", "WheelPlay", "1.0", "https://github.com/fython/wheelplay", "WheelPlay").forEachIndexed { index, value -> + val bytes = (value + '\u0000').toByteArray(Charsets.UTF_8) + check(link.controlTransfer(0x40, 52, 0, index, bytes, bytes.size, 2000) == bytes.size) + } + check(link.controlTransfer(0x40, 53, 0, 0, null, 0, 2000) >= 0) { "AOA mode switch failed" } + link.close(); connection = null + val deadline = android.os.SystemClock.elapsedRealtime() + 15000 + while (true) { + checkOpen() + val devices = manager.deviceList.values.filter { isAccessory(it) } + check(devices.size <= 1) { "Connect only one Android Auto phone" } + if (devices.size == 1) { selected = devices.single(); break } + check(android.os.SystemClock.elapsedRealtime() < deadline) { "Phone did not re-enumerate in accessory mode" } + Thread.sleep(100) + } + link = open(selected) + } + val iface = (0 until selected.interfaceCount).map(selected::getInterface).firstOrNull { i -> + (0 until i.endpointCount).map(i::getEndpoint).count { it.type == UsbConstants.USB_ENDPOINT_XFER_BULK } == 2 + } ?: error("Missing AOA bulk interface") + incoming = (0 until iface.endpointCount).map(iface::getEndpoint).first { it.direction == UsbConstants.USB_DIR_IN } + outgoing = (0 until iface.endpointCount).map(iface::getEndpoint).first { it.direction == UsbConstants.USB_DIR_OUT } + check(link.claimInterface(iface, true)) { "USB interface is busy" } + } + // Buffer whole USB transfers: reading just the four-byte AAP header would discard the rest of a USB packet. + override val input = object : InputStream() { + private val buffer = ByteArray(16384) + private var offset = 0; private var size = 0 + override fun read(): Int { val b = ByteArray(1); return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 255 } + override fun read(bytes: ByteArray, start: Int, length: Int): Int { + if (length == 0) return 0 + checkOpen() + if (offset == size) { + size = connection!!.bulkTransfer(incoming, buffer, buffer.size, 15000); offset = 0 + if (size <= 0) throw IOException("USB disconnected or timed out") + } + val n = minOf(length, size - offset); buffer.copyInto(bytes, start, offset, offset + n); offset += n; return n + } + } + override val output = object : OutputStream() { + override fun write(value: Int) = write(byteArrayOf(value.toByte())) + override fun write(bytes: ByteArray, start: Int, length: Int) { + var offset = start + while (offset < start + length) { + checkOpen() + val n = connection!!.bulkTransfer(outgoing, bytes, offset, minOf(16384, start + length - offset), 2000) + if (n <= 0) throw IOException("USB write failed") + offset += n + } + } + } + override fun close() { closed.set(true); permissionWait?.countDown(); connection?.close() } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapPcmWriter.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapPcmWriter.kt new file mode 100644 index 00000000..963d40aa --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapPcmWriter.kt @@ -0,0 +1,23 @@ +package com.shilapi.xcertplay.projection.androidauto + +import java.io.IOException + +/** Preserve unwritten PCM, but allow stop/focus loss to interrupt a full audio buffer. */ +internal object AapPcmWriter { + fun write(size: Int, active: () -> Boolean, write: (Int, Int) -> Int, + pause: () -> Unit = { Thread.sleep(5) }) { + var offset = 0 + var stalled = 0 + while (offset < size && active()) { + val count = write(offset, size - offset) + if (count < 0 || count > size - offset) throw IOException("AA PCM write failed: $count") + if (count == 0) { + if (++stalled >= 400) throw IOException("AA audio output made no progress") + pause() + } else { + offset += count + stalled = 0 + } + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt new file mode 100644 index 00000000..fa591ebd --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapTls.kt @@ -0,0 +1,99 @@ +package com.shilapi.xcertplay.projection.androidauto + +import com.shilapi.xcertplay.androidauto.AapFraming +import java.io.DataInputStream +import java.io.OutputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.util.Date +import javax.net.ssl.* +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder + +/** AAP is TLS carried in channel-zero envelopes, not TLS over the underlying socket directly. */ +internal class AapTls(clientMode: Boolean = true) { + internal val engine: SSLEngine + init { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val name = X500Name("CN=WheelPlay Android Auto") + val certificate = JcaX509CertificateConverter().getCertificate(JcaX509v3CertificateBuilder( + name, BigInteger(128, SecureRandom()), Date(System.currentTimeMillis() - 86400000), + Date(System.currentTimeMillis() + 365L * 86400000), name, pair.public + ).build(JcaContentSignerBuilder("SHA256withRSA").build(pair.private))) + val password = CharArray(0) + val keys = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null); setKeyEntry("headunit", pair.private, password, arrayOf(certificate)) + } + val km = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(keys, password) } + // AAP peers use protocol-specific/self-signed certificates; this is not HTTPS trust. + // Access is limited to an explicitly selected USB device or phone endpoint. + val trust = object : X509TrustManager { + override fun getAcceptedIssuers() = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + } + engine = SSLContext.getInstance("TLSv1.2").apply { init(km.keyManagers, arrayOf(trust), SecureRandom()) } + .createSSLEngine().apply { useClientMode = clientMode; enabledProtocols = arrayOf("TLSv1.2") } + } + private fun tasks() { while (true) (engine.delegatedTask ?: break).run() } + fun handshake(input: DataInputStream, output: OutputStream) { + AapFraming.write(output, 0, 3, AapFraming.message(1, byteArrayOf(0, 1, 0, 2))) + val version = AapFraming.read(input) + require(version.channel == 0 && version.flags == 3 && AapFraming.type(version.payload) == 2) + require(version.payload.size >= 8 && version.payload[6] == 0.toByte() && version.payload[7] == 0.toByte()) { "Unsupported AAP version" } + engine.beginHandshake() + var pending = ByteArray(0) + while (true) { + when (engine.handshakeStatus) { + SSLEngineResult.HandshakeStatus.NEED_TASK -> tasks() + SSLEngineResult.HandshakeStatus.NEED_WRAP -> { + val buffer = ByteBuffer.allocate(65535) + val result = engine.wrap(ByteBuffer.allocate(0), buffer) + check(result.status == SSLEngineResult.Status.OK) + if (buffer.position() > 0) AapFraming.write(output, 0, 3, AapFraming.message(3, buffer.array().copyOf(buffer.position()))) + } + SSLEngineResult.HandshakeStatus.NEED_UNWRAP -> { + if (pending.isEmpty()) pending = handshakePacket(input) + val source = ByteBuffer.wrap(pending) + val result = engine.unwrap(source, ByteBuffer.allocate(65535)) + pending = pending.copyOfRange(source.position(), pending.size) + when (result.status) { + SSLEngineResult.Status.BUFFER_UNDERFLOW -> { pending += handshakePacket(input); require(pending.size <= 262144) } + SSLEngineResult.Status.OK -> Unit + else -> error("AAP TLS handshake: ${result.status}") + } + } + SSLEngineResult.HandshakeStatus.FINISHED, SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING -> break + else -> error("Unexpected TLS handshake state") + } + } + require(pending.isEmpty()) + AapFraming.write(output, 0, 3, AapFraming.message(4, byteArrayOf(8, 0))) + } + private fun handshakePacket(input: DataInputStream): ByteArray { + val packet = AapFraming.read(input) + require(packet.channel == 0 && packet.flags == 3 && AapFraming.type(packet.payload) == 3) + return packet.payload.copyOfRange(2, packet.payload.size) + } + @Synchronized fun encrypt(bytes: ByteArray): ByteArray { + val src = ByteBuffer.wrap(bytes); val dst = ByteBuffer.allocate(65535) + val result = engine.wrap(src, dst) + check(result.status == SSLEngineResult.Status.OK && !src.hasRemaining()) + return dst.array().copyOf(dst.position()) + } + @Synchronized fun decrypt(bytes: ByteArray): ByteArray { + val src = ByteBuffer.wrap(bytes); val dst = ByteBuffer.allocate(65535) + while (src.hasRemaining()) { + val result = engine.unwrap(src, dst) + check(result.status == SSLEngineResult.Status.OK && result.bytesConsumed() > 0) { "Invalid AAP TLS record" } + tasks() + } + return dst.array().copyOf(dst.position()) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt new file mode 100644 index 00000000..7d4a6bd8 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideo.kt @@ -0,0 +1,53 @@ +package com.shilapi.xcertplay.projection.androidauto + +import com.shilapi.xcertplay.androidauto.AapMediaAssembler +import com.shilapi.xcertplay.media.HeapVideoFrame +import com.shilapi.xcertplay.media.VideoCodec +import com.shilapi.xcertplay.projection.ProjectionVideoOutput +import java.io.ByteArrayOutputStream + +/** Keep SPS/PPS out of the transport; convert Annex-B config to the host's avcC port. */ +internal class AapVideo(private val output: ProjectionVideoOutput, private val keyframe: () -> Unit) { + private var sps: ByteArray? = null + private var pps: ByteArray? = null + private var config: ByteArray? = null + private var revision = 0L + fun accept(unit: AapMediaAssembler.AccessUnit): Boolean { + val nals = annexB(unit.data) + nals.forEach { nal -> when (nal[0].toInt() and 31) { 7 -> sps = nal; 8 -> pps = nal } } + val s = sps; val p = pps + if (s != null && p != null) { + val next = avcC(s, p) + if (config?.contentEquals(next) != true) { revision = output.configure(VideoCodec.H264, next, keyframe); config = next } + } + return revision != 0L && nals.any { it[0].toInt() and 31 in 1..5 } && + output.frame(revision, HeapVideoFrame(unit.data), unit.ptsUs) + } + companion object { + fun annexB(data: ByteArray): List { + val starts = ArrayList>() + var i = 0 + while (i + 3 <= data.size) { + val size = when { + i + 4 <= data.size && data[i] == 0.toByte() && data[i+1] == 0.toByte() && data[i+2] == 0.toByte() && data[i+3] == 1.toByte() -> 4 + data[i] == 0.toByte() && data[i+1] == 0.toByte() && data[i+2] == 1.toByte() -> 3 + else -> 0 + } + if (size != 0) { starts += i to size; i += size } else i++ + } + require(starts.isNotEmpty() && starts.first().first == 0) { "Expected Annex-B H.264" } + return starts.mapIndexedNotNull { index, (start, size) -> + val end = starts.getOrNull(index + 1)?.first ?: data.size + if (end > start + size) data.copyOfRange(start + size, end) else null + } + } + fun avcC(sps: ByteArray, pps: ByteArray): ByteArray { + require(sps.size in 4..65535 && pps.size in 1..65535) + return ByteArrayOutputStream().apply { + write(byteArrayOf(1, sps[1], sps[2], sps[3], 0xff.toByte(), 0xe1.toByte())) + write(sps.size ushr 8); write(sps.size); write(sps); write(1) + write(pps.size ushr 8); write(pps.size); write(pps) + }.toByteArray() + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideoRecovery.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideoRecovery.kt new file mode 100644 index 00000000..369abe74 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AapVideoRecovery.kt @@ -0,0 +1,55 @@ +package com.shilapi.xcertplay.projection.androidauto + +import java.io.IOException + +/** AAP has no IDR request. Escalate an unanswered gain notification to a bounded focus cycle. */ +internal class AapVideoRecovery(private val now: () -> Long, private val focus: (Int) -> Unit) { + private var projected = true + private var closed = false + private var waitingSince: Long? = null + private var regainAt: Long? = null + private var lastNudge: Long? = null + private var lastCycle: Long? = null + private var cycles = 0 + + @Synchronized fun request() { + if (closed || !projected) return + val time = now() + if (waitingSince == null) waitingSince = time + if (regainAt != null || lastNudge?.let { time - it < 500 } == true) return + lastNudge = time + focus(1) + } + + /** Called by the send worker even when no packets arrive from the phone. */ + @Synchronized fun tick() { + if (closed || !projected) return + val time = now() + regainAt?.let { + if (time >= it) { regainAt = null; waitingSince = time; focus(1) } + return + } + val since = waitingSince ?: return + if (cycles >= 3) { + if (time - since >= 5000) throw IOException("Android Auto video did not recover; reconnect the input source") + return + } + if (time - since < 2000 || lastCycle?.let { time - it < 10000 } == true) return + cycles++ + lastCycle = time + regainAt = time + 250 + focus(2) + } + + @Synchronized fun recovered() { + // An in-flight IDR from before the release must not cancel the required regain. + if (regainAt == null) waitingSince = null + } + + @Synchronized fun setProjected(value: Boolean) { + projected = value + if (!value) { waitingSince = null; regainAt = null } + } + + @Synchronized fun close() { closed = true; waitingSince = null; regainAt = null } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt new file mode 100644 index 00000000..63b183c7 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt @@ -0,0 +1,288 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import com.shilapi.xcertplay.AirPlayPersistence +import com.shilapi.xcertplay.CarPlayMicrophoneSource +import com.shilapi.xcertplay.androidauto.* +import com.shilapi.xcertplay.projection.* +import java.io.DataInputStream +import java.nio.ByteBuffer +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicBoolean +import com.shilapi.xcertplay.shared.R as UiR + +internal enum class AndroidAutoMode { LOCAL, USB, TCP } + +internal class AndroidAutoSource(private val context: Context, private val mode: AndroidAutoMode, + private val outputFactory: () -> ProjectionVideoOutput) : ProjectionSourceProvider { + override val descriptor = when (mode) { + AndroidAutoMode.LOCAL -> SourceDescriptor(LocalAndroidAuto.SOURCE_ID, context.getString(UiR.string.aa_local_title)) + AndroidAutoMode.USB -> SourceDescriptor("android-auto-usb", "Android Auto · USB") + AndroidAutoMode.TCP -> SourceDescriptor("android-auto-tcp", "Android Auto · Head Unit Server (TCP)") + } + override val resources = buildSet { + add(ProjectionResource.DISPLAY); add(ProjectionResource.MICROPHONE) + if (mode == AndroidAutoMode.USB) add(ProjectionResource.USB) + if (mode == AndroidAutoMode.TCP) add(ProjectionResource.WIRELESS) + } + override fun probe(): SourceAvailability { + if (mode == AndroidAutoMode.LOCAL && !LocalAndroidAuto.isInstalledAndEnabled(context)) + return SourceAvailability.Unavailable(context.getString(UiR.string.aa_local_unavailable)) + if (mode == AndroidAutoMode.USB && AapUsbConnection.candidates(context).size != 1) return SourceAvailability.Unavailable("Connect exactly one Android phone by USB") + if (mode == AndroidAutoMode.TCP && context.getSharedPreferences("projection", 0).getString("aa_host", "").isNullOrBlank()) + return SourceAvailability.Unavailable("Configure the phone's Head Unit Server address first") + return SourceAvailability.Ready + } + internal fun tcpEndpoint(): Pair { + check(mode != AndroidAutoMode.USB) + if (mode == AndroidAutoMode.LOCAL) return "127.0.0.1" to 5277 + val prefs = context.getSharedPreferences("projection", 0) + return prefs.getString("aa_host", "")!! to prefs.getInt("aa_port", 5277) + } + override fun open(): CompletableFuture = try { + val connection = if (mode == AndroidAutoMode.USB) AapUsbConnection(context, AapUsbConnection.candidates(context).single()) else { + val (host, port) = tcpEndpoint() + AapTcpConnection(host, port) + } + val microphone = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && + AirPlayPersistence.loadMicrophoneSource(context) == CarPlayMicrophoneSource.ANDROID + val session = AndroidAutoSession(context, descriptor, connection, microphone, outputFactory) + // The runtime admits this session immediately, so Stop can interrupt connect/USB permission/handshake. + CompletableFuture.completedFuture(session) + } catch (failure: Throwable) { CompletableFuture().also { it.completeExceptionally(failure) } } +} + +internal class AndroidAutoSession(private val context: Context, override val descriptor: SourceDescriptor, + private val connection: AapConnection, private val micEnabled: Boolean, + private val outputFactory: () -> ProjectionVideoOutput) : ProjectionSession { + override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.ENCODED), 10, nightMode = true, + audioPlayback = true, microphone = micEnabled, browserAudio = false, browserMicrophone = false) + @Volatile override var statusText = "Android Auto: connecting" + private set + @Volatile private var ready = false + private var inputBound = false + private var videoFocusMode = 1 + override val inputReady get() = ready && !stopping.get() + private val stopping = AtomicBoolean() + private val finished = CompletableFuture() + private val outbound = ArrayBlockingQueue>(256) + private val frames = ArrayBlockingQueue(8) + private val touches = AapTouch(AapServices.WIDTH, AapServices.HEIGHT) + private val sessions = HashMap() + private val opened = HashSet() + private val assemblers = HashMap() + private var audio: AapAudio? = null + private var output: ProjectionVideoOutput? = null + private var writer: Thread? = null + private var renderer: Thread? = null + private var reader: Thread? = null + private val workerFailure = java.util.concurrent.atomic.AtomicReference() + @Volatile private var waitForIdr = true + @Volatile private var night = false + @Volatile private var nightSubscribed = false + private val videoRecovery = AapVideoRecovery(android.os.SystemClock::elapsedRealtime) { mode -> + if (mode == 2) { frames.clear(); waitForIdr = true } + send(2, 0x8008, AapProto.build { number(1, mode.toLong()); number(2, if (mode == 1) 1 else 0) }) + } + private val micCredits = java.util.concurrent.atomic.AtomicInteger(0) + @Volatile private var micWindow = 0 + override fun activate() = start() + @Synchronized fun start() { + if (reader != null || stopping.get()) { if (reader == null) finished.complete(Unit); return } + reader = Thread({ run() }, "aa-session").apply { start() } + } + private fun state(value: String) { statusText = value; projectionSessions.changed(this) } + private fun run() { + var failure: Throwable? = null + var connected = false + try { + connection.connect() + connected = true + state("Android Auto: negotiating") + val input = DataInputStream(connection.input) + val tls = AapTls() + // Overall handshake deadline also interrupts a peer that slowly drips bytes. + val timer = Executors.newSingleThreadScheduledExecutor() + val timeout = timer.schedule({ connection.close() }, 20, TimeUnit.SECONDS) + try { tls.handshake(input, connection.output) } finally { timeout.cancel(false); timer.shutdownNow() } + check(!stopping.get()) + output = outputFactory() + val video = AapVideo(output!!) { requestKeyframe() } + audio = AapAudio(context, { bytes -> + if (!microphoneAllowed()) false + else if (micCredits.getAndUpdate { if (it > 0) it - 1 else 0 } <= 0) true + else send(7, 0, ByteBuffer.allocate(8 + bytes.size).putLong(android.os.SystemClock.elapsedRealtimeNanos() / 1000).put(bytes).array()) + }, { focus -> send(0, 19, AapProto.build { number(1, focus.toLong()); number(2, 1) }) }, { error -> + workerFailure.compareAndSet(null, error); connection.close() + }, ::microphoneAllowed) + writer = Thread({ + try { + while (!stopping.get()) { + videoRecovery.tick() + val (channel, type, body) = outbound.poll(200, TimeUnit.MILLISECONDS) ?: continue + if (channel == 7 && type == 0 && !microphoneAllowed()) continue + val encrypted = tls.encrypt(AapFraming.message(type, body)) + AapFraming.write(connection.output, channel, if (channel != 0 && type in 2..31) 15 else 11, encrypted) + if (channel == 0 && type == 16) { stopping.set(true); connection.close() } + } + } catch (error: Throwable) { if (!stopping.get()) { workerFailure.compareAndSet(null, error); connection.close() } } + }, "aa-send").apply { start() } + renderer = Thread({ + try { + while (!stopping.get()) { + val frame = frames.poll(200, TimeUnit.MILLISECONDS) ?: continue + val types = AapVideo.annexB(frame.data).map { it[0].toInt() and 31 } + if (waitForIdr && 5 !in types && 7 !in types && 8 !in types) continue + if (5 in types) waitForIdr = false + if (video.accept(frame) && 5 in types) videoRecovery.recovered() + } + } catch (error: Throwable) { if (!stopping.get()) { workerFailure.compareAndSet(null, error); connection.close() } } + }, "aa-video").apply { start() } + state("Android Auto: waiting for phone projection") + while (!stopping.get()) { + val packet = AapFraming.read(input) + require(packet.flags and 8 != 0) { "Expected encrypted AAP packet" } + val payload = tls.decrypt(packet.payload) + if (packet.channel in setOf(2, 4, 5, 6) && (packet.flags and 3 in 0..2 || AapFraming.type(payload) in 0..1)) { + val channel = packet.channel + require(channel in opened && sessions.containsKey(channel)) + val unit = assemblers.getOrPut(channel) { AapMediaAssembler() }.accept(packet.flags, payload) + // ACK per transport fragment, independently of browser speed and decoder queue. + send(channel, 0x8004, AapProto.build { number(1, sessions[channel]!!); number(2, 1) }) + if (unit != null) { + if (channel == 2) { + if (!frames.offer(unit)) { frames.clear(); waitForIdr = true; requestKeyframe() } + } else { + require(unit.data.size % (if (channel == 6) 4 else 2) == 0) + audio?.offer(channel, unit.data) + } + } + continue + } + require(packet.flags and 3 == 3) { "Fragmented control message is unsupported" } + dispatch(packet.channel, AapFraming.type(payload), payload.copyOfRange(2, payload.size)) + } + } catch (caught: Throwable) { + val error = workerFailure.get() ?: caught + if (!stopping.get()) { + failure = error + if (!connected && descriptor.id == LocalAndroidAuto.SOURCE_ID) { + context.getSharedPreferences("projection", 0).edit().remove(LocalAndroidAuto.SETUP_SEEN).apply() + state(context.getString(UiR.string.aa_local_server_unavailable)) + } else state("Android Auto: ${error.message ?: error.javaClass.simpleName}") + Handler(Looper.getMainLooper()).post { android.widget.Toast.makeText(context, statusText, android.widget.Toast.LENGTH_LONG).show() } + } + } finally { + ready = false; stopping.set(true); videoRecovery.close() + val cleanup = runCatching { + connection.close() + writer?.join(5000); renderer?.join(5000) + check(writer?.isAlive != true && renderer?.isAlive != true) { "AA workers did not stop" } + audio?.close(); output?.close() + outbound.clear(); frames.clear() + } + if (cleanup.isFailure) { + finished.completeExceptionally(ProjectionCleanupException("Android Auto cleanup failed", cleanup.exceptionOrNull())) + } else { + finished.complete(Unit) + projectionSessions.ended(this) + } + failure?.let { android.util.Log.w("WheelPlay-AA", "Protocol session ended", it) } + } + } + private fun dispatch(channel: Int, type: Int, body: ByteArray) { + when { + type == 7 -> { + val id = AapProto.number(body, 2).toInt() + val supported = channel == id && (id in AapServices.channels || id == 7 && micEnabled) + if (supported) opened += id + send(channel, 8, AapProto.build { number(1, if (supported) 0 else -4) }) + } + channel == 0 -> when (type) { + 5 -> send(0, 6, AapServices.discovery(micEnabled)) + 11 -> send(0, 12, body) + 13 -> send(0, 14, AapProto.build { number(1, 1) }) + 15 -> { ready = false; send(0, 16, byteArrayOf()) } + 18 -> send(0, 19, AapProto.build { number(1, audio!!.requestFocus(AapProto.number(body, 1).toInt()).toLong()); number(2, 0) }) + } + channel !in opened -> error("AAP message on unopened channel") + channel == 3 && type == 0x8002 -> { + send(3, 0x8003, AapProto.build { number(1, 0) }); inputBound = true; ready = videoFocusMode == 1; state("Android Auto connected · audio on host") + } + channel == 1 && type == 0x8001 -> { + val supported = AapProto.number(body, 1) == 10L + send(1, 0x8002, AapProto.build { number(1, if (supported) 0 else -9) }) + if (supported) { nightSubscribed = true; send(1, 0x8003, AapServices.night(night)) } + } + channel in 2..7 && channel != 3 -> when (type) { + 0x8000 -> { + send(channel, 0x8003, AapProto.build { number(1, 2); number(2, 16); number(3, 0) }) + if (channel == 2) requestKeyframe() + if (channel in 4..6) send(0, 19, AapProto.build { number(1, audio!!.requestFocus(1).toLong()); number(2, 1) }) + } + 0x8001 -> { + require(AapProto.number(body, 2) == 0L) + sessions[channel] = AapProto.number(body, 1) + assemblers.remove(channel) + } + 0x8002 -> { + sessions.remove(channel); assemblers.remove(channel) + if (channel == 7) audio?.microphone(false) + if (channel in 4..6) audio?.stopChannel(channel) + } + 0x8007 -> if (channel == 2) { + val mode = AapProto.number(body, 2).toInt() + require(mode in 1..4) + if (mode != 1) touch(emptyList()) + videoFocusMode = mode + videoRecovery.setProjected(mode == 1 || mode == 4) + ready = inputBound && mode == 1 + send(2, 0x8008, AapProto.build { number(1, mode.toLong()); number(2, 0) }) + if (mode == 2) stop() + } + 0x8005 -> if (channel == 7 && micEnabled) { + val requested = AapProto.number(body, 1) != 0L + val open = requested && microphoneAllowed() + micWindow = AapProto.number(body, 4, 16).toInt().coerceIn(1, 32) + micCredits.set(if (open) micWindow else 0) + send(7, 0x8006, AapProto.build { number(1, if (requested && !open) 1 else 0); number(2, 0) }) + audio?.microphone(open) + } + 0x8004 -> if (channel == 7) { + val count = AapProto.number(body, 2).toInt() + require(count in 0..32) + micCredits.getAndUpdate { minOf(micWindow, it + count) } + } + + } + } + } + private fun microphoneAllowed(): Boolean = micEnabled && !stopping.get() && + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED && + AirPlayPersistence.loadMicrophoneSource(context) == CarPlayMicrophoneSource.ANDROID + + private fun send(channel: Int, type: Int, body: ByteArray): Boolean { + if (stopping.get()) return false + if (outbound.offer(Triple(channel, type, body))) return true + // Never drop a boundary event while keeping the session alive with a stuck finger. + state("Android Auto control queue overflow"); connection.close(); return false + } + private fun requestKeyframe() = videoRecovery.request() + @Synchronized override fun touch(contacts: List): Boolean { + if (!inputReady) return false + return touches.update(contacts).all { send(3, 0x8001, touches.encode(it, android.os.SystemClock.elapsedRealtimeNanos())) } + } + override fun setNightMode(night: Boolean): ProjectionCommandResult { + this.night = night + return if (nightSubscribed && send(1, 0x8003, AapServices.night(night))) ProjectionCommandResult.SENT else ProjectionCommandResult.UNAVAILABLE + } + @Synchronized override fun stop(): CompletableFuture { + stopping.set(true); ready = false; videoRecovery.close(); connection.close() + if (reader == null) finished.complete(Unit) + return finished + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt new file mode 100644 index 00000000..dd914f14 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt @@ -0,0 +1,25 @@ +package com.shilapi.xcertplay.projection.androidauto + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings + +/** Local head-unit server setup. Does not depend on private wireless-start broadcasts. */ +internal object LocalAndroidAuto { + const val PACKAGE = "com.google.android.projection.gearhead" + const val SOURCE_ID = "android-auto-local" + const val SETUP_SEEN = "aa_local_setup_seen" + + fun isInstalledAndEnabled(context: Context): Boolean = try { + context.packageManager.getApplicationInfo(PACKAGE, 0).enabled + } catch (_: PackageManager.NameNotFoundException) { false } + + // The AA settings activity is version-dependent. Callers try each intent and handle failure. + fun settingsIntents(): List = listOf( + Intent().setClassName(PACKAGE, "$PACKAGE.companion.settings.DefaultSettingsActivity"), + Intent(Intent.ACTION_APPLICATION_PREFERENCES).setPackage(PACKAGE), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$PACKAGE")) + ) +} diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt index 4993ee98..317428ce 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -223,8 +223,8 @@ internal object WebSession { val source = AirPlayPersistence.loadMicrophoneSource(context) val capabilities = projectionSessions.snapshot()?.capabilities audio.configure( - capabilities?.audioPlayback == true && AirPlayPersistence.loadBrowserAudioPlayback(context), - capabilities?.microphone == true && source == CarPlayMicrophoneSource.BROWSER, + capabilities?.browserAudio == true && AirPlayPersistence.loadBrowserAudioPlayback(context), + capabilities?.browserMicrophone == true && source == CarPlayMicrophoneSource.BROWSER, AirPlayPersistence.loadMediaBufferMillis(context), capabilities?.microphone == true && source != CarPlayMicrophoneSource.DISABLED, ) diff --git a/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt b/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt index 612583af..2ca90fd2 100644 --- a/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/ServerNavigationTest.kt @@ -268,15 +268,15 @@ class ServerNavigationTest : ComposeSettingsTest() { .filterIsInstance() .forEach { card -> val body = card.getChildAt(0) as android.widget.LinearLayout - val heading = body.getChildAt(0) as android.widget.LinearLayout + val heading = if (card.isClickable) body else body.getChildAt(0) as android.widget.LinearLayout val icon = heading.getChildAt(0) - val title = heading.getChildAt(1) as android.widget.TextView + val title = heading.getChildAt(1) val offset = kotlin.math.abs( (icon.top * 2 + icon.height) - (title.top * 2 + title.height) ) assertTrue( - "Card icon and title must share a vertical center: ${title.text}, offset=$offset", + "Card icon and title block must share a vertical center, offset=$offset", offset <= 2, ) } @@ -380,7 +380,7 @@ class ServerNavigationTest : ComposeSettingsTest() { val labels = views(nav).filterIsInstance().filter { it.visibility == View.VISIBLE && - it.text.toString() in listOf("启动", "iPhone", "设置") + it.text.toString() in listOf("启动", "输入源", "设置") } assertEquals(3, labels.size) val baselines = diff --git a/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt new file mode 100644 index 00000000..efb4f6e3 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt @@ -0,0 +1,116 @@ +package com.shilapi.xcertplay + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ScrollView +import android.widget.TextView +import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.card.MaterialCardView +import com.shilapi.xcertplay.projection.projectionSessions +import com.shilapi.xcertplay.web.WebSession +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], qualifiers = "zh-rCN-w360dp-h800dp-mdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SourceNavigationTest { + @Before fun provision() { + val context = RuntimeEnvironment.getApplication() + val identity = SyntheticMfiIdentity.create() + MfiAssetStore(context.noBackupFilesDir).installFiles({ identity.key.inputStream() }, { identity.certificate.inputStream() }) + DiPlayPreferences.saveAutoConnect(context, false) + } + + 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 tabs(activity: DiPlayActivity) = views(activity.window.decorView).filterIsInstance().single() + private fun page(activity: DiPlayActivity) = views(activity.window.decorView).filterIsInstance().single { it.visibility == View.VISIBLE } + private fun cards(activity: DiPlayActivity) = views(page(activity)).filterIsInstance() + private fun buttons(activity: DiPlayActivity) = views(page(activity)).filterIsInstance