diff --git a/.github/workflows/debug-build.yml b/.github/workflows/debug-build.yml index d308f13..80fa584 100644 --- a/.github/workflows/debug-build.yml +++ b/.github/workflows/debug-build.yml @@ -2,6 +2,11 @@ name: Android Debug Build on: push: + # PR branches are verified by pull_request; use workflow_dispatch before opening a PR. + branches: + - main + tags: + - '**' paths-ignore: - '**.md' - 'website/**' diff --git a/README.md b/README.md index 2e9a991..d198bf6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Run CarPlay on an Android device and use it from your car's browser. WheelPlay h | **CarPlay** | USB and wireless iPhone connections, browser display and touch control. Requires valid CarPlay authentication resources. | | **Android Auto — experimental** | USB AOA, explicit Head Unit Server TCP connections, and a guided same-phone connection. Device compatibility still needs validation. | | **VNC — experimental** | Remote desktop over RFB 3.3/3.7/3.8, JPEG/WebP display, touch, mouse and physical keyboard input, and view-only mode. Actual server/device compatibility still needs validation. | +| **Android virtual desktop — experimental** | Shizuku UserService, local app selection, fixed-size JPEG/WebP output, multi-touch and Back. Requires Android 10+/Shizuku 13+; no physical-device compatibility validated yet. See the [guide](docs/ANDROID_DESKTOP.md). | | **Diagnostic sources** | H.264, Surface, and framebuffer test sources for checking the display pipeline without a phone connection. | | **Plugin APKs** | Install and enable independent Surface video/touch sources. Includes a standalone SDK and sample APK; see the [plugin guide](docs/PROJECTION_PLUGINS.md). | | **ICCOA CarLink** | Planned as a separate plugin APK. No proprietary SDK or CarLink plugin is included. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 45c90f4..8ebac5b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,7 @@ | **CarPlay** | 支持 iPhone USB 与无线连接、浏览器显示与触控;需要有效的 CarPlay 认证资源。 | | **Android Auto(实验性)** | 已实现 USB AOA、显式 Head Unit Server TCP 连接及本机连接引导;设备兼容性仍需真机验证。 | | **VNC(实验性)** | 通过 RFB 3.3/3.7/3.8 连接远程桌面,支持 JPEG/WebP 画面、触控、鼠标、实体键盘及仅查看模式。实际服务端与设备兼容性仍需验证。 | +| **Android 虚拟桌面(实验性)** | Shizuku UserService、本机应用选择、固定尺寸 JPEG/WebP、多指触控与返回。需要 Android 10+/Shizuku 13+,尚未完成真机兼容性验证,见[使用与验收指南](docs/ANDROID_DESKTOP.md)。 | | **诊断输入源** | 提供 H.264、Surface 和帧缓冲测试源,无需连接手机即可检查画面链路。 | | **插件 APK** | 安装并启用独立的 Surface 画面/触控输入源;提供独立 SDK 和示例 APK,见[插件指南](docs/PROJECTION_PLUGINS.md)。 | | **ICCOA CarLink** | 计划通过独立插件 APK 提供;本版本不包含专有 SDK 或 CarLink 插件。 | diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 26da6e7..aa5377e 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -28,10 +28,13 @@ android { buildFeatures { compose = true + aidl = true } } dependencies { + implementation("dev.rikka.shizuku:api:13.1.5") + implementation("dev.rikka.shizuku:provider:13.1.5") implementation(project(":projection-plugin-api")) implementation("org.bouncycastle:bcpkix-jdk18on:1.79") implementation("io.github.g00fy2.quickie:quickie-bundled:1.12.0") diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml index 88e3812..8306d9a 100644 --- a/common/src/main/AndroidManifest.xml +++ b/common/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + + + @@ -20,6 +25,10 @@ + 0). + @nullable DesktopDisplayInfo display; + long enabledFeatures = 0; +} diff --git a/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStateResult.aidl b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStateResult.aidl new file mode 100644 index 0000000..c33786d --- /dev/null +++ b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStateResult.aidl @@ -0,0 +1,9 @@ +package moe.feng.wheelplay.projection.desktop; +import moe.feng.wheelplay.projection.desktop.DesktopStatus; +import moe.feng.wheelplay.projection.desktop.DesktopSessionState; + +@JavaDerive(equals=true, toString=true) +parcelable DesktopStateResult { + @nullable DesktopStatus status; + @nullable DesktopSessionState state; +} diff --git a/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStatus.aidl b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStatus.aidl new file mode 100644 index 0000000..e8466e9 --- /dev/null +++ b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopStatus.aidl @@ -0,0 +1,3 @@ +package moe.feng.wheelplay.projection.desktop; +// Custom Parcelable: fields and wire format are implemented in the matching Kotlin class. +parcelable DesktopStatus; diff --git a/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.aidl b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.aidl new file mode 100644 index 0000000..f47dba7 --- /dev/null +++ b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.aidl @@ -0,0 +1,3 @@ +package moe.feng.wheelplay.projection.desktop; +// Custom Parcelable: fields and wire format are implemented in the matching Kotlin class. +parcelable DesktopTouchFrame; diff --git a/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/IDesktopService.aidl b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/IDesktopService.aidl new file mode 100644 index 0000000..ff1a041 --- /dev/null +++ b/common/src/main/aidl/moe/feng/wheelplay/projection/desktop/IDesktopService.aidl @@ -0,0 +1,34 @@ +package moe.feng.wheelplay.projection.desktop; +import moe.feng.wheelplay.projection.desktop.DesktopStartConfig; +import moe.feng.wheelplay.projection.desktop.DesktopStateResult; +import android.view.Surface; +import android.os.IBinder; +import moe.feng.wheelplay.projection.desktop.DesktopHandshakeResult; +import moe.feng.wheelplay.projection.desktop.DesktopStartResult; +import moe.feng.wheelplay.projection.desktop.DesktopStatus; +import moe.feng.wheelplay.projection.desktop.DesktopTouchFrame; + +/** Version 7. One handshake and one immutable display per helper; session calls require the host UID. + * Control results carry DesktopStatus: zero for success, stable DesktopErrorCode plus diagnostics on failure. + * Malformed Parcel data and Binder transport failures use Android's standard exceptions. + */ +interface IDesktopService { + // Negotiated features are a supported subset of the host request; required bits must all be enabled. + DesktopHandshakeResult hello(int version, long hostFeatures, long requiredFeatures) = 0; + // Requires hello. Owner death releases the display and terminates the helper. + DesktopStartResult start(String token, IBinder owner, in Surface surface, in DesktopStartConfig config) = 1; + // Best-effort full snapshot, no per-frame reply. Persistent faults are reported by healthy(). + oneway void touch(String token, in DesktopTouchFrame frame) = 2; + // Advances inputEpoch by one, discards older touches and cancels the gesture before Back. + DesktopStatus back(String token, int layout, int inputEpoch) = 3; + // Success confirms the owned layout and renews its watchdog; errors do not renew it. + DesktopStateResult healthy(String token, int layout) = 4; + // Idempotent after release; success confirms producer release, not helper process exit. + DesktopStatus stop(String token) = 5; + // Cancels a gesture without an UP/click and invalidates delayed old input. Synchronous control barrier. + DesktopStatus cancelInput(String token, int layout, int inputEpoch) = 6; + // Read-only diagnostic snapshot; does not renew the watchdog. + DesktopStateResult getState(String token) = 7; + // Shizuku reserved destroy transaction. Client also waits for Binder death to confirm cleanup. + void destroy() = 16777114; +} diff --git a/common/src/main/assets/web/app.js b/common/src/main/assets/web/app.js index 573776f..c26fea3 100644 --- a/common/src/main/assets/web/app.js +++ b/common/src/main/assets/web/app.js @@ -933,7 +933,7 @@ if (typeof document !== 'undefined') { $('refresh-qr').addEventListener('click', startQr); const pointers = new Map(); let maxTouchContacts = 2; - let pointerInput = false, keyboardInput = false; + let pointerInput = false, keyboardInput = false, backButton = false; const send = (data, kind) => { if ((data.type === 'touch' || data.type === 'input') && presentation.current.videoEpoch !== null) { const { sessionId, videoEpoch } = presentation.current; @@ -944,6 +944,12 @@ if (typeof document !== 'undefined') { const desktop = createDesktopInput({ send, pointerEnabled: () => pointerInput, keyboardEnabled: () => keyboardInput, interactive: () => live, point: (event, surface, clamp) => mapPoint(event.clientX, event.clientY, surface.getBoundingClientRect(), width, height, clamp) }); + $('desktop-back').addEventListener('click', () => { + if (!backButton || !live) return; + release(); + send({ type: 'input', kind: 'key', key: 'BrowserBack', down: true }, 'boundary'); + send({ type: 'input', kind: 'key', key: 'BrowserBack', down: false }, 'boundary'); + }); let desktopRevision = presentation.current.revision; const browserMedia = createBrowserMediaSession({ send }); const browserAudio = createBrowserAudio({ @@ -1000,7 +1006,12 @@ if (typeof document !== 'undefined') { }); function cancelMove() { moveGeneration++; movePending = false; } function contacts() { return [...pointers.values()].map(p => ({ id: p.id, x: p.x, y: p.y, down: true })); } - function release() { cancelMove(); pointers.clear(); desktop.release(); send({ type: 'touch', contacts: [] }); } + function release() { + cancelMove(); pointers.clear(); desktop.release(false); + send(presentation.current.videoEpoch === null + ? { type: 'touch', contacts: [] } + : { type: 'input', kind: 'release' }); + } function renderPresentation(value) { if (value.revision !== desktopRevision) { desktop.release(false); desktopRevision = value.revision; @@ -1009,6 +1020,7 @@ if (typeof document !== 'undefined') { for (const surface of [...screens, video, canvas, heldFrame].filter(Boolean)) surface.classList.toggle('stale', !value.visible); touchSurface.classList.toggle('stale', !live); $('waiting').hidden = value.visible; + $('desktop-back').disabled = !live; if (!live && (pointers.size || desktop.active)) release(); const rtcVisible = rtc && value.visible && value.frame?.transport === 'rtc'; rendererStats.activeMode = rtcVisible ? launchMode : value.visible ? 'jpeg' : 'waiting'; @@ -1190,6 +1202,8 @@ if (typeof document !== 'undefined') { if (previousRevision !== presentation.current.revision) desktop.release(false); if (pointerInput !== (data.pointerInput === true) || keyboardInput !== (data.keyboardInput === true)) desktop.release(); pointerInput = data.pointerInput === true; keyboardInput = data.keyboardInput === true; + backButton = data.backButton === true; + $('desktop-back').hidden = !backButton; if (previousRevision !== presentation.current.revision) { jpeg.cancelPending(); if (rtcMedia.revision !== presentation.current.revision) { rtc.close(); rtcRequested = false; } @@ -1231,7 +1245,7 @@ if (typeof document !== 'undefined') { if (socket !== ws) return; if (outbox) { outbox.close(); outbox = null; } cancelMove(); - socket = null; pointers.clear(); desktop.release(false); pointerInput = false; keyboardInput = false; + socket = null; pointers.clear(); desktop.release(false); pointerInput = false; keyboardInput = false; backButton = false; $('desktop-back').hidden = true; clearInterval(heartbeat); presentation.close(); cleanupRtc(); cleanupImage(); browserMedia.close(); browserAudio.close(); $('audio-message').hidden = true; browserLocation.close(); @@ -1358,8 +1372,9 @@ if (typeof document !== 'undefined') { send({ type: 'touch', contacts: [...contacts(), { ...p, down: false }] }); } surface.addEventListener('pointerup', up); - surface.addEventListener('pointercancel', up); - surface.addEventListener('lostpointercapture', up); + const cancel = event => { if (pointers.has(event.pointerId)) release(); }; + surface.addEventListener('pointercancel', cancel); + surface.addEventListener('lostpointercapture', cancel); } for (const surface of [...screens, canvas, touchSurface]) bindSurface(surface); window.addEventListener('blur', release); diff --git a/common/src/main/assets/web/index.html b/common/src/main/assets/web/index.html index 0a13196..3e3c184 100644 --- a/common/src/main/assets/web/index.html +++ b/common/src/main/assets/web/index.html @@ -40,6 +40,7 @@

{{web_connect_your_carplay_display}}

