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
5 changes: 5 additions & 0 deletions .github/workflows/debug-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 插件。 |
Expand Down
3 changes: 3 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions common/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<queries>
<package android:name="moe.shizuku.privileged.api" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="moe.feng.wheelplay.action.PROJECTION_SOURCE" />
</intent>
Expand All @@ -20,6 +25,10 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application>
<provider android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku" android:multiprocess="false"
android:enabled="true" android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<activity android:name="com.shilapi.xcertplay.DiPlayActivity"
android:exported="true" android:launchMode="singleTop"
android:configChanges="density|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package moe.feng.wheelplay.projection.desktop;

@JavaDerive(equals=true, toString=true)
parcelable DesktopDisplayInfo {
int displayId = -1;
int width = 0;
int height = 0;
int dpi = 0;
int rotation = 0;
int layoutRevision = 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package moe.feng.wheelplay.projection.desktop;
import moe.feng.wheelplay.projection.desktop.DesktopStatus;
import moe.feng.wheelplay.projection.desktop.DesktopServiceInfo;

@JavaDerive(equals=true, toString=true)
parcelable DesktopHandshakeResult {
// A missing status is a malformed response, never an implicit success.
@nullable DesktopStatus status;
@nullable DesktopServiceInfo info;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package moe.feng.wheelplay.projection.desktop;
/** Accepted protocol operations and request limits, not a device compatibility guarantee. */
@JavaDerive(equals=true, toString=true)
parcelable DesktopServiceInfo {
int protocolVersion = 0;
int uid = -1;
long supportedFeatures = 0;
long enabledFeatures = 0;
int maxTouchContacts = 0;
int minSize = 0;
int maxSize = 0;
long maxPixels = 0;
int minDpi = 0;
int maxDpi = 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package moe.feng.wheelplay.projection.desktop;
import moe.feng.wheelplay.projection.desktop.DesktopDisplayInfo;
import moe.feng.wheelplay.projection.desktop.DesktopStatus;

@JavaDerive(equals=true, toString=true)
parcelable DesktopSessionState {
const int IDLE = 0;
const int RUNNING = 1;
const int STOPPED = 2;
const int FAILED = 3;
int phase = 0;
long enabledFeatures = 0;
int inputEpoch = 0;
@nullable DesktopDisplayInfo display;
@nullable DesktopStatus failure;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package moe.feng.wheelplay.projection.desktop;

@JavaDerive(equals=true, toString=true)
parcelable DesktopStartConfig {
String component;
int width = 0;
int height = 0;
int dpi = 0;
long requestedFeatures = 0;
long requiredFeatures = 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package moe.feng.wheelplay.projection.desktop;
import moe.feng.wheelplay.projection.desktop.DesktopStatus;
import moe.feng.wheelplay.projection.desktop.DesktopDisplayInfo;

@JavaDerive(equals=true, toString=true)
parcelable DesktopStartResult {
@nullable DesktopStatus status;
// Success must identify an owned, non-default display (> 0).
@nullable DesktopDisplayInfo display;
long enabledFeatures = 0;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 20 additions & 5 deletions common/src/main/assets/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions common/src/main/assets/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ <h1 id="title">{{web_connect_your_carplay_display}}</h1>
<canvas id="held-frame" width="0" height="0" aria-label="{{web_live_carplay_display}}" hidden></canvas>
<div id="media-message" role="status" aria-live="polite" hidden></div>
<canvas id="video-canvas" width="0" height="0" aria-label="{{web_live_carplay_video_canvas}}" hidden></canvas>
<button id="desktop-back" type="button" hidden>{{web_desktop_back}}</button>
<div id="touch-surface" aria-hidden="true"></div>
<div id="location-message" hidden role="status" aria-live="polite"></div>
<div id="audio-message" hidden role="status" aria-live="polite"></div>
Expand Down
3 changes: 3 additions & 0 deletions common/src/main/assets/web/style.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading