From 19e8b3ab8c4634aa72e52ffbf1106d902f008fd3 Mon Sep 17 00:00:00 2001 From: Siubeng Date: Wed, 21 Oct 2026 13:16:17 +0000 Subject: [PATCH] feat: introduce multi-source projection runtime and diagnostic inputs Add protocol-neutral session and input contracts, retain CarPlay through a compatibility adapter, and exercise encoded, Surface and framebuffer ingress. Keep resource ownership until teardown completes and allow the Web host without MFi provisioning. Refs #28; real Android Auto, ICCOA and desktop/VNC adapters remain follow-up stages. --- README.md | 3 +- .../src/main/assets/projection/demo.h264clip | Bin 0 -> 69420 bytes common/src/main/assets/web/app.js | 8 +- .../shilapi/xcertplay/CarPlayHostActivity.kt | 91 ++++++- .../com/shilapi/xcertplay/DiPlayActivity.kt | 50 +++- .../shilapi/xcertplay/DiPlaySessionService.kt | 36 +-- .../media/CarPlayMediaSessionBridge.kt | 6 +- .../projection/BrowserProjectionVideo.kt | 117 +++++++++ .../projection/CarPlaySourceSession.kt | 39 +++ .../projection/ProjectionSessions.kt | 14 ++ .../projection/ProjectionVideoOutput.kt | 18 ++ .../xcertplay/projection/SyntheticSource.kt | 122 +++++++++ .../com/shilapi/xcertplay/web/LanWebServer.kt | 9 +- .../xcertplay/web/NativeVideoDecoder.kt | 2 +- .../shilapi/xcertplay/web/RtcVideoStream.kt | 2 +- .../com/shilapi/xcertplay/web/TouchLease.kt | 18 +- .../com/shilapi/xcertplay/web/WebSession.kt | 110 ++++++--- .../shilapi/xcertplay/web/WebVideoSource.kt | 12 +- .../xcertplay/AuthenticationActivationTest.kt | 30 ++- .../projection/ProjectionRoutingTest.kt | 86 +++++++ .../projection/SyntheticSourceTest.kt | 83 +++++++ .../shilapi/xcertplay/web/LanWebServerTest.kt | 11 +- docs/AUTHENTICATION_IMPORT.md | 2 +- docs/INPUT_SOURCE_FRAMEWORK.md | 231 ++++++++++++++++++ docs/PROJECTION_IMPLEMENTATION.md | 48 ++++ docs/README.md | 4 +- scripts/generate-projection-fixture.py | 29 +++ scripts/test-media-presentation.mjs | 19 +- .../shilapi/xcertplay/airplay/AirPlayHid.kt | 9 +- .../shilapi/xcertplay/airplay/ScreenStream.kt | 2 +- .../xcertplay/media/AndroidMediaSink.kt | 56 ++++- .../xcertplay/media/CarPlayMediaSource.kt | 5 +- .../xcertplay/media/CarPlayTouchMapper.kt | 8 +- .../xcertplay/media/CompressedVideoFrame.kt | 1 - .../xcertplay/media/EncodedVideoSink.kt | 3 - .../xcertplay/media/MediaCodecSupport.kt | 1 - .../xcertplay/media/MicrophoneUplink.kt | 10 + .../com/shilapi/xcertplay/media/VideoCodec.kt | 4 + .../xcertplay/media/VideoDecodeQueue.kt | 1 - .../projection/ProjectionFramebuffer.kt | 27 ++ .../xcertplay/projection/ProjectionRuntime.kt | 159 ++++++++++++ .../xcertplay/projection/ProjectionSource.kt | 54 ++++ .../xcertplay/web/ImageFrameEncoder.kt | 2 +- .../xcertplay/web/OffscreenVideoOutput.kt | 20 +- .../res/values-zh-rCN/projection_strings.xml | 11 + .../res/values-zh-rTW/projection_strings.xml | 11 + .../main/res/values/projection_strings.xml | 11 + .../projection/ProjectionFramebufferTest.kt | 34 +++ .../projection/ProjectionRuntimeTest.kt | 119 +++++++++ 49 files changed, 1585 insertions(+), 163 deletions(-) create mode 100644 common/src/main/assets/projection/demo.h264clip create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt create mode 100644 common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/ProjectionRoutingTest.kt create mode 100644 common/src/test/java/com/shilapi/xcertplay/projection/SyntheticSourceTest.kt create mode 100644 docs/INPUT_SOURCE_FRAMEWORK.md create mode 100644 docs/PROJECTION_IMPLEMENTATION.md create mode 100644 scripts/generate-projection-fixture.py create mode 100644 shared/src/main/java/com/shilapi/xcertplay/media/VideoCodec.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionFramebuffer.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionRuntime.kt create mode 100644 shared/src/main/java/com/shilapi/xcertplay/projection/ProjectionSource.kt create mode 100644 shared/src/main/res/values-zh-rCN/projection_strings.xml create mode 100644 shared/src/main/res/values-zh-rTW/projection_strings.xml create mode 100644 shared/src/main/res/values/projection_strings.xml create mode 100644 shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionFramebufferTest.kt create mode 100644 shared/src/test/java/com/shilapi/xcertplay/projection/ProjectionRuntimeTest.kt diff --git a/README.md b/README.md index db366cc8..5c333924 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Install the app on an Android device and open the displayed address in your car' ## Features +- **Multiple-source framework**: A single active projection session routes video, touch and media controls independently of CarPlay. Three diagnostic sources exercise H.264, Surface and framebuffer output; Android Auto and ICCOA adapters are follow-up work. See the [implementation and device test guide](docs/PROJECTION_IMPLEMENTATION.md). - **Browser display and touch control**: CarPlay streaming, touch input, and fullscreen display. - **Selectable streaming modes**: Choose WebRTC video passthrough (H.264 / HEVC) or image compatibility mode in settings, with JPEG / WebP format and image quality from 1 to 100. Image streaming retains JPEG compatibility fallback and overlaps up to two frames while audio and touch keep their existing routes. - **Optional browser audio**: After pairing, the browser can play CarPlay audio. The browser microphone can provide CarPlay voice input through a trusted HTTPS page; Android handles audio and microphone input by default. @@ -79,7 +80,7 @@ Use Android NDK 28.2.13676358 and CMake 3.22.1. The first build fetches pinned v ./gradlew :mobile:assembleDebug ``` -Source-only builds omit CarPlay authentication resources. Web and CarPlay remain disabled until valid resources are configured; successful import automatically starts the Web service. APKs with valid bundled resources work immediately and still allow replacement, with imported resources taking priority. Open **Settings → CarPlay authentication resources** to select `identity.pk8` and `certificate.p7b`, or follow the DiPlay website download link and import the downloaded APK. You can also share that APK with WheelPlay from a file manager; DiPlay does not need to be installed. Imports run locally and replace existing resources only after key/certificate validation. Resources stay in private, backup-excluded app storage and are never uploaded. See the [authentication import guide](docs/AUTHENTICATION_IMPORT.md). +Source-only builds omit CarPlay authentication resources. The Web service and diagnostic projection sources work without these resources; only CarPlay connections require a valid identity. APKs with valid bundled resources work immediately and still allow replacement, with imported resources taking priority. Open **Settings → CarPlay authentication resources** to select `identity.pk8` and `certificate.p7b`, or follow the DiPlay website download link and import the downloaded APK. You can also share that APK with WheelPlay from a file manager; DiPlay does not need to be installed. Imports run locally and replace existing resources only after key/certificate validation. Resources stay in private, backup-excluded app storage and are never uploaded. See the [authentication import guide](docs/AUTHENTICATION_IMPORT.md). Developers may still set `WHEELPLAY_AUTH_ASSETS_DIR` (or `DIPLAY_AUTH_ASSETS_DIR`) to a local asset directory, or use `.local/auth-assets/`, containing `offline-mfi/identity.pk8` and `offline-mfi/certificate.p7b`. These files stay out of Git. Run `./gradlew :mobile:assembleProvisionedDebug` to build and verify both packaged assets against the local input. Public source-only APKs should use the ordinary build without configured authentication assets, letting users import their own resources after installation. diff --git a/common/src/main/assets/projection/demo.h264clip b/common/src/main/assets/projection/demo.h264clip new file mode 100644 index 0000000000000000000000000000000000000000..586b7ba95b57a452e24629248726020838e9479f GIT binary patch literal 69420 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 { if (data.type === 'touch' && presentation.current.videoEpoch !== null) { const { sessionId, videoEpoch } = presentation.current; @@ -1154,6 +1155,8 @@ if (typeof document !== 'undefined') { performanceStats.transport = data.performance?.transport ?? null; performanceStats.image = data.performance?.image ?? null; lastStatus = Date.now(); + const nextTouchLimit = Number.isInteger(data.maxTouchContacts) ? Math.max(0, Math.min(10, data.maxTouchContacts)) : 2; + if (nextTouchLimit !== maxTouchContacts) { release(); maxTouchContacts = nextTouchLimit; } const previousRevision = presentation.current.revision; if (!presentation.status(data)) return; if (previousRevision !== presentation.current.revision) { @@ -1287,11 +1290,12 @@ if (typeof document !== 'undefined') { function bindSurface(surface) { surface.addEventListener('contextmenu', event => event.preventDefault()); surface.addEventListener('pointerdown', event => { - if (!live || pointers.size >= 2 || (event.pointerType === 'mouse' && event.button !== 0)) return; + if (!live || pointers.size >= maxTouchContacts || (event.pointerType === 'mouse' && event.button !== 0)) return; const point = mapPoint(event.clientX, event.clientY, surface.getBoundingClientRect(), width, height); if (!point) return; event.preventDefault(); - const id = [...pointers.values()].some(p => p.id === 0) ? 1 : 0; + const used = new Set([...pointers.values()].map(p => p.id)); + let id = 0; while (used.has(id)) id++; pointers.set(event.pointerId, { id, ...point }); surface.setPointerCapture(event.pointerId); cancelMove(); diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index 5e1ef7d8..37327a12 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -1,5 +1,9 @@ package com.shilapi.xcertplay +import com.shilapi.xcertplay.projection.CarPlaySourceSession +import com.shilapi.xcertplay.projection.ProjectionResource +import com.shilapi.xcertplay.projection.projectionSessions + import com.shilapi.xcertplay.shared.R as UiR import com.shilapi.xcertplay.i18n.* @@ -413,6 +417,10 @@ class CarPlayHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) showSystemBars() + if (projectionSessions.hasSession() && + projectionSessions.snapshot()?.descriptor?.id != "carplay") { + showDiPlayHome("home"); finish(); return + } if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED") { AirPlayPersistence.saveWirelessEnabled(this, false) } @@ -2792,8 +2800,14 @@ class CarPlayHostActivity : ComponentActivity() { return AndroidMediaSink( surface = output.surface, encodedVideo = source, + frameDecoderFactory = { key -> + try { + com.shilapi.xcertplay.web.NativeVideoDecoder(key, platform = true) + .also { source.setProcessorName(it.name) } + } catch (_: LinkageError) { null } + }, remoteAudio = WebSession.audio.createRoute(), - onClosed = { output.close(); WebSession.endVideo(videoGeneration) }, + onClosed = { output.closeAndAwait(); WebSession.endVideo(videoGeneration) }, videoWidth = videoWidth, videoHeight = videoHeight, preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled, @@ -2833,6 +2847,7 @@ class CarPlayHostActivity : ComponentActivity() { override fun onSessionEnded(session: AirPlaySession) { runOnUiThread { + if (controllerGeneration != restartGeneration) return@runOnUiThread if (activeAirPlaySession === session) activeAirPlaySession = null CarPlayBackgroundSession.active = false WebSession.videoActive = false @@ -2899,10 +2914,6 @@ class CarPlayHostActivity : ComponentActivity() { private fun adoptBackgroundSession(): Boolean { val snapshot = CarPlayBackgroundSession.snapshot() ?: return false - if (snapshot.controller.isClosed()) { - CarPlayBackgroundSession.clear(snapshot.controller) - return false - } displayDiagnosticAttempt = DisplayDiagnosticSnapshot.currentAttempt(this) controller = snapshot.controller sink = snapshot.sink @@ -2915,6 +2926,10 @@ class CarPlayHostActivity : ComponentActivity() { if (snapshot.width > 0 && snapshot.height > 0) { activeDisplaySize = DisplaySize(snapshot.width, snapshot.height) } + if (snapshot.controller.isClosed()) { + restartCarPlay("Replacing closed background CarPlay controller") + return true + } val generation = restartGeneration snapshot.controller.attachUi( createSessionListener(generation), @@ -2956,6 +2971,13 @@ class CarPlayHostActivity : ComponentActivity() { setLocalizedConnectionStage(UiText(UiR.string.ui_authentication_resources_are_not_ready_import_files_or_a)) return } + if (!CarPlayBackgroundSession.reserve(this, { WebSession.stageDetail(this) }) { completion -> + runOnUiThread { shutdown(false, "WheelPlay disconnect", completion); finish() } + }) { + showDiPlayHome("home") + finish() + return + } val controllerGeneration = restartGeneration // The settings page can change branding while this activity remains in the back stack. simulatedManufacturerName = AirPlayPersistence.loadSimulatedManufacturerName(this) @@ -2988,6 +3010,8 @@ class CarPlayHostActivity : ComponentActivity() { ) } catch (error: Exception) { Log.e(TAG, "Offscreen video initialization failed", error) + if (error is com.shilapi.xcertplay.projection.ProjectionCleanupException) + CarPlayBackgroundSession.teardownFinished(false) setLocalizedConnectionStage(UiText(UiR.string.host_this_device_cannot_create_an_offscreen_decoder_surface_check)) return } @@ -3174,9 +3198,16 @@ class CarPlayHostActivity : ComponentActivity() { controller = null sink = null teardownExecutor.execute { - oldController?.close() - oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) - oldSink?.close() + val clean = runCatching { + oldController?.close() + val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true + if (transportStopped) oldSink?.close(awaitWorkers = true) + transportStopped && CarPlayBackgroundSession.teardownClean + }.getOrDefault(false) + if (!clean) { + CarPlayBackgroundSession.teardownFinished(false) + return@execute + } runOnUiThread { if (!shuttingDown.get() && generation == restartGeneration) { handshakeResetInProgress = false @@ -3247,16 +3278,22 @@ class CarPlayHostActivity : ComponentActivity() { sink = null Log.i(TAG, "shutdown reason=$reason terminateProcess=$terminateProcess") teardownExecutor.execute { - oldController?.close() - val clean = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true - oldSink?.close() + val clean = runCatching { + oldController?.close() + val transportStopped = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true + if (transportStopped) oldSink?.close(awaitWorkers = true) + transportStopped && CarPlayBackgroundSession.teardownClean + }.getOrDefault(false) if (terminateProcess) { applicationContext.stopService(Intent(applicationContext, CarPlayVpnService::class.java)) } Log.i(TAG, "shutdown complete clean=$clean") - applicationContext.stopService(Intent(applicationContext, DiPlaySessionService::class.java)) + if (terminateProcess) applicationContext.stopService(Intent(applicationContext, DiPlaySessionService::class.java)) teardownExecutor.shutdown() - mainHandler.post { completion() } + mainHandler.post { + completion() + CarPlayBackgroundSession.teardownFinished(clean) + } if (terminateProcess) Process.killProcess(Process.myPid()) } } @@ -3543,6 +3580,31 @@ internal object CarPlayBackgroundSession { val height: Int, ) + @Volatile var teardownClean = true + private set + + @Synchronized fun reserve(owner: Any, status: () -> String?, stop: (() -> Unit) -> Unit): Boolean { + if (projection != null) return this.owner === owner + val adapter = CarPlaySourceSession(status) + if (!projectionSessions.adopt(adapter, + ProjectionResource.entries.toSet())) return false + projection = adapter + this.owner = owner + stopAction = stop + teardownClean = true + return true + } + + fun teardownFinished(clean: Boolean) { + val adapter = synchronized(this) { + teardownClean = clean + projection.also { if (clean) projection = null } + } + adapter?.completed(clean) + if (clean) adapter?.let { projectionSessions.ended(it) } + } + + private var projection: CarPlaySourceSession? = null private var controller: CarPlayController? = null private var sink: AndroidMediaSink? = null private var width = 0 @@ -3568,9 +3630,11 @@ internal object CarPlayBackgroundSession { this.owner = owner this.controller = controller this.sink = sink + projection?.bind(controller, sink) this.width = width this.height = height } + projection?.let { projectionSessions.changed(it) } listeners.forEach { it(snapshot()) } } @@ -3579,6 +3643,7 @@ internal object CarPlayBackgroundSession { if (expected != null && controller !== expected) return controller = null sink = null + projection?.bind(null, null) if (!keepOwner) { stopAction = null; owner = null } active = false width = 0 diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 1f2b3f3b..4444156d 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.projectionSessions +import com.shilapi.xcertplay.projection.registerProjectionSources + import com.shilapi.xcertplay.shared.R as UiR import com.shilapi.xcertplay.i18n.* @@ -272,7 +275,7 @@ class DiPlayActivity : AppCompatActivity() { initialLaunch = false if (setupError == null && !DiPlayBootstrap.importing && selectedPrivateKey == null && intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_VIEW && - !CarPlayBackgroundSession.hasSession() && + !projectionSessions.hasSession() && DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) } } @@ -477,8 +480,7 @@ class DiPlayActivity : AppCompatActivity() { private fun refreshService() { val addresses = if (WebSession.running && WebSession.error == null) WebSession.addresses(this) else emptyList() val primary = addresses.firstOrNull() - val addressText = if (!WebSession.running && setupError != null) getString(UiR.string.ui_import_authentication_resources_to_activate_the_web_service) - else WebSession.error ?: if (WebSession.running) primary?.url + val addressText = WebSession.error ?: if (WebSession.running) primary?.url ?: getString(UiR.string.ui_no_local_network_address_found_connect_to_wi_fi) else getString(UiR.string.ui_starting_service) if (webAddress?.text?.toString() != addressText) webAddress?.text = addressText webInterface?.text = primary?.let { it.description(this) + if (it.hostname.isNotEmpty()) getString(UiR.string.address_direct_ip, it.ipUrl) else "" }.orEmpty() @@ -498,9 +500,8 @@ class DiPlayActivity : AppCompatActivity() { quickBrowserButton?.isEnabled = WebSession.running && BrowserExperienceLink.local(WebSession.code, WebSession.httpPort) != null val viewerConnected = WebSession.hasViewer webPairingControls?.visibility = if (viewerConnected) View.GONE else View.VISIBLE - webViewer?.text = if (!WebSession.running && setupError != null) getString(UiR.string.ui_import_authentication_resources_to_pair_a_browser) - else if (viewerConnected) getString(UiR.string.ui_head_unit_browser_connected) else getString(UiR.string.ui_waiting_for_head_unit_browser) - webStage?.text = setupError ?: if (WebSession.videoActive) getString(UiR.string.ui_streaming_carplay_video) else WebSession.stage(this) + webViewer?.text = if (viewerConnected) getString(UiR.string.ui_head_unit_browser_connected) else getString(UiR.string.ui_waiting_for_head_unit_browser) + webStage?.text = WebSession.stage(this) } private fun openLocalBrowserExperience() { @@ -521,7 +522,17 @@ class DiPlayActivity : AppCompatActivity() { } private fun home(content: LinearLayout) { - content.addView(label(getString(UiR.string.ui_connect_iphone), 30, TEXT, true)) + content.addView(label(getString(UiR.string.projection_sources), 30, TEXT, true)) + ui.card(content, getString(UiR.string.projection_sources), R.drawable.ic_server_phone) { body -> + body.addView(label(getString(UiR.string.projection_demo_hint), 15, MUTED)) + body.addView(button(getString(UiR.string.projection_choose_demo), false) { chooseProjectionSource() }, matchButton(12)) + body.addView(button(getString(UiR.string.projection_stop), false) { + projectionSessions.stop().whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + refreshStatus(); refreshService() + } } + }, ui.secondaryButtonLayout(12)) + } content.addView(label(getString(UiR.string.ui_this_device_receives_carplay_the_head_unit_browser_provides), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) status = label(getString(UiR.string.ui_waiting_for_connection), 20, ACCENT, true) ui.card(content, getString(UiR.string.ui_connection_status), R.drawable.ic_server_phone) { it.addView(status) } @@ -551,6 +562,19 @@ class DiPlayActivity : AppCompatActivity() { setupError?.let { content.addView(label(it, 16, WARNING)) } } + private fun chooseProjectionSource() { + registerProjectionSources(this) + val sources = projectionSessions.sources() + android.app.AlertDialog.Builder(this).setTitle(getString(UiR.string.projection_choose_demo)) + .setItems(sources.map { it.descriptor.displayName }.toTypedArray()) { _, index -> + startWebServiceIfReady() + projectionSessions.switchTo(sources[index].descriptor.id).whenComplete { _, failure -> runOnUiThread { + if (failure != null) toast(getString(UiR.string.projection_failed, failure.cause?.message ?: failure.message.orEmpty())) + refreshStatus(); refreshService() + } } + }.setNegativeButton(android.R.string.cancel, null).show() + } + private fun refreshAuthentication() { setupError = if (DiPlayBootstrap.importing) getString(UiR.string.ui_importing_authentication_resources_please_wait) else runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { @@ -559,9 +583,7 @@ class DiPlayActivity : AppCompatActivity() { } private fun startWebServiceIfReady() { - if (setupError == null && !DiPlayBootstrap.importing) { - startForegroundService(Intent(this, DiPlaySessionService::class.java)) - } + startForegroundService(Intent(this, DiPlaySessionService::class.java)) } private fun canImportAuthentication(): Boolean { @@ -719,8 +741,10 @@ class DiPlayActivity : AppCompatActivity() { AirPlayPersistence.saveWirelessEnabled(this, wireless) openProjection() } - if (CarPlayBackgroundSession.hasSession()) CarPlayBackgroundSession.stop { runOnUiThread { open() } } - else open() + 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 open() } private fun openProjection() { navigate("service") @@ -818,6 +842,8 @@ class DiPlayActivity : AppCompatActivity() { private fun refreshStatus() { val running = CarPlayBackgroundSession.hasSession() status?.text = when { + projectionSessions.snapshot()?.descriptor?.id?.let { it != "carplay" } == true -> + projectionSessions.snapshot()?.descriptor?.displayName setupError != null -> getString(UiR.string.ui_authentication_setup_needs_attention) CarPlayBackgroundSession.active -> getString(UiR.string.ui_carplay_connected) running -> getString(UiR.string.ui_connecting_to_iphone) diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt index 412bff7b..1ae895f2 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -27,6 +27,7 @@ import com.shilapi.xcertplay.shared.R as UiR import com.shilapi.xcertplay.web.RootAccess import com.shilapi.xcertplay.web.WebListenSettings import com.shilapi.xcertplay.web.WebSession +import com.shilapi.xcertplay.projection.projectionSessions /** Keeps the connection and its media session alive while no dashboard is visible. */ class DiPlaySessionService : Service() { @@ -35,13 +36,13 @@ class DiPlaySessionService : Service() { private var sessionSubscription: AutoCloseable? = null private val main = Handler(Looper.getMainLooper()) private var destroyed = false - private var activationBlocked = false private var currentState = NowPlayingState() private var currentSession: MediaSession? = null private var currentArtwork: Bitmap? = null override fun onCreate() { super.onCreate() + com.shilapi.xcertplay.projection.registerProjectionSources(this) if ( com.shilapi.xcertplay.web.TeslaHttpCompatibility.config(this).let { it.enabled && it.mode == com.shilapi.xcertplay.network.TeslaHttpMode.ROOT @@ -64,11 +65,12 @@ class DiPlaySessionService : Service() { if (!destroyed) updateNotification() } sessionSubscription = - CarPlayBackgroundSession.subscribe { _ -> + projectionSessions.subscribe { _ -> main.post { if (!destroyed) { - val current = CarPlayBackgroundSession.snapshot() - media.bind(current?.controller, current?.sink) + val current = projectionSessions.snapshot() + media.bind(current?.media, current?.mediaOutput) + updateNotification() } } } @@ -81,35 +83,19 @@ class DiPlaySessionService : Service() { if (intent?.action == ACTION_STOP) { media.close() runWebAction { WebSession.stop() } - CarPlayBackgroundSession.stop() + projectionSessions.stop() stopSelf() return START_NOT_STICKY } if ( intent?.action == ACTION_MEDIA && !currentState.available && - !CarPlayBackgroundSession.hasSession() + !projectionSessions.hasSession() ) { stopSelf() return START_NOT_STICKY } - activationBlocked = false updateNotification() - // Foreground startup must be acknowledged even when an external start is rejected. - // Never open listeners or acquire a wake lock without a validated identity. - if ( - !(DiPlayBootstrap.importing && WebSession.running) && - runCatching { DiPlayBootstrap.ensure(this) }.isFailure - ) { - activationBlocked = true - WebSession.stop() - CarPlayBackgroundSession.stop() - wakeLock?.let { if (it.isHeld) it.release() } - wakeLock = null - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - return START_NOT_STICKY - } if (intent?.action == ACTION_MEDIA) { if (intent.getLongExtra(EXTRA_GENERATION, -1) == media.commandGeneration) { intent.getStringExtra(EXTRA_COMMAND)?.let { name -> @@ -163,7 +149,7 @@ class DiPlaySessionService : Service() { } private fun updateNotification() { - if (destroyed || activationBlocked) return + if (destroyed) return val open = PendingIntent.getActivity( this, @@ -253,6 +239,8 @@ class DiPlaySessionService : Service() { types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK if ( Build.VERSION.SDK_INT >= 30 && + projectionSessions.snapshot()?.capabilities?.microphone == true && + AirPlayPersistence.loadMicrophoneSource(this) != CarPlayMicrophoneSource.DISABLED && checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED ) { @@ -269,7 +257,7 @@ class DiPlaySessionService : Service() { runWebAction { WebSession.stop() } wakeLock?.let { if (it.isHeld) it.release() } wakeLock = null - CarPlayBackgroundSession.stop() + projectionSessions.stop() super.onDestroy() } diff --git a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt index 905d39b6..e4f69c74 100644 --- a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt +++ b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt @@ -23,14 +23,14 @@ internal class CarPlayMediaSessionBridge( ) : AutoCloseable { private val main = Handler(Looper.getMainLooper()) private val audio = context.getSystemService(AudioManager::class.java) - internal val session = MediaSession(context, "WheelPlay iPhone") + internal val session = MediaSession(context, "WheelPlay projection") private val artworkWorker = Executors.newSingleThreadExecutor { Thread(it, "carplay-artwork-preview").apply { isDaemon = true } } private val defaultArtwork = Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888).also { bitmap -> context.applicationInfo.loadIcon(context.packageManager).apply { setBounds(0, 0, 128, 128); draw(Canvas(bitmap)) } } - private var source: CarPlayMediaSource? = null + private var source: MediaControlSource? = null private var output: MediaOutputControl? = null private var subscription: AutoCloseable? = null private var routeSubscription: AutoCloseable? = null @@ -90,7 +90,7 @@ internal class CarPlayMediaSessionBridge( }, main) } - fun bind(next: CarPlayMediaSource?, nextOutput: MediaOutputControl?) { + fun bind(next: MediaControlSource?, nextOutput: MediaOutputControl?) { if (closed || source === next && output === nextOutput) return val generation = ++epoch ++commandGeneration diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt new file mode 100644 index 00000000..d094a735 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/BrowserProjectionVideo.kt @@ -0,0 +1,117 @@ +package com.shilapi.xcertplay.projection + +import android.content.Context +import android.graphics.Bitmap +import android.view.Surface +import com.shilapi.xcertplay.AirPlayPersistence +import com.shilapi.xcertplay.media.AndroidMediaSink +import com.shilapi.xcertplay.media.CompressedVideoFrame +import com.shilapi.xcertplay.media.VideoCodec +import com.shilapi.xcertplay.web.ImageFrameEncoder +import com.shilapi.xcertplay.web.OffscreenVideoOutput +import com.shilapi.xcertplay.web.StreamTechnology +import com.shilapi.xcertplay.web.WebSession +import com.shilapi.xcertplay.web.WebVideoSource +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** Android output endpoint shared by encoded, Surface and framebuffer sources. */ +internal class BrowserProjectionVideo( + context: Context, val width: Int, val height: Int, + private val kind: VideoIngressKind, +) : ProjectionVideoOutput { + private val app = context.applicationContext + private val closed = AtomicBoolean() + private var generation = -1L + private val encoded = WebVideoSource(StreamTechnology.WEBRTC) + private var offscreen: OffscreenVideoOutput? = null + private var decoder: AndroidMediaSink? = null + private val images = Executors.newSingleThreadScheduledExecutor { Thread(it, "projection-images").apply { isDaemon = true } } + private val pending = AtomicReference() + private var latest: ProjectionFramebuffer.Snapshot? = null + private val imageEncoder = ImageFrameEncoder() + private var hadViewer = false + private var configured = false + private var revision = 0L + + init { + require(width in 1..8192 && height in 1..8192 && width.toLong() * height <= 8_388_608) + try { + if (kind != VideoIngressKind.FRAMEBUFFER) offscreen = OffscreenVideoOutput(width, height, + wanted = { !closed.get() && WebSession.hasViewer && (kind != VideoIngressKind.ENCODED || encoded.jpegNeeded) }, + encoding = { AirPlayPersistence.imageEncoding(app, WebSession.imageFormat) }, + publish = { bytes, format, quality -> + if (!closed.get()) { + WebSession.setVideoActive(generation, true) + WebSession.publish(generation, bytes, format, quality) + } + }) + if (kind == VideoIngressKind.ENCODED) decoder = AndroidMediaSink( + surface = offscreen!!.surface, videoWidth = width, videoHeight = height, encodedVideo = encoded) + generation = WebSession.beginVideo(width, height) + if (kind == VideoIngressKind.ENCODED) WebSession.attachVideoSource(generation, encoded) + if (kind == VideoIngressKind.FRAMEBUFFER) images.scheduleWithFixedDelay({ + if (!closed.get()) { + val next = pending.getAndSet(null) + if (next != null) latest = next + val viewer = WebSession.hasViewer + if (viewer && (next != null || !hadViewer)) latest?.let { snapshot -> + val bitmap = Bitmap.createBitmap(snapshot.argb, width, height, Bitmap.Config.ARGB_8888) + try { + val options = AirPlayPersistence.imageEncoding(app, WebSession.imageFormat) + val (bytes, format) = imageEncoder.encode(options) { format, quality, output -> + bitmap.compress(format.compression, quality, output) + } + WebSession.publish(generation, bytes, format, options.quality) + } finally { bitmap.recycle() } + } + hadViewer = viewer + } + }, 0, 66, TimeUnit.MILLISECONDS) + } catch (failure: Throwable) { + try { close() } catch (cleanup: Throwable) { + cleanup.addSuppressed(failure) + throw ProjectionCleanupException("Video startup cleanup failed", cleanup) + } + throw failure + } + } + + override val surface: Surface get() { + check(kind == VideoIngressKind.SURFACE && !closed.get()) + return offscreen!!.surface + } + @Synchronized override fun configure(codec: VideoCodec, codecData: ByteArray, requestKeyframe: () -> Unit): Long { + check(kind == VideoIngressKind.ENCODED && !closed.get()) + val target = decoder!! + target.setVideoRecoveryHandler(0, requestKeyframe) + target.onVideoCodec(0, codec) + target.onVideoConfig(0, codecData.copyOf()) + configured = true + return ++revision + } + @Synchronized override fun frame(formatRevision: Long, frame: CompressedVideoFrame, ptsUs: Long): Boolean { + if (closed.get() || !configured || formatRevision != revision) return false + decoder!!.onVideoFrame(0, frame, ptsUs, System.nanoTime(), 0) + WebSession.setVideoActive(generation, true) + return true + } + override fun framebuffer(snapshot: ProjectionFramebuffer.Snapshot): Boolean { + if (closed.get() || kind != VideoIngressKind.FRAMEBUFFER) return false + require(snapshot.width == width && snapshot.height == height && snapshot.argb.size == width * height) + pending.set(snapshot.copy(argb = snapshot.argb.copyOf())) + WebSession.setVideoActive(generation, true) + return true + } + override fun close() { + if (!closed.compareAndSet(false, true)) return + images.shutdownNow() + check(images.awaitTermination(5, TimeUnit.SECONDS)) { "Image producer did not stop" } + pending.set(null) + decoder?.close(awaitWorkers = true) + offscreen?.closeAndAwait() + WebSession.endVideo(generation) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt new file mode 100644 index 00000000..c09cdc9f --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/CarPlaySourceSession.kt @@ -0,0 +1,39 @@ +package com.shilapi.xcertplay.projection + +import com.shilapi.xcertplay.CarPlayBackgroundSession +import java.util.concurrent.CompletableFuture + +/** The only projection adapter that knows about the legacy CarPlay controller. */ +internal class CarPlaySourceSession(private val status: () -> String?) : ProjectionSession { + @Volatile private var controller: com.shilapi.xcertplay.orchestration.CarPlayController? = null + @Volatile private var sink: com.shilapi.xcertplay.media.AndroidMediaSink? = null + private val closed = CompletableFuture() + fun bind(controller: com.shilapi.xcertplay.orchestration.CarPlayController?, sink: com.shilapi.xcertplay.media.AndroidMediaSink?) { + this.controller = controller + this.sink = sink + } + fun completed(clean: Boolean) { + if (clean) closed.complete(Unit) + else closed.completeExceptionally(IllegalStateException("CarPlay transport did not stop")) + } + override val statusText get() = status() + override val descriptor = SourceDescriptor("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 media get() = controller + override val mediaOutput get() = sink + override fun touch(contacts: List): Boolean { + if (contacts.any { it.id !in 0..1 }) return false + // The CarPlay HID device has fixed slots, even when the first finger is released. + return controller?.sendTouch((0..1).map { id -> + contacts.find { it.id == id } ?: ProjectionContact(id, 0.0, 0.0, false) + }) ?: false + } + override fun setNightMode(night: Boolean) = + if (controller?.setNightMode(night) == true) ProjectionCommandResult.SENT else ProjectionCommandResult.UNAVAILABLE + override fun stop(): CompletableFuture { + CarPlayBackgroundSession.stop() + return closed + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt new file mode 100644 index 00000000..0857fc63 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionSessions.kt @@ -0,0 +1,14 @@ +package com.shilapi.xcertplay.projection + +/** Application-scoped coordinator; the foreground service owns its shutdown. */ +internal val projectionSessions = ProjectionRuntime() + +@Synchronized internal fun registerProjectionSources(context: android.content.Context) { + if (projectionSessions.sources().isNotEmpty()) return + val app = context.applicationContext + VideoIngressKind.entries.forEach { kind -> + projectionSessions.register(SyntheticSource(kind, + { ingress, width, height -> BrowserProjectionVideo(app, width, height, ingress) }, + { app.assets.open("projection/demo.h264clip") })) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt new file mode 100644 index 00000000..1bd6bcd8 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/ProjectionVideoOutput.kt @@ -0,0 +1,18 @@ +package com.shilapi.xcertplay.projection + +import android.view.Surface +import com.shilapi.xcertplay.media.CompressedVideoFrame +import com.shilapi.xcertplay.media.VideoCodec + +/** Android host port. A source receives this port from the composition layer, never a WebSession. + * Only one ingress kind is enabled per output. Stop the producer before closing the output. + */ +internal interface ProjectionVideoOutput : AutoCloseable { + 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 + /** Complete Annex-B access unit; borrowed for this call, retained by asynchronous consumers. PTS is microseconds. */ + fun frame(formatRevision: Long, frame: CompressedVideoFrame, ptsUs: Long): Boolean + /** Complete composed ARGB snapshot. The output copies it before returning and coalesces pending snapshots. */ + fun framebuffer(snapshot: ProjectionFramebuffer.Snapshot): Boolean +} diff --git a/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt b/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt new file mode 100644 index 00000000..43b637f2 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/projection/SyntheticSource.kt @@ -0,0 +1,122 @@ +package com.shilapi.xcertplay.projection + +import android.graphics.Color +import android.graphics.Paint +import com.shilapi.xcertplay.media.HeapVideoFrame +import com.shilapi.xcertplay.media.VideoCodec +import java.io.DataInputStream +import java.io.InputStream +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** Deterministic host diagnostic, with no network, authentication or microphone requirement. */ +internal class SyntheticSource( + private val kind: VideoIngressKind, + 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 resources = setOf(ProjectionResource.DISPLAY) + override fun probe() = SourceAvailability.Ready + override fun open(): CompletableFuture = CompletableFuture.supplyAsync { + val clip = if (kind == VideoIngressKind.ENCODED) Clip.read(encodedFixture()) else null + val video = output(kind, WIDTH, HEIGHT) + try { Session(video, clip) } catch (failure: Throwable) { + try { video.close() } catch (cleanup: Throwable) { + cleanup.addSuppressed(failure) + throw ProjectionCleanupException("Demo startup cleanup failed", cleanup) + } + throw failure + } + } + + private inner class Session(private val video: ProjectionVideoOutput, private val clip: Clip?) : ProjectionSession { + override val descriptor = this@SyntheticSource.descriptor + override val capabilities = ProjectionCapabilities(setOf(kind), if (clip == null) 10 else 0, nightMode = clip == null) + private val stopped = AtomicBoolean() + private val keyframe = AtomicBoolean() + private val worker = Executors.newSingleThreadScheduledExecutor { Thread(it, "projection-demo").apply { isDaemon = true } } + private val completion = CompletableFuture() + @Volatile private var contacts = emptyList() + @Volatile private var night = false + @Volatile private var failureMessage: String? = null + override val statusText get() = failureMessage + override val inputReady get() = !stopped.get() && failureMessage == null && capabilities.maxTouchContacts > 0 + private val pixels = ProjectionFramebuffer(WIDTH, HEIGHT) + private var frame = 0L + private var clipPosition = 0 + private val revision = clip?.let { video.configure(VideoCodec.H264, it.config) { keyframe.set(true) } } + + init { + worker.scheduleWithFixedDelay({ + if (!stopped.get() && failureMessage == null) try { draw() } catch (failure: Exception) { failureMessage = failure.message ?: failure.javaClass.simpleName } + }, 0, 66, TimeUnit.MILLISECONDS) + } + private fun draw() { + if (clip != null) { + if (keyframe.getAndSet(false)) clipPosition = 0 + HeapVideoFrame(clip.frames[clipPosition]).use { video.frame(revision!!, it, frame * 66_667) } + clipPosition = (clipPosition + 1) % clip.frames.size + } else if (kind == VideoIngressKind.SURFACE) { + val surface = video.surface + val canvas = surface.lockCanvas(null) + try { + canvas.drawColor(if (night) Color.DKGRAY else Color.rgb(20, 60, 90)) + val paint = Paint().apply { color = Color.CYAN; textSize = 18f } + canvas.drawText("Surface · $frame", 12f, 28f, paint) + canvas.drawRect((frame % WIDTH).toFloat(), 48f, (frame % WIDTH + 18).toFloat(), 72f, paint) + contacts.filter { it.down }.forEach { canvas.drawCircle((it.x * (WIDTH - 1)).toFloat(), (it.y * (HEIGHT - 1)).toFloat(), 12f, paint) } + } finally { surface.unlockCanvasAndPost(canvas) } + } else { + // Apply ordered increments, including overlapping CopyRect, before publishing a full snapshot. + pixels.copy(1, 0, 0, 0, WIDTH - 1, HEIGHT) + val color = if (night) Color.DKGRAY else Color.rgb((frame * 3 % 255).toInt(), 90, 150) + pixels.update(WIDTH - 1, 0, 1, HEIGHT, IntArray(HEIGHT) { color }) + contacts.filter { it.down }.forEach { + val x = (it.x * (WIDTH - 9)).toInt(); val y = (it.y * (HEIGHT - 9)).toInt() + pixels.update(x, y, 9, 9, IntArray(81) { Color.WHITE }) + } + video.framebuffer(pixels.snapshot()) + } + frame++ + } + override fun touch(contacts: List): Boolean { + if (!inputReady) return false + this.contacts = contacts.toList() + return true + } + override fun setNightMode(night: Boolean): ProjectionCommandResult { + if (!capabilities.nightMode) return ProjectionCommandResult.UNSUPPORTED + if (stopped.get()) return ProjectionCommandResult.UNAVAILABLE + this.night = night + return ProjectionCommandResult.SENT + } + override fun stop(): CompletableFuture { + if (stopped.compareAndSet(false, true)) { + contacts = emptyList() + worker.shutdownNow() + CompletableFuture.runAsync { + try { + check(worker.awaitTermination(5, TimeUnit.SECONDS)) { "Demo producer did not stop" } + video.close() + completion.complete(Unit) + } catch (failure: Throwable) { completion.completeExceptionally(failure) } + } + } + return completion + } + } + private data class Clip(val config: ByteArray, val frames: List) { + companion object { + fun read(stream: InputStream) = DataInputStream(stream).use { input -> + fun bytes(): ByteArray = ByteArray(input.readInt().also { require(it in 1..1_048_576) }).also(input::readFully) + val config = bytes() + val count = input.readInt().also { require(it in 1..300) } + Clip(config, List(count) { bytes() }) + } + } + } + companion object { const val WIDTH = 320; const val HEIGHT = 180 } +} 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 f50d5aad..21c36413 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt @@ -2,7 +2,7 @@ package com.shilapi.xcertplay.web import android.content.Context import android.os.SystemClock -import com.shilapi.xcertplay.airplay.AirPlayContact +import com.shilapi.xcertplay.projection.ProjectionContact import com.shilapi.xcertplay.shared.R as UiR import fi.iki.elonen.NanoHTTPD import fi.iki.elonen.NanoWSD @@ -666,6 +666,9 @@ internal class LanWebServer( .put("videoEpoch", media.videoEpoch) .put("sourceState", media.sourceState) .put("inputReady", media.inputReady) + .put("sourceId", media.sourceId) + .put("sourceName", media.sourceName) + .put("maxTouchContacts", media.maxTouchContacts) .put("stage", WebSession.stage(context)) .put( "streaming", @@ -753,11 +756,11 @@ internal class LanWebServer( } "touch" -> { val contacts = data.getJSONArray("contacts") - require(contacts.length() <= 2) + require(contacts.length() <= 10) val list = (0 until contacts.length()).map { val point = contacts.getJSONObject(it) - AirPlayContact( + ProjectionContact( point.getInt("id"), point.getDouble("x"), point.getDouble("y"), diff --git a/common/src/main/java/com/shilapi/xcertplay/web/NativeVideoDecoder.kt b/common/src/main/java/com/shilapi/xcertplay/web/NativeVideoDecoder.kt index 300bf320..da96a0f1 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/NativeVideoDecoder.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/NativeVideoDecoder.kt @@ -2,7 +2,7 @@ package com.shilapi.xcertplay.web import androidx.annotation.Keep import com.shilapi.xcertplay.airplay.AirPlayCrypto -import com.shilapi.xcertplay.airplay.VideoCodec +import com.shilapi.xcertplay.media.VideoCodec import com.shilapi.xcertplay.media.CompressedVideoFrame import com.shilapi.xcertplay.media.VideoFrameDecoder import java.util.concurrent.atomic.AtomicInteger diff --git a/common/src/main/java/com/shilapi/xcertplay/web/RtcVideoStream.kt b/common/src/main/java/com/shilapi/xcertplay/web/RtcVideoStream.kt index 7a597ad5..49b472e1 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/RtcVideoStream.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/RtcVideoStream.kt @@ -1,7 +1,7 @@ package com.shilapi.xcertplay.web import android.util.Log -import com.shilapi.xcertplay.airplay.VideoCodec +import com.shilapi.xcertplay.media.VideoCodec import com.shilapi.xcertplay.media.CompressedVideoFrame import java.io.Closeable import java.util.concurrent.Executors 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 7466b454..88a79d36 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/TouchLease.kt @@ -1,9 +1,9 @@ package com.shilapi.xcertplay.web -import com.shilapi.xcertplay.airplay.AirPlayContact +import com.shilapi.xcertplay.projection.ProjectionContact /** Serializes browser ownership and releases fingers when a client disappears. */ -class TouchLease(private val send: (List) -> Boolean) { +class TouchLease(private val maxContacts: () -> Int = { 2 }, private val send: (List) -> Boolean) { private var owner: Any? = null private var lastTouch = 0L private var pressed = false @@ -14,13 +14,11 @@ class TouchLease(private val send: (List) -> Boolean) { return true } - @Synchronized fun touch(client: Any, contacts: List, now: Long): Boolean { - if (owner !== client || contacts.size > 2 || contacts.any { - !it.x.isFinite() || !it.y.isFinite() || it.x !in 0.0..1.0 || it.y !in 0.0..1.0 || it.id !in 0..1 + @Synchronized fun touch(client: Any, contacts: List, now: Long): Boolean { + if (owner !== client || contacts.size > maxContacts() || contacts.any { + !it.x.isFinite() || !it.y.isFinite() || it.x !in 0.0..1.0 || it.y !in 0.0..1.0 || it.id !in 0 until maxContacts() } || contacts.map { it.id }.distinct().size != contacts.size) return false - // HID slots must not shift when the first of two fingers lifts. - val slots = (0..1).map { id -> contacts.find { it.id == id } ?: AirPlayContact(id, 0.0, 0.0, false) } - val accepted = send(slots) + val accepted = send(contacts.sortedBy { it.id }) pressed = accepted && contacts.any { it.down } lastTouch = now return accepted @@ -35,5 +33,9 @@ class TouchLease(private val send: (List) -> Boolean) { releaseFingers(); owner = null } + @Synchronized fun reset() { releaseFingers(); owner = null } + + @Synchronized fun cancel() { releaseFingers() } + private fun releaseFingers() { send(emptyList()); pressed = false } } 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 bc2773db..f90d0a3c 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebSession.kt @@ -7,9 +7,10 @@ import android.os.SystemClock import com.shilapi.xcertplay.AirPlayPersistence import com.shilapi.xcertplay.BrowserOfflinePolicy import com.shilapi.xcertplay.CarPlayAppearance -import com.shilapi.xcertplay.CarPlayBackgroundSession +import com.shilapi.xcertplay.projection.projectionSessions +import com.shilapi.xcertplay.projection.ProjectionContact +import com.shilapi.xcertplay.projection.ProjectionSession import com.shilapi.xcertplay.CarPlayMicrophoneSource -import com.shilapi.xcertplay.DiPlayBootstrap import com.shilapi.xcertplay.host.R import com.shilapi.xcertplay.i18n.* import com.shilapi.xcertplay.isDarkMode @@ -29,6 +30,7 @@ internal object WebSession { data class MediaSnapshot( 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, ) private var mediaSessionId = SecureRandom().nextLong().ushr(11) @@ -43,8 +45,10 @@ internal object WebSession { frame == null -> "starting" else -> "recovering" }, - !mediaEnded && videoActive && CarPlayBackgroundSession.snapshot()?.controller?.touchReady == true, + !mediaEnded && videoActive && projectionSessions.inputReady(inputSession), frame, videoSource, + inputSession?.descriptor?.id, inputSession?.descriptor?.displayName, + inputSession?.capabilities?.maxTouchContacts ?: 0, ) @Synchronized fun mediaMatches(sessionId: Long, epoch: Long): Boolean = @@ -52,12 +56,9 @@ internal object WebSession { /** Keep version validation and delivery in the same configuration critical section. */ @Synchronized fun mediaTouch(client: Any, sessionId: Long, epoch: Long, - contacts: List, at: Long): Boolean { - if (contacts.isNotEmpty() && (!mediaMatches(sessionId, epoch) || - CarPlayBackgroundSession.snapshot()?.controller?.touchReady != true)) { - touch.touch(client, emptyList(), at) - return false - } + contacts: List, at: Long): Boolean { + if (!mediaMatches(sessionId, epoch) || + !projectionSessions.inputReady(inputSession)) return false return touch.touch(client, contacts, at) } @@ -74,9 +75,18 @@ internal object WebSession { private set @Volatile var videoActive = false - @Volatile private var stageText = UiText(UiR.string.ui_waiting_to_connect_to_iphone) + @Volatile private var stageText = UiText(UiR.string.projection_waiting) + + fun stageDetail(context: Context): String = stageText.resolve(context) - fun stage(context: Context): String = stageText.resolve(context) + fun stage(context: Context): String { + val session = projectionSessions.snapshot() + return when { + session == null -> stageText.resolve(context) + session.statusText != null -> session.statusText!! + else -> context.getString(UiR.string.projection_active, session.descriptor.displayName) + } + } fun setStage(value: UiText) { stageText = value @@ -125,8 +135,11 @@ internal object WebSession { get() = browserViewport @Volatile private var browserViewportListener: ((Int?, Int?) -> Unit)? = null - val touch = TouchLease { contacts -> - CarPlayBackgroundSession.snapshot()?.controller?.sendTouch(contacts) ?: false + @Volatile private var inputSession: ProjectionSession? = null + val maxTouchContacts get() = projectionSessions.snapshot()?.capabilities?.maxTouchContacts ?: 0 + val touch = TouchLease(maxContacts = { maxTouchContacts }) { contacts -> + val target = inputSession + target != null && projectionSessions.touch(target, contacts) } val running get() = server?.ready == true @@ -153,21 +166,20 @@ internal object WebSession { private var offlineSubscription: AutoCloseable? = null private val preferenceExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { task -> - Thread(task, "carplay-preferences").apply { isDaemon = true } + Thread(task, "projection-preferences").apply { isDaemon = true } } private val offlineMonitor = BrowserOfflineMonitor( mainHandler, - { CarPlayBackgroundSession.snapshot()?.controller }, + { projectionSessions.snapshot() }, { hasViewer }, ) { expected -> if ( experienceContext != null && - CarPlayBackgroundSession.snapshot()?.controller === expected + projectionSessions.snapshot() === expected ) { - CarPlayBackgroundSession.stop { - setStage(UiText(UiR.string.ui_browser_offline_timeout_iphone_disconnected)) - } + projectionSessions.stop(expected as ProjectionSession) + } } @@ -195,24 +207,25 @@ internal object WebSession { } fun refreshAppearance(context: Context) { - val controller = CarPlayBackgroundSession.snapshot()?.controller ?: return + val controller = projectionSessions.snapshot() ?: return preferenceExecutor.execute { - if (CarPlayBackgroundSession.snapshot()?.controller === controller) { + if (projectionSessions.snapshot() === controller) { val night = AirPlayPersistence.loadAppearance(context) .night(isDarkMode(context.resources.configuration.uiMode)) - controller.setNightMode(night) + projectionSessions.setNightMode(controller, night) } } } private fun configureAudio(context: Context) { val source = AirPlayPersistence.loadMicrophoneSource(context) + val capabilities = projectionSessions.snapshot()?.capabilities audio.configure( - AirPlayPersistence.loadBrowserAudioPlayback(context), - source == CarPlayMicrophoneSource.BROWSER, + capabilities?.audioPlayback == true && AirPlayPersistence.loadBrowserAudioPlayback(context), + capabilities?.microphone == true && source == CarPlayMicrophoneSource.BROWSER, AirPlayPersistence.loadMediaBufferMillis(context), - source != CarPlayMicrophoneSource.DISABLED, + capabilities?.microphone == true && source != CarPlayMicrophoneSource.DISABLED, ) } @@ -262,9 +275,9 @@ internal object WebSession { val now = SystemClock.elapsedRealtime() val message = when { - CarPlayBackgroundSession.hasSession() -> + projectionSessions.hasSession() -> context.getString( - UiR.string.ui_an_iphone_session_has_started_wait_for_the_connection + UiR.string.projection_busy ) phoneConnectHandler == null -> context.getString( @@ -291,13 +304,6 @@ internal object WebSession { fun start(context: Context) { if (server?.ready == true) return if (server != null) stop() - if (runCatching { DiPlayBootstrap.ensure(context) }.isFailure) { - error = - context.getString( - UiR.string.ui_authentication_resources_are_not_ready_import_them_in_settings - ) - return - } adaptiveBrowserSize = AirPlayPersistence.loadAdaptiveBrowserSize(context) configureAudio(context) code = (100000 + SecureRandom().nextInt(900000)).toString() @@ -310,7 +316,31 @@ internal object WebSession { error = null mdns = WebMdns(context.applicationContext) experienceContext = context.applicationContext - offlineSubscription = CarPlayBackgroundSession.subscribe { viewerStateChanged() } + var previousSource = inputSession + offlineSubscription = projectionSessions.subscribe { + val changed = synchronized(this) { + // Read under the configuration lock: a delayed notification cannot restore an old route. + val current = projectionSessions.snapshot() + if (previousSource === current) false else { + touch.reset() + inputSession = current + if (current == null) { + stageText = UiText(UiR.string.projection_waiting) + endVideo(generation) + } + previousSource = current + true + } + } + if (changed) { + // Versionless clients cannot distinguish old touches after a source switch. + server?.disconnectViewer() + secureServer?.disconnectViewer() + configureAudio(context) + refreshAppearance(context) + } + viewerStateChanged() + } httpsPort = WebListenSettings.httpsPort(context) tlsError = null TeslaHttpCompatibility.start(context) @@ -686,6 +716,7 @@ internal object WebSession { @Synchronized fun beginVideo(w: Int, h: Int): Long { + touch.cancel() if (mediaEnded) mediaSessionId = SecureRandom().nextLong().ushr(11) mediaEnded = false videoSource?.detach() @@ -697,6 +728,11 @@ internal object WebSession { return ++generation } + @Synchronized + fun setVideoActive(owner: Long, active: Boolean) { + if (owner == generation && !mediaEnded) videoActive = active + } + @Synchronized fun attachVideoSource(owner: Long, source: WebVideoSource) { if (owner == generation) videoSource = source @@ -718,7 +754,8 @@ internal object WebSession { @Synchronized fun endVideo(owner: Long) { - if (owner == generation) { + if (owner == generation && !mediaEnded) { + touch.cancel() mediaEnded = true videoSource?.detach() videoSource = null @@ -736,6 +773,7 @@ internal object WebSession { experienceContext = null offlineSubscription?.close() offlineSubscription = null + inputSession = null if (Looper.myLooper() == Looper.getMainLooper()) offlineMonitor.close() else mainHandler.post { if (experienceContext == null) offlineMonitor.close() } serverGeneration++ diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebVideoSource.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebVideoSource.kt index fb77f56d..333ed25c 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebVideoSource.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebVideoSource.kt @@ -1,16 +1,14 @@ package com.shilapi.xcertplay.web -import com.shilapi.xcertplay.airplay.VideoCodec -import android.util.Log +import com.shilapi.xcertplay.media.VideoCodec import com.shilapi.xcertplay.media.CompressedVideoFrame import com.shilapi.xcertplay.media.HeapVideoFrame -import com.shilapi.xcertplay.media.VideoFrameDecoder import com.shilapi.xcertplay.media.EncodedVideoSink import com.shilapi.xcertplay.media.MediaCodecSupport import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong -/** Owned by one AirPlay session, including the decoder handoff and keyframe recovery. */ +/** Owned by one projection video stream, including the decoder handoff and keyframe recovery. */ internal class WebVideoSource(val technology: StreamTechnology) : EncodedVideoSink { val performance = VideoPerformance() @Volatile private var processor = "JVM" @@ -29,11 +27,7 @@ internal class WebVideoSource(val technology: StreamTechnology) : EncodedVideoSi private var lastConfig: ByteArray? = null val jpegNeeded get() = !displayed - override fun createFrameDecoder(key: ByteArray): VideoFrameDecoder? = try { - NativeVideoDecoder(key, platform = true).also { processor = it.name } - } catch (e: LinkageError) { - Log.w("WheelPlayRtc", "Native video processing unavailable; using JVM", e); null - } + fun setProcessorName(name: String) { processor = name } override fun recovery(type: Int, request: () -> Unit) { recovery[type] = request } diff --git a/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt b/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt index 8a67f791..bc38a8fa 100644 --- a/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/AuthenticationActivationTest.kt @@ -42,34 +42,31 @@ class AuthenticationActivationTest : ComposeSettingsTest() { else emptyList() @Test - fun missingResourcesGuideToSettingsWithoutStartingWebOrCarPlay() { + fun missingResourcesAllowHostButCarPlayRemainsUnavailable() { val controller = Robolectric.buildActivity(DiPlayActivity::class.java).setup() val activity = controller.get() val ui = views(activity.window.decorView) - assertEquals(3, ui.filterIsInstance().single().selectedItemId) - assertNull(shadowOf(activity).nextStartedService) - assertFalse(WebSession.running) + assertNotNull(shadowOf(activity).nextStartedService) assertFalse(CarPlayBackgroundSession.hasSession()) - compose.onNodeWithText("从 DiPlay APK 导入").assertExists() assertFalse(ui.filterIsInstance