+ diff --git a/common/src/main/assets/web/style.css b/common/src/main/assets/web/style.css index f32066e..c17810e 100644 --- a/common/src/main/assets/web/style.css +++ b/common/src/main/assets/web/style.css @@ -20,3 +20,6 @@ .renderer-options{border:0;margin:28px 0 0;padding:0;min-width:0}.renderer-options legend{padding:0;margin-bottom:12px;font-size:15px;font-weight:600}.renderer-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.renderer-card{display:flex;align-items:flex-start;gap:12px;margin:0;padding:18px 16px;min-height:130px;border:1px solid #46514e;border-radius:12px;background:#1c2428;cursor:pointer}.renderer-card.selected{border-color:#b4d9b1;background:#24352c;box-shadow:inset 0 0 0 1px #b4d9b1}.renderer-card input{appearance:auto;accent-color:#b4d9b1;flex:0 0 auto;width:20px;height:20px;margin:2px 0 0;padding:0;letter-spacing:0}.renderer-card span{display:flex;flex-wrap:wrap;align-items:center;gap:6px}.renderer-card strong{font-size:15px;line-height:1.5}.renderer-card em{font-style:normal;font-size:11px;color:#b4d9b1;border:1px solid #465e4b;border-radius:4px;padding:1px 5px}.renderer-card small{flex-basis:100%;margin-top:3px;font-size:13px;line-height:1.6;color:#b5beb9}.renderer-hint{font-size:13px;line-height:1.6;color:#efbe90;margin:12px 0 0}@media(max-width:759px){.renderer-cards{grid-template-columns:1fr}.renderer-card{min-height:0;padding:16px}} #location-message{position:absolute;right:16px;top:16px;z-index:2;max-width:min(85%,360px);padding:10px 14px;background:#1c2428e8;border-radius:12px;font-size:13px;color:#efbe90;pointer-events:none} + +/* Remains accessible in immersive display mode; absent for sources without Back. */ +#desktop-back{position:absolute;left:12px;bottom:12px;z-index:2;opacity:.85} diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index e806065..a5f990d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -1,5 +1,6 @@ package com.shilapi.xcertplay +import com.shilapi.xcertplay.projection.ProjectionSourceIds import com.shilapi.xcertplay.projection.CarPlaySourceSession import com.shilapi.xcertplay.projection.ProjectionResource import com.shilapi.xcertplay.projection.projectionSessions @@ -420,7 +421,7 @@ class CarPlayHostActivity : ComponentActivity() { super.onCreate(savedInstanceState) showSystemBars() if (projectionSessions.hasSession() && - projectionSessions.snapshot()?.descriptor?.id != "carplay") { + projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.CARPLAY) { showDiPlayHome("home"); finish(); return } if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED") { diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 491834d..aecf106 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -2,6 +2,9 @@ // UI copy and visual language adapted from DiAuto. See docs/THIRD_PARTY_NOTICES.md. package com.shilapi.xcertplay +import com.shilapi.xcertplay.projection.message +import com.shilapi.xcertplay.projection.projectionFailure +import com.shilapi.xcertplay.projection.ProjectionSourceIds import com.shilapi.xcertplay.projection.projectionSessions import com.shilapi.xcertplay.projection.plugin.projectionPlugins import com.shilapi.xcertplay.projection.plugin.InstalledProjectionPlugin @@ -10,6 +13,10 @@ import com.shilapi.xcertplay.projection.ProjectionSession import moe.feng.wheelplay.plugin.ProjectionPlugin import com.shilapi.xcertplay.projection.registerProjectionSources import com.shilapi.xcertplay.projection.androidauto.LocalAndroidAuto +import moe.feng.wheelplay.projection.desktop.* +import androidx.core.view.isVisible +import com.shilapi.xcertplay.projection.SourceAvailability +import rikka.shizuku.Shizuku import com.shilapi.xcertplay.projection.vnc.VncSource import com.shilapi.xcertplay.projection.vnc.VncEndpoint @@ -63,7 +70,7 @@ 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-vnc", "source-diagnostics", "source-plugins") + private val sourceDetailPages = SourcePage.entries.map { it.id }.toSet() private val pluginFailureLabels = mutableMapOf>() private val sourceStatuses = mutableMapOf() private val sourceStopButtons = mutableMapOf() @@ -76,6 +83,7 @@ class DiPlayActivity : AppCompatActivity() { private var webBusy = false private var awaitingTeslaVpnConsent = false private var vncDialog: android.app.AlertDialog? = null + private var desktopDialog: android.app.AlertDialog? = null private var localAndroidAutoDialog: android.app.AlertDialog? = null private val localAndroidAutoSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (!isFinishing && !isDestroyed) showLocalAndroidAutoSetup() @@ -136,7 +144,16 @@ class DiPlayActivity : AppCompatActivity() { private var homeSourceButton: Button? = null private var homeStopButton: Button? = null private var homeAudioHint: TextView? = null - private data class HomeSource(val id: String, val name: String, val page: String) + private enum class SourcePage(val id: String) { + CARPLAY("source-carplay"), + ANDROID_AUTO("source-android-auto"), + ICCOA("source-iccoa"), + VNC("source-vnc"), + DESKTOP("source-desktop"), + DIAGNOSTICS("source-diagnostics"), + PLUGINS("source-plugins"), + } + private data class HomeSource(val id: String, val name: String, val page: SourcePage) private var setupError: String? = null private var status: TextView? = null private var connectButton: com.google.android.material.button.MaterialButton? = null @@ -240,6 +257,7 @@ class DiPlayActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Shizuku.addRequestPermissionResultListener(desktopPermissionResult) if (TeslaHttpCompatibility.config(this).let { it.enabled && it.mode == TeslaHttpMode.ROOT }) RootAccess.checkOnStartup() com.shilapi.xcertplay.hud.BydNavigationOutputs.onAppOpened(applicationContext) ServerWindow.showSystemBars(window) @@ -308,17 +326,17 @@ class DiPlayActivity : AppCompatActivity() { if (setupError == null && !DiPlayBootstrap.importing && selectedPrivateKey == null && intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_VIEW && !projectionSessions.hasSession() && - selectedHomeSource().id == "carplay" && DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { + selectedHomeSource().id == ProjectionSourceIds.CARPLAY && DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) } } } } override fun onPause() { WebSession.setPhoneConnectHandler(null); handler.removeCallbacks(tick); super.onPause() } - override fun onDestroy() { vncDialog?.dismiss(); localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } + override fun onDestroy() { Shizuku.removeRequestPermissionResultListener(desktopPermissionResult); desktopDialog?.dismiss(); vncDialog?.dismiss(); localAndroidAutoDialog?.dismiss(); settingsDialogs.cancel(); pendingPemCertificate?.fill(0); pendingPemCertificate = null; super.onDestroy() } private fun normalizedPage(value: String) = when (value) { "home" -> "phone" - "wireless-recovery" -> "source-carplay" + "wireless-recovery" -> SourcePage.CARPLAY.id "about" -> "settings" "phone", "settings" -> value in sourceDetailPages -> value @@ -354,12 +372,13 @@ class DiPlayActivity : AppCompatActivity() { status = null; connectButton = null; disconnectButton = null; lastRunning = null sourceStatuses.clear(); sourceStopButtons.clear(); pluginFailureLabels.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-vnc", ui.content(::vncSource)) - shell.put("source-diagnostics", ui.content(::diagnosticSources)) - shell.put("source-plugins", ui.content(::pluginSources)) + shell.put(SourcePage.CARPLAY.id, ui.content(::carPlaySource)) + shell.put(SourcePage.ANDROID_AUTO.id, ui.content(::androidAutoSource)) + shell.put(SourcePage.ICCOA.id, ui.content(::iccoaSource)) + shell.put(SourcePage.VNC.id, ui.content(::vncSource)) + shell.put(SourcePage.DESKTOP.id, ui.content(::desktopSource)) + shell.put(SourcePage.DIAGNOSTICS.id, ui.content(::diagnosticSources)) + shell.put(SourcePage.PLUGINS.id, ui.content(::pluginSources)) refreshStatus() } @@ -510,9 +529,9 @@ class DiPlayActivity : AppCompatActivity() { sessionConnectButton = button(getString(UiR.string.ui_connect), false, R.drawable.ic_server_phone) { val source = selectedHomeSource() when (source.id) { - "carplay" -> connect(AirPlayPersistence.loadWirelessEnabled(this)) - "android-auto" -> navigate(source.page) - "vnc" -> configureVnc() + ProjectionSourceIds.CARPLAY -> connect(AirPlayPersistence.loadWirelessEnabled(this)) + ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.ANDROID_DESKTOP -> navigate(source.page.id) + ProjectionSourceIds.VNC -> configureVnc() else -> startProjectionSource(source.id) } } @@ -520,14 +539,14 @@ class DiPlayActivity : AppCompatActivity() { homeStopButton = button("", false) { homeStopButton?.isEnabled = false val active = projectionSessions.snapshot() - if (active != null) projectionSessions.stop(active).whenComplete { _, failure -> runOnUiThread { - if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + if (projectionSessions.hasSession()) projectionSessions.stop(active).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) refreshStatus(); refreshService() } } else CarPlayBackgroundSession.stop { runOnUiThread { refreshStatus(); refreshService() } } } body.addView(homeStopButton, ui.secondaryButtonLayout(12)) body.addView(button(getString(UiR.string.home_source_settings), false) { - navigate(selectedHomeSource().page) + navigate(selectedHomeSource().page.id) }, ui.secondaryButtonLayout(16)) } homeAudioHint = label(getString(UiR.string.ui_audio_and_microphone_use_this_device_by_default_enable), 14, MUTED) @@ -539,13 +558,14 @@ class DiPlayActivity : AppCompatActivity() { } private fun homeSources(): List = listOf( - HomeSource("carplay", getString(UiR.string.source_carplay), "source-carplay"), - HomeSource("android-auto", "Android Auto", "source-android-auto"), - HomeSource("vnc", "VNC", "source-vnc"), + HomeSource(ProjectionSourceIds.CARPLAY, getString(UiR.string.source_carplay), SourcePage.CARPLAY), + HomeSource(ProjectionSourceIds.ANDROID_AUTO, "Android Auto", SourcePage.ANDROID_AUTO), + HomeSource(ProjectionSourceIds.VNC, "VNC", SourcePage.VNC), + HomeSource(ProjectionSourceIds.ANDROID_DESKTOP, getString(UiR.string.desktop_title), SourcePage.DESKTOP), ) + projectionPlugins(this).let { registry -> registry.entries().filter { registry.available(it) }.map { plugin -> HomeSource(plugin.id, plugin.name, - if (plugin.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK) "source-iccoa" else "source-plugins") + if (plugin.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK) SourcePage.ICCOA else SourcePage.PLUGINS) } } @@ -573,34 +593,37 @@ class DiPlayActivity : AppCompatActivity() { val source = selectedHomeSource() val active = projectionSessions.snapshot() val carPlayRunning = CarPlayBackgroundSession.hasSession() - val activeName = active?.descriptor?.displayName ?: if (carPlayRunning) getString(UiR.string.source_carplay) else null - val selectedActive = (source.id == "carplay" && carPlayRunning) || active?.let { - it.descriptor.id == source.id || (source.id in setOf("android-auto", "vnc") && belongsToSource(it.descriptor.id, source.id)) + val runtimeState = projectionSessions.stateSnapshot() + val activeName = active?.descriptor?.displayName ?: if (carPlayRunning) getString(UiR.string.source_carplay) + else runtimeState.source?.displayName?.takeIf { projectionSessions.hasSession() } + val selectedActive = (source.id == ProjectionSourceIds.CARPLAY && carPlayRunning) || active?.let { + it.descriptor.id == source.id || (source.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC) && belongsToSource(it.descriptor.id, source.id)) } == true homeSourceButton?.text = getString(UiR.string.home_switch_source, source.name) webStage?.text = when { active != null -> getString(UiR.string.home_active_source, active.descriptor.displayName, - active.statusText ?: getString(UiR.string.projection_active, active.descriptor.displayName)) + runtimeState.failure?.message(this) ?: active.statusText ?: getString(UiR.string.projection_active, active.descriptor.displayName)) carPlayRunning -> getString(UiR.string.home_active_source, getString(UiR.string.source_carplay), WebSession.stage(this)) - source.id == "carplay" -> setupError ?: when { + source.id == ProjectionSourceIds.CARPLAY -> setupError ?: when { DiPlayPreferences.phoneAddress(this) != null -> getString(UiR.string.status_selected_phone, DiPlayPreferences.phoneName(this)) else -> getString(UiR.string.ui_waiting_to_connect_to_iphone) } projectionPlugins(this).lastFailure(source.id) != null -> getString(UiR.string.projection_failed, projectionPlugins(this).lastFailure(source.id)) + runtimeState.source?.id == source.id -> runtimeState.message(this) else -> getString(UiR.string.projection_waiting) } sessionConnectButton?.apply { - text = getString(if (source.id in setOf("android-auto", "vnc")) UiR.string.home_configure_connect else UiR.string.ui_connect) - visibility = if (selectedActive || (source.id == "carplay" && DiPlayPreferences.phoneAddress(this@DiPlayActivity) == null)) View.GONE else View.VISIBLE - isEnabled = source.id != "carplay" || (setupError == null && !DiPlayBootstrap.importing) + text = getString(if (source.id in setOf(ProjectionSourceIds.ANDROID_AUTO, ProjectionSourceIds.VNC, ProjectionSourceIds.ANDROID_DESKTOP)) UiR.string.home_configure_connect else UiR.string.ui_connect) + visibility = if (selectedActive || (source.id == ProjectionSourceIds.CARPLAY && DiPlayPreferences.phoneAddress(this@DiPlayActivity) == null)) View.GONE else View.VISIBLE + isEnabled = source.id != ProjectionSourceIds.CARPLAY || (setupError == null && !DiPlayBootstrap.importing) } homeStopButton?.apply { visibility = if (activeName != null) View.VISIBLE else View.GONE text = getString(UiR.string.home_stop_source, activeName.orEmpty()) isEnabled = activeName != null } - homeAudioHint?.visibility = if (source.id == "carplay") View.VISIBLE else View.GONE + homeAudioHint?.visibility = if (source.id == ProjectionSourceIds.CARPLAY) View.VISIBLE else View.GONE } private fun refreshService() { @@ -649,15 +672,16 @@ class DiPlayActivity : AppCompatActivity() { 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, getString(UiR.string.source_carplay), getString(UiR.string.source_carplay_summary), R.drawable.ic_server_phone) { navigate(SourcePage.CARPLAY.id) } + ui.sourceCard(content, "Android Auto", getString(UiR.string.source_aa_summary), R.drawable.ic_server_launch) { navigate(SourcePage.ANDROID_AUTO.id) } val carLinkPlugins = projectionPlugins(this).entriesForSource(ProjectionPlugin.SOURCE_ICCOA_CARLINK) val carLinkSummary = if (carLinkPlugins.isEmpty()) getString(UiR.string.source_iccoa_summary) else getString(UiR.string.plugin_iccoa_installed, carLinkPlugins.joinToString { it.name }) - ui.sourceCard(content, "ICCOA CarLink", carLinkSummary, R.drawable.ic_server_wifi) { navigate("source-iccoa") } - ui.sourceCard(content, "VNC", getString(UiR.string.vnc_summary), R.drawable.ic_server_server) { navigate("source-vnc") } - ui.sourceCard(content, getString(UiR.string.plugin_sources), getString(UiR.string.plugin_summary), R.drawable.ic_server_launch) { navigate("source-plugins") } - ui.sourceCard(content, getString(UiR.string.source_diagnostics), getString(UiR.string.source_diagnostics_summary), R.drawable.ic_server_server) { navigate("source-diagnostics") } + ui.sourceCard(content, "ICCOA CarLink", carLinkSummary, R.drawable.ic_server_wifi) { navigate(SourcePage.ICCOA.id) } + ui.sourceCard(content, "VNC", getString(UiR.string.vnc_summary), R.drawable.ic_server_server) { navigate(SourcePage.VNC.id) } + ui.sourceCard(content, getString(UiR.string.desktop_title), getString(UiR.string.desktop_summary), R.drawable.ic_server_phone) { navigate(SourcePage.DESKTOP.id) } + ui.sourceCard(content, getString(UiR.string.plugin_sources), getString(UiR.string.plugin_summary), R.drawable.ic_server_launch) { navigate(SourcePage.PLUGINS.id) } + ui.sourceCard(content, getString(UiR.string.source_diagnostics), getString(UiR.string.source_diagnostics_summary), R.drawable.ic_server_server) { navigate(SourcePage.DIAGNOSTICS.id) } } private fun pluginSources(content: LinearLayout) { @@ -667,7 +691,7 @@ class DiPlayActivity : AppCompatActivity() { content.addView(button(getString(UiR.string.plugin_refresh), false) { plugins.refresh(); refreshPhone() }, matchButton(12)) - sourceSessionControls(content, "plugin") + sourceSessionControls(content, ProjectionSourceIds.PLUGIN) if (plugins.entries().isEmpty()) content.addView(label(getString(UiR.string.plugin_none), 16, MUTED)) plugins.entries().forEach { pluginCard(content, it) } } @@ -740,7 +764,7 @@ class DiPlayActivity : AppCompatActivity() { private fun androidAutoSource(content: LinearLayout) { sourceHeading(content, "Android Auto") - sourceSessionControls(content, "android-auto") + sourceSessionControls(content, ProjectionSourceIds.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)) @@ -748,7 +772,7 @@ class DiPlayActivity : AppCompatActivity() { } 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)) + body.addView(button(getString(UiR.string.aa_usb_connect), false) { startProjectionSource(ProjectionSourceIds.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)) @@ -765,19 +789,115 @@ class DiPlayActivity : AppCompatActivity() { body.addView(button(getString(UiR.string.plugin_refresh), false) { projectionPlugins(this).refresh(); refreshPhone() }, matchButton(12)) - body.addView(button(getString(UiR.string.plugin_sources), false) { navigate("source-plugins") }, matchButton(12)) + body.addView(button(getString(UiR.string.plugin_sources), false) { navigate(SourcePage.PLUGINS.id) }, matchButton(12)) } } // Keep stop/status available if the APK disappears while its session is being cleaned up. - if (plugins.isNotEmpty() || projectionSessions.snapshot()?.let { sessionBelongsToSource(it, "iccoa") } == true) { - sourceSessionControls(content, "iccoa") + if (plugins.isNotEmpty() || projectionSessions.snapshot()?.let { sessionBelongsToSource(it, ProjectionSourceIds.ICCOA) } == true) { + sourceSessionControls(content, ProjectionSourceIds.ICCOA) } plugins.forEach { pluginCard(content, it) } } + private var desktopStatus: TextView? = null + private var desktopStart: Button? = null + private val desktopPermissionResult = Shizuku.OnRequestPermissionResultListener { code, _ -> + if (code == 40) runOnUiThread { if (!isDestroyed) refreshPhone() } + } + private fun desktopProvider(): AndroidDesktopSource { + registerProjectionSources(this) + return projectionSessions.sources().filterIsInstance().single() + } + private fun desktopSource(content: LinearLayout) { + val source = desktopProvider() + sourceHeading(content, getString(UiR.string.desktop_title)) + sourceSessionControls(content, ProjectionSourceIds.ANDROID_DESKTOP) + ui.card(content, getString(UiR.string.desktop_title), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.desktop_hint), 15, MUTED)) + val config = source.displayConfig() + body.addView(button(getString(UiR.string.desktop_display_summary, config.width, config.height, config.dpi), false) { + configureDesktopDisplay() + }, matchButton(12)) + desktopStatus = label("", 15, MUTED).also { body.addView(it) } + body.addView(button(getString(UiR.string.desktop_open_shizuku), false) { + openSystem(packageManager.getLaunchIntentForPackage(ShizukuDesktopBackend.PACKAGE) + ?: Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/download/"))) + }, matchButton(12)) + body.addView(button(getString(UiR.string.desktop_authorize), false) { + runCatching { + if (Shizuku.pingBinder() && Shizuku.getVersion() >= 13) Shizuku.requestPermission(40) + else toast((source.dependencyStatus() as? SourceAvailability.Unavailable)?.reason.orEmpty()) + }.onFailure { toast(getString(UiR.string.desktop_not_authorized)) } + }, matchButton(12)) + body.addView(button(getString(UiR.string.desktop_choose_app), false) { chooseDesktopApp() }, matchButton(12)) + if (source.selectedApp().isNotEmpty()) body.addView(label(source.selectedApp(), 13, MUTED)) + desktopStart = button(getString(UiR.string.desktop_start), true) { + startProjectionSource(ProjectionSourceIds.ANDROID_DESKTOP) + }.also { body.addView(it, matchButton(12)) } + } + } + private fun configureDesktopDisplay() { + val source = desktopProvider() + val config = source.displayConfig() + val fields = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), 0, dp(24), 0) } + fun field(title: Int, value: Int) = EditText(this).apply { + fields.addView(label(getString(title), 14, MUTED)) + contentDescription = getString(title) + inputType = android.text.InputType.TYPE_CLASS_NUMBER + setSingleLine(true); setText(value.toString()); fields.addView(this) + } + val width = field(UiR.string.desktop_width, config.width) + val height = field(UiR.string.desktop_height, config.height) + val dpi = field(UiR.string.desktop_dpi, config.dpi) + val error = label(getString(UiR.string.desktop_display_invalid, DesktopDisplayConfig.MIN_SIZE, + DesktopDisplayConfig.MAX_SIZE, DesktopDisplayConfig.MAX_PIXELS, DesktopDisplayConfig.MIN_DPI, + DesktopDisplayConfig.MAX_DPI), 14, MUTED).apply { isVisible = false; fields.addView(this) } + desktopDialog?.dismiss() + val dialog = android.app.AlertDialog.Builder(this).setTitle(UiR.string.desktop_display_settings) + .setMessage(UiR.string.desktop_display_hint).setView(ScrollView(this).apply { addView(fields) }) + .setPositiveButton(android.R.string.ok, null).setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(UiR.string.desktop_display_reset, null).create() + dialog.setOnDismissListener { if (desktopDialog === dialog) desktopDialog = null } + desktopDialog = dialog + dialog.show() + dialog.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + val defaults = DesktopDisplayConfig() + width.setText(defaults.width.toString()); height.setText(defaults.height.toString()); dpi.setText(defaults.dpi.toString()) + error.isVisible = false + } + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val selected = runCatching { + DesktopDisplayConfig( + width = width.text.toString().toInt(), + height = height.text.toString().toInt(), + dpi = dpi.text.toString().toInt(), + ) + }.getOrNull() + if (selected == null) { error.isVisible = true; return@setOnClickListener } + source.configureDisplay(selected) + dialog.dismiss() + refreshPhone() + } + } + + private fun chooseDesktopApp() { + val apps = packageManager.queryIntentActivities(AndroidDesktopSource.launcherIntent(), 0) + .filter { it.activityInfo.isDesktopLaunchable } + .distinctBy { it.activityInfo.desktopComponent } + .sortedBy { it.loadLabel(packageManager).toString().lowercase() } + if (apps.isEmpty()) { toast(getString(UiR.string.desktop_no_apps)); return } + android.app.AlertDialog.Builder(this).setTitle(UiR.string.desktop_choose_app) + .setItems(apps.map { "${it.loadLabel(packageManager)} (${it.activityInfo.packageName})" }.toTypedArray()) { _, index -> + val info = apps[index].activityInfo + runCatching { desktopProvider().selectApp(info.desktopComponent.flattenToString()) } + .onFailure { toast(getString(UiR.string.desktop_choose_app)) } + refreshPhone() + }.setNegativeButton(android.R.string.cancel, null).show() + } + private fun vncSource(content: LinearLayout) { sourceHeading(content, "VNC") - sourceSessionControls(content, "vnc") + sourceSessionControls(content, ProjectionSourceIds.VNC) ui.card(content, getString(UiR.string.vnc_desktop), R.drawable.ic_server_server) { body -> body.addView(label(getString(UiR.string.vnc_hint), 15, MUTED)) body.addView(button(getString(UiR.string.vnc_connect), true) { configureVnc() }, matchButton(12)) @@ -814,14 +934,14 @@ class DiPlayActivity : AppCompatActivity() { } source.configure(target, secret) dialog.dismiss() - startProjectionSource("vnc-tcp") + startProjectionSource(ProjectionSourceIds.VNC_TCP) } finally { secret.fill('\u0000') } } } private fun diagnosticSources(content: LinearLayout) { sourceHeading(content, getString(UiR.string.source_diagnostics)) - sourceSessionControls(content, "demo") + sourceSessionControls(content, ProjectionSourceIds.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)) @@ -831,16 +951,18 @@ class DiPlayActivity : AppCompatActivity() { private fun belongsToSource(id: String, group: String) = id.startsWith("$group-") private fun sessionBelongsToSource(session: ProjectionSession, group: String): Boolean = - if (group == "iccoa") (session as? RemotePluginSession)?.plugin?.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK - else belongsToSource(session.descriptor.id, group) + if (group == ProjectionSourceIds.ICCOA) (session as? RemotePluginSession)?.plugin?.sourceId == ProjectionPlugin.SOURCE_ICCOA_CARLINK + else session.descriptor.id == group || belongsToSource(session.descriptor.id, 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 { sessionBelongsToSource(it, 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())) + val state = projectionSessions.stateSnapshot() + val pendingHere = state.source?.id?.let { it == group || belongsToSource(it, group) } == true && projectionSessions.hasSession() + if (session != null || pendingHere) projectionSessions.stop(session).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) refreshStatus(); refreshService() } } }.also { body.addView(it, ui.secondaryButtonLayout(12)) } @@ -849,7 +971,7 @@ class DiPlayActivity : AppCompatActivity() { private fun chooseDiagnosticSource() { registerProjectionSources(this) - val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, "demo") } + val sources = projectionSessions.sources().filter { belongsToSource(it.descriptor.id, ProjectionSourceIds.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) @@ -872,7 +994,7 @@ class DiPlayActivity : AppCompatActivity() { 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") + startProjectionSource(ProjectionSourceIds.ANDROID_AUTO_TCP) } }.setNegativeButton(android.R.string.cancel, null).show() } @@ -922,8 +1044,8 @@ class DiPlayActivity : AppCompatActivity() { private fun startProjectionSource(id: String) { val homeId = when { - belongsToSource(id, "android-auto") -> "android-auto" - belongsToSource(id, "vnc") -> "vnc" + belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) -> ProjectionSourceIds.ANDROID_AUTO + belongsToSource(id, ProjectionSourceIds.VNC) -> ProjectionSourceIds.VNC else -> id } if (homeSources().any { it.id == homeId }) DiPlayPreferences.saveHomeSource(this, homeId) @@ -937,13 +1059,13 @@ class DiPlayActivity : AppCompatActivity() { startProjectionSourceNow(id) } - private fun isAndroidAutoSource(id: String) = id == LocalAndroidAuto.SOURCE_ID || id.startsWith("android-auto-") + private fun isAndroidAutoSource(id: String) = belongsToSource(id, ProjectionSourceIds.ANDROID_AUTO) private fun startProjectionSourceNow(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())) + if (failure != null) toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) refreshStatus(); refreshService() } } } @@ -1097,7 +1219,7 @@ class DiPlayActivity : AppCompatActivity() { } private fun connect(wireless: Boolean) { - DiPlayPreferences.saveHomeSource(this, "carplay") + DiPlayPreferences.saveHomeSource(this, ProjectionSourceIds.CARPLAY) refreshHomeSource() refreshAuthentication() if (setupError != null) { toast(setupError!!); return } @@ -1118,7 +1240,7 @@ class DiPlayActivity : AppCompatActivity() { } if (projectionSessions.hasSession()) projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { if (failure == null) open() - else toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + else toast(getString(UiR.string.projection_failed, projectionFailure(failure).message(this@DiPlayActivity))) } } else open() } private fun openProjection() { @@ -1171,7 +1293,7 @@ class DiPlayActivity : AppCompatActivity() { private fun handleWirelessRecovery() { if (intent.getStringExtra("page") != "wireless-recovery") return intent.removeExtra("page") - navigate("source-carplay") + navigate(SourcePage.CARPLAY.id) confirmWirelessReset() } @@ -1215,6 +1337,14 @@ class DiPlayActivity : AppCompatActivity() { } private fun refreshStatus() { + val desktop = desktopProvider() + val desktopState = desktop.probe() + desktopStatus?.text = if (desktop.preparing) getString(UiR.string.desktop_preparing) + else (desktopState as? SourceAvailability.Unavailable)?.reason + ?: desktop.lastFailure ?: getString(UiR.string.desktop_ready) + desktopStart?.isEnabled = !desktop.preparing && desktopState is SourceAvailability.Ready && + projectionSessions.snapshot()?.descriptor?.id != ProjectionSourceIds.ANDROID_DESKTOP + pluginFailureLabels.forEach { (id, labels) -> val failure = projectionPlugins(this).lastFailure(id) labels.forEach { label -> @@ -1225,13 +1355,18 @@ class DiPlayActivity : AppCompatActivity() { val projection = projectionSessions.snapshot() sourceStatuses.forEach { (group, label) -> val session = projection?.takeIf { sessionBelongsToSource(it, 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 state = projectionSessions.stateSnapshot() + val belongs = state.source?.id?.let { it == group || belongsToSource(it, group) } == true + label.text = when { + belongs && (state.failure != null || session == null) -> state.message(this) + session != null -> session.statusText ?: state.message(this) + else -> getString(UiR.string.projection_waiting) + } + sourceStopButtons[group]?.visibility = if (session != null || (belongs && projectionSessions.hasSession())) View.VISIBLE else View.GONE } val running = CarPlayBackgroundSession.hasSession() status?.text = when { - projectionSessions.snapshot()?.descriptor?.id?.let { it != "carplay" } == true -> + projectionSessions.snapshot()?.descriptor?.id?.let { it != ProjectionSourceIds.CARPLAY } == true -> projectionSessions.snapshot()?.descriptor?.displayName setupError != null -> getString(UiR.string.ui_authentication_setup_needs_attention) CarPlayBackgroundSession.active -> getString(UiR.string.ui_carplay_connected) diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt index 1519c3a..f6cf43d 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt @@ -1,5 +1,6 @@ package com.shilapi.xcertplay +import com.shilapi.xcertplay.projection.ProjectionSourceIds import android.content.Context import com.shilapi.xcertplay.airplay.AirPlayIdentity import com.shilapi.xcertplay.orchestration.MfiTarget @@ -40,7 +41,7 @@ internal object DiPlayPreferences { fun savePhone(context: Context, address: String, name: String) { prefs(context).edit().putString("phone_address", address).putString("phone_name", name).apply() } - fun homeSource(context: Context): String = prefs(context).getString("home_source", "carplay") ?: "carplay" + fun homeSource(context: Context): String = prefs(context).getString("home_source", ProjectionSourceIds.CARPLAY) ?: ProjectionSourceIds.CARPLAY fun saveHomeSource(context: Context, id: String) { prefs(context).edit().putString("home_source", id).apply() } fun autoConnect(context: Context) = prefs(context).getBoolean("auto_connect", false) fun saveAutoConnect(context: Context, value: Boolean) { diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt index 02dc651..4c3a25a 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt @@ -35,6 +35,9 @@ internal class BrowserProjectionVideo( private val imageEncoder = ImageFrameEncoder() private var hadViewer = false private var lastImageOptions: ImageEncodingOptions? = null + @Volatile private var published = false + override val hasFrame get() = !closed.get() && offscreen?.hasFrame == true + override val hasPublishedFrame get() = !closed.get() && published private var configured = false private var revision = 0L @@ -48,6 +51,7 @@ internal class BrowserProjectionVideo( if (!closed.get()) { WebSession.setVideoActive(generation, true) WebSession.publish(generation, bytes, format, quality) + published = true } }) if (kind == VideoIngressKind.ENCODED) decoder = AndroidMediaSink( diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt index c09cdc9..8ef4ab6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt @@ -8,19 +8,32 @@ internal class CarPlaySourceSession(private val status: () -> String?) : Project @Volatile private var controller: com.shilapi.xcertplay.orchestration.CarPlayController? = null @Volatile private var sink: com.shilapi.xcertplay.media.AndroidMediaSink? = null private val closed = CompletableFuture() + @Volatile private var stopping = false fun bind(controller: com.shilapi.xcertplay.orchestration.CarPlayController?, sink: com.shilapi.xcertplay.media.AndroidMediaSink?) { this.controller = controller this.sink = sink + projectionSessions.changed(this) } fun completed(clean: Boolean) { if (clean) closed.complete(Unit) - else closed.completeExceptionally(IllegalStateException("CarPlay transport did not stop")) + else { + val failure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, "CarPlay transport did not stop", "carplay") + lifecycleFailure = failure + closed.completeExceptionally(ProjectionException(failure)) + } + projectionSessions.changed(this) } + @Volatile private var lifecycleFailure: ProjectionFailure? = null + override val state get() = ProjectionSessionState( + if (closed.isDone) { if (lifecycleFailure == null) ProjectionPhase.STOPPED else ProjectionPhase.FAILED } + else if (stopping) ProjectionPhase.STOPPING + else if (inputReady) ProjectionPhase.RUNNING else ProjectionPhase.CONNECTING, + capabilities, inputReady, lifecycleFailure) override val statusText get() = status() - override val descriptor = SourceDescriptor("carplay", "CarPlay") + override val descriptor = SourceDescriptor(ProjectionSourceIds.CARPLAY, "CarPlay") override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.ENCODED), 2, nightMode = true, audioPlayback = true, microphone = true) - override val inputReady get() = controller?.touchReady == true + override val inputReady get() = !stopping && !closed.isDone && controller?.touchReady == true override val media get() = controller override val mediaOutput get() = sink override fun touch(contacts: List): Boolean { @@ -30,9 +43,11 @@ internal class CarPlaySourceSession(private val status: () -> String?) : Project contacts.find { it.id == id } ?: ProjectionContact(id, 0.0, 0.0, false) }) ?: false } + override fun cancelInput(reason: ProjectionCancelReason) = touch(emptyList()) override fun setNightMode(night: Boolean) = if (controller?.setNightMode(night) == true) ProjectionCommandResult.SENT else ProjectionCommandResult.UNAVAILABLE override fun stop(): CompletableFuture { + stopping = true CarPlayBackgroundSession.stop() return closed } diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt index 5df0d85..01271a9 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/IccoaSource.kt @@ -5,7 +5,7 @@ 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 descriptor = SourceDescriptor(ProjectionSourceIds.ICCOA_CARLINK, "ICCOA CarLink") override val resources = setOf(ProjectionResource.USB, ProjectionResource.WIRELESS, ProjectionResource.DISPLAY, ProjectionResource.MICROPHONE) override fun probe(): SourceAvailability = SourceAvailability.Unavailable( 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 38f3da0..28e31d6 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt @@ -4,7 +4,7 @@ package com.shilapi.xcertplay.projection internal val projectionSessions = ProjectionRuntime() @Synchronized internal fun registerProjectionSources(context: android.content.Context) { - if (projectionSessions.sources().any { it.descriptor.id == "demo-surface" }) return + if (projectionSessions.sources().any { it.descriptor.id == ProjectionSourceIds.DEMO_SURFACE }) return val app = context.applicationContext com.shilapi.xcertplay.projection.androidauto.AndroidAutoMode.entries.forEach { mode -> projectionSessions.register(com.shilapi.xcertplay.projection.androidauto.AndroidAutoSource(app, mode) { @@ -12,6 +12,7 @@ internal val projectionSessions = ProjectionRuntime() }) } projectionSessions.register(IccoaSource(app)) + projectionSessions.register(moe.feng.wheelplay.projection.desktop.AndroidDesktopSource(app)) projectionSessions.register(com.shilapi.xcertplay.projection.vnc.VncSource(app) { width, height -> BrowserProjectionVideo(app, width, height, VideoIngressKind.FRAMEBUFFER) }) diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSourceIds.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSourceIds.kt new file mode 100644 index 0000000..dd5ad64 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSourceIds.kt @@ -0,0 +1,23 @@ +package com.shilapi.xcertplay.projection + +/** Stable IDs shared by source providers, UI selection and saved preferences. + * Family IDs (Android Auto, VNC, diagnostics and plugins) group concrete sessions. + * Plugin session IDs remain dynamic: PLUGIN_PREFIX followed by the service component. + * These values are persisted; renaming a Kotlin symbol must not change its string value. + */ +internal object ProjectionSourceIds { + const val CARPLAY = "carplay" + const val ANDROID_AUTO = "android-auto" + const val ANDROID_AUTO_LOCAL = "$ANDROID_AUTO-local" + const val ANDROID_AUTO_USB = "$ANDROID_AUTO-usb" + const val ANDROID_AUTO_TCP = "$ANDROID_AUTO-tcp" + const val VNC = "vnc" + const val VNC_TCP = "$VNC-tcp" + const val ANDROID_DESKTOP = "android-desktop" + const val ICCOA = "iccoa" + const val ICCOA_CARLINK = "$ICCOA-carlink" + const val DEMO = "demo" + const val DEMO_SURFACE = "$DEMO-surface" + const val PLUGIN = "plugin" + const val PLUGIN_PREFIX = "$PLUGIN-" +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionStateText.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionStateText.kt new file mode 100644 index 0000000..99cce51 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionStateText.kt @@ -0,0 +1,25 @@ +package com.shilapi.xcertplay.projection + +import android.content.Context +import com.shilapi.xcertplay.shared.R + +/** Diagnostics stay in logs/state; UI messages depend only on the stable common category. */ +internal fun ProjectionFailure.message(context: Context): String = context.getString(when (category) { + ProjectionErrorCategory.PERMISSION -> R.string.projection_error_permission + ProjectionErrorCategory.CONNECTION -> R.string.projection_error_connection + ProjectionErrorCategory.PROTOCOL -> R.string.projection_error_protocol + ProjectionErrorCategory.CONFIGURATION -> R.string.projection_error_configuration + ProjectionErrorCategory.INPUT -> R.string.projection_error_input + ProjectionErrorCategory.TIMEOUT -> R.string.projection_error_timeout + ProjectionErrorCategory.CANCELLED -> R.string.projection_error_cancelled + ProjectionErrorCategory.CLEANUP -> R.string.projection_error_cleanup + ProjectionErrorCategory.UNAVAILABLE -> R.string.projection_error_unavailable + ProjectionErrorCategory.INTERNAL -> R.string.projection_error_internal +}) + +internal fun ProjectionSnapshot.message(context: Context): String = failure?.message(context) ?: context.getString(when (phase) { + ProjectionPhase.CONNECTING, ProjectionPhase.NEGOTIATING -> R.string.projection_state_connecting + ProjectionPhase.RUNNING -> R.string.projection_state_running + ProjectionPhase.STOPPING -> R.string.projection_state_stopping + ProjectionPhase.STOPPED, ProjectionPhase.FAILED -> R.string.projection_waiting +}) diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt index 1bd6bcd..0054dde 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt @@ -8,6 +8,8 @@ import com.shilapi.xcertplay.media.VideoCodec * Only one ingress kind is enabled per output. Stop the producer before closing the output. */ internal interface ProjectionVideoOutput : AutoCloseable { + val hasFrame: Boolean get() = false + val hasPublishedFrame: Boolean get() = false val surface: Surface /** ISO-BMFF avcC/hvcC configuration; returns a revision required by every subsequent frame. */ fun configure(codec: VideoCodec, codecData: ByteArray, requestKeyframe: () -> Unit): Long diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt index 43b637f..dd56a0b 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt @@ -17,11 +17,14 @@ internal class SyntheticSource( private val output: (VideoIngressKind, Int, Int) -> ProjectionVideoOutput, private val encodedFixture: () -> InputStream, ) : ProjectionSourceProvider { - override val descriptor = SourceDescriptor("demo-${kind.name.lowercase()}", "Demo · ${kind.name.lowercase()}") + override val descriptor = SourceDescriptor("${ProjectionSourceIds.DEMO}-${kind.name.lowercase()}", "Demo · ${kind.name.lowercase()}") override val resources = setOf(ProjectionResource.DISPLAY) override fun probe() = SourceAvailability.Ready - override fun open(): CompletableFuture = CompletableFuture.supplyAsync { + override fun open() = open(ProjectionStartup()) + override fun open(startup: ProjectionStartup): CompletableFuture = CompletableFuture.supplyAsync { + startup.checkActive() val clip = if (kind == VideoIngressKind.ENCODED) Clip.read(encodedFixture()) else null + startup.checkActive() val video = output(kind, WIDTH, HEIGHT) try { Session(video, clip) } catch (failure: Throwable) { try { video.close() } catch (cleanup: Throwable) { @@ -43,6 +46,9 @@ internal class SyntheticSource( @Volatile private var night = false @Volatile private var failureMessage: String? = null override val statusText get() = failureMessage + override val state get() = ProjectionSessionState( + when { failureMessage != null -> ProjectionPhase.FAILED; stopped.get() -> ProjectionPhase.STOPPED; else -> ProjectionPhase.RUNNING }, + capabilities, inputReady, failureMessage?.let { ProjectionFailure(ProjectionErrorCategory.INTERNAL, it, "demo") }) override val inputReady get() = !stopped.get() && failureMessage == null && capabilities.maxTouchContacts > 0 private val pixels = ProjectionFramebuffer(WIDTH, HEIGHT) private var frame = 0L @@ -51,7 +57,7 @@ internal class SyntheticSource( init { worker.scheduleWithFixedDelay({ - if (!stopped.get() && failureMessage == null) try { draw() } catch (failure: Exception) { failureMessage = failure.message ?: failure.javaClass.simpleName } + if (!stopped.get() && failureMessage == null) try { draw() } catch (failure: Exception) { failureMessage = failure.message ?: failure.javaClass.simpleName; projectionSessions.changed(this) } }, 0, 66, TimeUnit.MILLISECONDS) } private fun draw() { @@ -87,6 +93,7 @@ internal class SyntheticSource( this.contacts = contacts.toList() return true } + override fun cancelInput(reason: ProjectionCancelReason): Boolean { contacts = emptyList(); return !stopped.get() } override fun setNightMode(night: Boolean): ProjectionCommandResult { if (!capabilities.nightMode) return ProjectionCommandResult.UNSUPPORTED if (stopped.get()) return ProjectionCommandResult.UNAVAILABLE 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 index 63b183c..936045a 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/AndroidAutoSource.kt @@ -21,8 +21,8 @@ internal class AndroidAutoSource(private val context: Context, private val mode: 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)") + AndroidAutoMode.USB -> SourceDescriptor(ProjectionSourceIds.ANDROID_AUTO_USB, "Android Auto · USB") + AndroidAutoMode.TCP -> SourceDescriptor(ProjectionSourceIds.ANDROID_AUTO_TCP, "Android Auto · Head Unit Server (TCP)") } override val resources = buildSet { add(ProjectionResource.DISPLAY); add(ProjectionResource.MICROPHONE) @@ -60,11 +60,17 @@ internal class AndroidAutoSession(private val context: Context, override val des 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) + audioPlayback = true, microphone = micEnabled, browserAudio = false, browserMicrophone = false, cancellation = ProjectionCancellation.NATIVE) @Volatile override var statusText = "Android Auto: connecting" private set @Volatile private var ready = false - private var inputBound = false + @Volatile private var lifecycleFailure: ProjectionFailure? = null + override val state get() = ProjectionSessionState( + when { finished.isDone -> if (lifecycleFailure == null) ProjectionPhase.STOPPED else ProjectionPhase.FAILED + stopping.get() -> ProjectionPhase.STOPPING + inputBound -> ProjectionPhase.RUNNING + else -> ProjectionPhase.CONNECTING }, capabilities, inputReady, lifecycleFailure) + @Volatile private var inputBound = false private var videoFocusMode = 1 override val inputReady get() = ready && !stopping.get() private val stopping = AtomicBoolean() @@ -170,6 +176,7 @@ internal class AndroidAutoSession(private val context: Context, override val des val error = workerFailure.get() ?: caught if (!stopping.get()) { failure = error + lifecycleFailure = projectionFailure(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)) @@ -186,7 +193,9 @@ internal class AndroidAutoSession(private val context: Context, override val des outbound.clear(); frames.clear() } if (cleanup.isFailure) { + lifecycleFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, cleanup.exceptionOrNull().toString(), "android-auto") finished.completeExceptionally(ProjectionCleanupException("Android Auto cleanup failed", cleanup.exceptionOrNull())) + projectionSessions.changed(this) } else { finished.complete(Unit) projectionSessions.ended(this) @@ -237,7 +246,7 @@ internal class AndroidAutoSession(private val context: Context, override val des 0x8007 -> if (channel == 2) { val mode = AapProto.number(body, 2).toInt() require(mode in 1..4) - if (mode != 1) touch(emptyList()) + if (mode != 1) cancelInput(ProjectionCancelReason.FOCUS_LOST) videoFocusMode = mode videoRecovery.setProjected(mode == 1 || mode == 4) ready = inputBound && mode == 1 @@ -276,11 +285,16 @@ internal class AndroidAutoSession(private val context: Context, override val des if (!inputReady) return false return touches.update(contacts).all { send(3, 0x8001, touches.encode(it, android.os.SystemClock.elapsedRealtimeNanos())) } } + @Synchronized override fun cancelInput(reason: ProjectionCancelReason): Boolean { + if (!inputBound || stopping.get()) return false + return touches.cancel().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 { + cancelInput(ProjectionCancelReason.SOURCE_SWITCH) 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 index dd914f1..421b15b 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/androidauto/LocalAndroidAuto.kt @@ -1,5 +1,6 @@ package com.shilapi.xcertplay.projection.androidauto +import com.shilapi.xcertplay.projection.ProjectionSourceIds import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -9,7 +10,7 @@ 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 SOURCE_ID = ProjectionSourceIds.ANDROID_AUTO_LOCAL const val SETUP_SEEN = "aa_local_setup_seen" fun isInstalledAndEnabled(context: Context): Boolean = try { diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/ProjectionPlugins.kt b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/ProjectionPlugins.kt index 8d569d5..916ec70 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/ProjectionPlugins.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/ProjectionPlugins.kt @@ -25,7 +25,7 @@ internal data class InstalledProjectionPlugin( ) { val id get() = PREFIX + component.flattenToString() val consentKey get() = "$signer@$installedAt" - companion object { const val PREFIX = "plugin-" } + companion object { const val PREFIX = ProjectionSourceIds.PLUGIN_PREFIX } } /** Package metadata only: discovering/listing an installed APK never binds or starts its code. */ diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt index 44b8771..a571c48 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSession.kt @@ -38,6 +38,12 @@ internal class RemotePluginSession( { task -> Thread(task, "projection-plugin-ipc").apply { isDaemon = true } }) private val mediaWorker = Executors.newSingleThreadExecutor { task -> Thread(task, "projection-plugin-surface").apply { isDaemon = true } } private var failureReason: String? = null + @Volatile private var lifecycleFailure: ProjectionFailure? = null + override val state get() = ProjectionSessionState( + when { completion.isDone -> if (lifecycleFailure == null) ProjectionPhase.STOPPED else ProjectionPhase.FAILED + stopping -> ProjectionPhase.STOPPING; ready -> ProjectionPhase.RUNNING + infoReceived -> ProjectionPhase.NEGOTIATING; else -> ProjectionPhase.CONNECTING }, + capabilities, inputReady, lifecycleFailure) private var activated = false private var bound = false @Volatile private var remote: IProjectionPlugin? = null @@ -58,6 +64,7 @@ internal class RemotePluginSession( private fun rememberFailure(message: String) { if (failureReason != null) return failureReason = message.take(512) + lifecycleFailure = ProjectionFailure(ProjectionErrorCategory.CONNECTION, message.take(512), "plugin", ProjectionPlugin.ERROR.toString()) runCatching { onFailure(failureReason!!) } } private fun notifyStatus(message: String) { @@ -78,7 +85,8 @@ internal class RemotePluginSession( processExitRequired = video.processExitRequired main.removeCallbacks(deadline) main.postDelayed(deadline, startupTimeoutMs) - capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE), video.maxTouchContacts, video.nightMode) + capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE), video.maxTouchContacts, video.nightMode, + cancellation = if (video.nativeCancelInput) ProjectionCancellation.NATIVE else ProjectionCancellation.RELEASE_ONLY) runtime.changed(this@RemotePluginSession) output = CompletableFuture.supplyAsync({ outputFactory(video.width, video.height) }, mediaWorker).also { pending -> pending.whenComplete { port, failure -> main.post { @@ -186,6 +194,11 @@ internal class RemotePluginSession( enqueue { it.touch(token, copy.map { c -> c.id }.toIntArray(), copy.map { c -> c.x }.toDoubleArray(), copy.map { c -> c.y }.toDoubleArray(), copy.map { c -> c.down }.toBooleanArray()) } } + override fun cancelInput(reason: ProjectionCancelReason): Boolean = synchronized(admission) { + if (stopping || !startSent) return false + if (capabilities.cancellation == ProjectionCancellation.NATIVE) enqueue { it.cancelInput(token) } + else touch(emptyList()) + } override fun setNightMode(night: Boolean): ProjectionCommandResult = synchronized(admission) { if (stopping || !ready) return ProjectionCommandResult.UNAVAILABLE if (!capabilities.nightMode) return ProjectionCommandResult.UNSUPPORTED @@ -246,12 +259,14 @@ internal class RemotePluginSession( completion.complete(Unit) runtime.ended(this) } else { + lifecycleFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, failure.toString(), "plugin") val cleanupMessage = context.getString(UiR.string.plugin_cleanup_failed) // Preserve the original failure too, but make the unsafe cleanup actionable. val report = failureReason?.let { "$it\n$cleanupMessage" } ?: cleanupMessage runCatching { onFailure(report) } notifyStatus(report) completion.completeExceptionally(failure) + runtime.changed(this) } } } } diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/vnc/VncSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/vnc/VncSource.kt index 57895ae..62f61b4 100644 --- a/common/src/main/java/com/shilapi/xcertplay/projection/vnc/VncSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/projection/vnc/VncSource.kt @@ -29,7 +29,7 @@ internal data class VncEndpoint(val host: String, val port: Int, val readOnly: B } internal class VncSource(private val context: Context, private val output: (Int, Int) -> ProjectionVideoOutput) : ProjectionSourceProvider { - override val descriptor = SourceDescriptor("vnc-tcp", "VNC") + override val descriptor = SourceDescriptor(ProjectionSourceIds.VNC_TCP, "VNC") override val resources = setOf(ProjectionResource.DISPLAY, ProjectionResource.WIRELESS) private var password = CharArray(0) fun endpoint(): VncEndpoint = context.getSharedPreferences("projection", 0).let { @@ -58,9 +58,15 @@ internal class VncSource(private val context: Context, private val output: (Int, internal class VncSession(private val context: Context, private val endpoint: VncEndpoint, private val password: CharArray, private val outputFactory: (Int, Int) -> ProjectionVideoOutput) : ProjectionSession { - override val descriptor = SourceDescriptor("vnc-tcp", "VNC") + override val descriptor = SourceDescriptor(ProjectionSourceIds.VNC_TCP, "VNC") override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.FRAMEBUFFER), if (endpoint.readOnly) 0 else 1, pointerInput = !endpoint.readOnly, keyboardInput = !endpoint.readOnly) + @Volatile private var lifecycleFailure: ProjectionFailure? = null + override val state get() = ProjectionSessionState( + when { completion.isDone -> if (lifecycleFailure == null) ProjectionPhase.STOPPED else ProjectionPhase.FAILED + stopping.get() -> ProjectionPhase.STOPPING + ready -> ProjectionPhase.RUNNING + else -> ProjectionPhase.CONNECTING }, capabilities, inputReady, lifecycleFailure) @Volatile private var ready = false override val inputReady get() = ready && !stopping.get() && !endpoint.readOnly @Volatile override var statusText = "VNC: connecting" @@ -146,8 +152,10 @@ internal class VncSession(private val context: Context, private val endpoint: Vn } catch (caught: Throwable) { if (!stopping.get()) { val error = failure.get() ?: caught + lifecycleFailure = projectionFailure(error) state("VNC: ${error.message ?: error.javaClass.simpleName}") - Handler(Looper.getMainLooper()).post { android.widget.Toast.makeText(context, statusText, android.widget.Toast.LENGTH_LONG).show() } + val message = lifecycleFailure!!.message(context) + Handler(Looper.getMainLooper()).post { android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_LONG).show() } } } finally { stopping.set(true); ready = false; password.fill('\u0000') @@ -158,7 +166,11 @@ internal class VncSession(private val context: Context, private val endpoint: Vn video?.close(); queue.clear() synchronized(this) { input = null } } - if (cleanup.isFailure) completion.completeExceptionally(ProjectionCleanupException("VNC cleanup failed", cleanup.exceptionOrNull())) + if (cleanup.isFailure) { + lifecycleFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, cleanup.exceptionOrNull().toString(), "vnc") + completion.completeExceptionally(ProjectionCleanupException("VNC cleanup failed", cleanup.exceptionOrNull())) + projectionSessions.changed(this) + } else { completion.complete(Unit); projectionSessions.ended(this) } } } @@ -176,6 +188,10 @@ internal class VncSession(private val context: Context, private val endpoint: Vn if (event == ProjectionInput.Release) touchDown = false return input?.accept(event) ?: false } + @Synchronized override fun cancelInput(reason: ProjectionCancelReason): Boolean { + touchDown = false + return input?.accept(ProjectionInput.Release) ?: false + } @Synchronized override fun stop(): CompletableFuture { if (stopping.get()) return completion input?.accept(ProjectionInput.Release) diff --git a/common/src/main/java/com/shilapi/xcertplay/web/BrowserStrings.kt b/common/src/main/java/com/shilapi/xcertplay/web/BrowserStrings.kt index d8bafd6..4f40763 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/BrowserStrings.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/BrowserStrings.kt @@ -94,6 +94,7 @@ internal object BrowserStrings { UiR.string.web_use_the_browser_menu_to_toggle_full_screen, "web_exit_full_screen" to UiR.string.web_exit_full_screen, "web_full_screen" to UiR.string.web_full_screen, + "web_desktop_back" to UiR.string.web_desktop_back, "web_unsupported_microphone_sample_format" to UiR.string.web_unsupported_microphone_sample_format, "web_tap_the_display_to_allow_audio_playback" to diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt index d29910a..c9a686f 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt @@ -702,6 +702,9 @@ internal class LanWebServer( .put("maxTouchContacts", media.maxTouchContacts) .put("pointerInput", media.pointerInput) .put("keyboardInput", media.keyboardInput) + .put("backButton", media.backButton) + .put("sessionPhase", media.sessionPhase) + .put("sourceError", media.errorCategory ?: org.json.JSONObject.NULL) .put("stage", WebSession.stage(context)) .put( "streaming", diff --git a/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt b/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt index 13d4e79..0822db1 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt @@ -2,10 +2,12 @@ package com.shilapi.xcertplay.web import com.shilapi.xcertplay.projection.ProjectionContact import com.shilapi.xcertplay.projection.ProjectionInput +import com.shilapi.xcertplay.projection.ProjectionCancelReason /** Serializes browser ownership and releases fingers when a client disappears. */ class TouchLease(private val maxContacts: () -> Int = { 2 }, - private val sendInput: (ProjectionInput) -> Boolean = { false }, private val send: (List) -> Boolean) { + private val sendInput: (ProjectionInput) -> Boolean = { false }, + private val cancelInput: ((ProjectionCancelReason) -> Boolean)? = null, private val send: (List) -> Boolean) { private var owner: Any? = null private var lastTouch = 0L private var pressed = false @@ -28,11 +30,16 @@ class TouchLease(private val maxContacts: () -> Int = { 2 }, } @Synchronized fun expire(now: Long) { - if ((pressed || desktopActive) && now - lastTouch > 1500) releaseFingers() + if ((pressed || desktopActive) && now - lastTouch > 1500) releaseFingers(ProjectionCancelReason.LEASE_EXPIRED) } @Synchronized fun input(client: Any, event: ProjectionInput, now: Long): Boolean { - if (owner !== client || !sendInput(event)) return false + if (owner !== client) return false + if (event == ProjectionInput.Release) { + lastTouch = now + return releaseFingers(ProjectionCancelReason.FOCUS_LOST) + } + if (!sendInput(event)) return false desktopActive = event != ProjectionInput.Release lastTouch = now return true @@ -40,15 +47,20 @@ class TouchLease(private val maxContacts: () -> Int = { 2 }, @Synchronized fun drop(client: Any) { if (owner !== client) return - releaseFingers(); owner = null + releaseFingers(ProjectionCancelReason.DISCONNECTED); owner = null } - @Synchronized fun reset() { releaseFingers(); owner = null } + @Synchronized fun reset() { releaseFingers(ProjectionCancelReason.SOURCE_SWITCH); owner = null } - @Synchronized fun cancel() { releaseFingers() } + @Synchronized fun cancel() { releaseFingers(ProjectionCancelReason.LAYOUT_CHANGED) } - private fun releaseFingers() { - send(emptyList()); sendInput(ProjectionInput.Release) + private fun releaseFingers(reason: ProjectionCancelReason): Boolean { + val accepted = cancelInput?.invoke(reason) ?: run { + val touchReleased = send(emptyList()) + val keysReleased = sendInput(ProjectionInput.Release) + touchReleased || keysReleased + } pressed = false; desktopActive = false + return accepted } } 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 1316498..7ce0d4a 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -11,6 +11,7 @@ import com.shilapi.xcertplay.projection.projectionSessions import com.shilapi.xcertplay.projection.ProjectionContact import com.shilapi.xcertplay.projection.ProjectionSession import com.shilapi.xcertplay.projection.ProjectionInput +import com.shilapi.xcertplay.projection.message import com.shilapi.xcertplay.CarPlayMicrophoneSource import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.i18n.* @@ -32,14 +33,17 @@ internal object WebSession { val sessionId: Long, val videoEpoch: Long, val width: Int, val height: Int, val sourceState: String, val inputReady: Boolean, val frame: Frame?, val source: WebVideoSource?, val sourceId: String? = null, val sourceName: String? = null, val maxTouchContacts: Int = 0, - val pointerInput: Boolean = false, val keyboardInput: Boolean = false, + val pointerInput: Boolean = false, val keyboardInput: Boolean = false, val backButton: Boolean = false, + val sessionPhase: String = "stopped", val errorCategory: String? = null, ) private var mediaSessionId = SecureRandom().nextLong().ushr(11) private var mediaEnded = true /** One coherent configuration; a static source need not publish new frames. */ - @Synchronized fun mediaSnapshot(): MediaSnapshot = MediaSnapshot( + @Synchronized fun mediaSnapshot(): MediaSnapshot { + val runtime = projectionSessions.stateSnapshot() + return MediaSnapshot( mediaSessionId, generation, width, height, when { mediaEnded -> "stopped" @@ -52,7 +56,11 @@ internal object WebSession { inputSession?.descriptor?.id, inputSession?.descriptor?.displayName, inputSession?.capabilities?.maxTouchContacts ?: 0, inputSession?.capabilities?.pointerInput == true, inputSession?.capabilities?.keyboardInput == true, + inputSession?.capabilities?.backButton == true, + runtime.phase.name.lowercase(), + runtime.failure?.category?.name?.lowercase(), ) + } @Synchronized fun mediaMatches(sessionId: Long, epoch: Long): Boolean = !mediaEnded && videoActive && sessionId == mediaSessionId && epoch == generation @@ -66,6 +74,10 @@ internal object WebSession { } @Synchronized fun mediaInput(client: Any, sessionId: Long, epoch: Long, event: ProjectionInput, at: Long): Boolean { + if (event == ProjectionInput.Release) { + if (mediaEnded || sessionId != mediaSessionId || epoch != generation) return false + return touch.input(client, event, at) + } if (!mediaMatches(sessionId, epoch) || !projectionSessions.inputReady(inputSession)) return false return touch.input(client, event, at) } @@ -88,6 +100,7 @@ internal object WebSession { fun stageDetail(context: Context): String = stageText.resolve(context) fun stage(context: Context): String { + projectionSessions.stateSnapshot().failure?.let { return it.message(context) } val session = projectionSessions.snapshot() return when { session == null -> stageText.resolve(context) @@ -147,7 +160,7 @@ internal object WebSession { val maxTouchContacts get() = projectionSessions.snapshot()?.capabilities?.maxTouchContacts ?: 0 val touch = TouchLease(maxContacts = { maxTouchContacts }, sendInput = { event -> inputSession?.let { projectionSessions.input(it, event) } ?: false - }) { contacts -> + }, cancelInput = { reason -> inputSession?.let { projectionSessions.cancelInput(it, reason) } ?: false }) { contacts -> val target = inputSession target != null && projectionSessions.touch(target, contacts) } diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSource.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSource.kt new file mode 100644 index 0000000..4a33b13 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSource.kt @@ -0,0 +1,223 @@ +package moe.feng.wheelplay.projection.desktop + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.edit +import com.shilapi.xcertplay.shared.R +import com.shilapi.xcertplay.projection.* +import com.shilapi.xcertplay.web.WebSession +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicBoolean + +internal class AndroidDesktopSource( + private val context: Context, + private val backend: DesktopBackend = ShizukuDesktopBackend(context), + private val output: (DesktopDisplayConfig) -> ProjectionVideoOutput = { config -> + BrowserProjectionVideo(context, config.width, config.height, VideoIngressKind.SURFACE) + }, + private val targetAvailable: (String) -> Boolean = { component -> + val target = ComponentName.unflattenFromString(component) + target != null && context.packageManager.queryIntentActivities(launcherIntent(), 0).any { + it.activityInfo.run { isDesktopLaunchable && desktopComponent == target } + } + }, +) : ProjectionSourceProvider { + override val descriptor get() = SourceDescriptor(DesktopProtocol.SOURCE_ID, context.getString(R.string.desktop_title)) + override val resources = setOf(ProjectionResource.DISPLAY) + private val preferences = context.getSharedPreferences("android-desktop", Context.MODE_PRIVATE) + @Volatile var lastFailure: String? = null + private set + @Volatile var lastError: DesktopException? = null + private set + @Volatile var preparing = false + private set + fun selectedApp(): String = preferences.getString("component", "").orEmpty() + fun selectApp(component: String) { + require(targetAvailable(component)) + preferences.edit { putString("component", component) } + } + fun displayConfig(): DesktopDisplayConfig = runCatching { + val saved = preferences.all + val defaults = DesktopDisplayConfig() + DesktopDisplayConfig( + width = saved["width"] as? Int ?: defaults.width, + height = saved["height"] as? Int ?: defaults.height, + dpi = saved["dpi"] as? Int ?: defaults.dpi, + ) + }.getOrDefault(DesktopDisplayConfig()) + fun configureDisplay(config: DesktopDisplayConfig) { + preferences.edit { + putInt("width", config.width) + putInt("height", config.height) + putInt("dpi", config.dpi) + } + } + fun dependencyStatus() = backend.probe() + override fun probe(): SourceAvailability { + val dependency = backend.probe() + if (dependency is SourceAvailability.Unavailable) return dependency + return if (targetAvailable(selectedApp())) SourceAvailability.Ready + else SourceAvailability.Unavailable(context.getString(R.string.desktop_choose_app)) + } + override fun open() = open(ProjectionStartup()) + override fun open(startup: ProjectionStartup): CompletableFuture { + startup.checkActive() + val state = probe() + if (state is SourceAvailability.Unavailable) return CompletableFuture().also { + it.completeExceptionally(ProjectionException(state.failure)) + } + val target = selectedApp() + val config = displayConfig() + lastFailure = null + lastError = null + preparing = true + val pending = try { backend.connect(startup) } catch (error: Throwable) { + preparing = false; recordFailure(error) + return CompletableFuture().also { it.completeExceptionally(error) } + } + return pending.thenApply { Session(it, target, config) }.whenComplete { _, failure -> + preparing = false + if (failure != null && projectionFailure(failure).category != ProjectionErrorCategory.CANCELLED) recordFailure(failure) + } + } + + private fun recordFailure(error: Throwable): String { + val failure = desktopFailure(error) + lastError = failure + Log.w("AndroidDesktop", "Desktop error ${failure.code}: ${failure.diagnostic}", failure) + return context.getString(failure.category.message).also { lastFailure = it } + } + + private inner class Session(private val connection: DesktopConnection, private val target: String, + private val config: DesktopDisplayConfig) : ProjectionSession { + override val descriptor = this@AndroidDesktopSource.descriptor + override val capabilities get() = connection.capabilities + private val stopped = AtomicBoolean() + private val activated = AtomicBoolean() + private val completion = CompletableFuture() + private val worker = ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, ArrayBlockingQueue(64), + ThreadFactory { Thread(it, "android-desktop-input").apply { isDaemon = true } }) + private val monitor = Executors.newSingleThreadScheduledExecutor { Thread(it, "android-desktop-monitor").apply { isDaemon = true } } + @Volatile private var video: ProjectionVideoOutput? = null + @Volatile private var started = false + @Volatile private var failure: String? = null + @Volatile private var startupCleanupFailure: Throwable? = null + @Volatile private var lastProgress = System.nanoTime() + private var startedAt = 0L + @Volatile private var lifecycleFailure: ProjectionFailure? = null + @Volatile private var helperState: DesktopSessionState? = null + override val state get() = ProjectionSessionState( + when { completion.isDone -> if (lifecycleFailure != null) ProjectionPhase.FAILED else ProjectionPhase.STOPPED + stopped.get() -> ProjectionPhase.STOPPING + started && video?.hasFrame == true -> ProjectionPhase.RUNNING + started -> ProjectionPhase.NEGOTIATING + else -> ProjectionPhase.CONNECTING }, capabilities, inputReady, lifecycleFailure) + override val inputReady get() = started && !stopped.get() && video?.hasFrame == true && + (capabilities.maxTouchContacts > 0 || capabilities.backButton) + override val statusText: String get() = failure ?: when { + stopped.get() -> context.getString(R.string.desktop_stopping) + !started || video?.hasFrame != true -> context.getString(R.string.desktop_preparing) + !WebSession.hasViewer || video?.hasPublishedFrame != true -> context.getString(R.string.desktop_waiting_browser) + else -> context.getString(R.string.desktop_connected, connection.uid) + } + + override fun activate() { + if (stopped.get() || !activated.compareAndSet(false, true)) return + enqueue { + val output = output(config).also { video = it } + connection.start(output.surface, target, config) + startedAt = System.nanoTime() + started = true + } + try { monitor.scheduleWithFixedDelay({ + if (stopped.get()) return@scheduleWithFixedDelay + if (System.nanoTime() - lastProgress > TimeUnit.SECONDS.toNanos(15)) { + fail(DesktopException(DesktopErrorCode.DISCONNECTED, "No IPC progress for 15 seconds")) + } else enqueue { + if (!started) return@enqueue + val dependency = backend.probe() + desktopCheck(dependency is SourceAvailability.Ready, DesktopErrorCode.DISCONNECTED) { + (dependency as SourceAvailability.Unavailable).reason + } + helperState = connection.healthy() + projectionSessions.changed(this) + desktopCheck(video?.hasFrame == true || System.nanoTime() - startedAt < TimeUnit.SECONDS.toNanos(15), DesktopErrorCode.NO_FRAME) { + "No Surface frame within 15 seconds" + } + } + }, 500, 500, TimeUnit.MILLISECONDS) } catch (error: RejectedExecutionException) { + if (!stopped.get()) throw error + } + } + + private fun enqueue(block: () -> Unit): Boolean { + if (stopped.get()) return false + return try { + worker.execute { + if (!stopped.get()) try { block(); lastProgress = System.nanoTime() } catch (error: Throwable) { fail(error) } + } + true + } catch (_: RejectedExecutionException) { + if (!stopped.get()) fail(DesktopException(DesktopErrorCode.INPUT_QUEUE_FULL, "Desktop input queue overflow")) + false + } + } + override fun touch(contacts: List): Boolean { + if (capabilities.maxTouchContacts == 0 || (!inputReady && contacts.isNotEmpty()) || + contacts.size > capabilities.maxTouchContacts || contacts.map { it.id }.distinct().size != contacts.size || + contacts.any { it.id !in 0..31 || !it.x.isFinite() || !it.y.isFinite() || it.x !in 0.0..1.0 || it.y !in 0.0..1.0 }) return false + val copy = contacts.toList() + return enqueue { if (started) connection.touch(copy) } + } + override fun input(event: ProjectionInput): Boolean = when { + event == ProjectionInput.Release -> cancelInput(ProjectionCancelReason.USER_REQUEST) + event is ProjectionInput.Key && event.key == "BrowserBack" && capabilities.backButton && inputReady -> + if (event.down) enqueue { connection.back() } else true + else -> false + } + override fun cancelInput(reason: ProjectionCancelReason): Boolean = enqueue { + if (started) connection.cancelInput() + } + private fun fail(error: Throwable) { + lifecycleFailure = desktopFailure(error).projectionFailure() + // A failed video constructor may already have lost its object reference while + // retaining a GL thread/Surface. Never release runtime ownership in that case. + if (generateSequence(error) { it.cause }.any { it is ProjectionCleanupException }) startupCleanupFailure = error + failure = recordFailure(error) + stop().whenComplete { _, cleanup -> + if (cleanup == null) projectionSessions.ended(this) + else { failure = recordFailure(DesktopException(DesktopErrorCode.CLEANUP_FAILED, cleanup.toString(), cleanup)) } + } + } + override fun stop(): CompletableFuture { + if (!stopped.compareAndSet(false, true)) return completion + projectionSessions.changed(this) + monitor.shutdownNow() + worker.queue.clear() + worker.shutdown() + CompletableFuture.runAsync { + try { + check(worker.awaitTermination(5, TimeUnit.SECONDS)) { "Desktop IPC did not stop" } + // Helper releases gestures/display and exits before the consumer Surface is released. + connection.close() + video?.close() + startupCleanupFailure?.let { throw it } + completion.complete(Unit) + projectionSessions.changed(this) + } catch (error: Throwable) { + lifecycleFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, error.toString(), "desktop") + failure = recordFailure(DesktopException(DesktopErrorCode.CLEANUP_FAILED, error.toString(), error)) + completion.completeExceptionally(ProjectionCleanupException("Virtual desktop cleanup unconfirmed", error)) + projectionSessions.changed(this) + } + } + return completion + } + } + + companion object { + fun launcherIntent() = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopApps.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopApps.kt new file mode 100644 index 0000000..27e97c5 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopApps.kt @@ -0,0 +1,10 @@ +package moe.feng.wheelplay.projection.desktop + +import android.content.ComponentName +import android.content.pm.ActivityInfo + +internal val ActivityInfo.isDesktopLaunchable: Boolean + get() = exported && enabled && applicationInfo.enabled + +internal val ActivityInfo.desktopComponent: ComponentName + get() = ComponentName(packageName, name) diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopBackend.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopBackend.kt new file mode 100644 index 0000000..75fd6b8 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopBackend.kt @@ -0,0 +1,36 @@ +package moe.feng.wheelplay.projection.desktop + +import com.shilapi.xcertplay.projection.ProjectionSourceIds +import android.view.Surface +import com.shilapi.xcertplay.projection.ProjectionContact +import com.shilapi.xcertplay.projection.ProjectionCapabilities +import com.shilapi.xcertplay.projection.SourceAvailability +import com.shilapi.xcertplay.projection.ProjectionStartup +import java.util.concurrent.CompletableFuture + +/** Platform-only port; the shared projection API never depends on Shizuku or Binder. */ +internal interface DesktopBackend { + fun probe(): SourceAvailability + fun connect(): CompletableFuture + fun connect(startup: ProjectionStartup): CompletableFuture { startup.checkActive(); return connect() } +} + +internal interface DesktopConnection : AutoCloseable { + val uid: Int + val capabilities: ProjectionCapabilities + fun start(surface: Surface, component: String, config: DesktopDisplayConfig): DesktopDisplayInfo + fun touch(contacts: List) + fun cancelInput() + fun back() + /** Also renews the helper's watchdog; rejects unexpected rotation/size changes. */ + fun healthy(): DesktopSessionState + fun state(): DesktopSessionState + /** Stop producer, confirm release, then unbind/terminate the helper. */ + override fun close() +} + +internal object DesktopProtocol { + const val VERSION = 7 + const val LAYOUT = 1 + const val SOURCE_ID = ProjectionSourceIds.ANDROID_DESKTOP +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfig.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfig.kt new file mode 100644 index 0000000..781fecd --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfig.kt @@ -0,0 +1,23 @@ +package moe.feng.wheelplay.projection.desktop + +/** Immutable for the lifetime of a display, its video output and input mapping. */ +internal data class DesktopDisplayConfig( + val width: Int = 1280, + val height: Int = 720, + val dpi: Int = 160, +) { + init { + require(width in MIN_SIZE..MAX_SIZE && height in MIN_SIZE..MAX_SIZE && + width.toLong() * height <= MAX_PIXELS && dpi in MIN_DPI..MAX_DPI) { + "Unsupported virtual display size or density" + } + } + + companion object { + const val MIN_SIZE = 320 + const val MAX_SIZE = 3840 + const val MAX_PIXELS = 8_294_400L + const val MIN_DPI = 72 + const val MAX_DPI = 640 + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopErrors.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopErrors.kt new file mode 100644 index 0000000..1480f8b --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopErrors.kt @@ -0,0 +1,80 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.RemoteException +import com.shilapi.xcertplay.projection.* +import com.shilapi.xcertplay.shared.R +import java.lang.reflect.InvocationTargetException + +/** Stable wire codes. Diagnostics are for logs, never parsed for control flow or displayed as UI copy. */ +internal enum class DesktopErrorCode(val wire: Int, val message: Int) { + UNKNOWN(-1, R.string.desktop_error_internal), + PROTOCOL_MISMATCH(1, R.string.desktop_error_protocol), + PERMISSION_DENIED(2, R.string.desktop_error_permission), + INVALID_ARGUMENT(3, R.string.desktop_error_request), + INVALID_STATE(4, R.string.desktop_error_session), + UNSUPPORTED_CAPABILITY(5, R.string.desktop_error_capability), + APP_UNAVAILABLE(6, R.string.desktop_error_app), + DISPLAY_FAILED(7, R.string.desktop_error_display), + LAUNCH_FAILED(8, R.string.desktop_error_launch), + STALE_SESSION(9, R.string.desktop_error_session), + LAYOUT_CHANGED(10, R.string.desktop_error_layout), + INPUT_FAILED(11, R.string.desktop_error_input), + CLEANUP_FAILED(12, R.string.desktop_cleanup_failed), + INTERNAL(13, R.string.desktop_error_internal), + DISCONNECTED(14, R.string.desktop_disconnected), + BIND_TIMEOUT(15, R.string.desktop_bind_timeout), + NO_FRAME(16, R.string.desktop_no_frame), + INPUT_QUEUE_FULL(17, R.string.desktop_error_input), +} + +internal class DesktopException(val code: Int, val diagnostic: String, cause: Throwable? = null) : + ProjectionException(desktopProjectionFailure(code, diagnostic), cause) { + constructor(code: DesktopErrorCode, diagnostic: String, cause: Throwable? = null) : this(code.wire, diagnostic, cause) + val category get() = DesktopErrorCode.entries.firstOrNull { it.wire == code } ?: DesktopErrorCode.UNKNOWN +} + +internal inline fun desktopCheck(condition: Boolean, code: DesktopErrorCode, diagnostic: () -> String) { + if (!condition) throw DesktopException(code, diagnostic()) +} + +/** Normalize platform transport/permission failures while retaining their diagnostic causes. */ +internal inline fun desktopRemoteCall(block: () -> T): T = try { block() } +catch (error: RemoteException) { throw DesktopException(DesktopErrorCode.DISCONNECTED, error.toString(), error) } +catch (error: SecurityException) { throw DesktopException(DesktopErrorCode.PERMISSION_DENIED, error.toString(), error) } + +internal fun desktopFailure(error: Throwable): DesktopException = + generateSequence(error) { it.cause }.filterIsInstance().firstOrNull() + ?: DesktopException(DesktopErrorCode.INTERNAL, error.toString(), error) + +/** Only control-call errors carry text; successful control results share a singleton. */ +internal inline fun desktopServiceStatus(block: () -> Unit): DesktopStatus = try { desktopUnwrapReflection(block); DesktopStatus.OK } +catch (error: DesktopException) { DesktopStatus(error.code, error.diagnostic.take(1024)) } +catch (error: IllegalArgumentException) { DesktopStatus(DesktopErrorCode.INVALID_ARGUMENT.wire, error.toString().take(1024)) } +catch (error: SecurityException) { DesktopStatus(DesktopErrorCode.PERMISSION_DENIED.wire, error.toString().take(1024)) } +catch (error: Exception) { DesktopStatus(DesktopErrorCode.INTERNAL.wire, error.toString().take(1024)) } + +internal inline fun desktopOperation(code: DesktopErrorCode, block: () -> T): T = try { desktopUnwrapReflection(block) } +catch (error: DesktopException) { throw error } +catch (error: SecurityException) { throw DesktopException(DesktopErrorCode.PERMISSION_DENIED, error.toString(), error) } +catch (error: Exception) { throw DesktopException(code, error.toString(), error) } + +/** Method.invoke wraps platform exceptions. Classify and serialize the target, retaining its cause. */ +internal inline fun desktopUnwrapReflection(block: () -> T): T = try { block() } +catch (error: InvocationTargetException) { + throw generateSequence(error) { (it as? InvocationTargetException)?.targetException }.last() +} + +internal fun desktopProjectionFailure(code: Int, diagnostic: String) = ProjectionFailure( + when (DesktopErrorCode.entries.firstOrNull { it.wire == code }) { + DesktopErrorCode.PERMISSION_DENIED -> ProjectionErrorCategory.PERMISSION + DesktopErrorCode.PROTOCOL_MISMATCH, DesktopErrorCode.UNSUPPORTED_CAPABILITY -> ProjectionErrorCategory.PROTOCOL + DesktopErrorCode.INVALID_ARGUMENT, DesktopErrorCode.APP_UNAVAILABLE, DesktopErrorCode.LAYOUT_CHANGED -> ProjectionErrorCategory.CONFIGURATION + DesktopErrorCode.INPUT_FAILED, DesktopErrorCode.INPUT_QUEUE_FULL -> ProjectionErrorCategory.INPUT + DesktopErrorCode.CLEANUP_FAILED -> ProjectionErrorCategory.CLEANUP + DesktopErrorCode.BIND_TIMEOUT, DesktopErrorCode.NO_FRAME -> ProjectionErrorCategory.TIMEOUT + DesktopErrorCode.DISCONNECTED -> ProjectionErrorCategory.CONNECTION + else -> ProjectionErrorCategory.INTERNAL + }, diagnostic, "desktop", code.toString(), +) + +internal fun DesktopException.projectionFailure() = failure diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopFeatures.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopFeatures.kt new file mode 100644 index 0000000..0cce32c --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopFeatures.kt @@ -0,0 +1,49 @@ +package moe.feng.wheelplay.projection.desktop + +import com.shilapi.xcertplay.projection.ProjectionCapabilities +import com.shilapi.xcertplay.projection.VideoIngressKind + +/** Stable wire bits, never enum ordinals. Audio route support is separate from local playback/capture. */ +internal object DesktopFeatures { + const val VIDEO_SURFACE = 1L shl 0 + const val VIDEO_ENCODED = 1L shl 1 + const val VIDEO_FRAMEBUFFER = 1L shl 2 + const val TOUCH = 1L shl 3 + const val POINTER = 1L shl 4 + const val KEYBOARD = 1L shl 5 + const val BACK = 1L shl 6 + const val NIGHT_MODE = 1L shl 7 + const val AUDIO_PLAYBACK = 1L shl 8 + const val MICROPHONE = 1L shl 9 + const val BROWSER_AUDIO = 1L shl 10 + const val BROWSER_MICROPHONE = 1L shl 11 + const val CONFIGURE_DISPLAY = 1L shl 12 + const val RESIZE_DISPLAY = 1L shl 13 + const val HOME = 1L shl 14 + + // Advertise only implemented operations. Reserved bits do not promise future support. + const val HOST_SUPPORTED = VIDEO_SURFACE or TOUCH or BACK or CONFIGURE_DISPLAY + const val SERVICE_SUPPORTED = VIDEO_SURFACE or TOUCH or BACK or CONFIGURE_DISPLAY + const val REQUIRED = VIDEO_SURFACE or CONFIGURE_DISPLAY + const val MAX_CONTACTS = 10 + + fun projection(features: Long, maxContacts: Int) = ProjectionCapabilities( + video = buildSet { + if (features has VIDEO_SURFACE) add(VideoIngressKind.SURFACE) + if (features has VIDEO_ENCODED) add(VideoIngressKind.ENCODED) + if (features has VIDEO_FRAMEBUFFER) add(VideoIngressKind.FRAMEBUFFER) + }, + maxTouchContacts = if (features has TOUCH) maxContacts else 0, + nightMode = features has NIGHT_MODE, + audioPlayback = features has AUDIO_PLAYBACK, + microphone = features has MICROPHONE, + browserAudio = features has AUDIO_PLAYBACK && features has BROWSER_AUDIO, + browserMicrophone = features has MICROPHONE && features has BROWSER_MICROPHONE, + pointerInput = features has POINTER, + keyboardInput = features has KEYBOARD, + backButton = features has BACK, + cancellation = com.shilapi.xcertplay.projection.ProjectionCancellation.NATIVE, + ) + + infix fun Long.has(feature: Long) = this and feature == feature +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopNegotiation.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopNegotiation.kt new file mode 100644 index 0000000..6434c7d --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopNegotiation.kt @@ -0,0 +1,62 @@ +package moe.feng.wheelplay.projection.desktop + +import moe.feng.wheelplay.projection.desktop.DesktopFeatures.has + +/** Wire fields/serialization live in AIDL. Build once, then treat these session snapshots as read-only. */ +internal object DesktopNegotiation { + fun negotiate(version: Int, requested: Long, helperUid: Int, required: Long = DesktopFeatures.REQUIRED, + available: Long = DesktopFeatures.SERVICE_SUPPORTED): DesktopServiceInfo { + desktopCheck(version == DesktopProtocol.VERSION, DesktopErrorCode.PROTOCOL_MISMATCH) { "Host protocol $version; helper ${DesktopProtocol.VERSION}" } + desktopCheck(required and requested == required, DesktopErrorCode.INVALID_ARGUMENT) { "Required features must be requested" } + val enabled = requested and DesktopFeatures.SERVICE_SUPPORTED and available + desktopCheck(enabled has (required or DesktopFeatures.REQUIRED), DesktopErrorCode.UNSUPPORTED_CAPABILITY) { + "Surface video and display configuration are required" + } + return DesktopServiceInfo().apply { + protocolVersion = DesktopProtocol.VERSION + uid = helperUid + supportedFeatures = DesktopFeatures.SERVICE_SUPPORTED + enabledFeatures = enabled + maxTouchContacts = if (enabled has DesktopFeatures.TOUCH) DesktopFeatures.MAX_CONTACTS else 0 + minSize = DesktopDisplayConfig.MIN_SIZE + maxSize = DesktopDisplayConfig.MAX_SIZE + maxPixels = DesktopDisplayConfig.MAX_PIXELS + minDpi = DesktopDisplayConfig.MIN_DPI + maxDpi = DesktopDisplayConfig.MAX_DPI + } + } +} + +internal fun DesktopServiceInfo.accepts(config: DesktopDisplayConfig) = + config.width in minSize..maxSize && config.height in minSize..maxSize && + config.width.toLong() * config.height <= maxPixels && config.dpi in minDpi..maxDpi + +internal fun DesktopServiceInfo.validate(requested: Long = DesktopFeatures.HOST_SUPPORTED, required: Long = DesktopFeatures.REQUIRED) { + desktopCheck(protocolVersion == DesktopProtocol.VERSION, DesktopErrorCode.PROTOCOL_MISMATCH) { "Unexpected helper protocol $protocolVersion" } + desktopCheck(uid == 0 || uid == 2000, DesktopErrorCode.PERMISSION_DENIED) { "Unexpected helper UID $uid" } + desktopCheck(enabledFeatures and (supportedFeatures and requested) == enabledFeatures && + enabledFeatures has (required or DesktopFeatures.REQUIRED) && + maxTouchContacts in 0..DesktopFeatures.MAX_CONTACTS && + (enabledFeatures has DesktopFeatures.TOUCH) == (maxTouchContacts > 0) && + minSize > 0 && maxSize >= minSize && maxPixels >= minSize.toLong() * minSize && + minDpi > 0 && maxDpi >= minDpi, DesktopErrorCode.PROTOCOL_MISMATCH) { "Invalid helper capabilities or limits" } +} + +internal fun DesktopStatus?.checkSuccess() { + val status = this ?: throw DesktopException(DesktopErrorCode.PROTOCOL_MISMATCH, "Control response omitted status") + status.throwIfError() +} + +internal fun DesktopDisplayInfo.validate(config: DesktopDisplayConfig) { + desktopCheck(displayId > 0 && width == config.width && height == config.height && dpi == config.dpi && + rotation == 0 && layoutRevision == DesktopProtocol.LAYOUT, DesktopErrorCode.LAYOUT_CHANGED) { + "Unexpected actual desktop layout: $this" + } +} + +internal fun DesktopStartConfig.enabledWithin(negotiated: Long): Long { + desktopCheck(requiredFeatures and requestedFeatures == requiredFeatures, DesktopErrorCode.INVALID_ARGUMENT) { "Required session features must be requested" } + val enabled = requestedFeatures and negotiated + desktopCheck(enabled has (requiredFeatures or DesktopFeatures.REQUIRED), DesktopErrorCode.UNSUPPORTED_CAPABILITY) { "Required session features unavailable" } + return enabled +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopStatus.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopStatus.kt new file mode 100644 index 0000000..00b020b --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopStatus.kt @@ -0,0 +1,26 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.Parcel +import android.os.Parcelable + +/** Zero is success. Nonzero codes are stable DesktopErrorCode values; unknown codes remain intact. */ +data class DesktopStatus(val code: Int, val diagnostic: String? = null) : Parcelable { + override fun describeContents() = 0 + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(code) + if (code != 0) dest.writeString(diagnostic) + } + internal fun throwIfError() { + if (code != 0) throw DesktopException(code, diagnostic.orEmpty()) + } + companion object { + @JvmField val OK = DesktopStatus(0) + @JvmField val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): DesktopStatus { + val code = source.readInt() + return if (code == 0) OK else DesktopStatus(code, source.readString()) + } + override fun newArray(size: Int) = arrayOfNulls(size) + } + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouch.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouch.kt new file mode 100644 index 0000000..714d772 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouch.kt @@ -0,0 +1,44 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.SystemClock +import android.view.InputDevice +import android.view.MotionEvent + +/** Maintains Android pointer indices while browser IDs remain stable across lifts. */ +internal class DesktopTouch(private val config: DesktopDisplayConfig, private val inject: (MotionEvent) -> Unit) { + private data class Point(val id: Int, val x: Float, val y: Float) + private val active = mutableListOf() + private var downTime = 0L + + fun update(ids: IntArray, xs: FloatArray, ys: FloatArray, down: BooleanArray = BooleanArray(ids.size) { true }) { + require(ids.size <= 10 && ids.size == xs.size && ids.size == ys.size && ids.size == down.size) + require(ids.toSet().size == ids.size && ids.all { it in 0..31 }) + require(xs.all { it.isFinite() && it in 0f..1f } && ys.all { it.isFinite() && it in 0f..1f }) + val points = ids.indices.map { Point(ids[it], xs[it] * (config.width - 1), ys[it] * (config.height - 1)) } + val next = points.filterIndexed { index, _ -> down[index] } + // Update remaining contacts before emitting a boundary, including the lifting pointer if available. + active.indices.forEach { i -> points.find { it.id == active[i].id }?.let { active[i] = it } } + for (index in active.indices.reversed()) if (next.none { it.id == active[index].id }) { + emit(if (active.size == 1) MotionEvent.ACTION_UP else MotionEvent.ACTION_POINTER_UP or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)) + active.removeAt(index) + } + next.forEach { point -> if (active.none { it.id == point.id }) { + if (active.isEmpty()) downTime = SystemClock.uptimeMillis() + active.add(point) + emit(if (active.size == 1) MotionEvent.ACTION_DOWN else MotionEvent.ACTION_POINTER_DOWN or ((active.size - 1) shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)) + } } + if (active.isNotEmpty()) emit(MotionEvent.ACTION_MOVE) + } + + fun cancel() { + try { if (active.isNotEmpty()) emit(MotionEvent.ACTION_CANCEL) } finally { active.clear() } + } + + private fun emit(action: Int) { + val properties = active.map { point -> MotionEvent.PointerProperties().apply { id = point.id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() + val coords = active.map { point -> MotionEvent.PointerCoords().apply { x = point.x; y = point.y; pressure = 1f; size = 1f } }.toTypedArray() + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, active.size, properties, coords, + 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, 0) + try { inject(event) } finally { event.recycle() } + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.kt new file mode 100644 index 0000000..65a7229 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchFrame.kt @@ -0,0 +1,62 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.BadParcelableException +import android.os.Parcel +import android.os.Parcelable +import com.shilapi.xcertplay.projection.ProjectionContact + +/** Complete contact snapshot; absent IDs release, down=false supplies a final lift position. + * Coordinates are normalized to display content. Empty frames release all contacts. + * Wire: layout, input epoch, count, then (id, x, y, down) primitives. + * Back advances the epoch so delayed touches cannot act on the page it returns to. + */ +class DesktopTouchFrame internal constructor( + val layout: Int, + val inputEpoch: Int, + internal val ids: IntArray, + internal val xs: FloatArray, + internal val ys: FloatArray, + internal val down: BooleanArray, +) : Parcelable { + init { + require(ids.size <= DesktopFeatures.MAX_CONTACTS && ids.size == xs.size && ids.size == ys.size && ids.size == down.size) + } + override fun describeContents() = 0 + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(layout); dest.writeInt(inputEpoch); dest.writeInt(ids.size) + for (i in ids.indices) { + dest.writeInt(ids[i]); dest.writeFloat(xs[i]); dest.writeFloat(ys[i]); dest.writeInt(if (down[i]) 1 else 0) + } + } + + companion object { + @JvmField val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): DesktopTouchFrame { + if (source.dataAvail() < 12) throw BadParcelableException("Truncated desktop touch header") + val layout = source.readInt() + val inputEpoch = source.readInt() + val count = source.readInt() + if (count !in 0..DesktopFeatures.MAX_CONTACTS || source.dataAvail() < count * 16) + throw BadParcelableException("Invalid desktop contact count or truncated frame") + val ids = IntArray(count); val xs = FloatArray(count); val ys = FloatArray(count); val down = BooleanArray(count) + for (i in 0 until count) { + ids[i] = source.readInt(); xs[i] = source.readFloat(); ys[i] = source.readFloat() + val state = source.readInt() + if (state !in 0..1) throw BadParcelableException("Invalid desktop contact state") + down[i] = state == 1 + } + return DesktopTouchFrame(layout = layout, inputEpoch = inputEpoch, ids = ids, xs = xs, ys = ys, down = down) + } + override fun newArray(size: Int) = arrayOfNulls(size) + } + + internal fun from(contacts: List, inputEpoch: Int = 0) = DesktopTouchFrame( + layout = DesktopProtocol.LAYOUT, + inputEpoch = inputEpoch, + ids = IntArray(contacts.size) { contacts[it].id }, + xs = FloatArray(contacts.size) { contacts[it].x.toFloat() }, + ys = FloatArray(contacts.size) { contacts[it].y.toFloat() }, + down = BooleanArray(contacts.size) { contacts[it].down }, + ) + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueue.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueue.kt new file mode 100644 index 0000000..cf26007 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueue.kt @@ -0,0 +1,118 @@ +package moe.feng.wheelplay.projection.desktop + +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** Bounded helper-side work queue: the oneway Binder thread never waits for Android input injection. */ +internal class DesktopTouchQueue( + private val process: (DesktopTouchFrame) -> Unit, + private val cancel: () -> Unit, + private val diagnose: (DesktopException) -> Unit, + private val capacity: Int = 64, + private val operationLock: Any = Any(), + private val worker: ExecutorService = Executors.newSingleThreadExecutor { Thread(it, "desktop-touch").apply { isDaemon = true } }, +) : AutoCloseable { + private val lock = Any() + // Cancellation barriers also carry an epoch, including after they have been dequeued. + private data class Entry(val epoch: Int, val frame: DesktopTouchFrame?) + private val pending = ArrayDeque() + private var epoch = 0 + val inputEpoch: Int get() = synchronized(lock) { epoch } + private var draining = false + private var closed = false + private var failures = 0 + @Volatile var fault: DesktopException? = null + private set + + init { require(capacity >= 2) } + + fun offer(frame: DesktopTouchFrame) = synchronized(lock) { + if (closed || fault != null || frame.inputEpoch != epoch) return@synchronized + if (pending.size >= capacity) { + // Dropping a queued UP alone could leave a held pointer. Reset before accepting a fresh snapshot. + pending.clear() + pending.addLast(Entry(epoch, null)) + } + pending.addLast(Entry(epoch, frame)) + if (!draining) { + draining = true + worker.execute(::drain) + } + } + + /** Cancel old-page input before a synchronous navigation command. Late Binder calls are fenced too. */ + fun barrier(nextEpoch: Int, action: () -> Unit) = synchronized(operationLock) { + synchronized(lock) { + desktopCheck(!closed, DesktopErrorCode.INVALID_STATE) { "Desktop input is closed" } + fault?.let { throw it } + desktopCheck(nextEpoch > 0 && nextEpoch.toLong() == epoch.toLong() + 1, + DesktopErrorCode.STALE_SESSION) { "Unexpected input epoch $nextEpoch after $epoch" } + epoch = nextEpoch + pending.clear() + failures = 0 + } + if (!cancelGesture()) throw checkNotNull(fault) + action() + } + + private fun drain() { + while (true) { + val frame = synchronized(lock) { + if (closed || fault != null || pending.isEmpty()) { + pending.clear(); draining = false + return + } + pending.removeFirst() + } + consume(frame) + } + } + + private fun consume(entry: Entry) = synchronized(operationLock) { + if (closed || fault != null || entry.epoch != epoch) return@synchronized + val frame = entry.frame + if (frame == null) { + cancelGesture() + return@synchronized + } + try { + process(frame) + failures = 0 + } catch (_: IllegalArgumentException) { + // Validation precedes mutation in DesktopTouch. Malformed frames cannot change a gesture. + } catch (error: Exception) { + val failure = desktopFailure(error) + when (failure.category) { + DesktopErrorCode.STALE_SESSION, DesktopErrorCode.UNSUPPORTED_CAPABILITY -> Unit + DesktopErrorCode.INPUT_FAILED -> if (cancelGesture()) { + if (++failures >= 3) fail(failure) else diagnose(failure) + } + else -> if (cancelGesture()) fail(failure) + } + } + } + + private fun cancelGesture(): Boolean = try { cancel(); true } catch (error: Exception) { + val code = (error as? DesktopException)?.code ?: DesktopErrorCode.INPUT_FAILED.wire + fail(DesktopException(code, "Unable to cancel desktop gesture: $error", error)) + false + } + + private fun fail(error: DesktopException) { + synchronized(lock) { + if (closed || fault != null) return + fault = error + pending.clear() + } + diagnose(error) + } + + /** The owner serializes close/release with process/cancel, so a popped frame cannot use a released display. */ + override fun close() = synchronized(operationLock) { + synchronized(lock) { + closed = true + pending.clear() + worker.shutdown() + } + } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopBackend.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopBackend.kt new file mode 100644 index 0000000..7c9fd3e --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopBackend.kt @@ -0,0 +1,215 @@ +package moe.feng.wheelplay.projection.desktop + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.os.Binder +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.view.Surface +import com.shilapi.xcertplay.shared.R +import com.shilapi.xcertplay.projection.* +import rikka.shizuku.Shizuku +import moe.shizuku.server.IShizukuService +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +internal class ShizukuDesktopBackend(private val context: Context) : DesktopBackend { + override fun probe(): SourceAvailability { + val reason = when { + Build.VERSION.SDK_INT < 29 -> R.string.desktop_unsupported + !Shizuku.pingBinder() -> if (runCatching { context.packageManager.getPackageInfo(PACKAGE, 0) }.isSuccess) + R.string.desktop_not_running else R.string.desktop_not_installed + else -> try { + when { + Shizuku.getVersion() < 13 -> R.string.desktop_unsupported + // Shizuku.checkSelfPermission() caches a granted result. Query the pinned + // v13 Binder contract so external revocation is observed during a session. + IShizukuService.Stub.asInterface(Shizuku.getBinder())?.checkSelfPermission() != true -> R.string.desktop_not_authorized + Shizuku.getUid() !in setOf(0, 2000) -> R.string.desktop_unsupported + else -> return SourceAvailability.Ready + } + } catch (_: Exception) { R.string.desktop_not_running } + } + val message = context.getString(reason) + return SourceAvailability.Unavailable(message, ProjectionFailure( + if (reason == R.string.desktop_not_authorized) ProjectionErrorCategory.PERMISSION else ProjectionErrorCategory.UNAVAILABLE, + message, "shizuku")) + } + + override fun connect() = connect(ProjectionStartup()) + + override fun connect(startup: ProjectionStartup): CompletableFuture { + startup.checkActive() + val result = CompletableFuture() + val availability = probe() + if (availability is SourceAvailability.Unavailable) { + result.completeExceptionally(ProjectionException(availability.failure)); return result + } + val version = context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode.toInt() + val args = Shizuku.UserServiceArgs(ComponentName(context, ShizukuDesktopUserService::class.java)) + .tag("wheelplay-desktop-v${DesktopProtocol.VERSION}-${android.os.Process.myUid()}-${UUID.randomUUID()}") + .version(version).daemon(false).processNameSuffix("desktop") + val main = Handler(Looper.getMainLooper()) + lateinit var binding: ServiceConnection + var bound = false + var connection: Connection? = null // Binding lifecycle is serialized on main. + val aborting = java.util.concurrent.atomic.AtomicBoolean() + fun unbind() { Shizuku.unbindUserService(args, binding, true) } + fun abort(failure: Throwable) { + if (result.isDone || !aborting.compareAndSet(false, true)) return + val acquired = connection + CompletableFuture.runAsync { + val cleanup = runCatching { if (acquired != null) acquired.close() else if (bound) unbind() }.exceptionOrNull() + result.completeExceptionally(if (cleanup == null) failure else + ProjectionCleanupException("Desktop bind cancellation cleanup unconfirmed", cleanup).apply { addSuppressed(failure) }) + } + } + val timeout = Runnable { abort(DesktopException(DesktopErrorCode.BIND_TIMEOUT, "Helper bind/handshake exceeded 10 seconds")) } + binding = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + val acquired = runCatching { Connection(IDesktopService.Stub.asInterface(binder), ::unbind) }.getOrElse { abort(it); return } + connection = acquired + CompletableFuture.runAsync { + try { + // Cancellation before acquisition never sends hello/start or admits a late helper. + if (result.isDone || aborting.get() || startup.isCancelled) { acquired.close(); return@runAsync } + acquired.handshake() + main.post { + if (startup.isCancelled || aborting.get() || !result.complete(acquired)) { + CompletableFuture.runAsync { acquired.close() } + } + } + } catch (failure: Throwable) { + val cleanup = runCatching { acquired.close() }.exceptionOrNull() + main.post { abort(if (cleanup == null) failure else ProjectionCleanupException("Handshake cleanup failed", cleanup)) } + } + } + } + override fun onServiceDisconnected(name: ComponentName) { + abort(DesktopException(DesktopErrorCode.DISCONNECTED, "Helper disconnected while binding")) + } + } + val cancellation = startup.onCancel { main.post { abort(ProjectionException(checkNotNull(startup.cancellation))) } } + result.whenComplete { _, _ -> main.removeCallbacks(timeout); cancellation.close() } + main.post { + if (startup.isCancelled) { abort(ProjectionException(checkNotNull(startup.cancellation))); return@post } + main.postDelayed(timeout, 10_000) + try { bound = true; Shizuku.bindUserService(args, binding) } catch (failure: Throwable) { abort(failure) } + } + return result + } + + internal class Connection(private val service: IDesktopService, private val unbind: () -> Unit, + private val requestedFeatures: Long = DesktopFeatures.HOST_SUPPORTED, + private val requiredFeatures: Long = DesktopFeatures.REQUIRED, + private val sessionFeatures: Long = requestedFeatures) : DesktopConnection { + private val token = UUID.randomUUID().toString() + private val owner = Binder() + private val dead = CountDownLatch(1) + private val death = IBinder.DeathRecipient { dead.countDown() } + private var started = false + private var closed = false + // Touch and Back are submitted by the session's single input worker. + private var inputEpoch = 0 + private lateinit var info: DesktopServiceInfo + private var actual: DesktopDisplayInfo? = null + private var enabledFeatures = 0L + override val uid get() = info.uid + override lateinit var capabilities: ProjectionCapabilities + private set + init { service.asBinder().linkToDeath(death, 0) } + fun handshake() { + desktopRemoteCall { + val reply = service.hello(DesktopProtocol.VERSION, requestedFeatures, requiredFeatures) + reply.status.checkSuccess() + info = reply.info ?: throw DesktopException(DesktopErrorCode.PROTOCOL_MISMATCH, "Handshake omitted service info") + info.validate(requestedFeatures, requiredFeatures) + capabilities = DesktopFeatures.projection(info.enabledFeatures, info.maxTouchContacts) + } + } + override fun start(surface: Surface, component: String, config: DesktopDisplayConfig): DesktopDisplayInfo { + check(!closed && !started) + desktopCheck(info.accepts(config), DesktopErrorCode.INVALID_ARGUMENT) { "Display configuration exceeds helper limits" } + started = true + return desktopRemoteCall { + val request = DesktopStartConfig().apply { + this.component = component; width = config.width; height = config.height; dpi = config.dpi + requestedFeatures = sessionFeatures; requiredFeatures = this@Connection.requiredFeatures + } + val reply = service.start(token, owner, surface, request) + reply.status.checkSuccess() + desktopCheck(reply.enabledFeatures and (info.enabledFeatures and sessionFeatures) == reply.enabledFeatures && + reply.enabledFeatures and (requiredFeatures or DesktopFeatures.REQUIRED) == (requiredFeatures or DesktopFeatures.REQUIRED), + DesktopErrorCode.PROTOCOL_MISMATCH) { "Invalid enabled session features" } + val actual = reply.display ?: throw DesktopException(DesktopErrorCode.PROTOCOL_MISMATCH, "Missing display info") + actual.validate(config) + capabilities = DesktopFeatures.projection(reply.enabledFeatures, info.maxTouchContacts) + this.actual = actual + enabledFeatures = reply.enabledFeatures + actual + } + } + override fun touch(contacts: List) { + check(!closed) + desktopCheck(capabilities.maxTouchContacts > 0, DesktopErrorCode.UNSUPPORTED_CAPABILITY) { "Touch was not negotiated" } + desktopCheck(contacts.size <= capabilities.maxTouchContacts, DesktopErrorCode.INVALID_ARGUMENT) { "Too many contacts" } + desktopRemoteCall { service.touch(token, DesktopTouchFrame.from(contacts, inputEpoch)) } + } + override fun back() { + check(!closed) + desktopCheck(capabilities.backButton, DesktopErrorCode.UNSUPPORTED_CAPABILITY) { "Back was not negotiated" } + inputEpoch = Math.incrementExact(inputEpoch) + desktopRemoteCall { service.back(token, DesktopProtocol.LAYOUT, inputEpoch).checkSuccess() } + } + override fun cancelInput() { + check(!closed) + inputEpoch = Math.incrementExact(inputEpoch) + desktopRemoteCall { service.cancelInput(token, DesktopProtocol.LAYOUT, inputEpoch).checkSuccess() } + } + private fun DesktopStateResult.checkedState(): DesktopSessionState { + status.checkSuccess() + val snapshot = state ?: throw DesktopException(DesktopErrorCode.PROTOCOL_MISMATCH, "Missing desktop state") + desktopCheck(snapshot.phase in DesktopSessionState.IDLE..DesktopSessionState.FAILED, + DesktopErrorCode.PROTOCOL_MISMATCH) { "Unknown desktop phase ${snapshot.phase}" } + if (snapshot.phase == DesktopSessionState.RUNNING) { + desktopCheck(snapshot.enabledFeatures == enabledFeatures && snapshot.inputEpoch == inputEpoch, + DesktopErrorCode.PROTOCOL_MISMATCH) { "Unexpected active session capabilities/epoch" } + val expected = actual ?: throw DesktopException(DesktopErrorCode.INVALID_STATE, "Display has not started") + desktopCheck(snapshot.display == expected, DesktopErrorCode.LAYOUT_CHANGED) { "Actual layout changed" } + } + return snapshot + } + override fun healthy(): DesktopSessionState = desktopRemoteCall { + check(!closed) + service.healthy(token, DesktopProtocol.LAYOUT).checkedState().also { + desktopCheck(it.phase == DesktopSessionState.RUNNING, DesktopErrorCode.INVALID_STATE) { "Helper is not running" } + it.failure?.throwIfError() + } + } + override fun state(): DesktopSessionState = desktopRemoteCall { service.getState(token).checkedState() } + + @Synchronized override fun close() { + if (closed) return + try { + if (service.asBinder().isBinderAlive) { + try { if (started) service.stop(token).checkSuccess() } finally { runCatching { service.destroy() } } + } + } catch (_: Exception) { + // A revoked Shizuku grant still permits the owning app to destroy its own helper Binder. + runCatching { service.destroy() } + } finally { unbind() } + if (service.asBinder().isBinderAlive && !dead.await(5, TimeUnit.SECONDS)) + throw ProjectionCleanupException("Virtual desktop helper did not exit", + DesktopException(DesktopErrorCode.CLEANUP_FAILED, "Helper Binder remained alive after destroy")) + closed = true + runCatching { service.asBinder().unlinkToDeath(death, 0) } + } + } + + companion object { const val PACKAGE = "moe.shizuku.privileged.api" } +} diff --git a/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopUserService.kt b/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopUserService.kt new file mode 100644 index 0000000..c141cd7 --- /dev/null +++ b/common/src/main/java/moe/feng/wheelplay/projection/desktop/ShizukuDesktopUserService.kt @@ -0,0 +1,309 @@ +package moe.feng.wheelplay.projection.desktop + +import android.app.ActivityManager +import android.app.ActivityOptions +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Binder +import android.os.Bundle +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Process +import android.os.SystemClock +import android.util.DisplayMetrics +import android.util.Log +import android.view.InputDevice +import android.view.InputEvent +import android.view.KeyEvent +import android.view.Surface +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** Shizuku constructs this Binder in its shell/root app_process, not an Android Service. */ +@Keep +@RequiresApi(29) +class ShizukuDesktopUserService(context: Context) : IDesktopService.Stub() { + private val hostUid = context.applicationInfo.uid + private val shellContext = context.createPackageContext("com.android.shell", Context.CONTEXT_IGNORE_SECURITY) + private val displays = DisplayManager::class.java.getDeclaredConstructor(Context::class.java).apply { + isAccessible = true + }.newInstance(shellContext) + private val lock = Any() + @Volatile private var token: String? = null + private var owner: IBinder? = null + private var display: VirtualDisplay? = null + private var surface: Surface? = null + @Volatile private var lastHeartbeat = SystemClock.elapsedRealtime() + @Volatile private var used = false + private val taskDisplayId = Class.forName("android.app.TaskInfo").getField("displayId") + private val inputClass = Class.forName("android.hardware.input.InputManager") + private val inputManager = inputClass.getDeclaredMethod("getInstance").invoke(null) + private val injectMethod = inputClass.getMethod("injectInputEvent", InputEvent::class.java, Int::class.javaPrimitiveType) + private val setDisplay = InputEvent::class.java.getMethod("setDisplayId", Int::class.javaPrimitiveType) + private var negotiated: DesktopServiceInfo? = null + private var config: DesktopDisplayConfig? = null + private var sessionFeatures = 0L + private var lastToken: String? = null + private var sessionFailure: DesktopStatus? = null + private var actualDisplay: DesktopDisplayInfo? = null + private var lastInputEpoch = 0 + private var touches: DesktopTouch? = null + @Volatile private var touchQueue: DesktopTouchQueue? = null + private val death = IBinder.DeathRecipient { terminate() } + private val watchdog = Executors.newSingleThreadScheduledExecutor() + + init { + check(Process.myUid() == 2000 || Process.myUid() == 0) + watchdog.scheduleWithFixedDelay({ + val expired = SystemClock.elapsedRealtime() - lastHeartbeat > if (used) 10_000 else 30_000 + // Do not acquire the IPC lock here: a stuck system call must not block the + // watchdog too. Kernel Binder death destroys the display and its input target. + if (expired) Process.killProcess(Process.myPid()) + }, 1, 1, TimeUnit.SECONDS) + } + + private fun hostCall(block: () -> T): T { + desktopCheck(Binder.getCallingUid() == hostUid, DesktopErrorCode.PERMISSION_DENIED) { "Caller does not own this helper" } + val identity = Binder.clearCallingIdentity() + try { return synchronized(lock, block) } finally { Binder.restoreCallingIdentity(identity) } + } + + private fun statusCall(block: () -> Unit) = desktopServiceStatus { hostCall(block) } + + override fun hello(version: Int, hostFeatures: Long, requiredFeatures: Long): DesktopHandshakeResult = DesktopHandshakeResult().apply { + status = statusCall { + desktopCheck(negotiated == null && !used, DesktopErrorCode.INVALID_STATE) { "Handshake already completed" } + desktopCheck(Build.VERSION.SDK_INT >= 29, DesktopErrorCode.UNSUPPORTED_CAPABILITY) { "Android 10 or later required" } + info = DesktopNegotiation.negotiate(version = version, requested = hostFeatures, helperUid = Process.myUid(), required = requiredFeatures) + .also { negotiated = it } + } + } + + private fun requireFeature(feature: Long) { + desktopCheck((if (used) sessionFeatures else negotiated?.enabledFeatures ?: 0L) and feature == feature, + DesktopErrorCode.UNSUPPORTED_CAPABILITY) { "Operation was not negotiated: $feature" } + } + + override fun start(session: String, client: IBinder, target: Surface, config: DesktopStartConfig): DesktopStartResult = DesktopStartResult().apply { + status = desktopServiceStatus { + display = startDisplay(session, client, target, config) + enabledFeatures = sessionFeatures + } + } + + private fun startDisplay(session: String, client: IBinder, target: Surface, + request: DesktopStartConfig): DesktopDisplayInfo = hostCall { + desktopCheck(negotiated != null && !used && token == null, DesktopErrorCode.INVALID_STATE) { "Handshake required; helper may only start once" } + val capabilities = checkNotNull(negotiated) + val requested = DesktopDisplayConfig(width = request.width, height = request.height, dpi = request.dpi) + val enabled = request.enabledWithin(capabilities.enabledFeatures) + require(session.length in 16..128 && target.isValid) + val activity = requireNotNull(ComponentName.unflattenFromString(request.component)) + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setComponent(activity) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + val info = shellContext.packageManager.resolveActivity(intent, 0)?.activityInfo + desktopCheck(info?.isDesktopLaunchable == true, DesktopErrorCode.APP_UNAVAILABLE) { "Application cannot be launched" } + used = true; token = session; lastToken = session; owner = client; surface = target + sessionFeatures = enabled + config = requested + val touchInput = DesktopTouch(requested, ::inject).also { touches = it } + try { + client.linkToDeath(death, 0) + var flags = DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or + (1 shl 6) or (1 shl 8) // SUPPORTS_TOUCH, DESTROY_CONTENT_ON_REMOVAL; never AUTO_MIRROR or SECURE. + if (Build.VERSION.SDK_INT >= 33) flags = flags or (1 shl 10) or (1 shl 11) // TRUSTED, OWN_DISPLAY_GROUP + if (Build.VERSION.SDK_INT >= 34) flags = flags or (1 shl 14) // OWN_FOCUS + val virtualDisplay = desktopOperation(DesktopErrorCode.DISPLAY_FAILED) { + requireNotNull(displays.createVirtualDisplay("WheelPlay Android desktop", requested.width, + requested.height, requested.dpi, target, flags)) { "Virtual displays are unsupported" } + }.also { display = it } + val displayId = virtualDisplay.display.displayId + desktopCheck(displayId > 0, DesktopErrorCode.DISPLAY_FAILED) { "Refusing the default display" } + desktopCheck(layoutValid(), DesktopErrorCode.LAYOUT_CHANGED) { "Unsupported display layout" } + actualDisplay = displayInfo() + val options = ActivityOptions.makeBasic().apply { launchDisplayId = displayId } + desktopOperation(DesktopErrorCode.LAUNCH_FAILED) { startActivity(intent, options.toBundle()) } + // Some single-task apps ignore the requested display. Do not report their blank + // virtual display as a successful launch or send input to the main-screen task. + val activities = shellContext.getSystemService(ActivityManager::class.java) + val deadline = SystemClock.elapsedRealtime() + 2_000 + while (activities.getRunningTasks(100).none { task -> + taskDisplayId.getInt(task) == displayId && task.baseActivity?.packageName == activity.packageName + }) { + desktopCheck(SystemClock.elapsedRealtime() < deadline, DesktopErrorCode.LAUNCH_FAILED) { "Application did not start on the virtual display" } + Thread.sleep(50) + } + lastHeartbeat = SystemClock.elapsedRealtime() + // The queue also owns Back's epoch barrier when touch was not negotiated. + touchQueue = DesktopTouchQueue( + process = { frame -> synchronized(lock) { + // A synchronous stop can overtake pending oneway calls. Never inject after release. + if (token == session && display != null) { + owned(session, frame.layout) + requireFeature(DesktopFeatures.TOUCH) + require(frame.ids.size <= capabilities.maxTouchContacts) + touchInput.update(frame.ids, frame.xs, frame.ys, frame.down) + } + } }, + cancel = { synchronized(lock) { if (token == session && display != null) touchInput.cancel() } }, + diagnose = { Log.w("ShizukuDesktop", "Touch error ${it.code}: ${it.diagnostic}", it) }, + operationLock = lock, + ) + displayInfo().also { actualDisplay = it } + } catch (failure: Throwable) { + sessionFailure = desktopServiceStatus { throw failure } + try { release() } catch (cleanup: Throwable) { + sessionFailure = desktopServiceStatus { throw cleanup } + cleanup.addSuppressed(failure); throw cleanup + } + throw failure + } + } + + private fun startActivity(intent: Intent, options: Bundle) { + // A UserService has no registered application thread. Context.startActivity supplies + // an invalid caller; call the shell API with a null caller and an explicit host user. + val manager = Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null) + val method = Class.forName("android.app.IActivityManager").getMethod("startActivityAsUser", + Class.forName("android.app.IApplicationThread"), String::class.java, Intent::class.java, + String::class.java, IBinder::class.java, String::class.java, Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, Class.forName("android.app.ProfilerInfo"), Bundle::class.java, Int::class.javaPrimitiveType) + val result = method.invoke(manager, null, "com.android.shell", intent, null, null, null, 0, 0, null, options, hostUid / 100000) as Int + check(result in 0..3) { "Application launch rejected ($result)" } + } + + private fun owned(session: String, layout: Int) { + desktopCheck(token == session && layout == DesktopProtocol.LAYOUT && display != null, DesktopErrorCode.STALE_SESSION) { "Stale desktop session/layout" } + if (!layoutValid()) { + runCatching { touches?.cancel() } + throw DesktopException(DesktopErrorCode.LAYOUT_CHANGED, "Display layout changed; reconnect the virtual desktop") + } + } + + private fun layoutValid(): Boolean { + val expected = config ?: return false + val screen = display?.display ?: return false + if (screen.displayId <= 0 || !screen.isValid || screen.rotation != Surface.ROTATION_0) return false + val metrics = DisplayMetrics() + @Suppress("DEPRECATION") screen.getRealMetrics(metrics) + return metrics.widthPixels == expected.width && metrics.heightPixels == expected.height && + metrics.densityDpi == expected.dpi + } + + override fun touch(session: String, frame: DesktopTouchFrame) { + // Only enqueue here: no privileged operation, display lock or synchronous reply on the Binder thread. + if (Binder.getCallingUid() != hostUid || token != session || frame.layout != DesktopProtocol.LAYOUT) return + touchQueue?.offer(frame) + } + + override fun back(session: String, layout: Int, inputEpoch: Int) = statusCall { + owned(session, layout) + requireFeature(DesktopFeatures.BACK) + checkNotNull(touchQueue).barrier(inputEpoch) { + val now = SystemClock.uptimeMillis() + try { inject(KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK, 0, 0, -1, 0, 0, InputDevice.SOURCE_KEYBOARD)) } + finally { inject(KeyEvent(now, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK, 0, 0, -1, 0, 0, InputDevice.SOURCE_KEYBOARD)) } + } + } + + private fun inject(event: InputEvent) = desktopOperation(DesktopErrorCode.INPUT_FAILED) { + val id = display?.display?.displayId ?: error("No owned display") + check(id > 0) { "Refusing input to the default display" } + setDisplay.invoke(event, id) // Failure is fatal: never fall back to display 0. + check(injectMethod.invoke(inputManager, event, 0) == true) { "Display input injection rejected; check manufacturer permissions" } + } + + private fun displayInfo(): DesktopDisplayInfo { + val screen = checkNotNull(display).display + val metrics = DisplayMetrics() + @Suppress("DEPRECATION") screen.getRealMetrics(metrics) + return DesktopDisplayInfo().apply { + displayId = screen.displayId; width = metrics.widthPixels; height = metrics.heightPixels + dpi = metrics.densityDpi; rotation = screen.rotation; layoutRevision = DesktopProtocol.LAYOUT + } + } + + private fun snapshotState() = DesktopSessionState().apply { + failure = touchQueue?.fault?.let { DesktopStatus(it.code, it.diagnostic.take(1024)) } ?: sessionFailure + ?: if (this@ShizukuDesktopUserService.display != null && !layoutValid()) + DesktopStatus(DesktopErrorCode.LAYOUT_CHANGED.wire, "Display layout changed") else null + phase = when { + failure != null -> DesktopSessionState.FAILED + this@ShizukuDesktopUserService.display != null -> DesktopSessionState.RUNNING + used -> DesktopSessionState.STOPPED + else -> DesktopSessionState.IDLE + } + enabledFeatures = sessionFeatures + inputEpoch = touchQueue?.inputEpoch ?: lastInputEpoch + display = if (this@ShizukuDesktopUserService.display != null) displayInfo() else actualDisplay + } + + override fun healthy(session: String, layout: Int) = DesktopStateResult().apply { + status = statusCall { + try { + owned(session, layout) + touchQueue?.fault?.let { throw it } + lastHeartbeat = SystemClock.elapsedRealtime() + } catch (error: DesktopException) { + if (session == token) sessionFailure = DesktopStatus(error.code, error.diagnostic.take(1024)) + throw error + } finally { if (session == lastToken) state = snapshotState() } + } + } + + override fun getState(session: String) = DesktopStateResult().apply { + status = statusCall { + desktopCheck(session == lastToken, DesktopErrorCode.STALE_SESSION) { "Unknown desktop session" } + state = snapshotState() // Intentionally does not renew lastHeartbeat. + } + } + + override fun cancelInput(session: String, layout: Int, inputEpoch: Int) = statusCall { + owned(session, layout) + checkNotNull(touchQueue).barrier(inputEpoch) {} + } + + override fun stop(session: String) = statusCall { + desktopCheck(token == null || token == session, DesktopErrorCode.STALE_SESSION) { "Stale desktop session" } + try { release() } catch (error: Throwable) { + sessionFailure = desktopServiceStatus { throw error } + throw error + } + } + + private fun release() = desktopOperation(DesktopErrorCode.CLEANUP_FAILED) { + // Even rejected input must not prevent the display and its tasks from being destroyed. + lastInputEpoch = touchQueue?.inputEpoch ?: lastInputEpoch + sessionFailure = touchQueue?.fault?.let { DesktopStatus(it.code, it.diagnostic.take(1024)) } ?: sessionFailure + touchQueue?.close(); touchQueue = null + runCatching { touches?.cancel() } + display?.release() + display = null + touches = null; config = null + surface?.release(); surface = null + owner?.let { runCatching { it.unlinkToDeath(death, 0) } }; owner = null + token = null + } + + override fun destroy() { + // Shizuku's reserved transaction has no application result payload. + val caller = Binder.getCallingUid() + if (caller != hostUid && caller != 0 && caller != 2000) throw SecurityException("Caller cannot destroy this helper") + Handler(Looper.getMainLooper()).postDelayed({ terminate() }, 100) + } + + private fun terminate() { + try { synchronized(lock) { release() } } finally { + watchdog.shutdownNow() + // Required by the Shizuku UserService contract. Process death also releases system-owned display tokens. + Process.killProcess(Process.myPid()) + } + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/AuthenticationImportModelTest.kt b/common/src/test/java/com/shilapi/xcertplay/AuthenticationImportModelTest.kt index 61c1fbb..afbe29e 100644 --- a/common/src/test/java/com/shilapi/xcertplay/AuthenticationImportModelTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/AuthenticationImportModelTest.kt @@ -3,8 +3,6 @@ package com.shilapi.xcertplay import android.content.Intent import android.net.Uri import android.os.Looper -import android.view.View -import android.view.ViewGroup import androidx.compose.ui.test.* import androidx.lifecycle.ViewModelProvider import java.io.ByteArrayInputStream @@ -27,6 +25,10 @@ import org.robolectric.annotation.Config @Config(sdk = [29], qualifiers = "zh-rCN-mdpi") @org.robolectric.annotation.GraphicsMode(org.robolectric.annotation.GraphicsMode.Mode.NATIVE) class AuthenticationImportModelTest : ComposeSettingsTest() { + private companion object { + const val IMPORT_TIMEOUT_SECONDS = 30L + } + private val app get() = RuntimeEnvironment.getApplication() @@ -45,13 +47,13 @@ class AuthenticationImportModelTest : ComposeSettingsTest() { } private fun waitForCompletion(model: AuthenticationImportModel) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(IMPORT_TIMEOUT_SECONDS) do { shadowOf(Looper.getMainLooper()).idle() if (model.state.value?.busy == false && !DiPlayBootstrap.importing) return Thread.sleep(5) } while (System.nanoTime() < deadline) - fail("Import did not complete") + fail("Import did not complete: state=${model.state.value}, importing=${DiPlayBootstrap.importing}") } private fun apk(identity: SyntheticMfiIdentity.Identity): ByteArray = @@ -114,41 +116,50 @@ class AuthenticationImportModelTest : ComposeSettingsTest() { object : ByteArrayInputStream(bytes) { override fun read(buffer: ByteArray, offset: Int, length: Int): Int { entered.countDown() - check(release.await(5, TimeUnit.SECONDS)) + // The test owns this gate and releases it in finally. Activity recreation + // may be slow on CI; its duration must not turn the fixture into a failed import. + release.await() return super.read(buffer, offset, length) } } } val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() - val model = ViewModelProvider(controller.get())[AuthenticationImportModel::class.java] - assertSame(app, model.getApplication()) try { - model.import(apk = apkUri) - assertTrue(entered.await(2, TimeUnit.SECONDS)) - assertThrows(Exception::class.java) { DiPlayBootstrap.ensure(app) } - controller.recreate() - assertSame( - model, - ViewModelProvider(controller.get())[AuthenticationImportModel::class.java], - ) + val model = ViewModelProvider(controller.get())[AuthenticationImportModel::class.java] + assertSame(app, model.getApplication()) + try { + model.import(apk = apkUri) + assertTrue("Import worker did not reach the blocked read", + entered.await(IMPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + assertThrows(IllegalStateException::class.java) { DiPlayBootstrap.ensure(app) } + controller.recreate() + assertSame( + model, + ViewModelProvider(controller.get())[AuthenticationImportModel::class.java], + ) + assertTrue("Import must remain blocked until the test releases it", DiPlayBootstrap.importing) + assertTrue(model.state.value!!.busy) + } finally { + release.countDown() + waitForCompletion(model) + } + // The Activity consumes the result message. Check the installed identity before + // ensure() can fall back to bundled assets and obscure the actual import failure. + assertTrue("Import completed without installing authentication resources", store.exists()) + assertArrayEquals(identity.certificate, store.load().readCertificate()) + DiPlayBootstrap.ensure(controller.get()) + compose.onNodeWithText("资源已就绪 · 私钥与证书校验通过").assertExists() + assertNotNull(shadowOf(controller.get()).nextStartedService) + val service = Robolectric.buildService(DiPlaySessionService::class.java).create() + try { + service.get().onStartCommand(Intent(app, DiPlaySessionService::class.java), 0, 1) + assertTrue(com.shilapi.xcertplay.web.WebSession.running) + } finally { + service.destroy() + } } finally { - release.countDown() - waitForCompletion(model) + controller.pause().stop().destroy() } - fun views(view: View): List = - listOf(view) + - if (view is ViewGroup) - (0 until view.childCount).flatMap { views(view.getChildAt(it)) } - else emptyList() - DiPlayBootstrap.ensure(controller.get()) - compose.onNodeWithText("资源已就绪 · 私钥与证书校验通过").assertExists() - assertNotNull(shadowOf(controller.get()).nextStartedService) - assertArrayEquals(identity.certificate, store.load().readCertificate()) - val service = Robolectric.buildService(DiPlaySessionService::class.java).create() - service.get().onStartCommand(Intent(app, DiPlaySessionService::class.java), 0, 1) - assertTrue(com.shilapi.xcertplay.web.WebSession.running) - service.destroy() - controller.pause().stop().destroy() } @Test diff --git a/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt index eaabe92..6c70b90 100644 --- a/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/SourceNavigationTest.kt @@ -69,7 +69,7 @@ class SourceNavigationTest { assertEquals("输入源", nav.menu.findItem(2).title) nav.selectedItemId = 2 val titles = cards(activity).map { views(it).filterIsInstance().first().text.toString() } - assertEquals(listOf("CarPlay (iPhone)", "Android Auto", "ICCOA CarLink", "VNC", "插件输入源", "诊断输入源"), titles) + assertEquals(listOf("CarPlay (iPhone)", "Android Auto", "ICCOA CarLink", "VNC", "Android 虚拟桌面", "插件输入源", "诊断输入源"), titles) assertTrue(buttons(activity).isEmpty()) val initialSession = projectionSessions.snapshot() capture(activity, "index") @@ -79,7 +79,7 @@ class SourceNavigationTest { assertFalse(buttons(activity).any { it.text == "连接本机 Android Auto" }) capture(activity, "carplay") activity.onBackPressedDispatcher.onBackPressed() - assertEquals(6, cards(activity).size) + assertEquals(7, cards(activity).size) cards(activity)[1].performClick() assertTrue(buttons(activity).any { it.text == "连接本机 Android Auto" }) assertTrue(buttons(activity).any { it.text == "通过 USB 连接 Android Auto" }) @@ -114,6 +114,118 @@ class SourceNavigationTest { } finally { controller.pause().stop().destroy(); WebSession.stop() } } + @Test fun desktopCardOnlyNavigatesAndMissingShizukuKeepsStartDisabled() { + val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() + val activity = controller.get() + try { + val initial = projectionSessions.snapshot() + tabs(activity).selectedItemId = 2 + cards(activity)[4].performClick() + assertTrue(buttons(activity).any { it.text == "安装 / 打开 Shizuku" }) + assertTrue(buttons(activity).any { it.text == "授权 WheelPlay" }) + assertTrue(buttons(activity).any { it.text == "选择可启动的本机应用" }) + assertFalse(buttons(activity).single { it.text == "启动虚拟桌面" }.isEnabled) + assertTrue(views(page(activity)).filterIsInstance().any { it.text.toString().contains("未安装 Shizuku") }) + assertSame(initial, projectionSessions.snapshot()) + assertNull(shadowOf(activity).nextStartedActivity) + assertTrue(shadowOf(RuntimeEnvironment.getApplication()).boundServiceConnections.isEmpty()) + tabs(activity).selectedItemId = 1 + selectHomeSource(activity, "Android 虚拟桌面") + buttons(activity).single { it.text == "设置并连接" }.performClick() + assertFalse(buttons(activity).single { it.text == "启动虚拟桌面" }.isEnabled) + assertSame(initial, projectionSessions.snapshot()) + val active = object : ProjectionSession { + override val descriptor = SourceDescriptor("android-desktop", "Android 虚拟桌面") + override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE)) + override val inputReady = false + override val statusText = "desktop-test-connected" + override fun touch(contacts: List) = true + override fun stop() = java.util.concurrent.CompletableFuture.completedFuture(Unit) + } + assertTrue(projectionSessions.adopt(active, setOf(ProjectionResource.DISPLAY))) + shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(1)) + assertTrue(views(page(activity)).filterIsInstance().any { it.text == "desktop-test-connected" }) + val stop = buttons(activity).single { it.text == "停止投影" } + assertEquals(View.VISIBLE, stop.visibility) + stop.performClick() + assertNull(projectionSessions.snapshot()) + } finally { projectionSessions.stop().join(); controller.pause().stop().destroy(); WebSession.stop() } + } + + @Test fun homeStopCancelsPendingAcquisitionBeforeASessionExists() { + val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() + val activity = controller.get() + val pending = java.util.concurrent.CompletableFuture() + val cancelled = java.util.concurrent.CountDownLatch(1) + projectionSessions.register(object : ProjectionSourceProvider { + override val descriptor = SourceDescriptor("pending-test", "Pending source") + override val resources = setOf(ProjectionResource.DISPLAY) + override fun probe() = SourceAvailability.Ready + override fun open() = pending + override fun open(startup: ProjectionStartup): java.util.concurrent.CompletableFuture { + startup.onCancel { + pending.completeExceptionally(java.util.concurrent.CancellationException("cancelled")) + cancelled.countDown() + } + return pending + } + }) + try { + tabs(activity).selectedItemId = 1 + val opening = projectionSessions.open("pending-test") + shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(1)) + assertNull(projectionSessions.snapshot()) + val stop = buttons(activity).single { it.text == activity.getString(com.shilapi.xcertplay.shared.R.string.home_stop_source, "Pending source") } + assertEquals(View.VISIBLE, stop.visibility) + stop.performClick() + assertTrue(cancelled.await(2, java.util.concurrent.TimeUnit.SECONDS)) + assertTrue(opening.isCompletedExceptionally) + assertFalse(projectionSessions.hasSession()) + } finally { + pending.completeExceptionally(java.util.concurrent.CancellationException("test cleanup")) + projectionSessions.stop().join() + controller.pause().stop().destroy(); WebSession.stop() + } + } + + @Test fun desktopDisplaySettingsValidateSaveCancelAndRestoreDefaultsWithoutStartingProjection() { + val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() + val activity = controller.get() + try { + val initial = projectionSessions.snapshot() + tabs(activity).selectedItemId = 2 + cards(activity)[4].performClick() + val source = projectionSessions.sources().filterIsInstance().single() + fun open(): android.app.AlertDialog { + buttons(activity).single { it.text.startsWith("画面:") }.performClick() + return org.robolectric.shadows.ShadowAlertDialog.getLatestAlertDialog() + } + val dialog = open() + val fields = views(dialog.window!!.decorView).filterIsInstance() + assertEquals(listOf("1280", "720", "160"), fields.map { it.text.toString() }) + fields[0].setText("999999999999") + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).performClick() + assertTrue(dialog.isShowing) + assertEquals(1280, source.displayConfig().width) + fields[0].setText("1080"); fields[1].setText("1920"); fields[2].setText("320") + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).performClick() + assertFalse(dialog.isShowing) + assertEquals(moe.feng.wheelplay.projection.desktop.DesktopDisplayConfig(1080, 1920, 320), source.displayConfig()) + assertTrue(buttons(activity).any { it.text == "画面:1080 × 1920 · 320 DPI" }) + val canceled = open() + canceled.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).performClick() + canceled.getButton(android.app.AlertDialog.BUTTON_NEGATIVE).performClick() + assertEquals(320, source.displayConfig().dpi) + val reset = open() + reset.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).performClick() + assertTrue(reset.isShowing) + reset.getButton(android.app.AlertDialog.BUTTON_POSITIVE).performClick() + assertEquals(moe.feng.wheelplay.projection.desktop.DesktopDisplayConfig(), source.displayConfig()) + assertSame(initial, projectionSessions.snapshot()) + assertTrue(shadowOf(RuntimeEnvironment.getApplication()).boundServiceConnections.isEmpty()) + } finally { controller.pause().stop().destroy(); WebSession.stop() } + } + private fun installPlugin(pkg: String, sourceId: String?, api: Int = ProjectionPlugin.API_VERSION) { val context = RuntimeEnvironment.getApplication() val pm = shadowOf(context.packageManager) @@ -251,7 +363,7 @@ class SourceNavigationTest { buttons(activity).single { it.text.startsWith("切换输入源:") }.performClick() val dialog = org.robolectric.shadows.ShadowAlertDialog.getLatestAlertDialog() val names = (0 until dialog.listView.count).map { dialog.listView.adapter.getItem(it).toString() } - assertEquals(listOf("CarPlay (iPhone)", "Android Auto", "VNC", "ICCOA CarLink test.carlink"), names) + assertEquals(listOf("CarPlay (iPhone)", "Android Auto", "VNC", "Android 虚拟桌面", "ICCOA CarLink test.carlink"), names) dialog.dismiss() selectHomeSource(activity, "ICCOA CarLink test.carlink") assertTrue(buttons(activity).single { it.text == "连接" }.isEnabled) diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/ProjectionRoutingTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/ProjectionRoutingTest.kt index 36e2943..4bdad11 100644 --- a/common/src/test/java/com/shilapi/xcertplay/projection/ProjectionRoutingTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/projection/ProjectionRoutingTest.kt @@ -17,7 +17,7 @@ class ProjectionRoutingTest { private class Source(val name: String, val touches: Int, val desktop: Boolean = false) : ProjectionSession { override val descriptor = SourceDescriptor(name, name) override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.FRAMEBUFFER), touches, pointerInput = desktop, keyboardInput = desktop) - override val inputReady = touches > 0 + override var inputReady = touches > 0 val inputs = mutableListOf() override fun input(event: ProjectionInput): Boolean { inputs += event; return true } val reports = mutableListOf>() @@ -85,6 +85,28 @@ class ProjectionRoutingTest { WebSession.audio.createRoute().use { assertFalse(it.microphoneEnabled) } } finally { WebSession.audio.detach(viewer) } } + @Test fun cancellationWhileVideoAndInputArePausedStillChecksOwnershipAndEpoch() { + val app = RuntimeEnvironment.getApplication() + WebListenSettings.saveHttpsEnabled(app, false); WebSession.start(app) + val source = Source("paused", 1) + projectionSessions.adopt(source, emptySet()) + val epoch = WebSession.beginVideo(320, 180) + WebSession.setVideoActive(epoch, true) + val version = WebSession.mediaSnapshot() + val owner = Any(); WebSession.touch.claim(owner) + assertTrue(WebSession.mediaTouch(owner, version.sessionId, epoch, + listOf(ProjectionContact(0, .5, .5, true)), 1)) + source.inputReady = false + WebSession.setVideoActive(epoch, false) + val before = source.inputs.size + assertFalse(WebSession.mediaInput(Any(), version.sessionId, epoch, ProjectionInput.Release, 2)) + assertFalse(WebSession.mediaInput(owner, version.sessionId, epoch - 1, ProjectionInput.Release, 2)) + assertEquals(before, source.inputs.size) + assertTrue(WebSession.mediaInput(owner, version.sessionId, epoch, ProjectionInput.Release, 2)) + assertEquals(ProjectionInput.Release, source.inputs.last()) + assertEquals(before + 1, source.inputs.size) + } + @Test fun desktopInputChecksLeaseBoundsCapabilitiesAndVideoEpoch() { val app = RuntimeEnvironment.getApplication() WebListenSettings.saveHttpsEnabled(app, false); WebSession.start(app) diff --git a/common/src/test/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSessionTest.kt b/common/src/test/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSessionTest.kt index 42305d4..7dca017 100644 --- a/common/src/test/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSessionTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/projection/plugin/RemotePluginSessionTest.kt @@ -44,6 +44,8 @@ class RemotePluginSessionTest { override fun connect(token: String, apiVersion: Int, host: IProjectionHost) { this.host = host; this.token = token; host.onInfo(token, info) } + val cancellations = LinkedBlockingQueue() + override fun cancelInput(token: String) { cancellations.offer(token) } override fun start(token: String, surface: Surface) { starts++; host.onState(token, ProjectionPlugin.READY, "Ready") } override fun touch(token: String, ids: IntArray, xs: DoubleArray, ys: DoubleArray, down: BooleanArray) { touches.offer(ids) } override fun setNightMode(token: String, night: Boolean) = Unit @@ -67,6 +69,24 @@ class RemotePluginSessionTest { while (!predicate() && System.nanoTime() < deadline) { shadowOf(Looper.getMainLooper()).idle(); Thread.sleep(5) } assertTrue("Timed out", predicate()) } + @Test fun nativeCancelIsCapabilityGatedAndLegacyPluginsReceiveOnlyRelease() { + for (native in listOf(false, true)) { + val peer = Peer().apply { info = PluginVideoInfo(640, 360, 5, nativeCancelInput = native).toBundle() } + val context = Binding(peer); val runtime = ProjectionRuntime(); val output = Output() + val session = RemotePluginSession(context, plugin, runtime, { true }, { _, _ -> output }) + runtime.adopt(session, setOf(ProjectionResource.DISPLAY)); session.activate(); until { session.inputReady } + assertTrue(runtime.cancelInput(session, ProjectionCancelReason.FOCUS_LOST)) + if (native) { + assertEquals(peer.token, peer.cancellations.poll(2, TimeUnit.SECONDS)) + assertTrue(peer.touches.isEmpty()) + } else { + assertArrayEquals(intArrayOf(), peer.touches.poll(2, TimeUnit.SECONDS)) + assertTrue(peer.cancellations.isEmpty()) + } + val stopped = runtime.stop(); until { peer.stops == 1 }; peer.acknowledge(); until { stopped.isDone }; stopped.join() + } + } + @Test fun touchAndStopWaitForAcknowledgementAndIgnoreStaleCallbacks() { val peer = Peer(); val context = Binding(peer); val runtime = ProjectionRuntime(); val output = Output() val session = RemotePluginSession(context, plugin, runtime, { true }, { _, _ -> output }) diff --git a/common/src/test/java/com/shilapi/xcertplay/web/TouchLeaseTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/TouchLeaseTest.kt index b48e535..9b64793 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/TouchLeaseTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/TouchLeaseTest.kt @@ -10,6 +10,20 @@ class TouchLeaseTest { private val owner = Any() private fun finger(id: Int = 0, x: Double = .5, down: Boolean = true) = AirPlayContact(id, x, .5, down) + @Test fun cancellationUsesDedicatedCallbackWithoutSynthesizingFingerUp() { + val reasons = mutableListOf() + val contacts = mutableListOf>() + val lease = TouchLease(cancelInput = { reasons += it; true }) { contacts += it; true } + lease.claim(owner); lease.touch(owner, listOf(finger()), 0) + lease.expire(1501) + assertEquals(listOf(com.shilapi.xcertplay.projection.ProjectionCancelReason.LEASE_EXPIRED), reasons) + assertEquals(1, contacts.size) + lease.touch(owner, emptyList(), 1502) // Real finger-up retains its original meaning. + assertEquals(2, contacts.size) + lease.drop(owner) + assertEquals(com.shilapi.xcertplay.projection.ProjectionCancelReason.DISCONNECTED, reasons.last()) + } + @Test fun onlyOwnerCanControlAndRelease() { val other = Any() assertTrue(lease.claim(owner)); assertFalse(lease.claim(other)) diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSourceTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSourceTest.kt new file mode 100644 index 0000000..d67dc99 --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/AndroidDesktopSourceTest.kt @@ -0,0 +1,265 @@ +package moe.feng.wheelplay.projection.desktop + +import android.graphics.SurfaceTexture +import android.view.Surface +import com.shilapi.xcertplay.media.CompressedVideoFrame +import com.shilapi.xcertplay.media.VideoCodec +import com.shilapi.xcertplay.projection.* +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.concurrent.* + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class AndroidDesktopSourceTest { + private val events = CopyOnWriteArrayList() + private val firstTouch = CountDownLatch(1) + private val back = CountDownLatch(1) + private val started = CountDownLatch(1) + private val finished = CountDownLatch(1) + private var touchGate: CountDownLatch? = null + private var closeFailure = false + private var startFailure = false + private var startError: DesktopException? = null + private var appExists = true + private var pendingConnection: CompletableFuture? = null + private var startedConfig: DesktopDisplayConfig? = null + private var outputConfig: DesktopDisplayConfig? = null + @Volatile private var available: SourceAvailability = SourceAvailability.Ready + private val connection = object : DesktopConnection { + override val uid = 2000 + override var capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE), 10, backButton = true) + override fun start(surface: Surface, component: String, config: DesktopDisplayConfig): DesktopDisplayInfo { + startedConfig = config + events += "start:$component"; started.countDown() + startError?.let { throw it } + if (startFailure) error("display rejected") + return DesktopDisplayInfo().apply { displayId = 12; width = config.width; height = config.height; dpi = config.dpi; layoutRevision = 1 } + } + override fun touch(contacts: List) { events += "touch:${contacts.size}"; firstTouch.countDown(); touchGate?.await(3, TimeUnit.SECONDS) } + override fun back() { events += "back"; back.countDown() } + override fun cancelInput() { events += "cancel" } + override fun healthy() = DesktopSessionState().apply { phase = DesktopSessionState.RUNNING } + override fun state() = healthy() + override fun close() { events += "release-display"; if (closeFailure) error("still owned") } + } + private val backend = object : DesktopBackend { + override fun probe() = available + override fun connect(): CompletableFuture { events += "bind"; return pendingConnection ?: CompletableFuture.completedFuture(connection) } + } + private val output = object : ProjectionVideoOutput { + override val surface = Surface(SurfaceTexture(0)) + override var hasFrame = false + override val hasPublishedFrame get() = hasFrame + override fun configure(codec: VideoCodec, codecData: ByteArray, requestKeyframe: () -> Unit) = error("Surface only") + override fun frame(formatRevision: Long, frame: CompressedVideoFrame, ptsUs: Long) = false + override fun framebuffer(snapshot: ProjectionFramebuffer.Snapshot) = false + override fun close() { events += "release-surface"; surface.release(); finished.countDown() } + } + private fun source() = AndroidDesktopSource(RuntimeEnvironment.getApplication(), backend, + { outputConfig = it; events += "surface"; output }, { appExists && it == "test.app/.Main" }).apply { selectApp("test.app/.Main") } + private fun ready(session: ProjectionSession) { + assertTrue(started.await(3, TimeUnit.SECONDS)) + output.hasFrame = true + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!session.inputReady && System.nanoTime() < deadline) Thread.yield() + assertTrue(session.inputReady) + } + @Test fun independentCancellationDoesNotSendAnUpSnapshotOrBack() { + val session = source().open().get() + try { + session.activate(); ready(session) + assertTrue(session.cancelInput(ProjectionCancelReason.FOCUS_LOST)) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!events.contains("cancel") && System.nanoTime() < deadline) Thread.yield() + assertTrue(events.contains("cancel")) + assertFalse(events.contains("touch:0")); assertFalse(events.contains("back")) + } finally { session.stop().get(3, TimeUnit.SECONDS) } + } + + @Test fun readOnlyDesktopReachesRunningWithoutEnablingInput() { + connection.capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE)) + val session = source().open().get() + try { + session.activate() + assertTrue(started.await(3, TimeUnit.SECONDS)) + output.hasFrame = true + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (session.state.phase != ProjectionPhase.RUNNING && System.nanoTime() < deadline) Thread.yield() + assertEquals(ProjectionPhase.RUNNING, session.state.phase) + assertFalse(session.inputReady) + assertFalse(session.touch(listOf(ProjectionContact(0, .5, .5, true)))) + } finally { session.stop().get(3, TimeUnit.SECONDS) } + } + + @Test fun sessionAdvertisesAndEnforcesNegotiatedInputCapabilities() { + connection.capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE), 2, backButton = false) + val session = source().open().get() + try { + session.activate(); ready(session) + assertEquals(2, session.capabilities.maxTouchContacts) + assertFalse(session.capabilities.backButton) + assertFalse(session.touch(List(3) { ProjectionContact(it, .5, .5, true) })) + assertFalse(session.input(ProjectionInput.Key("BrowserBack", true))) + assertTrue(session.touch(listOf(ProjectionContact(0, .5, .5, true)))) + assertTrue(firstTouch.await(3, TimeUnit.SECONDS)) + assertFalse(events.contains("back")) + } finally { session.stop().get(3, TimeUnit.SECONDS) } + } + + @Test fun unknownErrorRetainsDiagnosticsWithoutExposingThemAsUiCopy() { + startError = DesktopException(9001, "device diagnostic that must not become UI text") + val provider = source() + val session = provider.open().get() + session.activate() + assertTrue(finished.await(3, TimeUnit.SECONDS)) + assertEquals(9001, provider.lastError!!.code) + assertEquals(startError!!.diagnostic, provider.lastError!!.diagnostic) + assertEquals(RuntimeEnvironment.getApplication().getString(com.shilapi.xcertplay.shared.R.string.desktop_error_internal), provider.lastFailure) + assertFalse(provider.lastFailure!!.contains("device diagnostic")) + } + + @Test fun displaySettingsPersistAndInvalidStoredDimensionsFallBackToDefaults() { + val provider = source() + assertEquals(DesktopDisplayConfig(1280, 720, 160), provider.displayConfig()) + val custom = DesktopDisplayConfig(1080, 1920, 320) + provider.configureDisplay(custom) + assertEquals(custom, source().displayConfig()) + RuntimeEnvironment.getApplication().getSharedPreferences("android-desktop", 0) + .edit().putInt("width", Int.MAX_VALUE).apply() + assertEquals(DesktopDisplayConfig(), provider.displayConfig()) + assertTrue(events.isEmpty()) + output.close() + } + + @Test fun pendingSessionKeepsItsDisplayConfigWhenSettingsChange() { + val provider = source() + val original = DesktopDisplayConfig(1920, 1080, 240) + val next = DesktopDisplayConfig(720, 1280, 320) + provider.configureDisplay(original) + pendingConnection = CompletableFuture() + val opening = provider.open() + provider.configureDisplay(next) + pendingConnection!!.complete(connection) + val session = opening.get(3, TimeUnit.SECONDS) + try { + session.activate(); ready(session) + assertEquals(original, outputConfig) + assertEquals(original, startedConfig) + assertEquals(next, source().displayConfig()) + } finally { session.stop().get(3, TimeUnit.SECONDS) } + } + + @Test fun missingDependenciesDoNotStopTheCurrentSourceOrBindHelper() { + val runtime = ProjectionRuntime() + val old = object : ProjectionSession { + override val descriptor = SourceDescriptor("carplay", "CarPlay") + override val capabilities = ProjectionCapabilities(emptySet()) + override val inputReady = true + override fun touch(contacts: List) = true + override fun stop(): CompletableFuture = error("Must not stop current source") + } + runtime.adopt(old, emptySet()) + val provider = source(); runtime.register(provider) + for (reason in listOf("not installed", "not started", "permission denied", "unsupported")) { + available = SourceAvailability.Unavailable(reason) + assertTrue(runtime.switchTo(DesktopProtocol.SOURCE_ID).isCompletedExceptionally) + assertSame(old, runtime.snapshot()); assertTrue(events.isEmpty()) + } + available = SourceAvailability.Ready; appExists = false + assertTrue(runtime.switchTo(DesktopProtocol.SOURCE_ID).isCompletedExceptionally) + assertSame(old, runtime.snapshot()); assertTrue(events.isEmpty()) + output.close() + } + @Test fun activationWaitsForAdmissionAndFirstFrameAndStopsProducerBeforeSurface() { + val session = source().open().get(3, TimeUnit.SECONDS) + assertEquals(listOf("bind"), events) + assertFalse(session.inputReady) + session.activate(); ready(session) + assertFalse(session.capabilities.browserAudio); assertFalse(session.capabilities.browserMicrophone) + assertFalse(session.capabilities.keyboardInput); assertTrue(session.capabilities.backButton) + assertTrue(session.touch(listOf(ProjectionContact(2, .2, .8, true)))) + assertTrue(firstTouch.await(3, TimeUnit.SECONDS)) + assertTrue(session.input(ProjectionInput.Key("BrowserBack", true))) + assertTrue(back.await(3, TimeUnit.SECONDS)) + assertFalse(session.input(ProjectionInput.Key("a", true))) + session.stop().get(3, TimeUnit.SECONDS) + assertEquals(listOf("release-display", "release-surface"), events.takeLast(2)) + assertSame(session.stop(), session.stop()); assertFalse(session.inputReady) + assertFalse(session.touch(listOf(ProjectionContact(2, .2, .8, true)))) + } + @Test fun displayStartupFailureCleansPartialResourcesAndReportsFailure() { + startFailure = true + val provider = source() + val session = provider.open().get() + session.activate() + assertTrue(finished.await(3, TimeUnit.SECONDS)) + assertEquals(listOf("release-display", "release-surface"), events.takeLast(2)) + assertNotNull(provider.lastFailure) + assertFalse(session.inputReady) + } + @Test fun unconfirmedCleanupRetainsRuntimeOwnershipAndConsumerSurface() { + val runtime = ProjectionRuntime(); val provider = source(); runtime.register(provider) + val session = runtime.open(DesktopProtocol.SOURCE_ID).get() + ready(session); closeFailure = true + assertThrows(ExecutionException::class.java) { runtime.stop().get(3, TimeUnit.SECONDS) } + assertSame(session, runtime.snapshot()) + assertEquals(setOf(ProjectionResource.DISPLAY), runtime.resources()) + assertFalse(events.contains("release-surface")); assertFalse(runtime.inputReady(session)) + output.close() + } + @Test fun revokedAuthorizationStopsTheHelperAndVideo() { + val provider = source() + val session = provider.open().get() + session.activate(); ready(session) + available = SourceAvailability.Unavailable("permission revoked") + assertTrue(finished.await(3, TimeUnit.SECONDS)) + assertFalse(session.inputReady) + assertTrue(provider.lastError!!.diagnostic.contains("permission revoked")) + assertFalse(provider.lastFailure!!.contains("permission revoked")) + assertEquals(listOf("release-display", "release-surface"), events.takeLast(2)) + } + + @Test fun failedVideoConstructorCleanupCannotReleaseRuntimeOwnership() { + val attempted = CountDownLatch(1) + val provider = AndroidDesktopSource(RuntimeEnvironment.getApplication(), backend, { + attempted.countDown() + throw ProjectionCleanupException("GL worker still running") + }, { true }).apply { selectApp("test.app/.Main") } + val runtime = ProjectionRuntime(); runtime.register(provider) + val session = runtime.open(DesktopProtocol.SOURCE_ID).get() + assertTrue(attempted.await(3, TimeUnit.SECONDS)) + assertThrows(ExecutionException::class.java) { session.stop().get(3, TimeUnit.SECONDS) } + assertSame(session, runtime.snapshot()) + assertTrue(runtime.hasSession()); assertFalse(session.inputReady) + output.close() + } + + @Test fun saturatedInputQueueStopsTheProducerInsteadOfDroppingReleases() { + val gate = CountDownLatch(1); touchGate = gate + val provider = source() + val session = provider.open().get() + session.activate(); ready(session) + assertTrue(session.touch(listOf(ProjectionContact(0, .2, .8, true)))) + assertTrue(firstTouch.await(3, TimeUnit.SECONDS)) + try { + repeat(80) { session.touch(listOf(ProjectionContact(0, .3, .7, true))) } + assertFalse(session.inputReady) + assertNotNull(provider.lastFailure) + } finally { gate.countDown() } + session.stop().get(3, TimeUnit.SECONDS) + assertEquals(listOf("release-display", "release-surface"), events.takeLast(2)) + } + + @Test fun stopBeforeActivationDoesNotCreateDisplayOrSurface() { + val session = source().open().get() + session.stop().get(3, TimeUnit.SECONDS) + session.activate() + assertEquals(listOf("bind", "release-display"), events) + output.close() + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopBinderProtocolTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopBinderProtocolTest.kt new file mode 100644 index 0000000..a4a78c5 --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopBinderProtocolTest.kt @@ -0,0 +1,184 @@ +package moe.feng.wheelplay.projection.desktop + +import android.graphics.SurfaceTexture +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import android.view.Surface +import com.shilapi.xcertplay.projection.ProjectionContact +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Exercise generated AIDL proxy/stub marshalling instead of local-interface shortcuts. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class DesktopBinderProtocolTest { + private class Helper : IDesktopService.Stub() { + var offered = DesktopFeatures.SERVICE_SUPPORTED + var status = DesktopStatus.OK + var config: DesktopDisplayConfig? = null + var actual: DesktopDisplayInfo? = null + var enabled = 0L + var healthCalls = 0 + var omitState = false + var frame: DesktopTouchFrame? = null + var omitStatus = false + var backCalls = 0 + var inputEpoch = 0 + var touchFlags = 0 + var touchReplyWasNull = false + override fun hello(version: Int, hostFeatures: Long, requiredFeatures: Long): DesktopHandshakeResult { + val enabled = offered and hostFeatures + val serviceInfo = DesktopNegotiation.negotiate(version = version, requested = enabled, helperUid = 2000, required = requiredFeatures) + .apply { supportedFeatures = offered } + return DesktopHandshakeResult().apply { + status = if (omitStatus) null else this@Helper.status + info = if (this@Helper.status.code == 0) serviceInfo else null + } + } + override fun start(token: String, owner: IBinder, surface: Surface, request: DesktopStartConfig): DesktopStartResult { + config = DesktopDisplayConfig(request.width, request.height, request.dpi) + return DesktopStartResult().apply { + status = if (omitStatus) null else this@Helper.status + display = DesktopDisplayInfo().apply { displayId = 42; width = request.width; height = request.height; dpi = request.dpi; layoutRevision = 1 } + enabledFeatures = request.enabledWithin(offered) + actual = display + enabled = enabledFeatures + } + } + override fun touch(token: String, frame: DesktopTouchFrame) { this.frame = frame } + override fun back(token: String, layout: Int, inputEpoch: Int): DesktopStatus { + assertEquals(this.inputEpoch + 1, inputEpoch) + this.inputEpoch = inputEpoch + backCalls++; return status + } + private fun snapshot() = DesktopStateResult().apply { + status = if (omitStatus) null else this@Helper.status + state = if (omitState) null else DesktopSessionState().apply { + phase = DesktopSessionState.RUNNING; enabledFeatures = enabled + inputEpoch = this@Helper.inputEpoch; display = actual + } + } + override fun healthy(token: String, layout: Int): DesktopStateResult { healthCalls++; return snapshot() } + override fun getState(token: String) = snapshot() + override fun cancelInput(token: String, layout: Int, inputEpoch: Int): DesktopStatus { + assertEquals(this.inputEpoch + 1, inputEpoch); this.inputEpoch = inputEpoch; return status + } + override fun stop(token: String) = status + override fun destroy() = Unit + } + + private fun connect(helper: Helper, sessionFeatures: Long = DesktopFeatures.HOST_SUPPORTED): ShizukuDesktopBackend.Connection { + val remote = object : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (code == IBinder.FIRST_CALL_TRANSACTION + 2) { + helper.touchFlags = flags + helper.touchReplyWasNull = reply == null + } + return helper.asBinder().transact(code, data, reply, flags) + } + } + val proxy = IDesktopService.Stub.asInterface(remote) + assertNotSame(helper, proxy) + return ShizukuDesktopBackend.Connection(proxy, {}, sessionFeatures = sessionFeatures).apply { handshake() } + } + + @Test fun generatedProxyTransportsHandshakeDisplaySettingsTouchAndCodedFailures() { + val helper = Helper() + val connection = connect(helper) + assertEquals(2000, connection.uid) + assertEquals(10, connection.capabilities.maxTouchContacts) + assertTrue(connection.capabilities.backButton) + val surface = Surface(SurfaceTexture(0)) + try { + val config = DesktopDisplayConfig(1080, 1920, 320) + assertEquals(42, connection.start(surface, "test.app/.Main", config).displayId) + assertEquals(config, helper.config) + connection.touch(listOf(ProjectionContact(7, .25, .75, false))) + assertEquals(0, helper.frame!!.inputEpoch) + assertEquals(IBinder.FLAG_ONEWAY, helper.touchFlags and IBinder.FLAG_ONEWAY) + assertTrue(helper.touchReplyWasNull) + assertArrayEquals(intArrayOf(7), helper.frame!!.ids) + assertArrayEquals(floatArrayOf(.25f), helper.frame!!.xs, 0f) + assertArrayEquals(booleanArrayOf(false), helper.frame!!.down) + connection.back(); assertEquals(1, helper.backCalls) + connection.touch(emptyList()); assertEquals(1, helper.frame!!.inputEpoch) + connection.back(); connection.touch(emptyList()) + assertEquals(2, helper.frame!!.inputEpoch) + assertEquals(DesktopSessionState.RUNNING, connection.healthy().phase) + helper.status = DesktopStatus(DesktopErrorCode.LAYOUT_CHANGED.wire, "expected 1080x1920; received 1920x1080") + val error = assertThrows(DesktopException::class.java) { connection.healthy() } + assertEquals(DesktopErrorCode.LAYOUT_CHANGED, error.category) + assertEquals(helper.status.diagnostic, error.diagnostic) + helper.status = DesktopStatus(9001, "future helper failure") + connection.touch(emptyList()) // No per-frame business failure is returned. + assertEquals(9001, assertThrows(DesktopException::class.java) { connection.healthy() }.code) + } finally { surface.release() } + } + + @Test fun cancellationAdvancesEpochWithoutBackAndStateQueryDoesNotSendHeartbeat() { + val helper = Helper(); val connection = connect(helper) + val surface = Surface(SurfaceTexture(0)) + try { + connection.start(surface, "app/.Main", DesktopDisplayConfig()) + connection.cancelInput(); connection.touch(emptyList()) + assertEquals(1, helper.frame!!.inputEpoch); assertEquals(0, helper.backCalls) + val state = connection.state() + assertEquals(1, state.inputEpoch); assertEquals(0, helper.healthCalls) + connection.healthy(); assertEquals(1, helper.healthCalls) + helper.omitState = true + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, assertThrows(DesktopException::class.java) { connection.state() }.category) + } finally { surface.release() } + } + + @Test fun perSessionReadOnlyDowngradeControlsActualCapabilities() { + val helper = Helper(); val connection = connect(helper, sessionFeatures = DesktopFeatures.REQUIRED) + assertTrue(connection.capabilities.backButton) + val surface = Surface(SurfaceTexture(0)) + try { + connection.start(surface, "app/.Main", DesktopDisplayConfig()) + assertFalse(connection.capabilities.backButton); assertEquals(0, connection.capabilities.maxTouchContacts) + connection.cancelInput() // Unconditional lifecycle command also works for read-only sessions. + assertEquals(1, connection.healthy().inputEpoch) + assertThrows(DesktopException::class.java) { connection.back() } + assertThrows(DesktopException::class.java) { connection.touch(emptyList()) } + } finally { surface.release() } + } + + @Test fun negotiatedReadOnlyHelperDoesNotReceiveTouchOrBack() { + val helper = Helper().apply { offered = DesktopFeatures.REQUIRED } + val connection = connect(helper) + assertEquals(0, connection.capabilities.maxTouchContacts) + assertFalse(connection.capabilities.backButton) + assertEquals(DesktopErrorCode.UNSUPPORTED_CAPABILITY, assertThrows(DesktopException::class.java) { + connection.touch(listOf(ProjectionContact(0, .5, .5, true))) + }.category) + assertThrows(DesktopException::class.java) { connection.back() } + assertNull(helper.frame); assertEquals(0, helper.backCalls) + } + + @Test fun rejectedHandshakeRetainsErrorCodeAndDiagnosticAcrossBinder() { + val helper = Helper().apply { status = DesktopStatus(DesktopErrorCode.PROTOCOL_MISMATCH.wire, "incompatible helper") } + val error = assertThrows(DesktopException::class.java) { connect(helper) } + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, error.category) + assertEquals("incompatible helper", error.diagnostic) + } + + @Test fun missingStructuredStatusIsRejectedForHandshakeAndStart() { + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, assertThrows(DesktopException::class.java) { + connect(Helper().apply { omitStatus = true }) + }.category) + val helper = Helper() + val connection = connect(helper) + helper.omitStatus = true + val surface = Surface(SurfaceTexture(0)) + try { + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, assertThrows(DesktopException::class.java) { + connection.start(surface, "test.app/.Main", DesktopDisplayConfig()) + }.category) + } finally { surface.release() } + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfigTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfigTest.kt new file mode 100644 index 0000000..a2416a8 --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopDisplayConfigTest.kt @@ -0,0 +1,22 @@ +package moe.feng.wheelplay.projection.desktop + +import org.junit.Assert.assertThrows +import org.junit.Test + +class DesktopDisplayConfigTest { + @Test fun acceptsDisplayLimitsInEitherOrientation() { + DesktopDisplayConfig(320, 320, 72) + DesktopDisplayConfig(3840, 2160, 640) + DesktopDisplayConfig(2160, 3840, 160) + } + + @Test fun rejectsInvalidDimensionsDensityAndExcessivePixelCount() { + for ((width, height, dpi) in listOf( + Triple(0, 720, 160), Triple(1280, -1, 160), Triple(319, 720, 160), + Triple(1280, 3841, 160), Triple(3840, 2161, 160), Triple(3840, 3840, 160), + Triple(Int.MAX_VALUE, Int.MAX_VALUE, 160), Triple(1280, 720, 71), Triple(1280, 720, 641), + )) { + assertThrows(IllegalArgumentException::class.java) { DesktopDisplayConfig(width, height, dpi) } + } + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopErrorsTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopErrorsTest.kt new file mode 100644 index 0000000..0ab95fb --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopErrorsTest.kt @@ -0,0 +1,60 @@ +package moe.feng.wheelplay.projection.desktop + +import java.lang.reflect.InvocationTargetException +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class DesktopErrorsTest { + class PlatformApi(private val failure: Throwable) { + fun call(): Unit = throw failure + } + + private fun invoke(failure: Throwable) = PlatformApi::class.java.getMethod("call").invoke(PlatformApi(failure)) + + @Test fun reflectedPermissionFailuresKeepTheirCategoryCauseAndWireDiagnostic() { + for (operation in listOf(DesktopErrorCode.INPUT_FAILED, DesktopErrorCode.LAUNCH_FAILED)) { + val denied = SecurityException("INJECT_EVENTS permission denied by system") + val failure = assertThrows(DesktopException::class.java) { desktopOperation(operation) { invoke(denied) } } + assertEquals(DesktopErrorCode.PERMISSION_DENIED, failure.category) + assertSame(denied, failure.cause) + val wire = parcelRoundTrip(desktopServiceStatus { throw failure }, DesktopStatus.CREATOR) + assertEquals(DesktopErrorCode.PERMISSION_DENIED.wire, wire.code) + assertEquals(denied.toString(), wire.diagnostic) + } + } + + @Test fun nestedReflectionRetainsBusinessErrorsAndOperationSpecificFallback() { + val known = DesktopException(9001, "platform-specific diagnostic") + assertSame(known, assertThrows(DesktopException::class.java) { + desktopOperation(DesktopErrorCode.LAUNCH_FAILED) { invoke(InvocationTargetException(known)) } + }) + val cause = IllegalStateException("Activity launch rejected by system") + val failure = assertThrows(DesktopException::class.java) { + desktopOperation(DesktopErrorCode.LAUNCH_FAILED) { invoke(InvocationTargetException(cause)) } + } + assertEquals(DesktopErrorCode.LAUNCH_FAILED, failure.category) + assertSame(cause, failure.cause) + assertEquals(cause.toString(), failure.diagnostic) + } + + @Test fun directControlCallsUnwrapReflectionAndStillBoundDiagnostics() { + assertEquals(DesktopErrorCode.INVALID_ARGUMENT.wire, + desktopServiceStatus { invoke(IllegalArgumentException("invalid target")) }.code) + val status = desktopServiceStatus { invoke(SecurityException("denied ".repeat(300))) } + assertEquals(DesktopErrorCode.PERMISSION_DENIED.wire, status.code) + assertEquals(1024, status.diagnostic!!.length) + assertTrue(status.diagnostic!!.startsWith("java.lang.SecurityException: denied")) + } + + @Test fun fatalPlatformErrorsAreNotConvertedToRecoverableStatus() { + val fatal = AssertionError("fatal platform error") + assertSame(fatal, assertThrows(AssertionError::class.java) { + desktopServiceStatus { desktopOperation(DesktopErrorCode.INPUT_FAILED) { invoke(fatal) } } + }) + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopProtocolTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopProtocolTest.kt new file mode 100644 index 0000000..521b8e0 --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopProtocolTest.kt @@ -0,0 +1,181 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.BadParcelableException +import com.shilapi.xcertplay.projection.ProjectionContact +import com.shilapi.xcertplay.projection.VideoIngressKind +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class DesktopProtocolTest { + @Test fun structuredResultsPreserveNestedStatusAndNullableHandshakeInfo() { + val serviceInfo = DesktopNegotiation.negotiate(version = DesktopProtocol.VERSION, requested = DesktopFeatures.HOST_SUPPORTED, helperUid = 2000) + val success = DesktopHandshakeResult().apply { status = DesktopStatus.OK; info = serviceInfo } + val decoded = parcelRoundTrip(success, DesktopHandshakeResult.CREATOR) + assertEquals(success, decoded) + assertSame(DesktopStatus.OK, decoded.status) + val failed = DesktopHandshakeResult().apply { status = DesktopStatus(9001, "future error"); info = null } + assertEquals(failed, parcelRoundTrip(failed, DesktopHandshakeResult.CREATOR)) + val started = DesktopStartResult().apply { status = DesktopStatus.OK; display = DesktopDisplayInfo().apply { displayId = 42 } } + assertEquals(started, parcelRoundTrip(started, DesktopStartResult.CREATOR)) + val missing = parcelRoundTrip(DesktopStartResult(), DesktopStartResult.CREATOR) + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, assertThrows(DesktopException::class.java) { + missing.status.checkSuccess() + }.category) + } + + @Test fun structuredSizeHeadersSkipUnknownFieldsAndRejectInvalidOrIncompleteInfo() { + val info = DesktopNegotiation.negotiate(version = DesktopProtocol.VERSION, requested = DesktopFeatures.HOST_SUPPORTED, helperUid = 2000) + withParcel { parcel -> + info.writeToParcel(parcel, 0) + parcel.writeInt(123) // Simulate an appended future field within the declared object size. + val end = parcel.dataPosition() + parcel.setDataPosition(0); parcel.writeInt(end); parcel.setDataPosition(0) + assertEquals(info, DesktopServiceInfo.CREATOR.createFromParcel(parcel)) + assertEquals(0, parcel.dataAvail()) + } + withParcel { parcel -> + parcel.writeInt(4); parcel.setDataPosition(0) // Struct with all fields absent. + val empty = DesktopServiceInfo.CREATOR.createFromParcel(parcel) + assertEquals(-1, empty.uid) + assertThrows(DesktopException::class.java) { empty.validate() } + } + withParcel { parcel -> + parcel.writeInt(3); parcel.setDataPosition(0) + assertThrows(BadParcelableException::class.java) { DesktopServiceInfo.CREATOR.createFromParcel(parcel) } + } + } + + @Test fun handshakeIntersectsCapabilitiesAndPreservesLimitsAcrossParcel() { + val info = DesktopNegotiation.negotiate(DesktopProtocol.VERSION, DesktopFeatures.HOST_SUPPORTED or DesktopFeatures.KEYBOARD or (1L shl 60), 2000) + assertEquals(DesktopFeatures.HOST_SUPPORTED, info.enabledFeatures) + info.validate() + assertTrue(info.accepts(DesktopDisplayConfig(1080, 1920, 320))) + assertFalse(info.copyWith { maxSize = 1280 }.accepts(DesktopDisplayConfig(1920, 1080, 160))) + assertEquals(info, parcelRoundTrip(info, DesktopServiceInfo.CREATOR)) + } + + @Test fun optionalInputCanBeDisabledAndMalformedNegotiationIsRejected() { + val info = DesktopNegotiation.negotiate(DesktopProtocol.VERSION, DesktopFeatures.REQUIRED, 2000) + assertEquals(0, info.maxTouchContacts) + info.validate(DesktopFeatures.REQUIRED) + val capabilities = DesktopFeatures.projection(info.enabledFeatures, info.maxTouchContacts) + assertEquals(setOf(VideoIngressKind.SURFACE), capabilities.video) + assertEquals(0, capabilities.maxTouchContacts); assertFalse(capabilities.backButton) + assertFalse(capabilities.keyboardInput); assertFalse(capabilities.pointerInput) + assertFalse(capabilities.audioPlayback); assertFalse(capabilities.microphone) + assertFalse(capabilities.browserAudio); assertFalse(capabilities.browserMicrophone) + assertEquals(DesktopErrorCode.PROTOCOL_MISMATCH, assertThrows(DesktopException::class.java) { + DesktopNegotiation.negotiate(6, DesktopFeatures.HOST_SUPPORTED, 2000) + }.category) + assertEquals(DesktopErrorCode.UNSUPPORTED_CAPABILITY, assertThrows(DesktopException::class.java) { + DesktopNegotiation.negotiate(DesktopProtocol.VERSION, DesktopFeatures.TOUCH, 2000, required = 0) + }.category) + for (invalid in listOf(info.copyWith { uid = 12345 }, info.copyWith { enabledFeatures = enabledFeatures or DesktopFeatures.KEYBOARD }, + info.copyWith { maxTouchContacts = 10 }, info.copyWith { minSize = -1 }, info.copyWith { maxPixels = 0 })) { + assertThrows(DesktopException::class.java) { invalid.validate(DesktopFeatures.REQUIRED) } + } + } + + @Test fun supportedRequestedAndEnabledFeaturesCanDifferButRequiredFeaturesCannotDisappear() { + val info = DesktopNegotiation.negotiate(DesktopProtocol.VERSION, DesktopFeatures.HOST_SUPPORTED, 2000, + available = DesktopFeatures.REQUIRED) + assertEquals(DesktopFeatures.SERVICE_SUPPORTED, info.supportedFeatures) + assertEquals(DesktopFeatures.REQUIRED, info.enabledFeatures) + info.validate() + assertThrows(DesktopException::class.java) { + DesktopNegotiation.negotiate(DesktopProtocol.VERSION, DesktopFeatures.HOST_SUPPORTED, 2000, + required = DesktopFeatures.REQUIRED or DesktopFeatures.TOUCH, available = DesktopFeatures.REQUIRED) + } + val request = DesktopStartConfig().apply { + component = "app/.Main"; width = 720; height = 1280; dpi = 320 + requestedFeatures = DesktopFeatures.REQUIRED; requiredFeatures = DesktopFeatures.REQUIRED + } + assertEquals(DesktopFeatures.REQUIRED, request.enabledWithin(DesktopFeatures.HOST_SUPPORTED)) + assertEquals(request, parcelRoundTrip(request, DesktopStartConfig.CREATOR)) + request.requiredFeatures = DesktopFeatures.HOST_SUPPORTED + assertEquals(DesktopErrorCode.INVALID_ARGUMENT, assertThrows(DesktopException::class.java) { + request.enabledWithin(DesktopFeatures.HOST_SUPPORTED) + }.category) + } + + @Test fun actualDisplayAndStateSnapshotsRoundTripAndRejectUnexpectedLayout() { + val config = DesktopDisplayConfig(720, 1280, 320) + val display = DesktopDisplayInfo().apply { + displayId = 12; width = config.width; height = config.height; dpi = config.dpi; layoutRevision = 1 + } + display.validate(config) + val snapshot = DesktopSessionState().apply { + phase = DesktopSessionState.FAILED; inputEpoch = 5; enabledFeatures = DesktopFeatures.REQUIRED + this.display = display; failure = DesktopStatus(9001, "specific device diagnostic") + } + val response = DesktopStateResult().apply { status = DesktopStatus.OK; state = snapshot } + assertEquals(response, parcelRoundTrip(response, DesktopStateResult.CREATOR)) + display.rotation = 1 + assertEquals(DesktopErrorCode.LAYOUT_CHANGED, assertThrows(DesktopException::class.java) { display.validate(config) }.category) + } + + @Test fun audioRoutesRemainSeparateFromLocalAudioAndOtherInputs() { + val local = DesktopFeatures.projection(DesktopFeatures.VIDEO_ENCODED or DesktopFeatures.AUDIO_PLAYBACK or + DesktopFeatures.MICROPHONE or DesktopFeatures.NIGHT_MODE, 0) + assertTrue(local.audioPlayback); assertTrue(local.microphone); assertTrue(local.nightMode) + assertFalse(local.browserAudio); assertFalse(local.browserMicrophone) + val remote = DesktopFeatures.projection(DesktopFeatures.VIDEO_FRAMEBUFFER or DesktopFeatures.POINTER or DesktopFeatures.KEYBOARD, 0) + assertTrue(remote.pointerInput); assertTrue(remote.keyboardInput); assertFalse(remote.backButton) + val orphanRoute = DesktopFeatures.projection(DesktopFeatures.BROWSER_AUDIO or DesktopFeatures.BROWSER_MICROPHONE, 0) + assertFalse(orphanRoute.browserAudio); assertFalse(orphanRoute.browserMicrophone) + } + + @Test fun packedTouchRoundTripsWithEpochAndRemainsSmallerThanOriginalArrays() { + for (count in listOf(0, 1, 2, 10)) { + val frame = DesktopTouchFrame.from(List(count) { ProjectionContact(it, it / 10.0, 1.0 - it / 10.0, it % 2 == 0) }, inputEpoch = 42) + withParcel { old -> + withParcel { packed -> + old.writeString("same-session-token"); old.writeInt(frame.layout) + old.writeIntArray(frame.ids); old.writeFloatArray(frame.xs); old.writeFloatArray(frame.ys); old.writeBooleanArray(frame.down) + packed.writeString("same-session-token"); packed.writeTypedObject(frame, 0) + assertEquals("contacts=$count", old.dataSize() - 4, packed.dataSize()) + packed.setDataPosition(0); assertEquals("same-session-token", packed.readString()) + val decoded = packed.readTypedObject(DesktopTouchFrame.CREATOR)!! + assertEquals(frame.layout, decoded.layout) + assertEquals(42, decoded.inputEpoch) + assertArrayEquals(frame.ids, decoded.ids); assertArrayEquals(frame.xs, decoded.xs, 0f) + assertArrayEquals(frame.ys, decoded.ys, 0f); assertArrayEquals(frame.down, decoded.down) + assertEquals(0, packed.dataAvail()) + // A oneway touch has no reply Parcel at all; only the compact request is sent. + } + } + } + } + + @Test fun touchDecoderRejectsHugeCountsTruncationAndInvalidBooleansBeforeInjection() { + for (count in listOf(-1, 11, Int.MAX_VALUE, 1)) { + withParcel { parcel -> + parcel.writeInt(1); parcel.writeInt(0); parcel.writeInt(count); parcel.setDataPosition(0) + assertThrows(BadParcelableException::class.java) { DesktopTouchFrame.CREATOR.createFromParcel(parcel) } + } + } + withParcel { parcel -> + parcel.writeInt(1); parcel.writeInt(0); parcel.writeInt(1); parcel.writeInt(0) + parcel.writeFloat(.5f); parcel.writeFloat(.5f); parcel.writeInt(2); parcel.setDataPosition(0) + assertThrows(BadParcelableException::class.java) { DesktopTouchFrame.CREATOR.createFromParcel(parcel) } + } + } + + @Test fun serviceErrorsPreserveStableCodeAndDiagnosticsIncludingUnknownCodes() { + for (code in listOf(DesktopErrorCode.LAUNCH_FAILED.wire, 9001)) { + val wire = desktopServiceStatus { throw DesktopException(code, "launch detail") } + withParcel { parcel -> + parcel.writeTypedObject(wire, 0); parcel.setDataPosition(0) + val error = assertThrows(DesktopException::class.java) { parcel.readTypedObject(DesktopStatus.CREATOR)!!.throwIfError() } + assertEquals(code, error.code); assertTrue(error.diagnostic.contains("launch detail")) + assertEquals(if (code == 9001) DesktopErrorCode.UNKNOWN else DesktopErrorCode.LAUNCH_FAILED, error.category) + } + } + assertEquals(DesktopErrorCode.INVALID_ARGUMENT.wire, desktopServiceStatus { require(false) { "invalid input" } }.code) + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueueTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueueTest.kt new file mode 100644 index 0000000..bc040ba --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchQueueTest.kt @@ -0,0 +1,220 @@ +package moe.feng.wheelplay.projection.desktop + +import android.view.MotionEvent +import com.shilapi.xcertplay.projection.ProjectionContact +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class DesktopTouchQueueTest { + private fun frame(down: Boolean = true, epoch: Int = 0) = + DesktopTouchFrame.from(listOf(ProjectionContact(0, .5, .5, down)), inputEpoch = epoch) + private fun await(latch: CountDownLatch) = assertTrue(latch.await(3, TimeUnit.SECONDS)) + + @Test fun backCancelsGestureAndDropsQueuedAndLateOldTouches() { + val operationLock = Any() + val first = CountDownLatch(1); val next = CountDownLatch(1) + val events = CopyOnWriteArrayList() + val queue = DesktopTouchQueue( + process = { events += "touch:${it.inputEpoch}"; if (it.inputEpoch == 0) first.countDown() else next.countDown() }, + cancel = { events += "cancel" }, diagnose = {}, operationLock = operationLock, capacity = 2, + ) + queue.use { + queue.offer(frame()); await(first) + synchronized(operationLock) { + repeat(10) { queue.offer(frame()) } // Includes an overflow cancellation barrier. + queue.barrier(1) { events += "back" } + queue.offer(frame(false)) // Binder can deliver this old release after synchronous Back. + queue.offer(frame(epoch = 2)) // A future epoch is not authorized either. + queue.offer(frame(epoch = 1)) + } + await(next); queue.close() + assertEquals(listOf("touch:0", "cancel", "back", "touch:1"), events) + assertNull(queue.fault) + } + } + + @Test fun backInvalidatesDequeuedFramesAndCancellationBarriers() { + for (overflow in listOf(false, true)) { + val operationLock = Any(); val next = CountDownLatch(1); val startWorker = CountDownLatch(1) + val events = CopyOnWriteArrayList() + lateinit var workerThread: Thread + val worker = Executors.newSingleThreadExecutor { task -> + Thread { startWorker.await(); task.run() }.also { workerThread = it } + } + val queue = DesktopTouchQueue( + process = { events += "touch:${it.inputEpoch}"; next.countDown() }, + cancel = { events += "cancel" }, diagnose = {}, capacity = 2, + operationLock = operationLock, worker = worker, + ) + queue.use { + try { + synchronized(operationLock) { + // Three offers overflow to [cancel, frame] before the worker starts. + repeat(if (overflow) 3 else 1) { queue.offer(frame()) } + startWorker.countDown() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (workerThread.state != Thread.State.BLOCKED && System.nanoTime() < deadline) Thread.yield() + assertEquals(Thread.State.BLOCKED, workerThread.state) + queue.barrier(1) { events += "back" } + queue.offer(frame(epoch = 1)) + } + await(next); queue.close() + assertEquals(listOf("cancel", "back", "touch:1"), events) + } finally { startWorker.countDown() } + } + } + } + + @Test fun backRejectsRepeatedEpochAndDoesNotNavigateIfCancellationFails() { + var navigated = 0 + DesktopTouchQueue({}, {}, {}).use { queue -> + queue.barrier(1) { navigated++ } + for (epoch in listOf(0, 1, 3)) { + assertEquals(DesktopErrorCode.STALE_SESSION, assertThrows(DesktopException::class.java) { + queue.barrier(epoch) { navigated++ } + }.category) + } + queue.barrier(2) { navigated++ } + assertEquals(2, navigated) + } + DesktopTouchQueue({}, { error("cancel failed") }, {}).use { queue -> + val error = assertThrows(DesktopException::class.java) { queue.barrier(1) { navigated++ } } + assertSame(queue.fault, error) + assertEquals(2, navigated) + } + } + + @Test fun failedFinalUpCancelsWithoutWaitingForAnotherFrameAndNextGestureWorks() { + val actions = CopyOnWriteArrayList() + val cancel = CountDownLatch(1); val nextUp = CountDownLatch(1) + val rejectUp = AtomicBoolean(true) + val touch = DesktopTouch(DesktopDisplayConfig()) { + if (it.actionMasked == MotionEvent.ACTION_UP && rejectUp.compareAndSet(true, false)) + throw DesktopException(DesktopErrorCode.INPUT_FAILED, "transient UP rejection") + actions += it.actionMasked + if (it.actionMasked == MotionEvent.ACTION_CANCEL) cancel.countDown() + if (it.actionMasked == MotionEvent.ACTION_UP) nextUp.countDown() + } + val queue = DesktopTouchQueue({ touch.update(it.ids, it.xs, it.ys, it.down) }, touch::cancel, {}) + queue.use { + queue.offer(frame()); queue.offer(frame(false)) + await(cancel) + assertNull(queue.fault) + assertEquals(MotionEvent.ACTION_CANCEL, actions.last()) + queue.offer(frame()); queue.offer(frame(false)); await(nextUp) + assertEquals(2, actions.count { it == MotionEvent.ACTION_DOWN }) + assertNull(queue.fault) + } + } + + @Test fun permissionDeniedDuringCancellationRetainsItsCodeAndPreventsBack() { + val denied = DesktopException(DesktopErrorCode.PERMISSION_DENIED, "INJECT_EVENTS denied") + DesktopTouchQueue({}, { throw denied }, {}).use { queue -> + val status = desktopServiceStatus { queue.barrier(1) { fail("Back must not run") } } + assertEquals(DesktopErrorCode.PERMISSION_DENIED.wire, status.code) + assertTrue(status.diagnostic!!.contains("INJECT_EVENTS denied")) + assertSame(denied, queue.fault!!.cause) + } + } + + @Test fun cancellationFailureLatchesFaultAndDiscardsSubsequentFrames() { + val reported = CountDownLatch(1); val calls = AtomicInteger() + val queue = DesktopTouchQueue( + process = { calls.incrementAndGet(); throw DesktopException(DesktopErrorCode.INPUT_FAILED, "input rejected") }, + cancel = { error("cancel rejected") }, diagnose = { reported.countDown() }, + ) + queue.use { + queue.offer(frame()); await(reported) + assertEquals(DesktopErrorCode.INPUT_FAILED, queue.fault!!.category) + assertTrue(queue.fault!!.diagnostic.contains("cancel rejected")) + queue.offer(frame()); queue.close() + assertEquals(1, calls.get()) + } + } + + @Test fun repeatedInputFailuresAndFatalLayoutErrorsAreAvailableToHealthChecks() { + for ((code, expectedCalls) in listOf(DesktopErrorCode.INPUT_FAILED to 3, DesktopErrorCode.LAYOUT_CHANGED to 1, + DesktopErrorCode.PERMISSION_DENIED to 1)) { + val done = CountDownLatch(1); val calls = AtomicInteger(); val canceled = AtomicInteger() + lateinit var queue: DesktopTouchQueue + queue = DesktopTouchQueue( + process = { calls.incrementAndGet(); throw DesktopException(code, "rejected") }, + cancel = { canceled.incrementAndGet() }, diagnose = { if (queue.fault != null) done.countDown() }, + ) + queue.use { + repeat(4) { queue.offer(frame()) } + await(done); queue.close() + assertEquals(code, queue.fault!!.category) + assertEquals(expectedCalls, calls.get()); assertEquals(expectedCalls, canceled.get()) + } + } + } + + @Test fun overloadedQueueAdmitsWithoutWaitingAndCancelsBeforeTheLatestRelease() { + val entered = CountDownLatch(1); val gate = CountDownLatch(1); val ended = CountDownLatch(1) + val actions = CopyOnWriteArrayList() + val first = AtomicBoolean(true) + val touch = DesktopTouch(DesktopDisplayConfig()) { actions += it.actionMasked } + val queue = DesktopTouchQueue( + process = { + touch.update(it.ids, it.xs, it.ys, it.down) + if (first.compareAndSet(true, false)) { entered.countDown(); gate.await(3, TimeUnit.SECONDS) } + if (it.ids.isEmpty()) ended.countDown() + }, cancel = touch::cancel, diagnose = {}, capacity = 2, + ) + queue.use { + try { + queue.offer(frame()); await(entered) + CompletableFuture.runAsync { + repeat(100) { queue.offer(frame()) } + queue.offer(DesktopTouchFrame.from(emptyList())) + }.get(1, TimeUnit.SECONDS) + gate.countDown(); await(ended); queue.close() + assertTrue(actions.contains(MotionEvent.ACTION_CANCEL)) + assertTrue(actions.last() in listOf(MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP)) + assertNull(queue.fault) + } finally { gate.countDown() } + } + } + + @Test fun stopDiscardsWorkWaitingForTheDisplayLockAndLateFrames() { + val operationLock = Any(); val calls = AtomicInteger() + val queue = DesktopTouchQueue({ calls.incrementAndGet() }, {}, {}, operationLock = operationLock) + synchronized(operationLock) { + queue.offer(frame()) + // Simulate display release under the same lock; even an already popped frame must not inject. + queue.close() + } + queue.offer(frame(false)); queue.close() + assertEquals(0, calls.get()) + } + + @Test fun invalidAndStaleFramesDoNotPoisonTheNextValidFrame() { + val done = CountDownLatch(1); val canceled = AtomicInteger(); val calls = AtomicInteger() + val queue = DesktopTouchQueue( + process = { + when (calls.incrementAndGet()) { + 1 -> throw IllegalArgumentException("invalid coordinates") + 2 -> throw DesktopException(DesktopErrorCode.STALE_SESSION, "stale layout") + else -> done.countDown() + } + }, cancel = { canceled.incrementAndGet() }, diagnose = {}, + ) + queue.use { + repeat(3) { queue.offer(frame()) }; await(done); queue.close() + assertNull(queue.fault); assertEquals(0, canceled.get()) + } + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchTest.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchTest.kt new file mode 100644 index 0000000..2eb23ea --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/DesktopTouchTest.kt @@ -0,0 +1,68 @@ +package moe.feng.wheelplay.projection.desktop + +import android.view.MotionEvent +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class DesktopTouchTest { + private data class Event(val action: Int, val index: Int, val ids: List, val x: Float, val y: Float) + private val events = mutableListOf() + private val touch = DesktopTouch(DesktopDisplayConfig()) { event -> + events += Event(event.actionMasked, event.actionIndex, (0 until event.pointerCount).map { event.getPointerId(it) }, event.x, event.y) + } + @Test fun customLandscapeAndPortraitDisplaysMapNormalizedCoordinatesToTheirOwnBounds() { + for (config in listOf(DesktopDisplayConfig(1920, 1080, 240), DesktopDisplayConfig(720, 1280, 320))) { + val positions = mutableListOf>() + val custom = DesktopTouch(config) { positions += it.x to it.y } + custom.update(intArrayOf(0), floatArrayOf(0f), floatArrayOf(0f)) + assertEquals(0f to 0f, positions.first()) + custom.update(intArrayOf(0), floatArrayOf(.5f), floatArrayOf(.5f)) + assertEquals((config.width - 1) * .5f to (config.height - 1) * .5f, positions.last()) + custom.update(intArrayOf(0), floatArrayOf(1f), floatArrayOf(1f), booleanArrayOf(false)) + assertEquals((config.width - 1).toFloat() to (config.height - 1).toFloat(), positions.last()) + } + } + @Test fun stablePointerIdsSurviveReorderingAndLifts() { + touch.update(intArrayOf(7), floatArrayOf(1f), floatArrayOf(1f)) + assertEquals(MotionEvent.ACTION_DOWN, events.first().action) + assertEquals(1279f, events.first().x); assertEquals(719f, events.first().y) + touch.update(intArrayOf(2, 7), floatArrayOf(.2f, .7f), floatArrayOf(.3f, .8f)) + val down = events.single { it.action == MotionEvent.ACTION_POINTER_DOWN } + assertEquals(listOf(7, 2), down.ids); assertEquals(1, down.index) + touch.update(intArrayOf(2), floatArrayOf(.4f), floatArrayOf(.5f)) + val up = events.single { it.action == MotionEvent.ACTION_POINTER_UP } + assertEquals(listOf(7, 2), up.ids); assertEquals(0, up.index) + touch.update(intArrayOf(), floatArrayOf(), floatArrayOf()) + assertEquals(MotionEvent.ACTION_UP, events.last().action) + assertEquals(listOf(2), events.last().ids) + } + @Test fun upUsesFinalCoordinatesEvenWithoutAnIntermediateMove() { + touch.update(intArrayOf(0), floatArrayOf(0f), floatArrayOf(0f)) + touch.update(intArrayOf(0), floatArrayOf(1f), floatArrayOf(1f), booleanArrayOf(false)) + assertEquals(MotionEvent.ACTION_UP, events.last().action) + assertEquals(1279f, events.last().x); assertEquals(719f, events.last().y) + } + @Test fun cancellationReleasesAllContactsOnceAndNextGestureStartsFresh() { + touch.update(intArrayOf(1, 3), floatArrayOf(.1f, .3f), floatArrayOf(.2f, .4f)) + touch.cancel(); touch.cancel() + assertEquals(1, events.count { it.action == MotionEvent.ACTION_CANCEL }) + assertEquals(listOf(1, 3), events.last().ids) + touch.update(intArrayOf(4), floatArrayOf(.5f), floatArrayOf(.5f)) + assertEquals(MotionEvent.ACTION_DOWN, events[events.size - 2].action) + } + @Test fun malformedCoordinatesAndDuplicateIdsNeverReachAndroid() { + for (input in listOf(floatArrayOf(Float.NaN), floatArrayOf(Float.POSITIVE_INFINITY), floatArrayOf(1.1f))) { + assertThrows(IllegalArgumentException::class.java) { touch.update(intArrayOf(0), input, floatArrayOf(0f)) } + } + assertThrows(IllegalArgumentException::class.java) { + touch.update(intArrayOf(1, 1), floatArrayOf(0f, 0f), floatArrayOf(0f, 0f)) + } + assertThrows(IllegalArgumentException::class.java) { touch.update(intArrayOf(32), floatArrayOf(0f), floatArrayOf(0f)) } + assertTrue(events.isEmpty()) + } +} diff --git a/common/src/test/java/moe/feng/wheelplay/projection/desktop/ParcelTestSupport.kt b/common/src/test/java/moe/feng/wheelplay/projection/desktop/ParcelTestSupport.kt new file mode 100644 index 0000000..d0ab50e --- /dev/null +++ b/common/src/test/java/moe/feng/wheelplay/projection/desktop/ParcelTestSupport.kt @@ -0,0 +1,19 @@ +package moe.feng.wheelplay.projection.desktop + +import android.os.Parcel +import android.os.Parcelable + +/** Parcel is not AutoCloseable on the tested Android API; always recycle it explicitly. */ +internal inline fun withParcel(block: (Parcel) -> T): T { + val parcel = Parcel.obtain() + return try { block(parcel) } finally { parcel.recycle() } +} + +internal fun parcelRoundTrip(value: T, creator: Parcelable.Creator): T = withParcel { parcel -> + parcel.writeTypedObject(value, 0) + parcel.setDataPosition(0) + requireNotNull(parcel.readTypedObject(creator)) +} + +internal fun DesktopServiceInfo.copyWith(change: DesktopServiceInfo.() -> Unit): DesktopServiceInfo = + parcelRoundTrip(this, DesktopServiceInfo.CREATOR).apply(change) diff --git a/docs/ANDROID_DESKTOP.md b/docs/ANDROID_DESKTOP.md new file mode 100644 index 0000000..40ed0ff --- /dev/null +++ b/docs/ANDROID_DESKTOP.md @@ -0,0 +1,146 @@ +# Android 虚拟桌面(实验性) + +本实现对应 [Issue #40](https://github.com/fython/wheelplay/issues/40) 的首版软件链路。**尚未完成 P0 非 root 真机可行性验证,不能视为 Issue 全部验收通过。** Android 10+ 是代码门槛,不是已验证支持范围。直接 ADB 后端、VDM、桌面 Launcher、会话内动态旋转/DPI、浏览器音频/麦克风和键盘输入均未实现。 + +## 使用 + +1. 在宿主手机安装并启动 [Shizuku](https://shizuku.rikka.app/download/),需要服务 API 13 或更高。Android 11+ 可用无线调试启动,旧系统需要电脑;非 root 模式重启后通常要重新启动 Shizuku。 +2. 在 WheelPlay 打开“输入源 → Android 虚拟桌面”。卡片只导航,不切换当前输入源。 +3. 点“授权 WheelPlay”。拒绝过授权时,在 Shizuku 管理界面重新授权。只在用户点击时请求权限,不自动重试或启动投影。 +4. 点击画面参数按钮设置宽度、高度和 DPI(可恢复默认值);设置会保存并在下次启动时生效,不改变当前会话。点“选择可启动的本机应用”,再点“启动虚拟桌面”。只列出 PackageManager 可查询、已启用且导出的 Launcher Activity。 +5. 从已经配对的浏览器查看画面,使用单指/多指触控与左下角“返回”按钮。返回按钮在全屏模式仍可用,不意味着支持实体键盘或中文输入法。 +6. 点二级页或首页的停止按钮释放虚拟桌面。出现“无法确认已完整释放”时,资源所有权保留,不能继续切源;重启 WheelPlay 恢复。 + +缺少 Shizuku、服务未启动、未授权、API/身份不满足要求、所选应用被卸载或禁用时,切源前的只读检查失败,不停止当前 CarPlay/Android Auto 会话。具体设备的显示创建、应用启动和输入权限只能在显式启动后验证;这些失败会显示原因并清理新会话,不保证恢复已停止的旧输入源。 + +## 能力和限制 + +- 单虚拟屏,默认 **1280 × 720、160 dpi、布局版本 1**;可设置宽高各 320–3840 像素、总像素数不超过 8,294,400、DPI 为 72–640。横屏、竖屏均可配置。参数在连接开始时固定,并用于输出 Surface、虚拟显示、触控映射和布局校验。独立画面,不镜像手机主屏,不使用 `AUTO_MIRROR`/`SECURE`/`ALWAYS_UNLOCKED`。 +- 主进程拥有公共离屏 Surface,经 Binder 传句柄;助手只拥有 producer 和输入状态。复用现有有界、合并帧的 JPEG/WebP 管线,**本源没有 WebRTC 编码器**,其他输入源的 WebRTC 行为不变。 +- 分开记录显示创建、Surface 首帧与浏览器图像发布。创建成功不会直接开放输入。首帧超时会失败;“图像输出已就绪”只说明收到并发布图像,不能自动判断应用内容是否正确。 +- 部分受保护内容可能输出黑帧,目前不能可靠识别这类画面的应用语义。禁止副屏/DRM 应用不在支持范围,不能以收到黑帧宣称该应用通过验收。 +- 声音使用手机原有行为;能力声明关闭音频播放、浏览器音频、麦克风、鼠标、键盘和 Home。不会创建第二套 Android 或隔离应用数据;单任务应用可能复用/移动主屏任务。 +- 显示旋转、DPI 或尺寸变化时取消触点并停止会话,要求重新连接;首版不做动态坐标重映射。锁屏行为、手机同时操作和输入法显示均需实测,不强制解锁设备。 +- 应用启动采用指定 displayId 的 `startActivityAsUser`,以宿主用户启动;返回值错误或两秒内未在目标屏发现所选应用的任务会中止。后续任务仍可能被系统/应用重定向,不保证任意应用留在副屏。销毁屏幕时请求 `DESTROY_CONTENT_ON_REMOVAL`,不对整个应用执行 force-stop。 + +## 进程、权限和清理 + +`DesktopBackend` / `DesktopConnection` 是 Android 平台层接口,将可用性、连接、身份能力与停止同 Shizuku 解耦;共享 `ProjectionSourceProvider` 不暴露 Binder/ADB 类型。 + +- 固定依赖 `dev.rikka.shizuku:api` 和 `provider` **13.1.5**。只接受实际 UID 2000(shell)或 0(root),状态页展示实际身份;二者不等价。 +- `IDesktopService` 协议版本 7,仅包含协商、带尺寸/DPI 的显示启动、触控、返回、健康检查、停止和销毁。主进程与助手共用参数范围校验,助手在创建显示前再次校验。不接收命令行、任意 Intent 或任意系统调用。 +- 每次连接使用独立 Shizuku tag,携带应用版本,避免旧绑定回调解除新会话。设置 `daemon(false)`,正常停止同时等待助手 Binder 死亡,按 UserService 协议实现销毁事务 `16777115` 和进程退出。 +- 每个入口验证宿主 UID;每个会话绑定随机 token、客户端 Binder 和布局版本。输入必须显式设置属于本助手且大于 0 的 displayId,设置失败即拒绝,绝不回退到默认显示屏。 +- 主进程输入工作队列上限 64,满队列即停止会话,不静默丢弃抬手;公共浏览器控制租约负责失焦、断网、过期、切源时释放触点。返回键的按下/抬起在助手同一调用内完成。 +- 正常停止:拒绝新输入 → 清空未执行输入并等待正在执行调用 → 助手取消触点、销毁显示/任务、释放收到的 Surface 副本 → 助手退出 → 主进程关闭 Surface/GL → runtime 释放资源。无法确认任何步骤时保留资源所有权。 +- 主进程 Binder 死亡立即触发助手清理/退出;健康检查续期独立看门狗(10 秒,未启动助手 30 秒)。Shizuku/助手死亡或授权失效由会话监测触发清理,不依赖 Activity 销毁。 +- UserService 不是常规应用进程。显示使用 shell 包上下文创建 `DisplayManager`;启动 Activity 走无应用线程 caller 的系统接口;隐藏 API 仅在 shell/root 助手进程中反射调用。接口变化/厂商拒绝均显式失败。 + +## Binder 协议 + +虚拟桌面实现与 AIDL 协议统一位于 `moe.feng.wheelplay.projection.desktop`;同名测试使用相同包层级。Shizuku UserService 通过新包下的 `ShizukuDesktopUserService` 类绑定。 + +握手请求传协议版本、宿主请求的功能位和必需功能位,响应 `DesktopHandshakeResult` 包含状态与 `DesktopServiceInfo`:助手 UID、协议版本、助手支持位、协商启用位、最大触点数、宽高/DPI/总像素范围。主进程验证返回值后,将协商结果转换为公共 `ProjectionCapabilities`;会话及助手均拒绝未启用的输入操作。功能位使用固定数值,不使用 enum ordinal;未知可选功能位忽略,启用位可以是支持与请求交集的子集;缺少客户端必需能力或基础 Surface 视频/屏幕配置能力时明确失败。同一 APK 内仍要求协议版本完全匹配;同一版本内通过功能位协商可选能力。 + +| 功能位 | 语义 | +| --- | --- | +| 0 / 1 / 2 | Surface / 编码视频 / framebuffer 视频输入 | +| 3 | 触控,数量另由 `maxTouchContacts` 表达 | +| 4 / 5 | 鼠标指针及滚轮 / 键盘 | +| 6 / 7 | 独立 Back / 夜间模式 | +| 8 / 9 | 源管理的本机音频播放 / 麦克风采集 | +| 10 / 11 | 浏览器音频 / 浏览器麦克风路由,分别依赖位 8 / 9 | +| 12 / 13 | 启动时配置尺寸及 DPI / 会话内动态调整显示 | +| 14 | Home,独立于 Back 和通用键盘 | + +当前双方仅声明 **0、3、6、12**,最多 10 个触点。其余位仅定义语义,未实现对应传输和控制方法,也不会因为本机应用自行播放声音而声明音频管线可用。能力声明表示已实现的协议功能,不保证厂商系统或具体应用兼容。 + +`oneway void touch(token, DesktopTouchFrame)` 不返回逐帧状态。Binder 入口仅校验调用者、会话/布局标识并快速入队,不等待显示锁或系统输入注入;握手、启动、返回、健康检查和停止仍使用同步结果。帧是完整触点快照:缺席 ID 释放、`down=false` 携带最终抬起坐标,空帧释放全部触点;坐标相对画面内容归一化。手写 Parcelable 编码为布局版本、输入代次、触点数,再逐个写入 ID/x/y/down 基本类型,避免每个触点的 Parcelable 标记或类名。解码在分配数组前限制数量并检查剩余长度。 + +v6 为输入代次增加 4 字节;触控请求相较最初的四个平行数组仍减少 4 字节,并且完全不创建响应 Parcel;生成的 Proxy/Stub 测试验证 `FLAG_ONEWAY` 和空响应。请求体积由 0/1/2/10 触点的 Parcel 序列化回归验证,不等同于真机 Binder 吞吐或延迟基准。 + +助手拥有独立的单线程触控队列,最多保留 64 个待处理项。过载时丢弃积压帧,先取消当前手势,再处理最新快照;不会单独丢掉抬手后保留旧触点。无效帧/旧会话帧丢弃,瞬时输入失败先取消当前手势并继续下一帧;取消失败、连续三次注入失败、权限或显示故障会锁存会话错误,拒绝后续触控,并由健康检查报告。故障时不续期看门狗,宿主按原流程清理。发送端原有 64 项队列限制仍保留;Binder 自身缓冲区限制及传输异常仍由系统处理。 + +Back 保留同步状态响应,并作为输入代次屏障:客户端每次 Back 将代次递增,助手在同一显示锁下清空旧队列、取消当前手势后再注入返回键。触控快照携带代次,已出队但等待显示锁的旧帧、旧取消屏障以及迟到的单向 Binder 请求均被丢弃;只有 Back 完成后的新代次可以继续输入。代次必须逐次递增,取消失败时不执行 Back。 + +触控处理和显示释放共用锁。同步 `stop` 即使先于尚未执行的单向调用到达,也会关闭队列、清空待处理项并取消手势;已取出但还未获得显示锁的帧也无法在释放后注入。单向提交成功只表示事务已提交,不保证触控已执行。 + +### Structured 与自定义 Parcelable + +`DesktopServiceInfo`、`DesktopHandshakeResult` 和 `DesktopStartResult` 使用 structured parcelable:字段、缺省值和可空性声明在 `.aidl` 中,由编译器生成 Java 类、`CREATOR` 和序列化实现。Kotlin 通过 `apply` 构建响应,`DesktopNegotiation` 和扩展函数负责能力协商、参数范围及响应校验。生成类型是可变字段对象,构建完成后在会话内按只读快照使用。 + +生成器使用对象长度头,能够跳过未知尾字段并为缺失字段提供缺省值;这不代替业务校验。缺少 `status` 视为协议错误,缺少版本/UID/必要显示范围的握手结果也会被拒绝。structured parcelable 在 v5 引入;v6 引入触控输入代次及 Back 屏障;当前 **v7** 使用 structured 启动配置、实际显示信息和会话状态响应,新增必需能力协商、独立取消和只读状态查询,严格拒绝 v6 等旧版本;未启用 Stable AIDL 的接口冻结和兼容性检查。 + +`DesktopTouchFrame` 和 `DesktopStatus` 继续使用自定义 Parcelable。它们的 `.aidl` 只声明类型,字段和读写实现分别位于同名 Kotlin 类:前者保留紧凑触控格式及解码前长度校验,后者保留仅失败时传输诊断及成功单例。生成类不应提交到源码或再由同名 Kotlin 类实现。测试覆盖 structured 类型及嵌套自定义状态的往返、未知尾字段、无效长度头和缺失状态拒绝。 + +`DesktopStatus.code == 0` 表示成功;启动结果还包含实际显示信息与本次会话启用能力。控制调用的业务失败返回固定错误码和最多 1024 字符的独立诊断;隐藏 API 的反射异常先逐层解包,按真实异常分类并保留实际诊断,本地异常链保留原始原因,致命 Error 不转换为普通状态;单向触控的瞬时失败只写助手日志,会话故障通过健康检查返回错误码和诊断,不依赖隐藏异常 API。宿主在 `lastError` 中保留原始错误码(包括未知新码)、诊断和本地异常原因,并写入 `AndroidDesktop` Logcat 日志;UI 只按错误类别显示本地化提示,不解析或直接展示诊断。Binder 断连、权限异常和损坏 Parcel 仍使用 Android 的标准传输异常;Shizuku 保留的 `destroy` 事务保持原签名。 + +| 错误码 | 名称 | +| --- | --- | +| 1 | PROTOCOL_MISMATCH | +| 2 | PERMISSION_DENIED | +| 3 | INVALID_ARGUMENT | +| 4 | INVALID_STATE | +| 5 | UNSUPPORTED_CAPABILITY | +| 6 | APP_UNAVAILABLE | +| 7 | DISPLAY_FAILED | +| 8 | LAUNCH_FAILED | +| 9 | STALE_SESSION | +| 10 | LAYOUT_CHANGED | +| 11 | INPUT_FAILED | +| 12 | CLEANUP_FAILED | +| 13 | INTERNAL | +| 14 | DISCONNECTED | +| 15 | BIND_TIMEOUT | +| 16 | NO_FRAME | +| 17 | INPUT_QUEUE_FULL | + +每个助手只握手一次、启动一次,会话参数不可变。`healthy` 返回 `DesktopStateResult`,成功确认布局并续期看门狗,失败不续期;`getState` 使用相同状态快照但从不续期看门狗;`stop` 释放后幂等,成功只确认 producer 释放。宿主仍需调用 `destroy` 并等待 Binder 死亡,才能确认助手退出并释放 consumer Surface。 + +## 验证与设备记录 + +自动化覆盖:依赖缺失保留现有会话、延迟激活与首帧输入门控、配置保存/非法值回退、连接期间配置快照、自定义横竖屏触控边界、多指索引和边界坐标、取消手势、拒绝非法坐标/重复 ID、独立 Back 能力、旧会话拒绝、部分启动失败清理,以及未确认清理时保留 Surface/资源。UI 回归检查独立二级页和原输入源导航;现有浏览器回归覆盖公共租约、图像与桌面输入。 + +v6 输入源及首页导航回归共 100 项通过,新增覆盖 Back 前清空积压触控、迟到/已出队的旧触控与取消屏障、重复代次拒绝、取消失败阻止返回,以及真实反射调用的权限分类、嵌套异常、诊断往返和致命错误传播。 + +2026-10-26 云环境软件验证(Temurin 25.0.1):`shared` 479 项、`common` 435 项单元测试全部通过,`npm test`、`npm run check`、Android lint 和 arm64 Debug APK 构建通过。可复现的 Gradle 目标: + +```sh +./gradlew :shared:testDebugUnitTest :common:testDebugUnitTest :mobile:lintDebug :mobile:assembleDebug -Pandroid.injected.build.abi=arm64-v8a +``` + +**真机结果:未测试。** 完成以下记录后才能判断支持范围,不能用单元测试代替 P0: + +| 项目 | 记录内容 / 验收动作 | +| --- | --- | +| 设备 | 厂商、型号、系统/API、是否 root、厂商额外权限/后台设置 | +| Shizuku | 应用/服务版本、启动方式、实际 UID;至少一台非 root shell 模式设备 | +| 图像 | 默认 1280×720/160 dpi、自定义横屏/竖屏尺寸与 DPI、保存后重新启动;浏览器版本、首帧时间、JPEG/WebP、延迟、CPU/内存和连续运行时长 | +| 输入 | 单指、两指交替抬起、10 指、边缘坐标、返回;手机主屏不得收到注入 | +| 主屏与任务 | 主屏同时使用、同应用已运行/单任务、弹窗、权限页、IME、应用退出后的任务去向 | +| 布局 | 应用请求旋转、DPI/尺寸变化、屏幕休眠、锁屏/解锁;当前实现应安全停止或明确报告限制 | +| 失效 | 浏览器失焦/断网、控制租约失效、慢浏览器、切 CarPlay/AA/VNC;无卡住触点 | +| 清理 | 停止、杀助手、停 Shizuku、撤销授权、杀宿主;用 `dumpsys display`/进程列表检查无残留显示/助手 | +| 恢复 | 重启手机、更新 APK(含相同 versionCode 的调试重装)、重新授权、快速失败后再次启动 | +| 受限应用 | 禁副屏、受保护黑屏、任务重定向按不支持记录;不能标为成功 | + +P0 真机验证及 P2/P3 后续工作仍由 Issue #40 跟踪。 + +Shizuku API 使用 MIT 许可,版权及许可文本见 [SHIZUKU-API-LICENSE.txt](SHIZUKU-API-LICENSE.txt)。 + +## 固定参考版本 + +- [Shizuku API 13.1.5](https://github.com/RikkaApps/Shizuku-API),UserService/Binder/销毁契约与 Maven source artifact 13.1.5。 +- [scrcpy v3.3.3 虚拟显示](https://github.com/Genymobile/scrcpy/blob/v3.3.3/server/src/main/java/com/genymobile/scrcpy/video/NewDisplayCapture.java),用于核对显示标志/API 版本边界。 +- [scrcpy v3.3.3 ActivityManager 包装](https://github.com/Genymobile/scrcpy/blob/v3.3.3/server/src/main/java/com/genymobile/scrcpy/wrappers/ActivityManager.java),用于核对 shell 启动 Activity 的隐藏 API 签名。 + + +## v7 配置、状态与取消 + +`start(token, owner, surface, DesktopStartConfig)` 接收组件、宽高、DPI、会话请求能力和必需能力。握手描述服务支持及本次连接允许的能力,会话可以进一步选择只读等子集。返回的 `DesktopStartResult` 含实际 `DesktopDisplayInfo`(displayId、宽高、DPI、旋转、布局修订号)及最终启用能力;宿主验证实际布局和请求一致后才接受,当前不做静默尺寸调整或动态旋转。 + +`DesktopSessionState` 描述 IDLE/RUNNING/STOPPED/FAILED、有效能力、输入代次、实际显示信息及故障状态。停止后保留最后的显示参数、输入代次及终止故障。`healthy` 用于续期健康检查;`getState` 只读诊断不会延长助手寿命。两者均返回明确操作状态,缺失状态/快照、非法阶段或不一致的活动布局均拒绝。 + +`cancelInput(token, layout, inputEpoch)` 是独立同步控制操作,使用与 Back 相同的代次屏障,只发 ACTION_CANCEL,不发 UP 或 Back;即使只读会话也可用于清理。触控仍为单向无响应调用,数据包布局与 v6 一致。取消失败返回稳定错误码并保留诊断。Desktop 的阶段和错误同时映射到公共 ProjectionSessionState,启动绑定接受公共协作取消信号。 + +v7 自动化覆盖配置/实际显示信息的 Parcelable 往返、连接与会话能力降级、只读就绪、独立取消与输入代次、只读查询不续期,以及公共框架的迟到获取、就绪超时、清理失败/超时和终止错误保留。浏览器回归区分正常抬手与取消,并覆盖旧协议兼容及暂停期间的会话/租约校验;插件测试覆盖可选取消和旧插件回退。这些检查不替代上面的真机验收。 diff --git a/docs/PROJECTION_IMPLEMENTATION.md b/docs/PROJECTION_IMPLEMENTATION.md index 564b675..640302c 100644 --- a/docs/PROJECTION_IMPLEMENTATION.md +++ b/docs/PROJECTION_IMPLEMENTATION.md @@ -47,6 +47,17 @@ Web 服务和浏览器配对不再依赖 MFi 身份。CarPlay 的连接入口仍 2. 使用慢网络和重连验证有界背压;H.264 的 WebRTC→图像回退能请求 IDR 并恢复。测试固定画面重连、尺寸变化、夜间模式和后台服务退出。 3. CarPlay 有线/无线、重连、双指、媒体按键、浏览器音频、浏览器/本机/禁用麦克风均回归;对比同机同网的帧率、延迟、内存和耗电。本环境的编译和 JVM/Chromium 测试不能证明这些设备指标。 -下一真实源仍优先 Android Auto:从 DiAuto 提取一种传输及协议会话,将完整压缩访问单元交给视频端口,并在适配器处理 ACK、明确的 touch action/actionIndex、音频和焦点。此次没有打包未经端到端验证的 AA 协议实现,也未复制外部证书私钥。音频 PCM/麦克风的跨协议端口和更完整的生命周期状态/取消启动接口仍需由第二个真实源驱动收敛;当前音频回路仍为 CarPlay 兼容路径。 +下一真实源仍优先 Android Auto:从 DiAuto 提取一种传输及协议会话,将完整压缩访问单元交给视频端口,并在适配器处理 ACK、明确的 touch action/actionIndex、音频和焦点。此次没有打包未经端到端验证的 AA 协议实现,也未复制外部证书私钥。音频 PCM/麦克风的跨协议端口仍需继续收敛;公共生命周期和启动取消契约见下文。 ICCOA 继续受制于 MiAuto 未分发的 `icar-sdk.dex`。拿到适用 SDK 后,先验证离屏 Surface、触控注入和停止释放,再实现 provider;没有 SDK 时不能把包装层视作可用源。VNC 可复用 framebuffer 合成,但 Pointer/Key/Text 等能力尚未实现。新源只能声明实际支持的能力。 + + +## 公共取消、状态与启动契约 + +`ProjectionSession.cancelInput(reason)` 与 `touch(emptyList())` 分开:前者用于失焦、断连、租约失效、布局变化和切源,后者仍表示正常抬手。`ProjectionCapabilities.cancellation` 明确 `NATIVE` 与 `RELEASE_ONLY`:Desktop 使用带输入代次的 CANCEL 屏障,Android Auto 发送 ACTION_CANCEL,VNC/CarPlay 使用协议支持的按键/触点释放,插件按可选能力选择原生取消或兼容释放。释放型协议不能保证没有点击副作用。取消不依赖键盘、Back 或当前输入就绪能力;正常输入仍受原有能力和会话门控保护。浏览器(包括 pointercancel / lostpointercapture)、TouchLease 与 Runtime 停止流程均使用同一取消入口。当前 sessionId/videoEpoch 的取消允许在画面或输入暂停时提交,仍校验租约所有者;旧版本或旧会话不能取消新会话。无版本信息的旧浏览器协议保持空触点释放兼容。 + +`ProjectionSessionState` 是输入源的生命周期、有效能力、输入就绪及类型化错误快照;只读源可以 RUNNING 而不开放输入。`ProjectionRuntime.stateSnapshot()` / `subscribeState()` 同时提供资源清理状态,并在会话移除后保留终止原因。公共错误类别独立于后端,`backend` / `backendCode` 保留协议特定信息,`diagnostic` 仅用于诊断。界面按公共类别显示本地化文案,浏览器配置额外提供 `sessionPhase` 和 `sourceError`,不发送原始诊断。原来的 `statusText` 保留为兼容的详细展示文本。 + +`open()` 完成表示会话已接纳并调用 activate;`awaitReady(session)` 独立等待 RUNNING。`ProjectionStartup` 提供协作取消信号,provider 可在 `open(startup)` 内注册非阻塞取消回调、检查取消。取消信号和 Future 的取消都不是资源释放证明;调用者通过 `runtime.stop()` 请求取消并等待清理确认。Shizuku 会撤销待处理绑定、终止已取得的助手;同步返回会话的 AA/VNC/插件通过各自 stop 中断后续连接;诊断源在异步分配前检查信号。 + +Runtime 默认限制获取会话和就绪等待各 60 秒,取消/停止清理 15 秒,可通过构造参数调整。启动期间停止不会激活晚到会话,而是等待其 stop;清理超时或失败会保留资源所有权。晚到回调不能重新激活会话,也不能自动解除已进入的隔离状态。后端清理完成之前不能开启下一输入源,页面在启动期间提供停止入口。现有旧 provider 默认适配仍需完成获取/清理 Future;不响应取消时按同样超时规则隔离。 diff --git a/docs/PROJECTION_PLUGINS.md b/docs/PROJECTION_PLUGINS.md index c596604..66430e8 100644 --- a/docs/PROJECTION_PLUGINS.md +++ b/docs/PROJECTION_PLUGINS.md @@ -117,3 +117,8 @@ adb shell am instrument -w -r -e class moe.feng.wheelplay.plugin.PluginSurfaceIn ``` 设备验收需同时安装宿主和示例 APK:看到动态画面、多个触点反馈和夜间模式;与 CarPlay/AA/VNC 来回切换至少 30 次;连接时更新/卸载/强停插件;禁用后不能继续输入;错误版本和缺失权限有可恢复提示;后台与锁屏时检查 OEM 行为。示例 APK 的独立 UID 和真实跨进程 Surface 链路必须通过设备测试确认。 + + +### 可选原生取消扩展 + +API v2 的 `PluginVideoInfo` 新增可选 `nativeCancelInput`(缺省 false)。明确声明 true 的插件可以实现 `ProjectionPluginService.onCancelInput()`,宿主才会调用追加在 AIDL 末尾的 `cancelInput(token)`;该调用与触控在同一 oneway Binder/SDK 队列中排序,并沿用 UID/token 检查。只有确实取消手势而非正常抬手的实现才应声明此能力。旧插件未携带此字段,继续收到空触控快照,旧事务号保持不变。新插件也可被旧宿主使用,旧宿主忽略此可选字段。SDK 状态消息在宿主映射到公共生命周期/错误模型,停止确认与专属进程退出要求保持不变。 diff --git a/docs/README.md b/docs/README.md index 468cc5e..de5fdef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [架构与协议](ARCHITECTURE.md):离屏解码、Web 服务、触摸及生命周期。 - [Android Auto 协议接入与 ICCOA 依赖边界](ANDROID_AUTO.md):USB/TCP、宿主音频、协议测试及真机验收清单。 - [VNC 远程桌面](VNC.md):连接方法、RFB 协议范围、通用桌面输入和设备验收。 +- [Android 虚拟桌面](ANDROID_DESKTOP.md):Shizuku 安装授权、本机应用选择、图像/触控链路、生命周期及尚未完成的真机验收。 - [APK 输入源插件](PROJECTION_PLUGINS.md):安装启用、独立 SDK/示例 APK、Surface 与触控协议,以及 ICCOA 插件接入边界。 - [投影输入源实现与设备验收](PROJECTION_IMPLEMENTATION.md):当前框架、CarPlay 适配、三类诊断源及后续协议接入范围。 - [通用输入源框架设计草案](INPUT_SOURCE_FRAMEWORK.md):CarPlay、Android Auto、ICCOA、桌面与 VNC 的接口边界、研究证据及分阶段迁移建议(研究时点的方案)。 diff --git a/docs/SHIZUKU-API-LICENSE.txt b/docs/SHIZUKU-API-LICENSE.txt new file mode 100644 index 0000000..2a05844 --- /dev/null +++ b/docs/SHIZUKU-API-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 RikkaW + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/projection-plugin-api/src/main/aidl/moe/feng/wheelplay/plugin/IProjectionPlugin.aidl b/projection-plugin-api/src/main/aidl/moe/feng/wheelplay/plugin/IProjectionPlugin.aidl index 952be57..9aff382 100644 --- a/projection-plugin-api/src/main/aidl/moe/feng/wheelplay/plugin/IProjectionPlugin.aidl +++ b/projection-plugin-api/src/main/aidl/moe/feng/wheelplay/plugin/IProjectionPlugin.aidl @@ -10,4 +10,6 @@ oneway interface IProjectionPlugin { void touch(String token, in int[] ids, in double[] xs, in double[] ys, in boolean[] down); void setNightMode(String token, boolean night); void stop(String token); + // Optional extension: call only when PluginVideoInfo.nativeCancelInput is true. + void cancelInput(String token); } diff --git a/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPlugin.kt b/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPlugin.kt index 36ad703..a8f9c3b 100644 --- a/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPlugin.kt +++ b/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPlugin.kt @@ -22,6 +22,7 @@ data class PluginVideoInfo( val maxTouchContacts: Int = 0, val nightMode: Boolean = false, val processExitRequired: Boolean = false, + val nativeCancelInput: Boolean = false, ) { init { require(width in 1..4096 && height in 1..4096 && width.toLong() * height <= 8_388_608) @@ -31,13 +32,13 @@ data class PluginVideoInfo( putInt("apiVersion", ProjectionPlugin.API_VERSION) putInt("width", width); putInt("height", height) putInt("maxTouchContacts", maxTouchContacts); putBoolean("nightMode", nightMode) - putBoolean("processExitRequired", processExitRequired) + putBoolean("processExitRequired", processExitRequired); putBoolean("nativeCancelInput", nativeCancelInput) } companion object { fun fromBundle(bundle: Bundle): PluginVideoInfo { require(bundle.getInt("apiVersion") == ProjectionPlugin.API_VERSION) { "Unsupported plugin API" } return PluginVideoInfo(bundle.getInt("width"), bundle.getInt("height"), - bundle.getInt("maxTouchContacts"), bundle.getBoolean("nightMode"), bundle.getBoolean("processExitRequired")) + bundle.getInt("maxTouchContacts"), bundle.getBoolean("nightMode"), bundle.getBoolean("processExitRequired"), bundle.getBoolean("nativeCancelInput")) } } } diff --git a/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPluginService.kt b/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPluginService.kt index f7495a8..cc53850 100644 --- a/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPluginService.kt +++ b/projection-plugin-api/src/main/java/moe/feng/wheelplay/plugin/ProjectionPluginService.kt @@ -28,6 +28,8 @@ abstract class ProjectionPluginService : Service() { protected open fun projectionProcessName(): String = Application.getProcessName() protected open fun exitProjectionProcess() { android.os.Process.killProcess(android.os.Process.myPid()) } protected open fun onTouch(contacts: List) = Unit + /** Advertise nativeCancelInput only when this hook performs protocol-native cancellation. */ + protected open fun onCancelInput() { onTouch(emptyList()) } protected open fun onNightMode(night: Boolean) = Unit /** Override to apply additional host signing-certificate policy if required by your SDK. */ @@ -163,6 +165,9 @@ abstract class ProjectionPluginService : Service() { override fun setNightMode(token: String, night: Boolean) = dispatch(token) { if (it.started && it.info.nightMode) onNightMode(night) } + override fun cancelInput(token: String) = dispatch(token) { + if (it.started && it.info.nativeCancelInput) onCancelInput() + } override fun stop(token: String) { val uid = Binder.getCallingUid() // Do not drop stop when the input queue is full; preserve connect/start ordering. diff --git a/projection-plugin-api/src/test/java/moe/feng/wheelplay/plugin/ProjectionPluginServiceTest.kt b/projection-plugin-api/src/test/java/moe/feng/wheelplay/plugin/ProjectionPluginServiceTest.kt index 658f9b7..380e95c 100644 --- a/projection-plugin-api/src/test/java/moe/feng/wheelplay/plugin/ProjectionPluginServiceTest.kt +++ b/projection-plugin-api/src/test/java/moe/feng/wheelplay/plugin/ProjectionPluginServiceTest.kt @@ -17,17 +17,20 @@ import java.util.concurrent.* class ProjectionPluginServiceTest { open class Service : ProjectionPluginService() { var allowHost = true + var nativeCancel = false + val cancellations = LinkedBlockingQueue() val started = CountDownLatch(1) val stopRequested = CountDownLatch(1) val stopped = CompletableFuture() val touches = LinkedBlockingQueue>() var beforeInfo: (() -> Unit)? = null override fun isHostAllowed(uid: Int) = allowHost - override fun videoInfo(): PluginVideoInfo { beforeInfo?.invoke(); return PluginVideoInfo(640, 360, 2, true) } + override fun videoInfo(): PluginVideoInfo { beforeInfo?.invoke(); return PluginVideoInfo(640, 360, 2, true, nativeCancelInput = nativeCancel) } override fun onStartProjection(surface: Surface, events: Events) { started.countDown(); events.state(ProjectionPlugin.READY) } override fun onStopProjection(): CompletableFuture { stopRequested.countDown(); return stopped } + override fun onCancelInput() { cancellations.offer(true) } override fun onTouch(contacts: List) { touches.add(contacts) } } class DedicatedService : Service() { @@ -45,6 +48,24 @@ class ProjectionPluginServiceTest { override fun onState(token: String, state: Int, message: String) { if (state == ProjectionPlugin.READY) ready.countDown() } override fun onStopped(token: String, clean: Boolean, message: String) { stopped.add(clean) } } + @Test fun nativeCancellationIsNegotiatedAndRejectsForeignTokens() { + val controller = Robolectric.buildService(Service::class.java).create() + val service = controller.get().apply { nativeCancel = true }; val host = Host() + val binder = IProjectionPlugin.Stub.asInterface(service.onBind(Intent(ProjectionPlugin.ACTION))) + val texture = SurfaceTexture(0); val surface = Surface(texture) + try { + binder.connect("session", ProjectionPlugin.API_VERSION, host) + assertTrue(PluginVideoInfo.fromBundle(host.info.poll(3, TimeUnit.SECONDS)!!).nativeCancelInput) + binder.start("session", surface); assertTrue(host.ready.await(3, TimeUnit.SECONDS)) + binder.cancelInput("foreign"); binder.cancelInput("session") + assertEquals(true, service.cancellations.poll(3, TimeUnit.SECONDS)) + binder.touch("session", intArrayOf(0), doubleArrayOf(.5), doubleArrayOf(.5), booleanArrayOf(true)) + assertEquals(1, service.touches.poll(3, TimeUnit.SECONDS)!!.size) + assertTrue(service.cancellations.isEmpty()) + assertFalse(PluginVideoInfo.fromBundle(PluginVideoInfo(640, 360).toBundle()).nativeCancelInput) + } finally { service.stopped.complete(Unit); controller.destroy(); texture.release() } + } + @Test fun sdkAcknowledgesStopOnlyAfterProducerCompletionAndRejectsForeignTokens() { val controller = Robolectric.buildService(Service::class.java).create() val service = controller.get(); val host = Host() diff --git a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt index abe0d5b..0ae8155 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/androidauto/AapTouch.kt @@ -24,6 +24,10 @@ class AapTouch(private val width: Int, private val height: Int) { if (events.isEmpty() && active.isNotEmpty() && previous != active) events += Event(2, 0, active.values.toList()) return events } + fun cancel(): List { + if (active.isEmpty()) return emptyList() + return listOf(Event(3, 0, active.values.toList())).also { active.clear() } + } fun encode(event: Event, nanos: Long): ByteArray = AapProto.build { number(1, nanos) bytes(3, AapProto.build { diff --git a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt index 0b17c55..2dbb6b7 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt @@ -2,10 +2,20 @@ package com.shilapi.xcertplay.projection import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit /** Single active source. A failed stop retains ownership; a second source must not steal its resources. */ -class ProjectionRuntime { +class ProjectionRuntime(private val startupTimeoutMs: Long = 60_000, private val cleanupTimeoutMs: Long = 15_000) { + init { require(startupTimeoutMs > 0 && cleanupTimeoutMs > 0) } private val lock = Any() + private val stateListeners = CopyOnWriteArrayList<(ProjectionSnapshot) -> Unit>() + private var lastState = ProjectionSnapshot() + private var activeFailure: ProjectionFailure? = null + private var startup = ProjectionStartup() + private var openingCancel: CompletableFuture? = null + private var deadline: ScheduledFuture<*>? = null private val listeners = CopyOnWriteArrayList<(ProjectionSession?) -> Unit>() private val providers = linkedMapOf() @Volatile private var current: ProjectionSession? = null @@ -36,7 +46,49 @@ class ProjectionRuntime { listener(current) return AutoCloseable { listeners.remove(listener) } } - private fun publish() { listeners.forEach { runCatching { it(current) } } } + fun stateSnapshot(): ProjectionSnapshot = synchronized(lock) { + current?.let { session -> + val state = session.state + ProjectionSnapshot(session.descriptor, + when { stopFailed -> ProjectionPhase.FAILED; stopping != null -> ProjectionPhase.STOPPING; else -> state.phase }, + state.capabilities, inputReady(session), activeFailure ?: state.failure, + when { stopFailed -> ProjectionCleanup.UNCONFIRMED; stopping != null -> ProjectionCleanup.PENDING; else -> ProjectionCleanup.NONE }) + } ?: lastState + } + fun subscribeState(listener: (ProjectionSnapshot) -> Unit): AutoCloseable { + stateListeners += listener + listener(stateSnapshot()) + return AutoCloseable { stateListeners -= listener } + } + private fun publish() { + listeners.forEach { runCatching { it(current) } } + stateListeners.forEach { runCatching { it(stateSnapshot()) } } + } + /** open() acknowledges admission. This future separately waits for source readiness, including read-only sources. */ + fun awaitReady(expected: ProjectionSession): CompletableFuture { + val result = CompletableFuture() + val subscription = subscribeState { state -> + if (current === expected && state.phase == ProjectionPhase.RUNNING) result.complete(expected) + else if (current !== expected || state.phase in setOf(ProjectionPhase.STOPPING, ProjectionPhase.STOPPED, ProjectionPhase.FAILED)) + result.completeExceptionally(ProjectionException(state.failure ?: ProjectionFailure(ProjectionErrorCategory.CANCELLED, "Source stopped before readiness"))) + } + result.whenComplete { _, _ -> subscription.close() } + return result + } + private fun watchReadiness(session: ProjectionSession) { + synchronized(lock) { + if (current !== session || stopping != null || session.state.phase == ProjectionPhase.RUNNING) return + deadline = schedule(startupTimeoutMs) { + synchronized(lock) { + if (current !== session || stopping != null || session.state.phase == ProjectionPhase.RUNNING) return@schedule + activeFailure = ProjectionFailure(ProjectionErrorCategory.TIMEOUT, "Source readiness timed out") + } + stop(session) + } + } + } + private fun schedule(delay: Long, action: () -> Unit) = timer.schedule({ CompletableFuture.runAsync(action) }, delay, TimeUnit.MILLISECONDS) + /** Compatibility admission for an existing platform-owned connection flow. */ fun adopt(session: ProjectionSession, resources: Set): Boolean { @@ -44,6 +96,7 @@ class ProjectionRuntime { if (current === session) return true if (current != null || opening != null || stopping != null || quarantined) return false current = session + activeFailure = null stopFailed = false held = resources.toSet() } @@ -51,12 +104,22 @@ class ProjectionRuntime { return true } - fun changed(expected: ProjectionSession) { if (current === expected) publish() } + fun changed(expected: ProjectionSession) { + synchronized(lock) { + if (current !== expected) return + if (expected.state.phase == ProjectionPhase.RUNNING) deadline?.cancel(false) + } + publish() + if (expected.state.phase == ProjectionPhase.FAILED) stop(expected) + } /** Natural session teardown; callers must have already stopped all producers. */ fun ended(expected: ProjectionSession) { synchronized(lock) { - if (current !== expected || stopping != null) return + if (current !== expected || stopping != null || stopFailed || quarantined) return + deadline?.cancel(false) + lastState = stateSnapshot().copy(phase = if (activeFailure != null || expected.state.failure != null) ProjectionPhase.FAILED else ProjectionPhase.STOPPED, + inputReady = false, cleanup = ProjectionCleanup.RELEASED) current = null held = emptySet() } @@ -65,29 +128,54 @@ class ProjectionRuntime { fun open(id: String): CompletableFuture { val provider: ProjectionSourceProvider + val attempt: ProjectionStartup val result = CompletableFuture() synchronized(lock) { provider = providers[id] ?: return failed("Unknown projection source: $id") if (current != null || opening != null || stopping != null || quarantined) return failed("A projection source still owns resources") val available = provider.probe() - if (available is SourceAvailability.Unavailable) return failed(available.reason) + if (available is SourceAvailability.Unavailable) return CompletableFuture().also { it.completeExceptionally(ProjectionException(available.failure)) } opening = result + openingCancel = null + startup = ProjectionStartup() + attempt = startup + activeFailure = null + lastState = ProjectionSnapshot(provider.descriptor, ProjectionPhase.CONNECTING, cleanup = ProjectionCleanup.NONE) + deadline = schedule(startupTimeoutMs) { + synchronized(lock) { + if (opening === result) cancelOpening(result, ProjectionFailure(ProjectionErrorCategory.TIMEOUT, "Source acquisition timed out")) + } + } held = provider.resources.toSet() } + publish() try { - provider.open().whenComplete { session, failure -> - synchronized(lock) { - opening = null - if (failure == null) { current = session; stopFailed = false } - else { - quarantined = cleanupFailed(failure) - if (!quarantined) held = emptySet() + provider.open(attempt).whenComplete { session, failure -> + val cancelled = synchronized(lock) { + if (opening !== result || openingCancel != null) true else { + deadline?.cancel(false) + opening = null + if (failure == null) { current = session; stopFailed = false } + else { + quarantined = cleanupFailed(failure) + if (!quarantined) held = emptySet() + lastState = lastState.copy(phase = ProjectionPhase.FAILED, failure = projectionFailure(failure), + cleanup = if (quarantined) ProjectionCleanup.UNCONFIRMED else ProjectionCleanup.RELEASED) + } + false } } + if (cancelled) { + val cleanup = if (session != null) runCatching { session.stop() }.getOrElse { failedCleanup(it) } + else if (failure != null && cleanupFailed(failure)) failedCleanup(failure) else CompletableFuture.completedFuture(Unit) + cleanup.whenComplete { _, error -> finishCancelledOpening(result, attempt, error) } + return@whenComplete + } publish() if (failure == null) { - try { session.activate(); result.complete(session) } + try { session.activate(); watchReadiness(session); publish(); result.complete(session) } catch (activationFailure: Throwable) { + synchronized(lock) { activeFailure = projectionFailure(activationFailure) } stop(session).whenComplete { _, cleanupFailure -> if (cleanupFailure != null) activationFailure.addSuppressed(cleanupFailure) result.completeExceptionally(activationFailure) @@ -96,39 +184,101 @@ class ProjectionRuntime { } else result.completeExceptionally(failure) } } catch (failure: Throwable) { + if (synchronized(lock) { openingCancel != null || opening !== result }) { + finishCancelledOpening(result, attempt, if (cleanupFailed(failure)) failure else null) + return result + } synchronized(lock) { + deadline?.cancel(false) opening = null quarantined = cleanupFailed(failure) + lastState = lastState.copy(phase = ProjectionPhase.FAILED, failure = projectionFailure(failure), + cleanup = if (quarantined) ProjectionCleanup.UNCONFIRMED else ProjectionCleanup.RELEASED) if (!quarantined) held = emptySet() } result.completeExceptionally(failure) + publish() } return result } + private fun failedCleanup(error: Throwable) = CompletableFuture().also { it.completeExceptionally(error) } + + /** Called with lock held; signal callbacks and all cleanup I/O execute outside it. */ + private fun cancelOpening(result: CompletableFuture, reason: ProjectionFailure): CompletableFuture { + openingCancel?.let { return it } + val cancelled = CompletableFuture().also { openingCancel = it } + val attempt = startup + lastState = lastState.copy(phase = ProjectionPhase.STOPPING, failure = reason, cleanup = ProjectionCleanup.PENDING) + deadline?.cancel(false) + deadline = schedule(cleanupTimeoutMs) { + finishCancelledOpening(result, attempt, ProjectionCleanupException("Startup cancellation cleanup timed out")) + } + CompletableFuture.runAsync { attempt.cancel(reason); publish() } + return cancelled + } + + private fun finishCancelledOpening(result: CompletableFuture, attempt: ProjectionStartup, error: Throwable?) { + val cancelled: CompletableFuture? + val failure: ProjectionFailure + synchronized(lock) { + if (opening !== result) return // Late completion must never admit a session or release a quarantined lease. + cancelled = openingCancel + deadline?.cancel(false) + opening = null + openingCancel = null + quarantined = error != null + failure = if (error != null) ProjectionFailure(ProjectionErrorCategory.CLEANUP, error.toString()) + else attempt.cancellation ?: lastState.failure ?: ProjectionFailure(ProjectionErrorCategory.CANCELLED, "Startup cancelled") + lastState = lastState.copy(phase = if (error != null || failure.category != ProjectionErrorCategory.CANCELLED) ProjectionPhase.FAILED else ProjectionPhase.STOPPED, + failure = failure, cleanup = if (quarantined) ProjectionCleanup.UNCONFIRMED else ProjectionCleanup.RELEASED) + if (!quarantined) held = emptySet() + } + publish() + result.completeExceptionally(ProjectionException(failure, error)) + if (error == null) cancelled?.complete(Unit) else cancelled?.completeExceptionally(error) + } + fun stop(expected: ProjectionSession? = null): CompletableFuture { val session: ProjectionSession val result: CompletableFuture synchronized(lock) { if (expected != null && current !== expected) return CompletableFuture.completedFuture(Unit) stopping?.let { return it } - if (quarantined) return failed("Source startup resources did not stop; restart the application") + if (quarantined || stopFailed) return failed("Source resources did not stop; restart the application") // An opening provider must finish/cancel its own startup before resources can be released. - opening?.let { pending -> return pending.handle { _, _ -> Unit }.thenCompose { stop() } } + opening?.let { pending -> return cancelOpening(pending, ProjectionFailure(ProjectionErrorCategory.CANCELLED, "Source startup cancelled")) } session = current ?: return CompletableFuture.completedFuture(Unit) if (expected != null && expected !== session) return CompletableFuture.completedFuture(Unit) result = CompletableFuture() stopping = result + deadline?.cancel(false) + deadline = schedule(cleanupTimeoutMs) { + val error = ProjectionCleanupException("Source stop cleanup timed out") + synchronized(lock) { + if (stopping !== result) return@schedule + activeFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, error.message.orEmpty()) + stopFailed = true + stopping = null + } + publish() + result.completeExceptionally(error) + } } // Invalidate input before calling transport code. Never hold the runtime lock during I/O. publish() - runCatching { session.touch(emptyList()) } - runCatching { session.input(ProjectionInput.Release) } + runCatching { session.cancelInput(ProjectionCancelReason.SOURCE_SWITCH) } val close = try { session.stop() } catch (failure: Throwable) { CompletableFuture().also { it.completeExceptionally(failure) } } close.whenComplete { _, failure -> synchronized(lock) { + if (stopping !== result) return@whenComplete + deadline?.cancel(false) + if (failure != null) activeFailure = ProjectionFailure(ProjectionErrorCategory.CLEANUP, failure.toString()) + lastState = stateSnapshot().copy(phase = if (failure != null || activeFailure != null || session.state.failure != null) ProjectionPhase.FAILED else ProjectionPhase.STOPPED, + inputReady = false, failure = activeFailure ?: session.state.failure, + cleanup = if (failure == null) ProjectionCleanup.RELEASED else ProjectionCleanup.UNCONFIRMED) if (failure == null && current === session) { current = null; held = emptySet() } stopFailed = failure != null stopping = null @@ -142,7 +292,7 @@ class ProjectionRuntime { fun switchTo(id: String): CompletableFuture { val provider = synchronized(lock) { providers[id] } ?: return failed("Unknown projection source: $id") val available = provider.probe() - if (available is SourceAvailability.Unavailable) return failed(available.reason) + if (available is SourceAvailability.Unavailable) return CompletableFuture().also { it.completeExceptionally(ProjectionException(available.failure)) } return stop().thenCompose { open(id) } } @@ -167,20 +317,29 @@ class ProjectionRuntime { }) return false expected.touch(contacts) } + fun cancelInput(expected: ProjectionSession, reason: ProjectionCancelReason): Boolean = synchronized(lock) { + if (current !== expected || stopFailed) return false + expected.cancelInput(reason) + } fun input(expected: ProjectionSession, event: ProjectionInput): Boolean = synchronized(lock) { - if (current !== expected || (!inputReady(expected) && event != ProjectionInput.Release)) return false + if (event == ProjectionInput.Release) return cancelInput(expected, ProjectionCancelReason.USER_REQUEST) + if (current !== expected || !inputReady(expected)) return false fun point(x: Double, y: Double) = x.isFinite() && y.isFinite() && x in 0.0..1.0 && y in 0.0..1.0 val supported = when (event) { is ProjectionInput.Pointer -> expected.capabilities.pointerInput && point(event.x, event.y) && event.buttons in 0..7 is ProjectionInput.Scroll -> expected.capabilities.pointerInput && point(event.x, event.y) && event.dx in -10..10 && event.dy in -10..10 - is ProjectionInput.Key -> expected.capabilities.keyboardInput && event.key.length in 1..32 - else -> expected.capabilities.pointerInput || expected.capabilities.keyboardInput + is ProjectionInput.Key -> (expected.capabilities.keyboardInput && event.key.length in 1..32) || + (expected.capabilities.backButton && event.key == "BrowserBack") + else -> expected.capabilities.pointerInput || expected.capabilities.keyboardInput || expected.capabilities.backButton } supported && expected.input(event) } private fun cleanupFailed(failure: Throwable): Boolean = generateSequence(failure) { it.cause }.any { it is ProjectionCleanupException } + companion object { + private val timer = Executors.newSingleThreadScheduledExecutor { Thread(it, "projection-startup-deadline").apply { isDaemon = true } } + } private fun failed(message: String) = CompletableFuture().also { it.completeExceptionally(IllegalStateException(message)) } diff --git a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt index daabfea..bea9cea 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt @@ -30,6 +30,9 @@ data class ProjectionCapabilities( val browserMicrophone: Boolean = microphone, val pointerInput: Boolean = false, val keyboardInput: Boolean = false, + /** Discrete Android Back; does not imply general keyboard/text support. */ + val backButton: Boolean = false, + val cancellation: ProjectionCancellation = ProjectionCancellation.RELEASE_ONLY, ) { init { require(maxTouchContacts in 0..10) @@ -41,7 +44,7 @@ data class ProjectionCapabilities( data class SourceDescriptor(val id: String, val displayName: String) sealed class SourceAvailability { data object Ready : SourceAvailability() - data class Unavailable(val reason: String) : SourceAvailability() + data class Unavailable(val reason: String, val failure: ProjectionFailure = ProjectionFailure(ProjectionErrorCategory.UNAVAILABLE, reason)) : SourceAvailability() } /** One connection attempt. Implementations must capture their own transport, never look up another source. */ @@ -50,6 +53,8 @@ interface ProjectionSession { val capabilities: ProjectionCapabilities val inputReady: Boolean val statusText: String? get() = null + val state: ProjectionSessionState get() = ProjectionSessionState( + if (inputReady) ProjectionPhase.RUNNING else ProjectionPhase.CONNECTING, capabilities, inputReady) val media: MediaControlSource? get() = null val mediaOutput: MediaOutputControl? get() = null /** Called after runtime admission. Start asynchronous producers here; stop may race activation. */ @@ -57,6 +62,12 @@ interface ProjectionSession { /** Nonblocking admission; true means accepted, not acknowledged by the remote device. */ fun touch(contacts: List): Boolean fun input(event: ProjectionInput): Boolean = false + /** Nonblocking cancellation admission; normal touch(emptyList()) still means finger-up. */ + fun cancelInput(reason: ProjectionCancelReason): Boolean { + val touchReleased = touch(emptyList()) + val keysReleased = input(ProjectionInput.Release) + return touchReleased || keysReleased + } fun setNightMode(night: Boolean): ProjectionCommandResult = ProjectionCommandResult.UNSUPPORTED /** Completes only after transport, media producers and platform resources have stopped. */ fun stop(): CompletableFuture @@ -72,4 +83,9 @@ interface ProjectionSourceProvider { fun probe(): SourceAvailability /** Failure completes only after partial startup resources have been cleaned up. */ fun open(): CompletableFuture + /** Complete only after acquisition or failed/cancelled acquisition cleanup. Runtime disposes late sessions without activation. */ + fun open(startup: ProjectionStartup): CompletableFuture { + startup.checkActive() + return open() + } } diff --git a/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionState.kt b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionState.kt new file mode 100644 index 0000000..6505443 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionState.kt @@ -0,0 +1,79 @@ +package com.shilapi.xcertplay.projection + +import java.util.concurrent.CancellationException + +/** Source lifecycle is independent of browser connectivity and whether input is read-only. */ +enum class ProjectionPhase { CONNECTING, NEGOTIATING, RUNNING, STOPPING, STOPPED, FAILED } +enum class ProjectionCleanup { NONE, PENDING, RELEASED, UNCONFIRMED } +enum class ProjectionErrorCategory { UNAVAILABLE, PERMISSION, CONNECTION, PROTOCOL, CONFIGURATION, INPUT, TIMEOUT, CANCELLED, CLEANUP, INTERNAL } + +data class ProjectionFailure( + val category: ProjectionErrorCategory, + val diagnostic: String, + val backend: String? = null, + val backendCode: String? = null, +) + +open class ProjectionException(val failure: ProjectionFailure, cause: Throwable? = null) : + IllegalStateException(failure.diagnostic, cause) + +fun projectionFailure(error: Throwable): ProjectionFailure { + val causes = generateSequence(error) { it.cause }.toList() + val typed = causes.filterIsInstance().firstOrNull()?.failure + if (causes.any { it is ProjectionCleanupException }) return ProjectionFailure( + ProjectionErrorCategory.CLEANUP, causes.joinToString("; caused by: "), typed?.backend, typed?.backendCode) + return typed ?: ProjectionFailure( + when { + causes.any { it is CancellationException } -> ProjectionErrorCategory.CANCELLED + causes.any { it is SecurityException } -> ProjectionErrorCategory.PERMISSION + causes.any { it is java.util.concurrent.TimeoutException } -> ProjectionErrorCategory.TIMEOUT + causes.any { it is java.io.IOException } -> ProjectionErrorCategory.CONNECTION + else -> ProjectionErrorCategory.INTERNAL + }, causes.last().toString(), + ) +} + +data class ProjectionSessionState( + val phase: ProjectionPhase, + val capabilities: ProjectionCapabilities, + val inputReady: Boolean = false, + val failure: ProjectionFailure? = null, +) + +/** Retained after teardown, including the reason for termination and resource ownership. */ +data class ProjectionSnapshot( + val source: SourceDescriptor? = null, + val phase: ProjectionPhase = ProjectionPhase.STOPPED, + val capabilities: ProjectionCapabilities = ProjectionCapabilities(emptySet()), + val inputReady: Boolean = false, + val failure: ProjectionFailure? = null, + val cleanup: ProjectionCleanup = ProjectionCleanup.RELEASED, +) + +enum class ProjectionCancelReason { FOCUS_LOST, DISCONNECTED, LEASE_EXPIRED, LAYOUT_CHANGED, SOURCE_SWITCH, USER_REQUEST } +/** RELEASE_ONLY protocols cannot guarantee that releasing a held contact will not click. */ +enum class ProjectionCancellation { RELEASE_ONLY, NATIVE } + +/** Cooperative signal, never a cleanup acknowledgement. Callbacks must initiate cancellation without blocking. */ +class ProjectionStartup { + private val lock = Any() + private val callbacks = linkedSetOf<() -> Unit>() + @Volatile var cancellation: ProjectionFailure? = null + private set + val isCancelled get() = cancellation != null + + fun checkActive() { cancellation?.let { throw ProjectionException(it) } } + fun onCancel(callback: () -> Unit): AutoCloseable { + val invoke = synchronized(lock) { if (isCancelled) true else { callbacks += callback; false } } + if (invoke) callback() + return AutoCloseable { synchronized(lock) { callbacks -= callback } } + } + fun cancel(reason: ProjectionFailure = ProjectionFailure(ProjectionErrorCategory.CANCELLED, "Projection startup cancelled")) { + val pending = synchronized(lock) { + if (isCancelled) return + cancellation = reason + callbacks.toList().also { callbacks.clear() } + } + pending.forEach { runCatching(it) } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/web/OffscreenVideoOutput.kt b/shared/src/main/java/com/shilapi/xcertplay/web/OffscreenVideoOutput.kt index ecdddfd..2d37bae 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/web/OffscreenVideoOutput.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/web/OffscreenVideoOutput.kt @@ -38,7 +38,8 @@ class OffscreenVideoOutput( private var bitmap: Bitmap? = null lateinit var surface: Surface private set - private var hasImage = false + @Volatile private var hasImage = false + val hasFrame: Boolean get() = hasImage && !closed.get() private var hadViewer = false private var encodedOptions: ImageEncodingOptions? = null private val encoder = ImageFrameEncoder() diff --git a/shared/src/main/res/values-zh-rCN/plugin_strings.xml b/shared/src/main/res/values-zh-rCN/plugin_strings.xml deleted file mode 100644 index 5291a66..0000000 --- a/shared/src/main/res/values-zh-rCN/plugin_strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - 正在释放插件资源… - 正在等待插件进程退出… - 插件输入源 - 通过已安装的插件应用添加输入源 - 通过 Android 安装兼容的插件 APK,然后刷新列表。连接前请打开插件设置,完成配置与授权。当前插件接口提供画面与触控,暂不提供音频和麦克风路由。 - 未发现兼容的插件服务。 - 刷新已安装插件 - 启用插件 - 停用插件 - 已启用 - 插件未启用或已变更,请启用后连接。 - 将 %1$s(%2$s)启用为投影输入源?选中连接后,它可以提供显示内容并接收触控操作。请只启用信任的插件。插件的 Android 权限需单独管理。 - 插件设置与权限 - 连接插件 - 正在连接插件… - 插件已连接 - 插件连接已断开 - 插件服务或设置不可用,请刷新列表或检查 Android 应用设置。 - 插件接口版本不兼容,请更新 WheelPlay 或插件。 - 无法验证插件签名证书。 - 插件未在限定时间内就绪。 - 未确认插件完成资源释放,已阻止继续投影。请在 Android 设置中强行停止插件,再重启 WheelPlay 后连接。 - 请安装兼容的 ICCOA CarLink 插件 APK,然后返回此页面完成设置、启用并连接。 - 安装 CarLink 插件 - CarLink 插件:%1$s - diff --git a/shared/src/main/res/values-zh-rCN/projection_strings.xml b/shared/src/main/res/values-zh-rCN/projection_strings.xml deleted file mode 100644 index 07b0896..0000000 --- a/shared/src/main/res/values-zh-rCN/projection_strings.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - 投影输入源 - 诊断源无需 CarPlay 认证。Surface 和 framebuffer 演示支持触控反馈,H.264 测试流为只读画面。 - 选择投影输入源 - 停止投影 - 无法切换输入源:%1$s - 等待连接投影输入源 - 输入源:%1$s - 已有投影输入源运行,请在 WheelPlay 中切换输入源。 - Android Auto 手机地址 - 在手机 Android Auto 开发者设置中启动 Head Unit Server,输入宿主可访问的手机地址(TCP 5277)。音频由宿主播放;麦克风使用 Android 设置,不支持浏览器麦克风。 - 请输入有效的手机 IPv4 地址。 - ICCOA CarLink 需要专有 SDK。MiAuto 公开仓库及本安装包均不包含该运行库,目前无法接入 CarLink 协议。 - Android Auto · 本机 - 将本机 Android Auto 投影到车机浏览器。首次需要开启车机服务器,声音由本机播放;此功能仍为实验性。 - 连接本机 Android Auto - 首次设置与连接帮助 - 打开 Android Auto 设置 - 1. 打开 Android Auto 设置,完成首次使用配置。\n2. 连续点击版本信息(通常为 10 次),确认开启开发者模式。\n3. 在菜单中选择“启动车机服务器”(Start head unit server)。\n4. 返回 WheelPlay,点击“连接本机 Android Auto”。\n\n保持车机服务器运行,下次即可一键连接;手机重启后可能需要重新开启。无需填写地址或连接数据线。声音由本机播放,麦克风遵循 Android 麦克风设置。同机兼容性仍需真机验证。 - 本机未安装 Android Auto,或已将其停用。请在受支持的设备上安装或启用 Android Auto,完成首次配置后返回 WheelPlay。部分版本的设置按钮会打开系统应用详情页。 - 无法连接本机 Android Auto。请打开“首次设置与连接帮助”,在 Android Auto 中启动车机服务器后重试。 - 无法打开 Android Auto 设置。请在系统设置中搜索 Android Auto,并按照引导完成设置。 - 输入源 - CarPlay (iPhone) - 连接 iPhone 使用 CarPlay - 使用本机或另一台 Android 手机投影 - CarLink 手机互联 · 需安装插件 - 诊断输入源 - 检查画面输出与触控 - 返回输入源 - 暂不可用 - 使用数据线将另一台 Android 手机接到本设备的 USB 接口,并按提示允许 USB 访问。 - 通过 USB 连接 Android Auto - 另一台手机 · 车机服务器 - 设置地址并连接 - 在车机浏览器中使用投影 - 投影输入源 - 选择输入源 - 切换输入源:%1$s - 输入源设置 - 设置并连接 - 当前投影:%1$s\n%2$s - 停止投影:%1$s - diff --git a/shared/src/main/res/values-zh-rCN/ui_strings.xml b/shared/src/main/res/values-zh-rCN/strings.xml similarity index 80% rename from shared/src/main/res/values-zh-rCN/ui_strings.xml rename to shared/src/main/res/values-zh-rCN/strings.xml index 0f11949..8b903af 100644 --- a/shared/src/main/res/values-zh-rCN/ui_strings.xml +++ b/shared/src/main/res/values-zh-rCN/strings.xml @@ -567,4 +567,140 @@ 暂时无法获取定位,请检查设备定位服务。 已获得定位权限,将在连接期间共享浏览器 GPS 定位。 正在转发浏览器 GPS 定位。 + + 投影输入源 + 诊断源无需 CarPlay 认证。Surface 和 framebuffer 演示支持触控反馈,H.264 测试流为只读画面。 + 选择投影输入源 + 停止投影 + 无法切换输入源:%1$s + 等待连接投影输入源 + 输入源:%1$s + 已有投影输入源运行,请在 WheelPlay 中切换输入源。 + Android Auto 手机地址 + 在手机 Android Auto 开发者设置中启动 Head Unit Server,输入宿主可访问的手机地址(TCP 5277)。音频由宿主播放;麦克风使用 Android 设置,不支持浏览器麦克风。 + 请输入有效的手机 IPv4 地址。 + ICCOA CarLink 需要专有 SDK。MiAuto 公开仓库及本安装包均不包含该运行库,目前无法接入 CarLink 协议。 + Android Auto · 本机 + 将本机 Android Auto 投影到车机浏览器。首次需要开启车机服务器,声音由本机播放;此功能仍为实验性。 + 连接本机 Android Auto + 首次设置与连接帮助 + 打开 Android Auto 设置 + 1. 打开 Android Auto 设置,完成首次使用配置。\n2. 连续点击版本信息(通常为 10 次),确认开启开发者模式。\n3. 在菜单中选择“启动车机服务器”(Start head unit server)。\n4. 返回 WheelPlay,点击“连接本机 Android Auto”。\n\n保持车机服务器运行,下次即可一键连接;手机重启后可能需要重新开启。无需填写地址或连接数据线。声音由本机播放,麦克风遵循 Android 麦克风设置。同机兼容性仍需真机验证。 + 本机未安装 Android Auto,或已将其停用。请在受支持的设备上安装或启用 Android Auto,完成首次配置后返回 WheelPlay。部分版本的设置按钮会打开系统应用详情页。 + 无法连接本机 Android Auto。请打开“首次设置与连接帮助”,在 Android Auto 中启动车机服务器后重试。 + 无法打开 Android Auto 设置。请在系统设置中搜索 Android Auto,并按照引导完成设置。 + 输入源 + CarPlay (iPhone) + 连接 iPhone 使用 CarPlay + 使用本机或另一台 Android 手机投影 + CarLink 手机互联 · 需安装插件 + 诊断输入源 + 检查画面输出与触控 + 返回输入源 + 暂不可用 + 使用数据线将另一台 Android 手机接到本设备的 USB 接口,并按提示允许 USB 访问。 + 通过 USB 连接 Android Auto + 另一台手机 · 车机服务器 + 设置地址并连接 + 在车机浏览器中使用投影 + 投影输入源 + 选择输入源 + 切换输入源:%1$s + 输入源设置 + 设置并连接 + 当前投影:%1$s\n%2$s + 停止投影:%1$s + + 通过 VNC 访问远程桌面 + 远程桌面 + 连接 VNC + 请输入有效的 IPv4 或 IPv6 地址,以及 1–65535 范围内的端口。 + 服务器 IP 地址 + 端口(通常为 5900) + 密码(不会保存) + 仅查看 + 连接局域网或 VPN 中已有的 VNC 服务器。触屏对应鼠标左键;鼠标支持右键、拖动和滚动。点击桌面后可用实体键盘输入。暂不支持音频、剪贴板和输入法组合输入。 + 仅支持无认证或传统 VNC 密码认证(最多 8 个 Latin-1 字符)。传输不加密,请使用可信局域网或 VPN。不支持 TLS/VeNCrypt。 + 密码最多包含 8 个 Latin-1 字符。 + + Android 虚拟桌面 + 虚拟屏设置 + 画面:%1$d × %2$d · %3$d DPI + 修改将在下次启动虚拟桌面时生效。提高分辨率会增加运算和内存开销;提高 DPI 会放大文字和控件。 + 宽度(像素) + 高度(像素) + 密度(DPI) + 宽度和高度均须为 %1$d–%2$d 像素,总像素数不得超过 %3$d;DPI 须为 %4$d–%5$d。请输入整数。 + 恢复默认值 + 桌面助手协议不兼容,请重启 WheelPlay 后重新连接。 + 桌面助手权限被拒绝,请检查 Shizuku 授权和设备限制。 + 桌面助手拒绝了请求参数,请检查虚拟屏设置后重新连接。 + 桌面会话已失效,请停止后重新连接。 + 桌面助手不支持所请求的功能。 + 所选应用无法启动,请选择已安装且已启用的应用。 + 无法创建虚拟屏,请尝试较小尺寸或检查设备兼容性。 + 应用未能在虚拟屏上启动,请尝试其他应用。 + 虚拟屏的尺寸、密度或方向已改变,请重新连接。 + 虚拟桌面输入失败,请停止会话后重新连接。 + 虚拟桌面发生意外错误,请停止会话后重新连接。 + 实验性 · 通过 Shizuku 在独立显示屏运行本机应用 + 实验性功能,需真机验证。可设置画面尺寸和 DPI,使用图像输出,仅支持触控与返回。音频保留手机原有行为;不支持浏览器音频、麦克风、键盘和 Home。这与手机共享系统和应用数据;部分应用可能从主屏移动过来或拒绝副屏,受保护内容可能黑屏。旋转后需重新连接。 + 未安装 Shizuku。请先安装并启动,再授权 WheelPlay。 + Shizuku 未启动。手机重启后通常需再次启动;Android 11 及以上可使用本机无线调试,旧版本需要电脑。 + WheelPlay 未获授权。请允许 Shizuku 访问;如曾拒绝,请在 Shizuku 的应用授权设置中修改。 + 需要 Android 10 及以上、Shizuku 13 及以上的 shell 或 root 模式。厂商兼容性尚属实验阶段。 + Shizuku 已授权 · 可以启动实验性会话 + 安装 / 打开 Shizuku + 授权 WheelPlay + 选择可启动的本机应用 + 未找到可启动的应用。 + 启动虚拟桌面 + 正在准备虚拟桌面 · 等待首帧 + 已收到虚拟屏画面 · 等待浏览器图像输出 + 浏览器图像输出已就绪 · 实验性 · 助手 UID %1$d + 正在释放虚拟桌面… + Shizuku 助手启动超时,请检查授权和厂商后台限制。 + Shizuku 或虚拟桌面助手已断开,请恢复 Shizuku 后重新连接。 + 未收到虚拟屏画面,此设备或应用可能不支持虚拟显示。 + 无法确认虚拟桌面已完整释放,请重启 WheelPlay 后再切换输入源。 + 返回 + + 正在释放插件资源… + 正在等待插件进程退出… + 插件输入源 + 通过已安装的插件应用添加输入源 + 通过 Android 安装兼容的插件 APK,然后刷新列表。连接前请打开插件设置,完成配置与授权。当前插件接口提供画面与触控,暂不提供音频和麦克风路由。 + 未发现兼容的插件服务。 + 刷新已安装插件 + 启用插件 + 停用插件 + 已启用 + 插件未启用或已变更,请启用后连接。 + 将 %1$s(%2$s)启用为投影输入源?选中连接后,它可以提供显示内容并接收触控操作。请只启用信任的插件。插件的 Android 权限需单独管理。 + 插件设置与权限 + 连接插件 + 正在连接插件… + 插件已连接 + 插件连接已断开 + 插件服务或设置不可用,请刷新列表或检查 Android 应用设置。 + 插件接口版本不兼容,请更新 WheelPlay 或插件。 + 无法验证插件签名证书。 + 插件未在限定时间内就绪。 + 未确认插件完成资源释放,已阻止继续投影。请在 Android 设置中强行停止插件,再重启 WheelPlay 后连接。 + 请安装兼容的 ICCOA CarLink 插件 APK,然后返回此页面完成设置、启用并连接。 + 安装 CarLink 插件 + CarLink 插件:%1$s + 输入源权限被拒绝,请检查授权。 + 输入源连接已中断,请重新连接。 + 输入源协议不兼容或数据无效。 + 输入源配置无效,请检查设置。 + 输入源控制失败,请重新连接。 + 输入源准备超时,请尝试重新连接。 + 已取消输入源启动。 + 无法确认输入源已完整释放,请重启应用后再切换输入源。 + 输入源不可用,请检查设置和依赖。 + 输入源发生错误,请停止后重新连接。 + 正在连接输入源… + 输入源已就绪 + 正在释放输入源资源… diff --git a/shared/src/main/res/values-zh-rCN/vnc_strings.xml b/shared/src/main/res/values-zh-rCN/vnc_strings.xml deleted file mode 100644 index 1e0179b..0000000 --- a/shared/src/main/res/values-zh-rCN/vnc_strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - 通过 VNC 访问远程桌面 - 远程桌面 - 连接 VNC - 请输入有效的 IPv4 或 IPv6 地址,以及 1–65535 范围内的端口。 - 服务器 IP 地址 - 端口(通常为 5900) - 密码(不会保存) - 仅查看 - 连接局域网或 VPN 中已有的 VNC 服务器。触屏对应鼠标左键;鼠标支持右键、拖动和滚动。点击桌面后可用实体键盘输入。暂不支持音频、剪贴板和输入法组合输入。 - 仅支持无认证或传统 VNC 密码认证(最多 8 个 Latin-1 字符)。传输不加密,请使用可信局域网或 VPN。不支持 TLS/VeNCrypt。 - 密码最多包含 8 个 Latin-1 字符。 - diff --git a/shared/src/main/res/values-zh-rTW/plugin_strings.xml b/shared/src/main/res/values-zh-rTW/plugin_strings.xml deleted file mode 100644 index d7e59d0..0000000 --- a/shared/src/main/res/values-zh-rTW/plugin_strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - 正在釋放外掛資源… - 正在等待外掛程序結束… - 外掛輸入來源 - 透過已安裝的外掛應用程式新增輸入來源 - 透過 Android 安裝相容的外掛 APK,然後重新整理清單。連線前請開啟外掛設定,完成設定與授權。目前外掛介面提供畫面與觸控,暫不提供音訊和麥克風路由。 - 未找到相容的外掛服務。 - 重新整理已安裝外掛 - 啟用外掛 - 停用外掛 - 已啟用 - 外掛未啟用或已變更,請啟用後連線。 - 將 %1$s(%2$s)啟用為投影輸入來源?選取連線後,它可以提供顯示內容並接收觸控操作。請只啟用信任的外掛。外掛的 Android 權限需單獨管理。 - 外掛設定與權限 - 連線外掛 - 正在連線外掛… - 外掛已連線 - 外掛連線已中斷 - 外掛服務或設定無法使用,請重新整理清單或檢查 Android 應用程式設定。 - 外掛介面版本不相容,請更新 WheelPlay 或外掛。 - 無法驗證外掛簽章憑證。 - 外掛未在限定時間內就緒。 - 未確認外掛完成資源釋放,已阻止繼續投影。請在 Android 設定中強制停止外掛,再重新啟動 WheelPlay 後連線。 - 請安裝相容的 ICCOA CarLink 外掛 APK,然後返回此頁面完成設定、啟用並連線。 - 安裝 CarLink 外掛 - CarLink 外掛:%1$s - diff --git a/shared/src/main/res/values-zh-rTW/projection_strings.xml b/shared/src/main/res/values-zh-rTW/projection_strings.xml deleted file mode 100644 index fbf0ad7..0000000 --- a/shared/src/main/res/values-zh-rTW/projection_strings.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - 投影輸入源 - 診斷源無需 CarPlay 認證。Surface 和 framebuffer 示範支援觸控回饋,H.264 測試串流為唯讀畫面。 - 選擇投影輸入源 - 停止投影 - 無法切換輸入源:%1$s - 等待連接投影輸入源 - 輸入源:%1$s - 已有投影輸入源運行,請在 WheelPlay 中切換輸入源。 - Android Auto 手機位址 - 在手機 Android Auto 開發者設定中啟動 Head Unit Server,輸入宿主可存取的手機位址(TCP 5277)。音訊由宿主播放;麥克風使用 Android 設定,不支援瀏覽器麥克風。 - 請輸入有效的手機 IPv4 位址。 - ICCOA CarLink 需要專有 SDK。MiAuto 公開倉庫及本安裝套件均不包含該執行庫,目前無法接入 CarLink 協議。 - Android Auto · 本機 - 將本機 Android Auto 投影到車機瀏覽器。首次需要開啟車機伺服器,聲音由本機播放;此功能仍為實驗性。 - 連接本機 Android Auto - 首次設定與連線說明 - 開啟 Android Auto 設定 - 1. 開啟 Android Auto 設定,完成首次使用設定。\n2. 連續點選版本資訊(通常為 10 次),確認開啟開發人員模式。\n3. 在選單中選擇「啟動車機伺服器」(Start head unit server)。\n4. 返回 WheelPlay,點選「連接本機 Android Auto」。\n\n保持車機伺服器執行,下次即可一鍵連線;手機重新啟動後可能需要重新開啟。無需填寫位址或連接傳輸線。聲音由本機播放,麥克風遵循 Android 麥克風設定。同機相容性仍需實機驗證。 - 本機未安裝 Android Auto,或已將其停用。請在支援的裝置上安裝或啟用 Android Auto,完成首次設定後返回 WheelPlay。部分版本的設定按鈕會開啟系統應用程式詳細資料頁面。 - 無法連接本機 Android Auto。請開啟「首次設定與連線說明」,在 Android Auto 中啟動車機伺服器後重試。 - 無法開啟 Android Auto 設定。請在系統設定中搜尋 Android Auto,並按照說明完成設定。 - 輸入源 - CarPlay (iPhone) - 連接 iPhone 使用 CarPlay - 使用本機或另一台 Android 手機投影 - CarLink 手機互聯 · 需安裝外掛 - 診斷輸入源 - 檢查畫面輸出與觸控 - 返回輸入源 - 暫不可用 - 使用傳輸線將另一台 Android 手機接到本裝置的 USB 介面,並依照提示允許 USB 存取。 - 透過 USB 連接 Android Auto - 另一台手機 · 車機伺服器 - 設定位址並連線 - 在車機瀏覽器中使用投影 - 投影輸入來源 - 選擇輸入來源 - 切換輸入來源:%1$s - 輸入來源設定 - 設定並連線 - 目前投影:%1$s\n%2$s - 停止投影:%1$s - diff --git a/shared/src/main/res/values-zh-rTW/ui_strings.xml b/shared/src/main/res/values-zh-rTW/strings.xml similarity index 80% rename from shared/src/main/res/values-zh-rTW/ui_strings.xml rename to shared/src/main/res/values-zh-rTW/strings.xml index 21f3b33..e4ba27d 100644 --- a/shared/src/main/res/values-zh-rTW/ui_strings.xml +++ b/shared/src/main/res/values-zh-rTW/strings.xml @@ -567,4 +567,140 @@ 暫時無法取得定位,請檢查裝置定位服務。 已取得定位權限,將在連線期間分享瀏覽器 GPS 定位。 正在轉送瀏覽器 GPS 定位。 + + 投影輸入源 + 診斷源無需 CarPlay 認證。Surface 和 framebuffer 示範支援觸控回饋,H.264 測試串流為唯讀畫面。 + 選擇投影輸入源 + 停止投影 + 無法切換輸入源:%1$s + 等待連接投影輸入源 + 輸入源:%1$s + 已有投影輸入源運行,請在 WheelPlay 中切換輸入源。 + Android Auto 手機位址 + 在手機 Android Auto 開發者設定中啟動 Head Unit Server,輸入宿主可存取的手機位址(TCP 5277)。音訊由宿主播放;麥克風使用 Android 設定,不支援瀏覽器麥克風。 + 請輸入有效的手機 IPv4 位址。 + ICCOA CarLink 需要專有 SDK。MiAuto 公開倉庫及本安裝套件均不包含該執行庫,目前無法接入 CarLink 協議。 + Android Auto · 本機 + 將本機 Android Auto 投影到車機瀏覽器。首次需要開啟車機伺服器,聲音由本機播放;此功能仍為實驗性。 + 連接本機 Android Auto + 首次設定與連線說明 + 開啟 Android Auto 設定 + 1. 開啟 Android Auto 設定,完成首次使用設定。\n2. 連續點選版本資訊(通常為 10 次),確認開啟開發人員模式。\n3. 在選單中選擇「啟動車機伺服器」(Start head unit server)。\n4. 返回 WheelPlay,點選「連接本機 Android Auto」。\n\n保持車機伺服器執行,下次即可一鍵連線;手機重新啟動後可能需要重新開啟。無需填寫位址或連接傳輸線。聲音由本機播放,麥克風遵循 Android 麥克風設定。同機相容性仍需實機驗證。 + 本機未安裝 Android Auto,或已將其停用。請在支援的裝置上安裝或啟用 Android Auto,完成首次設定後返回 WheelPlay。部分版本的設定按鈕會開啟系統應用程式詳細資料頁面。 + 無法連接本機 Android Auto。請開啟「首次設定與連線說明」,在 Android Auto 中啟動車機伺服器後重試。 + 無法開啟 Android Auto 設定。請在系統設定中搜尋 Android Auto,並按照說明完成設定。 + 輸入源 + CarPlay (iPhone) + 連接 iPhone 使用 CarPlay + 使用本機或另一台 Android 手機投影 + CarLink 手機互聯 · 需安裝外掛 + 診斷輸入源 + 檢查畫面輸出與觸控 + 返回輸入源 + 暫不可用 + 使用傳輸線將另一台 Android 手機接到本裝置的 USB 介面,並依照提示允許 USB 存取。 + 透過 USB 連接 Android Auto + 另一台手機 · 車機伺服器 + 設定位址並連線 + 在車機瀏覽器中使用投影 + 投影輸入來源 + 選擇輸入來源 + 切換輸入來源:%1$s + 輸入來源設定 + 設定並連線 + 目前投影:%1$s\n%2$s + 停止投影:%1$s + + 透過 VNC 存取遠端桌面 + 遠端桌面 + 連接 VNC + 請輸入有效的 IPv4 或 IPv6 位址,以及 1–65535 範圍內的連接埠。 + 伺服器 IP 位址 + 連接埠(通常為 5900) + 密碼(不會儲存) + 僅檢視 + 連接區域網路或 VPN 中已有的 VNC 伺服器。觸控對應滑鼠左鍵;滑鼠支援右鍵、拖曳和捲動。點擊桌面後可用實體鍵盤輸入。暫不支援音訊、剪貼簿和輸入法組合輸入。 + 僅支援無驗證或傳統 VNC 密碼驗證(最多 8 個 Latin-1 字元)。傳輸不加密,請使用可信區域網路或 VPN。不支援 TLS/VeNCrypt。 + 密碼最多包含 8 個 Latin-1 字元。 + + Android 虛擬桌面 + 虛擬螢幕設定 + 畫面:%1$d × %2$d · %3$d DPI + 修改將在下次啟動虛擬桌面時生效。提高解析度會增加運算和記憶體用量;提高 DPI 會放大文字和控制項。 + 寬度(像素) + 高度(像素) + 密度(DPI) + 寬度和高度均須為 %1$d–%2$d 像素,總像素數不得超過 %3$d;DPI 須為 %4$d–%5$d。請輸入整數。 + 恢復預設值 + 桌面助手協定不相容,請重新啟動 WheelPlay 後重新連線。 + 桌面助手權限遭拒,請檢查 Shizuku 授權和裝置限制。 + 桌面助手拒絕了請求參數,請檢查虛擬螢幕設定後重新連線。 + 桌面工作階段已失效,請停止後重新連線。 + 桌面助手不支援所請求的功能。 + 所選應用程式無法啟動,請選擇已安裝且已啟用的應用程式。 + 無法建立虛擬螢幕,請嘗試較小尺寸或檢查裝置相容性。 + 應用程式未能在虛擬螢幕上啟動,請嘗試其他應用程式。 + 虛擬螢幕的尺寸、密度或方向已改變,請重新連線。 + 虛擬桌面輸入失敗,請停止工作階段後重新連線。 + 虛擬桌面發生非預期錯誤,請停止工作階段後重新連線。 + 實驗性 · 透過 Shizuku 在獨立顯示螢幕執行本機應用程式 + 實驗性功能,需實機驗證。可設定畫面尺寸和 DPI,使用圖像輸出,僅支援觸控與返回。音訊保留手機原有行為;不支援瀏覽器音訊、麥克風、鍵盤和 Home。這與手機共用系統和應用程式資料;部分應用程式可能從主螢幕移動過來或拒絕副螢幕,受保護內容可能黑屏。旋轉後需重新連線。 + 尚未安裝 Shizuku。請先安裝並啟動,再授權 WheelPlay。 + Shizuku 尚未啟動。手機重新啟動後通常需再次啟動;Android 11 及以上可使用本機無線偵錯,舊版本需要電腦。 + WheelPlay 尚未獲授權。請允許 Shizuku 存取;如曾拒絕,請在 Shizuku 的應用程式授權設定中修改。 + 需要 Android 10 及以上、Shizuku 13 及以上的 shell 或 root 模式。廠商相容性仍屬實驗階段。 + Shizuku 已授權 · 可以啟動實驗性連線 + 安裝 / 開啟 Shizuku + 授權 WheelPlay + 選擇可啟動的本機應用程式 + 未找到可啟動的應用程式。 + 啟動虛擬桌面 + 正在準備虛擬桌面 · 等待首個畫面 + 已收到虛擬螢幕畫面 · 等待瀏覽器圖像輸出 + 瀏覽器圖像輸出已就緒 · 實驗性 · 助手 UID %1$d + 正在釋放虛擬桌面… + Shizuku 助手啟動逾時,請檢查授權和廠商背景限制。 + Shizuku 或虛擬桌面助手已中斷,請恢復 Shizuku 後重新連線。 + 未收到虛擬螢幕畫面,此裝置或應用程式可能不支援虛擬顯示。 + 無法確認虛擬桌面已完整釋放,請重新啟動 WheelPlay 後再切換輸入源。 + 返回 + + 正在釋放外掛資源… + 正在等待外掛程序結束… + 外掛輸入來源 + 透過已安裝的外掛應用程式新增輸入來源 + 透過 Android 安裝相容的外掛 APK,然後重新整理清單。連線前請開啟外掛設定,完成設定與授權。目前外掛介面提供畫面與觸控,暫不提供音訊和麥克風路由。 + 未找到相容的外掛服務。 + 重新整理已安裝外掛 + 啟用外掛 + 停用外掛 + 已啟用 + 外掛未啟用或已變更,請啟用後連線。 + 將 %1$s(%2$s)啟用為投影輸入來源?選取連線後,它可以提供顯示內容並接收觸控操作。請只啟用信任的外掛。外掛的 Android 權限需單獨管理。 + 外掛設定與權限 + 連線外掛 + 正在連線外掛… + 外掛已連線 + 外掛連線已中斷 + 外掛服務或設定無法使用,請重新整理清單或檢查 Android 應用程式設定。 + 外掛介面版本不相容,請更新 WheelPlay 或外掛。 + 無法驗證外掛簽章憑證。 + 外掛未在限定時間內就緒。 + 未確認外掛完成資源釋放,已阻止繼續投影。請在 Android 設定中強制停止外掛,再重新啟動 WheelPlay 後連線。 + 請安裝相容的 ICCOA CarLink 外掛 APK,然後返回此頁面完成設定、啟用並連線。 + 安裝 CarLink 外掛 + CarLink 外掛:%1$s + 輸入源權限遭拒,請檢查授權。 + 輸入源連線已中斷,請重新連線。 + 輸入源通訊協定不相容或資料無效。 + 輸入源設定無效,請檢查設定。 + 輸入源控制失敗,請重新連線。 + 輸入源準備逾時,請嘗試重新連線。 + 已取消輸入源啟動。 + 無法確認輸入源已完整釋放,請重新啟動應用程式後再切換輸入源。 + 輸入源無法使用,請檢查設定與相依項目。 + 輸入源發生錯誤,請停止後重新連線。 + 正在連線至輸入源… + 輸入源已就緒 + 正在釋放輸入源資源… diff --git a/shared/src/main/res/values-zh-rTW/vnc_strings.xml b/shared/src/main/res/values-zh-rTW/vnc_strings.xml deleted file mode 100644 index 8f667c1..0000000 --- a/shared/src/main/res/values-zh-rTW/vnc_strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - 透過 VNC 存取遠端桌面 - 遠端桌面 - 連接 VNC - 請輸入有效的 IPv4 或 IPv6 位址,以及 1–65535 範圍內的連接埠。 - 伺服器 IP 位址 - 連接埠(通常為 5900) - 密碼(不會儲存) - 僅檢視 - 連接區域網路或 VPN 中已有的 VNC 伺服器。觸控對應滑鼠左鍵;滑鼠支援右鍵、拖曳和捲動。點擊桌面後可用實體鍵盤輸入。暫不支援音訊、剪貼簿和輸入法組合輸入。 - 僅支援無驗證或傳統 VNC 密碼驗證(最多 8 個 Latin-1 字元)。傳輸不加密,請使用可信區域網路或 VPN。不支援 TLS/VeNCrypt。 - 密碼最多包含 8 個 Latin-1 字元。 - diff --git a/shared/src/main/res/values/plugin_strings.xml b/shared/src/main/res/values/plugin_strings.xml deleted file mode 100644 index 156001f..0000000 --- a/shared/src/main/res/values/plugin_strings.xml +++ /dev/null @@ -1,27 +0,0 @@ - - Releasing plugin resources… - Waiting for the plugin process to exit… - Plugin sources - Add sources from installed plugin apps - Install a compatible plugin APK using Android, then refresh this list. Open the plugin settings to complete its setup and permissions before connecting. Plugins currently provide video and touch; audio and microphone routing are not part of the plugin API. - No compatible plugin services found. - Refresh installed plugins - Enable plugin - Disable plugin - Enabled - Plugin disabled or changed. Enable it to connect. - Enable %1$s (%2$s) as a projection source? It can provide the displayed content and receive touch input while selected. Only enable plugins you trust. Its Android permissions are managed separately. - Plugin settings and permissions - Connect plugin - Connecting to plugin… - Plugin connected - Plugin disconnected - Plugin service or settings are unavailable. Refresh the list or check Android app settings. - Unsupported plugin API version. Update WheelPlay or the plugin. - Cannot verify this plugin signing certificate. - Plugin did not become ready in time. - Plugin cleanup was not confirmed. Projection is blocked. Force stop the plugin in Android settings and restart WheelPlay before reconnecting. - Install a compatible ICCOA CarLink plugin APK, then return to this page to configure, enable and connect it. - Install a CarLink plugin - CarLink plugin: %1$s - diff --git a/shared/src/main/res/values/projection_strings.xml b/shared/src/main/res/values/projection_strings.xml deleted file mode 100644 index 947e425..0000000 --- a/shared/src/main/res/values/projection_strings.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - Projection sources - Diagnostic sources work without CarPlay authentication. Surface and framebuffer demos show touch feedback; the H.264 clip is read-only. - Choose projection source - Stop projection - Could not change source: %1$s - Waiting for a projection source - Source: %1$s - A projection source is already active. Switch sources in WheelPlay. - Android Auto phone address - Enable Android Auto developer mode and start its Head Unit Server. Enter the phone address reachable from this host (TCP 5277). Audio plays on the host. Microphone uses the Android setting; browser microphone is unavailable. - Enter a valid phone IPv4 address. - ICCOA CarLink needs the proprietary SDK. It is not included in the public MiAuto repository or this build; CarLink protocol integration is unavailable. - Android Auto · This phone - Use Android Auto on this phone in the car browser. First enable its head unit server. Audio plays on this phone; this feature is experimental. - Connect this phone - Setup and connection help - Open Android Auto settings - 1. Open Android Auto settings and complete any initial setup.\n2. Tap the version information repeatedly (usually 10 times), then confirm developer mode.\n3. Open the menu and select Start head unit server.\n4. Return to WheelPlay and tap Connect this phone.\n\nKeep the server running for one-tap connections next time. After a restart, you may need to start it again. No address or cable is needed. Audio plays on this phone; microphone follows the Android microphone setting. Same-phone compatibility still needs device testing. - Android Auto is not installed or is disabled on this phone. Install or enable it on a supported device, finish its initial setup, then return to WheelPlay. Settings may open the system app details page on some versions. - Could not connect to local Android Auto. Open Setup and connection help, start the head unit server in Android Auto, then try again. - Could not open Android Auto settings. Find Android Auto in system Settings and follow the setup instructions. - Sources - CarPlay (iPhone) - Connect an iPhone - Project Android Auto from this phone or another Android phone - CarLink phone projection · Plugin required - Diagnostic sources - Check video output and touch input - Back to sources - Currently unavailable - Connect another Android phone to this device with a USB data cable, then allow USB access when prompted. - Connect Android Auto over USB - Another phone · Head Unit Server - Set address and connect - Use projection in your head unit browser - Projection source - Choose input source - Switch source: %1$s - Input source settings - Set up and connect - Current projection: %1$s\n%2$s - Stop projection: %1$s - diff --git a/shared/src/main/res/values/ui_strings.xml b/shared/src/main/res/values/strings.xml similarity index 80% rename from shared/src/main/res/values/ui_strings.xml rename to shared/src/main/res/values/strings.xml index 3b4769a..bee15f9 100644 --- a/shared/src/main/res/values/ui_strings.xml +++ b/shared/src/main/res/values/strings.xml @@ -567,4 +567,140 @@ Location is currently unavailable. Check the device location service. Location permission granted. Browser GPS will be shared while connected. Browser GPS forwarding is active. + + Projection sources + Diagnostic sources work without CarPlay authentication. Surface and framebuffer demos show touch feedback; the H.264 clip is read-only. + Choose projection source + Stop projection + Could not change source: %1$s + Waiting for a projection source + Source: %1$s + A projection source is already active. Switch sources in WheelPlay. + Android Auto phone address + Enable Android Auto developer mode and start its Head Unit Server. Enter the phone address reachable from this host (TCP 5277). Audio plays on the host. Microphone uses the Android setting; browser microphone is unavailable. + Enter a valid phone IPv4 address. + ICCOA CarLink needs the proprietary SDK. It is not included in the public MiAuto repository or this build; CarLink protocol integration is unavailable. + Android Auto · This phone + Use Android Auto on this phone in the car browser. First enable its head unit server. Audio plays on this phone; this feature is experimental. + Connect this phone + Setup and connection help + Open Android Auto settings + 1. Open Android Auto settings and complete any initial setup.\n2. Tap the version information repeatedly (usually 10 times), then confirm developer mode.\n3. Open the menu and select Start head unit server.\n4. Return to WheelPlay and tap Connect this phone.\n\nKeep the server running for one-tap connections next time. After a restart, you may need to start it again. No address or cable is needed. Audio plays on this phone; microphone follows the Android microphone setting. Same-phone compatibility still needs device testing. + Android Auto is not installed or is disabled on this phone. Install or enable it on a supported device, finish its initial setup, then return to WheelPlay. Settings may open the system app details page on some versions. + Could not connect to local Android Auto. Open Setup and connection help, start the head unit server in Android Auto, then try again. + Could not open Android Auto settings. Find Android Auto in system Settings and follow the setup instructions. + Sources + CarPlay (iPhone) + Connect an iPhone + Project Android Auto from this phone or another Android phone + CarLink phone projection · Plugin required + Diagnostic sources + Check video output and touch input + Back to sources + Currently unavailable + Connect another Android phone to this device with a USB data cable, then allow USB access when prompted. + Connect Android Auto over USB + Another phone · Head Unit Server + Set address and connect + Use projection in your head unit browser + Projection source + Choose input source + Switch source: %1$s + Input source settings + Set up and connect + Current projection: %1$s\n%2$s + Stop projection: %1$s + + Remote desktop over VNC + Remote desktop + Connect to VNC + Enter a numeric IPv4 or IPv6 address and a port between 1 and 65535. + Server IP address + Port (usually 5900) + Password (not saved) + View only + Connect to an existing VNC server on your LAN or VPN. Touch controls the left mouse button; a mouse supports right-click, dragging and scrolling. Click the desktop to use a physical keyboard. Audio, clipboard and IME input are not supported. + Supports no authentication or traditional VNC passwords only (up to 8 Latin-1 characters). Traffic is not encrypted; use a trusted LAN or VPN. TLS/VeNCrypt is not supported. + Use at most 8 Latin-1 characters. + + Android virtual desktop + Virtual display settings + Display: %1$d × %2$d · %3$d DPI + Changes apply the next time you start the virtual desktop. Higher resolutions use more processing power and memory; higher DPI makes text and controls larger. + Width (pixels) + Height (pixels) + Density (DPI) + Width and height must each be %1$d–%2$d pixels, with at most %3$d pixels in total. DPI must be %4$d–%5$d. Enter whole numbers. + Restore defaults + The desktop helper protocol is incompatible. Restart WheelPlay and reconnect. + The desktop helper was denied permission. Check Shizuku authorization and device restrictions. + The desktop helper rejected the request parameters. Check the display settings and reconnect. + The desktop session is no longer valid. Stop it and reconnect. + The desktop helper does not support the requested capability. + The selected app cannot be launched. Choose an installed, enabled app. + Could not create the virtual display. Try a smaller size or check device compatibility. + The app did not launch on the virtual display. Try another app. + The virtual display size, density or rotation changed. Reconnect to continue. + Virtual desktop input failed. Stop the session and reconnect. + The virtual desktop encountered an unexpected error. Stop the session and reconnect. + Experimental · run a local app on a separate display using Shizuku + Experimental, device validation required. Configurable display size and DPI, image output, touch and Back only. Audio stays on this phone; browser audio, microphone, keyboard and Home are unavailable. This shares your Android system and app data. Some apps may move from the main screen or refuse secondary displays. Protected content may be blank. Rotation requires reconnecting. + Shizuku is not installed. Install and start it, then authorize WheelPlay. + Shizuku is not running. Start it again after a reboot. Android 11+ supports local wireless debugging; older versions need a computer. + WheelPlay is not authorized. Allow access in Shizuku; if previously denied, change its app authorization setting. + Requires Android 10+ and Shizuku 13+ in shell or root mode. Manufacturer compatibility is experimental. + Shizuku authorized · ready to start an experimental session + Install / open Shizuku + Authorize WheelPlay + Choose a launchable local app + No launchable apps are available. + Start virtual desktop + Preparing virtual desktop · waiting for its first frame + Display frame received · waiting for browser image output + Browser image output active · experimental · helper UID %1$d + Releasing virtual desktop… + Shizuku helper timed out. Check authorization and manufacturer background restrictions. + Shizuku or the desktop helper disconnected. Restart Shizuku and reconnect. + No display frame received. This device or app may not support virtual displays. + Virtual desktop cleanup could not be confirmed. Restart WheelPlay before switching sources. + Back + + Releasing plugin resources… + Waiting for the plugin process to exit… + Plugin sources + Add sources from installed plugin apps + Install a compatible plugin APK using Android, then refresh this list. Open the plugin settings to complete its setup and permissions before connecting. Plugins currently provide video and touch; audio and microphone routing are not part of the plugin API. + No compatible plugin services found. + Refresh installed plugins + Enable plugin + Disable plugin + Enabled + Plugin disabled or changed. Enable it to connect. + Enable %1$s (%2$s) as a projection source? It can provide the displayed content and receive touch input while selected. Only enable plugins you trust. Its Android permissions are managed separately. + Plugin settings and permissions + Connect plugin + Connecting to plugin… + Plugin connected + Plugin disconnected + Plugin service or settings are unavailable. Refresh the list or check Android app settings. + Unsupported plugin API version. Update WheelPlay or the plugin. + Cannot verify this plugin signing certificate. + Plugin did not become ready in time. + Plugin cleanup was not confirmed. Projection is blocked. Force stop the plugin in Android settings and restart WheelPlay before reconnecting. + Install a compatible ICCOA CarLink plugin APK, then return to this page to configure, enable and connect it. + Install a CarLink plugin + CarLink plugin: %1$s + The source was denied permission. Check its authorization. + The source connection was interrupted. Reconnect to continue. + The source protocol is incompatible or invalid. + The source configuration is invalid. Check its settings. + Source input failed. Reconnect to continue. + The source did not become ready in time. Try reconnecting. + Source startup cancelled. + Source cleanup could not be confirmed. Restart the app before switching sources. + The source is unavailable. Check its settings and dependencies. + The source encountered an error. Stop it and reconnect. + Connecting to source… + Source ready + Releasing source resources… diff --git a/shared/src/main/res/values/vnc_strings.xml b/shared/src/main/res/values/vnc_strings.xml deleted file mode 100644 index 58fadeb..0000000 --- a/shared/src/main/res/values/vnc_strings.xml +++ /dev/null @@ -1,13 +0,0 @@ - - Remote desktop over VNC - Remote desktop - Connect to VNC - Enter a numeric IPv4 or IPv6 address and a port between 1 and 65535. - Server IP address - Port (usually 5900) - Password (not saved) - View only - Connect to an existing VNC server on your LAN or VPN. Touch controls the left mouse button; a mouse supports right-click, dragging and scrolling. Click the desktop to use a physical keyboard. Audio, clipboard and IME input are not supported. - Supports no authentication or traditional VNC passwords only (up to 8 Latin-1 characters). Traffic is not encrypted; use a trusted LAN or VPN. TLS/VeNCrypt is not supported. - Use at most 8 Latin-1 characters. - diff --git a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionInputCancellationTest.kt b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionInputCancellationTest.kt new file mode 100644 index 0000000..6404f34 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionInputCancellationTest.kt @@ -0,0 +1,18 @@ +package com.shilapi.xcertplay.projection + +import com.shilapi.xcertplay.androidauto.AapTouch +import org.junit.Assert.* +import org.junit.Test + +class ProjectionInputCancellationTest { + @Test fun androidAutoCancelDoesNotEmitUpAndNextGestureStartsFresh() { + val touch = AapTouch(1280, 720) + val contacts = listOf(ProjectionContact(0, .2, .3, true), ProjectionContact(1, .6, .7, true)) + touch.update(contacts) + val cancel = touch.cancel().single() + assertEquals(3, cancel.action); assertEquals(2, cancel.pointers.size) + assertTrue(touch.cancel().isEmpty()) + assertEquals(0, touch.update(contacts.take(1)).single().action) + assertEquals(1, touch.update(emptyList()).single().action) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionLifecycleTest.kt b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionLifecycleTest.kt new file mode 100644 index 0000000..7db06a8 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionLifecycleTest.kt @@ -0,0 +1,135 @@ +package com.shilapi.xcertplay.projection + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.* +import org.junit.Test + +class ProjectionLifecycleTest { + private class Session : ProjectionSession { + override val descriptor = SourceDescriptor("source", "Source") + override val capabilities = ProjectionCapabilities(emptySet()) + override val inputReady = false // Read-only readiness must not depend on input. + var phase = ProjectionPhase.CONNECTING + var error: ProjectionFailure? = null + override val state get() = ProjectionSessionState(phase, capabilities, failure = error) + var activations = 0 + var cancellations = 0 + var stops = 0 + val released = CompletableFuture() + override fun activate() { activations++ } + override fun touch(contacts: List) = error("Cancellation must not synthesize finger-up") + override fun cancelInput(reason: ProjectionCancelReason): Boolean { cancellations++; return true } + override fun stop(): CompletableFuture { stops++; return released } + } + private class Provider : ProjectionSourceProvider { + override val descriptor = SourceDescriptor("source", "Source") + override val resources = setOf(ProjectionResource.DISPLAY) + val pending = CompletableFuture() + val cancelled = CountDownLatch(1) + override fun probe() = SourceAvailability.Ready + override fun open() = pending + override fun open(startup: ProjectionStartup): CompletableFuture { + startup.onCancel { cancelled.countDown() } + return pending + } + } + + @Test fun cancellationSignalsAcquisitionButWaitsForLateSessionCleanupWithoutActivation() { + val runtime = ProjectionRuntime(); val provider = Provider(); runtime.register(provider) + val opening = runtime.open("source") + val stopping = runtime.stop() + assertTrue(provider.cancelled.await(2, TimeUnit.SECONDS)) + assertFalse(stopping.isDone) + assertEquals(ProjectionCleanup.PENDING, runtime.stateSnapshot().cleanup) + val session = Session(); provider.pending.complete(session) + assertEquals(0, session.activations); assertEquals(1, session.stops) + assertEquals(setOf(ProjectionResource.DISPLAY), runtime.resources()) + session.released.complete(Unit); stopping.get(2, TimeUnit.SECONDS) + assertTrue(opening.isCompletedExceptionally) + assertFalse(runtime.hasSession()) + assertEquals(ProjectionErrorCategory.CANCELLED, runtime.stateSnapshot().failure!!.category) + assertEquals(ProjectionCleanup.RELEASED, runtime.stateSnapshot().cleanup) + } + + @Test fun cancellationTimeoutQuarantinesAndLateCompletionCannotReopenOrReleaseOwnership() { + val runtime = ProjectionRuntime(startupTimeoutMs = 20, cleanupTimeoutMs = 30) + val provider = Provider(); runtime.register(provider) + val opening = runtime.open("source") + assertTrue(provider.cancelled.await(2, TimeUnit.SECONDS)) + assertThrows(java.util.concurrent.ExecutionException::class.java) { opening.get(2, TimeUnit.SECONDS) } + assertEquals(ProjectionCleanup.UNCONFIRMED, runtime.stateSnapshot().cleanup) + val session = Session(); provider.pending.complete(session); session.released.complete(Unit) + assertEquals(0, session.activations); assertEquals(1, session.stops) + assertTrue(runtime.hasSession()); assertNull(runtime.snapshot()) + assertFalse(runtime.resources().isEmpty()) + assertTrue(runtime.open("source").isCompletedExceptionally) + } + + @Test fun admissionAndReadOnlyReadinessAreSeparateAndTerminationRetainsTypedError() { + val runtime = ProjectionRuntime(); val provider = Provider(); runtime.register(provider) + val opening = runtime.open("source"); val session = Session(); provider.pending.complete(session) + assertSame(session, opening.join()); assertEquals(1, session.activations) + val ready = runtime.awaitReady(session); assertFalse(ready.isDone) + session.phase = ProjectionPhase.RUNNING; runtime.changed(session) + assertSame(session, ready.join()); assertFalse(runtime.stateSnapshot().inputReady) + session.error = ProjectionFailure(ProjectionErrorCategory.PROTOCOL, "wire detail", "test", "42") + session.phase = ProjectionPhase.FAILED; runtime.ended(session) + assertNull(runtime.snapshot()) + assertEquals(session.error, runtime.stateSnapshot().failure) + assertEquals(ProjectionPhase.FAILED, runtime.stateSnapshot().phase) + } + + @Test fun readinessTimeoutCancelsInputAndStopsButDoesNotReleaseBeforeAcknowledgement() { + val runtime = ProjectionRuntime(startupTimeoutMs = 30) + val session = Session(); val stopped = CountDownLatch(1) + runtime.subscribeState { if (it.phase == ProjectionPhase.STOPPING) stopped.countDown() } + val provider = Provider(); runtime.register(provider) + val opening = runtime.open("source"); provider.pending.complete(session); opening.join() + val ready = runtime.awaitReady(session) + assertTrue(stopped.await(2, TimeUnit.SECONDS)) + assertThrows(java.util.concurrent.ExecutionException::class.java) { ready.get(2, TimeUnit.SECONDS) } + assertFalse(runtime.resources().isEmpty()) + session.released.complete(Unit) + runtime.stop().get(2, TimeUnit.SECONDS) + assertEquals(1, session.cancellations) + assertEquals(ProjectionErrorCategory.TIMEOUT, runtime.stateSnapshot().failure!!.category) + } + + @Test fun stopTimeoutRetainsOwnershipDespiteLateAcknowledgementAndEndedCallback() { + val runtime = ProjectionRuntime(cleanupTimeoutMs = 30) + val session = Session() + runtime.adopt(session, setOf(ProjectionResource.DISPLAY)) + val stopping = runtime.stop() + assertThrows(java.util.concurrent.ExecutionException::class.java) { stopping.get(2, TimeUnit.SECONDS) } + session.released.complete(Unit) + runtime.ended(session) + assertSame(session, runtime.snapshot()) + assertEquals(setOf(ProjectionResource.DISPLAY), runtime.resources()) + assertEquals(ProjectionCleanup.UNCONFIRMED, runtime.stateSnapshot().cleanup) + assertEquals(ProjectionErrorCategory.CLEANUP, runtime.stateSnapshot().failure!!.category) + assertFalse(runtime.cancelInput(session, ProjectionCancelReason.USER_REQUEST)) + } + + @Test fun failedAcquisitionCleanupOverridesTheOriginalBackendErrorCategory() { + val backend = ProjectionFailure(ProjectionErrorCategory.INPUT, "injection failed", "desktop", "11") + val failure = projectionFailure(ProjectionCleanupException("producer still active", ProjectionException(backend))) + assertEquals(ProjectionErrorCategory.CLEANUP, failure.category) + assertEquals("desktop", failure.backend) + assertEquals("11", failure.backendCode) + assertTrue(failure.diagnostic.contains("producer still active")) + assertTrue(failure.diagnostic.contains("injection failed")) + } + + @Test fun cleanupFailureRemainsTypedAndCancellationBypassesInputCapabilities() { + val runtime = ProjectionRuntime(); val session = Session(); runtime.adopt(session, setOf(ProjectionResource.DISPLAY)) + assertTrue(runtime.cancelInput(session, ProjectionCancelReason.FOCUS_LOST)) + val stopping = runtime.stop() + session.released.completeExceptionally(IllegalStateException("producer still active")) + assertTrue(stopping.isCompletedExceptionally) + assertEquals(ProjectionErrorCategory.CLEANUP, runtime.stateSnapshot().failure!!.category) + assertEquals(ProjectionCleanup.UNCONFIRMED, runtime.stateSnapshot().cleanup) + assertSame(session, runtime.snapshot()) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt index 1ead699..c362397 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt @@ -30,6 +30,28 @@ class ProjectionRuntimeTest { private val finger = ProjectionContact(0, .2, .8, true) private fun setup() { runtime.adopt(old, setOf(ProjectionResource.DISPLAY)); runtime.register(next) } + @Test fun backCapabilityDoesNotGrantKeyboardAndRejectsPreviousSession() { + val keys = mutableListOf() + val desktop = object : ProjectionSession { + override val descriptor = SourceDescriptor("desktop", "desktop") + override val capabilities = ProjectionCapabilities(setOf(VideoIngressKind.SURFACE), 10, backButton = true) + override val inputReady = true + override fun touch(contacts: List) = true + override fun input(event: ProjectionInput): Boolean { keys += event; return true } + override fun stop() = CompletableFuture.completedFuture(Unit) + } + runtime.adopt(desktop, setOf(ProjectionResource.DISPLAY)) + assertTrue(runtime.input(desktop, ProjectionInput.Key("BrowserBack", true))) + assertFalse(runtime.input(desktop, ProjectionInput.Key("a", true))) + assertFalse(runtime.input(desktop, ProjectionInput.Key("Home", true))) + assertTrue(runtime.input(desktop, ProjectionInput.Release)) + runtime.stop().join() + runtime.adopt(old, emptySet()) + assertFalse(runtime.input(desktop, ProjectionInput.Key("BrowserBack", true))) + assertFalse(runtime.touch(desktop, listOf(finger))) + assertEquals(1, keys.count { it == ProjectionInput.Key("BrowserBack", true) }) + } + @Test fun discoveryRemovalCannotReleaseAnActiveSessionOrReplaceBuiltins() { setup() val plugin = Provider("plugin-example") diff --git a/test/stream-client.test.js b/test/stream-client.test.js index 7cba909..33b2c53 100644 --- a/test/stream-client.test.js +++ b/test/stream-client.test.js @@ -590,12 +590,33 @@ test('a same-version RTC offer keeps the pending JPEG available if negotiation f assert.equal(f.element('touch-surface').classList.contains('stale'), false); }); +test('versioned pointer cancellation sends a cancel command while ordinary up still releases the contact', async () => { + for (const event of ['pointercancel', 'lostpointercapture']) { + const f = fixture(), ws = await f.connect(); mediaStatus(ws); + ws.onmessage({ data: videoPacket(1) }); finishJpeg(f); + f.pointer('pointerdown', 100, 'touch-surface'); + f.pointer('pointermove', 200, 'touch-surface'); + f.pointer(event, 200, 'touch-surface'); + const afterCancel = ws.sent.length; + f.flushMoves(); f.pointer('pointerup', 200, 'touch-surface'); + assert.equal(ws.sent.length, afterCancel); + assert.deepEqual(ws.sent.filter(m => m.type === 'input').at(-1), { + type: 'input', kind: 'release', sessionId: 7, videoEpoch: 1, + }); + assert.deepEqual(ws.sent.filter(m => m.type === 'touch').map(m => m.contacts[0].down), [true]); + f.pointer('pointerdown', 100, 'touch-surface'); f.pointer('pointerup', 100, 'touch-surface'); + f.pointer('lostpointercapture', 100, 'touch-surface'); + assert.equal(ws.sent.filter(m => m.type === 'input').length, 1); + assert.equal(ws.sent.filter(m => m.type === 'touch').at(-1).contacts[0].down, false); + } +}); + test('a source gap releases touch, keeps the picture, and expires with no more status messages', async () => { const f = fixture(), ws = await f.connect(); mediaStatus(ws); ws.onmessage({ data: videoPacket(1) }); finishJpeg(f); f.pointer('pointerdown', 100, 'touch-surface'); mediaStatus(ws, { sourceState: 'recovering', streaming: false, inputReady: false }); - assert.deepEqual(ws.sent.filter(m => m.type === 'touch').at(-1).contacts, []); + assert.deepEqual(ws.sent.filter(m => m.type === 'input').at(-1), { type: 'input', kind: 'release', sessionId: 7, videoEpoch: 1 }); assert.equal(f.element('waiting').hidden, true); assert.equal(f.element('media-message').hidden, false); assert.match(f.element('media-message').textContent, /触控已暂停/); @@ -643,7 +664,7 @@ test('a rotated JPEG arriving before status disables old touch and commits the n ws.onmessage({ data: videoPacket(1) }); finishJpeg(f); f.pointer('pointerdown', 100, 'touch-surface'); ws.onmessage({ data: videoPacket(2, { width: 720, height: 1280 }) }); - assert.deepEqual(ws.sent.filter(m => m.type === 'touch').at(-1).contacts, []); + assert.deepEqual(ws.sent.filter(m => m.type === 'input').at(-1), { type: 'input', kind: 'release', sessionId: 7, videoEpoch: 2 }); finishJpeg(f, 720, 1280); assert.equal(f.element('touch-surface').classList.contains('stale'), true, 'input needs a ready status'); mediaStatus(ws, { videoEpoch: 2, width: 720, height: 1280 }); @@ -828,7 +849,7 @@ test('Direct Canvas preserves versioned media isolation and input readiness acro await f.offer(ws, 1, { sessionId: 7, videoEpoch: 1 }); await f.decoded(); f.pointer('pointerdown', 100, 'touch-surface'); mediaStatus(ws, { videoEpoch: 2, sourceState: 'recovering', inputReady: false }); - assert.deepEqual(ws.sent.filter(m => m.type === 'touch').at(-1).contacts, []); + assert.deepEqual(ws.sent.filter(m => m.type === 'input').at(-1), { type: 'input', kind: 'release', sessionId: 7, videoEpoch: 2 }); assert.equal(f.element('held-frame').hidden, false); assert.equal(f.element('touch-surface').classList.contains('stale'), true); const count = f.peers.length;