Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -82,7 +83,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.

Expand Down
Binary file added common/src/main/assets/projection/demo.h264clip
Binary file not shown.
8 changes: 6 additions & 2 deletions common/src/main/assets/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,7 @@ if (typeof document !== 'undefined') {
}
$('refresh-qr').addEventListener('click', startQr);
const pointers = new Map();
let maxTouchContacts = 2;
const send = (data, kind) => {
if (data.type === 'touch' && presentation.current.videoEpoch !== null) {
const { sessionId, videoEpoch } = presentation.current;
Expand Down Expand Up @@ -1172,6 +1173,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) {
Expand Down Expand Up @@ -1306,11 +1309,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();
Expand Down
91 changes: 78 additions & 13 deletions common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
Original file line number Diff line number Diff line change
@@ -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.*

Expand Down Expand Up @@ -415,6 +419,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)
}
Expand Down Expand Up @@ -2796,8 +2804,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,
Expand Down Expand Up @@ -2837,6 +2851,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
Expand Down Expand Up @@ -2903,10 +2918,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
Expand All @@ -2919,6 +2930,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),
Expand Down Expand Up @@ -2960,6 +2975,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)
Expand Down Expand Up @@ -2993,6 +3015,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
}
Expand Down Expand Up @@ -3179,9 +3203,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
Expand Down Expand Up @@ -3252,16 +3283,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())
}
}
Expand Down Expand Up @@ -3548,6 +3585,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
Expand All @@ -3573,9 +3635,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()) }
}

Expand All @@ -3584,6 +3648,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
Expand Down
Loading
Loading