diff --git a/.gitignore b/.gitignore index 9837e06..9da59a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Local analysis artifacts (proprietary / large) -apk/ -out/ -src/ -tools/apktool.jar +/apk/ +/out/ +/src/ +/tools/apktool.jar __pycache__/ *.pyc diff --git a/README.md b/README.md index 4982c8f..278df66 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,24 @@ Stock apps enable the feature only when: The community “Xperia 1 VIII Vlog Monitor” mod simply forces that flag true; the VID/PID check is unchanged. There is no security chip or cryptographic handshake. +## Working third-party enabler + +`spike/` contains a small release-signed Android app (**XQZ Enabler 1.0**) that +was confirmed on a **Galaxy S23**: + +- requests USB permission for `054c:0e48`; +- sends `show` every 400 ms from a foreground service; +- auto-reconnects when the monitor is replugged; +- provides a Quick Settings tile; +- lets One UI mirror the phone screen (including the Camera app) to the monitor. + +With DeX auto-start off, the S23 treats the monitor as a normal 1280×720 secondary +display and mirrors the screen. No MediaProjection or Sony code is used. +APK: `spike/XQZEnabler.apk`; instructions in `spike/README.md`. + +Caveat: this relies on the phone OS mirroring the screen to the external display. +Other Android vendors may show a desktop mode or need a MediaProjection fallback. + ## Reimplementation blueprint 1. Open `054c:0e48`, claim interface 0, find the bulk IN/OUT pair, request USB diff --git a/spike/.gitignore b/spike/.gitignore new file mode 100644 index 0000000..d9e5650 --- /dev/null +++ b/spike/.gitignore @@ -0,0 +1,4 @@ +build/ +debug.keystore +release.keystore +*.idsig diff --git a/spike/AndroidManifest.xml b/spike/AndroidManifest.xml new file mode 100644 index 0000000..d278c73 --- /dev/null +++ b/spike/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spike/README.md b/spike/README.md new file mode 100644 index 0000000..676eb7d --- /dev/null +++ b/spike/README.md @@ -0,0 +1,79 @@ +# XQZ Enabler + +A minimal Android app that **enables the Sony XQZ-IV01 Vlog Monitor** by sending +the vendor heartbeat, then lets the phone's built-in secondary-display mirroring +show the screen on it. No MediaProjection, Sony code, or audio routing. + +Confirmed on a **Galaxy S23** (One UI, DeX auto-start off): the monitor mirrors +the Samsung Camera app, scaled/letterboxed to 1280×720. + +## Features + +- Requests USB permission for the monitor's control device (`054c:0e48`). +- Claims its vendor interface and sends `show` every **400 ms** from a foreground + service (the DP sink disappears after 1 s without it). +- **Auto-reconnects** when the monitor is replugged while the service is running. +- **Quick Settings tile**: add “XQZ monitor” for one-tap start/stop. +- Notification with a **Stop** action; 4-hour partial wake lock. +- Sends `stop` on teardown; logs `flip`/ACKs to logcat (`XQZEnabler`). + +Not included: audio input, FLIP handling, MediaProjection, or DeX control. + +## Install + +`XQZEnabler.apk` is release-signed (version 1.0). If you already installed the +earlier 0.1 spike, **uninstall it first** — the signing key changed. + +```sh +adb install -r XQZEnabler.apk +``` + +Or transfer the APK to the phone. If Samsung Auto Blocker blocks it, allow +installs for the file manager or temporarily disable Auto Blocker. + +## Use + +1. On the S23, turn off DeX auto-start: + **Settings → Connected devices → Samsung DeX → “Auto start when HDMI is + connected” = off.** +2. Turn the monitor's screen switch **ON**, then connect the XQZ-IV01 to the + phone with its USB-C cable. +3. Open **XQZ Enabler**, tap **Start**, allow USB access. +4. Open the Samsung **Camera** app. The monitor should show a scaled/letterboxed + mirror of the phone screen. +5. Optional: add the **XQZ monitor** Quick Settings tile for one-tap toggling. + +If DeX starts instead of mirroring, switch to **Screen mirroring** from the +notification, or turn off DeX auto-start and reconnect. + +## Troubleshooting + +- **Black camera preview only:** the camera app is using a secure window. The + next step would be a MediaProjection mirror or a custom Camera2 preview. +- **No display:** check that `show` is running (app status / notification) and + that the monitor's screen switch is on. +- **Monitor replugged but not enabled:** if Android dropped the USB permission, + open XQZ Enabler and tap Start to grant it again. + +## Build + +Requires Android SDK platform 35 and build-tools 35.0.0 at `$ANDROID_HOME` +(default `~/Android/Sdk`): + +```sh +./build.sh +``` + +The APK is written to `./XQZEnabler.apk`. + +Signing: `release.keystore` is local and gitignored. The committed APK is signed +with it. Keep that file to sign future updates that install over this build; if +it is missing, `build.sh` generates a new key and the resulting APK will not +update over the committed one. + +Source layout: + +- `src/ee/wut/xqz/enabler/MainActivity.java` — UI, USB permission, start/stop. +- `src/ee/wut/xqz/enabler/HeartbeatService.java` — USB claim, 400 ms `show`, + reconnect handling, notification/wake lock. +- `src/ee/wut/xqz/enabler/MonitorTileService.java` — Quick Settings tile. diff --git a/spike/XQZEnabler.apk b/spike/XQZEnabler.apk new file mode 100644 index 0000000..5628999 Binary files /dev/null and b/spike/XQZEnabler.apk differ diff --git a/spike/build.sh b/spike/build.sh new file mode 100755 index 0000000..252beef --- /dev/null +++ b/spike/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +SDK="${ANDROID_HOME:-$HOME/Android/Sdk}" +BT="$SDK/build-tools/35.0.0" +PLATFORM="$SDK/platforms/android-35/android.jar" +ROOT="$(cd "$(dirname "$0")" && pwd)" +OUT="$ROOT/build" + +rm -rf "$OUT" +mkdir -p "$OUT/classes" "$OUT/dex" + +"$BT/aapt2" compile --dir "$ROOT/res" -o "$OUT/res.zip" +"$BT/aapt2" link -o "$OUT/unsigned.apk" -I "$PLATFORM" \ + --manifest "$ROOT/AndroidManifest.xml" -R "$OUT/res.zip" --auto-add-overlay + +find "$ROOT/src" -name '*.java' > "$OUT/sources.txt" +javac --release 17 -classpath "$PLATFORM" -d "$OUT/classes" @"$OUT/sources.txt" + +find "$OUT/classes" -name '*.class' -print0 \ + | xargs -0 "$BT/d8" --lib "$PLATFORM" --min-api 29 --output "$OUT/dex" + +(cd "$OUT/dex" && zip -q "$OUT/unsigned.apk" classes.dex) + +"$BT/zipalign" -f 4 "$OUT/unsigned.apk" "$OUT/aligned.apk" + +KS="$ROOT/release.keystore" +STORE_PASS="${XQZ_STORE_PASS:-xqz-release}" +if [ ! -f "$KS" ]; then + keytool -genkeypair -keystore "$KS" -alias xqz \ + -storepass "$STORE_PASS" -keypass "$STORE_PASS" \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=XQZ Enabler,O=Arti,C=EE" +fi + +"$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$STORE_PASS" \ + --key-pass "pass:$STORE_PASS" --out "$ROOT/XQZEnabler.apk" "$OUT/aligned.apk" +"$BT/apksigner" verify "$ROOT/XQZEnabler.apk" + +echo "APK: $ROOT/XQZEnabler.apk" diff --git a/spike/res/drawable/ic_launcher.xml b/spike/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..ea2d9a5 --- /dev/null +++ b/spike/res/drawable/ic_launcher.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/spike/res/drawable/ic_tile.xml b/spike/res/drawable/ic_tile.xml new file mode 100644 index 0000000..732dbc9 --- /dev/null +++ b/spike/res/drawable/ic_tile.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/spike/res/values/strings.xml b/spike/res/values/strings.xml new file mode 100644 index 0000000..d6c00f5 --- /dev/null +++ b/spike/res/values/strings.xml @@ -0,0 +1,4 @@ + + + XQZ Enabler + diff --git a/spike/src/ee/wut/xqz/enabler/HeartbeatService.java b/spike/src/ee/wut/xqz/enabler/HeartbeatService.java new file mode 100644 index 0000000..8a46620 --- /dev/null +++ b/spike/src/ee/wut/xqz/enabler/HeartbeatService.java @@ -0,0 +1,365 @@ +package ee.wut.xqz.enabler; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.ServiceInfo; +import android.hardware.usb.UsbConstants; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.util.Log; + +import java.nio.charset.StandardCharsets; + +public final class HeartbeatService extends Service { + private static final String TAG = "XQZEnabler"; + private static final String CHANNEL_ID = "xqz_heartbeat"; + private static final String ACTION_STOP = "ee.wut.xqz.enabler.STOP"; + private static final int NOTIF_ID = 1; + private static final int VID = 0x054C; + private static final int PID = 0x0E48; + private static final int HEARTBEAT_MS = 400; + + private static volatile boolean serviceActive; + private static volatile boolean waiting; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Object workerLock = new Object(); + private volatile boolean stopping; + private Thread worker; + private UsbDeviceConnection connection; + private UsbInterface usbInterface; + private UsbEndpoint epIn; + private UsbEndpoint epOut; + private boolean receiverRegistered; + private PowerManager.WakeLock wakeLock; + + public static boolean isRunning() { + return serviceActive; + } + + public static boolean isWaiting() { + return waiting; + } + + private final BroadcastReceiver usbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (device == null || device.getVendorId() != VID || device.getProductId() != PID) { + return; + } + if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(intent.getAction())) { + Log.i(TAG, "monitor detached"); + stopping = true; + waiting = true; + updateNotification("Monitor disconnected — waiting for reconnect"); + } else if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + Log.i(TAG, "monitor attached"); + waiting = true; + updateNotification("Monitor connected — starting…"); + handler.postDelayed(HeartbeatService.this::startWorker, 300); + } + } + }; + + @Override + public void onCreate() { + super.onCreate(); + createChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopSelf(); + return START_NOT_STICKY; + } + if (!serviceActive) { + try { + startForeground(NOTIF_ID, buildNotification("Starting…"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE); + } catch (Throwable t) { + Log.e(TAG, "startForeground failed", t); + stopSelf(); + return START_NOT_STICKY; + } + serviceActive = true; + waiting = false; + acquireWakeLock(); + registerUsbReceiver(); + } + startWorker(); + return START_NOT_STICKY; + } + + private void startWorker() { + synchronized (workerLock) { + if (!serviceActive) return; + if (worker != null && worker.isAlive()) return; + stopping = false; + waiting = false; + worker = new Thread(this::runHeartbeat, "xqz-heartbeat"); + worker.start(); + } + } + + private void runHeartbeat() { + UsbManager manager = getSystemService(UsbManager.class); + try { + UsbDevice device = findDevice(manager); + if (device == null) { + waiting = true; + updateNotification("Waiting for monitor"); + return; + } + if (!manager.hasPermission(device)) { + waiting = true; + updateNotification("Monitor connected — open XQZ Enabler to allow USB access"); + return; + } + UsbDeviceConnection conn = manager.openDevice(device); + if (conn == null) { + fail("Could not open USB device"); + return; + } + connection = conn; + findEndpoints(device); + if (usbInterface == null || epIn == null || epOut == null) { + fail("No bulk IN/OUT interface"); + return; + } + if (!conn.claimInterface(usbInterface, true)) { + fail("Could not claim interface"); + return; + } + waiting = false; + Log.i(TAG, "connected; heartbeat every " + HEARTBEAT_MS + " ms"); + updateNotification("Running — " + HEARTBEAT_MS + " ms heartbeat"); + + Thread reader = new Thread(this::readLoop, "xqz-reader"); + reader.start(); + + byte[] show = "show".getBytes(StandardCharsets.UTF_8); + while (!stopping && serviceActive) { + int n = conn.bulkTransfer(epOut, show, show.length, 50); + if (n < 0) Log.w(TAG, "show bulkTransfer failed"); + try { + Thread.sleep(HEARTBEAT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + reader.interrupt(); + try { + reader.join(300); + } catch (InterruptedException ignored) { + } + } catch (Throwable t) { + Log.e(TAG, "heartbeat failed", t); + if (serviceActive) { + waiting = true; + updateNotification("Error: " + t.getMessage()); + } + } finally { + cleanup(); + synchronized (workerLock) { + worker = null; + } + maybeRestart(); + } + } + + private void maybeRestart() { + if (!serviceActive || !waiting) return; + UsbManager manager = getSystemService(UsbManager.class); + UsbDevice device = findDevice(manager); + if (device != null && manager.hasPermission(device)) { + handler.postDelayed(this::startWorker, 300); + } + } + + private UsbDevice findDevice(UsbManager manager) { + if (manager == null) return null; + for (UsbDevice d : manager.getDeviceList().values()) { + if (d.getVendorId() == VID && d.getProductId() == PID) return d; + } + return null; + } + + private void readLoop() { + byte[] buf = new byte[64]; + while (!stopping && serviceActive && connection != null) { + try { + int n = connection.bulkTransfer(epIn, buf, buf.length, 50); + if (n > 0) { + String s = new String(buf, 0, n, StandardCharsets.UTF_8).trim(); + Log.i(TAG, "RX " + s); + if (s.contains("flip")) updateNotification("FLIP pressed"); + } + } catch (Throwable t) { + if (!stopping) Log.w(TAG, "read failed", t); + return; + } + } + } + + private void findEndpoints(UsbDevice device) { + for (int i = 0; i < device.getInterfaceCount(); i++) { + UsbInterface intf = device.getInterface(i); + UsbEndpoint in = null; + UsbEndpoint out = null; + for (int j = 0; j < intf.getEndpointCount(); j++) { + UsbEndpoint ep = intf.getEndpoint(j); + if (ep.getType() != UsbConstants.USB_ENDPOINT_XFER_BULK) continue; + if (ep.getDirection() == UsbConstants.USB_DIR_IN) in = ep; + else if (ep.getDirection() == UsbConstants.USB_DIR_OUT) out = ep; + } + if (in != null && out != null) { + usbInterface = intf; + epIn = in; + epOut = out; + return; + } + } + } + + private synchronized void cleanup() { + try { + if (connection != null && epOut != null) { + connection.bulkTransfer(epOut, + "stop".getBytes(StandardCharsets.UTF_8), 4, 50); + } + } catch (Throwable ignored) { + } + try { + if (connection != null && usbInterface != null) { + connection.releaseInterface(usbInterface); + } + } catch (Throwable ignored) { + } + try { + if (connection != null) connection.close(); + } catch (Throwable ignored) { + } + connection = null; + usbInterface = null; + epIn = null; + epOut = null; + } + + private void fail(String message) { + Log.e(TAG, message); + stopping = true; + waiting = false; + serviceActive = false; + updateNotification(message); + stopSelf(); + } + + private void registerUsbReceiver() { + if (receiverRegistered) return; + IntentFilter f = new IntentFilter(); + f.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + f.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver(usbReceiver, f, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(usbReceiver, f); + } + receiverRegistered = true; + } + + private void unregisterUsbReceiver() { + if (!receiverRegistered) return; + try { + unregisterReceiver(usbReceiver); + } catch (Throwable ignored) { + } + receiverRegistered = false; + } + + private void acquireWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) return; + PowerManager pm = getSystemService(PowerManager.class); + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "XQZEnabler:heartbeat"); + wakeLock.setReferenceCounted(false); + wakeLock.acquire(4 * 60 * 60 * 1000L); + } + + private void createChannel() { + NotificationManager nm = getSystemService(NotificationManager.class); + NotificationChannel ch = new NotificationChannel(CHANNEL_ID, + "XQZ monitor heartbeat", NotificationManager.IMPORTANCE_LOW); + ch.setDescription("Keeps the XQZ-IV01 display enabled"); + nm.createNotificationChannel(ch); + } + + private Notification buildNotification(String text) { + Intent open = new Intent(this, MainActivity.class); + PendingIntent content = PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + Intent stop = new Intent(this, HeartbeatService.class).setAction(ACTION_STOP); + PendingIntent stopPi = PendingIntent.getService(this, 1, stop, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + Notification.Action action = new Notification.Action.Builder( + android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPi).build(); + return new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentTitle("XQZ monitor") + .setContentText(text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(content) + .addAction(action) + .build(); + } + + private void updateNotification(String text) { + try { + NotificationManager nm = getSystemService(NotificationManager.class); + nm.notify(NOTIF_ID, buildNotification(text)); + } catch (Throwable ignored) { + } + } + + @Override + public void onDestroy() { + serviceActive = false; + waiting = false; + stopping = true; + handler.removeCallbacksAndMessages(null); + unregisterUsbReceiver(); + if (worker != null) { + worker.interrupt(); + try { + worker.join(500); + } catch (InterruptedException ignored) { + } + } + cleanup(); + if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); + wakeLock = null; + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/spike/src/ee/wut/xqz/enabler/MainActivity.java b/spike/src/ee/wut/xqz/enabler/MainActivity.java new file mode 100644 index 0000000..f90337e --- /dev/null +++ b/spike/src/ee/wut/xqz/enabler/MainActivity.java @@ -0,0 +1,244 @@ +package ee.wut.xqz.enabler; + +import android.Manifest; +import android.app.Activity; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +public final class MainActivity extends Activity { + private static final String ACTION_USB_PERMISSION = "ee.wut.xqz.enabler.USB_PERMISSION"; + private static final int VID = 0x054C; + private static final int PID = 0x0E48; + + private UsbManager usbManager; + private TextView statusView; + private Button startButton; + private Button stopButton; + private final Handler handler = new Handler(Looper.getMainLooper()); + private boolean receiverRegistered; + + private final BroadcastReceiver permissionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!ACTION_USB_PERMISSION.equals(intent.getAction())) return; + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + boolean granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + if (granted && isOurDevice(device)) { + startHeartbeat(); + } else { + toast("USB permission denied"); + } + } + }; + + private final Runnable statusTick = new Runnable() { + @Override + public void run() { + updateStatus(); + handler.postDelayed(this, 1000); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + usbManager = getSystemService(UsbManager.class); + setContentView(buildUi()); + registerPermissionReceiver(); + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1); + } + } + + private ScrollView buildUi() { + int pad = dp(24); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(pad, pad, pad, pad); + + TextView title = new TextView(this); + title.setText("XQZ Monitor Enabler"); + title.setTextSize(24); + title.setGravity(Gravity.START); + root.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + TextView help = new TextView(this); + help.setText( + "1. Turn the monitor's screen switch ON and connect it to the phone.\n\n" + + "2. Turn OFF DeX auto-start:\n" + + "Settings > Connected devices > Samsung DeX >\n" + + "\"Auto start when HDMI is connected\" = off.\n\n" + + "3. Tap Start and allow USB access.\n\n" + + "4. Open the Camera app. The monitor should mirror the screen.\n\n" + + "If DeX starts instead, switch to Screen mirroring from the " + + "notification, or turn off DeX auto-start and reconnect.\n\n" + + "The service keeps running and reconnects when the monitor is " + + "replugged. Add the XQZ monitor tile to Quick Settings for " + + "one-tap toggling.\n\n" + + "No MediaProjection, no audio, no FLIP handling; mirroring is " + + "provided by One UI."); + help.setTextSize(16); + help.setPadding(0, dp(16), 0, dp(16)); + root.addView(help); + + statusView = new TextView(this); + statusView.setTextSize(16); + statusView.setPadding(0, dp(8), 0, dp(16)); + root.addView(statusView); + + LinearLayout buttons = new LinearLayout(this); + buttons.setOrientation(LinearLayout.HORIZONTAL); + startButton = new Button(this); + startButton.setText("Start"); + startButton.setOnClickListener(v -> onStartClicked()); + stopButton = new Button(this); + stopButton.setText("Stop"); + stopButton.setOnClickListener(v -> stopHeartbeat()); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f); + buttons.addView(startButton, lp); + buttons.addView(stopButton, new LinearLayout.LayoutParams(lp)); + root.addView(buttons); + + TextView foot = new TextView(this); + foot.setText("\nIf the camera preview is black on the monitor, the camera app is " + + "probably blocking screen capture; that is the signal to try the " + + "MediaProjection path."); + foot.setTextSize(14); + root.addView(foot); + + ScrollView scroll = new ScrollView(this); + scroll.addView(root); + return scroll; + } + + private void onStartClicked() { + UsbDevice device = findDevice(); + if (device == null) { + toast("Monitor not connected (054c:0e48)"); + return; + } + if (usbManager == null) { + toast("USB host service unavailable"); + return; + } + if (usbManager.hasPermission(device)) { + startHeartbeat(); + return; + } + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 31) flags |= PendingIntent.FLAG_MUTABLE; + PendingIntent pi = PendingIntent.getBroadcast(this, 0, + new Intent(ACTION_USB_PERMISSION).setPackage(getPackageName()), flags); + statusView.setText("Waiting for USB permission…"); + usbManager.requestPermission(device, pi); + } + + private void startHeartbeat() { + try { + startForegroundService(new Intent(this, HeartbeatService.class)); + statusView.setText("Heartbeat starting…"); + } catch (Throwable t) { + toast("Could not start service: " + t.getMessage()); + } + } + + private void stopHeartbeat() { + stopService(new Intent(this, HeartbeatService.class)); + statusView.setText("Heartbeat stopped"); + } + + private UsbDevice findDevice() { + if (usbManager == null) return null; + for (UsbDevice d : usbManager.getDeviceList().values()) { + if (isOurDevice(d)) return d; + } + return null; + } + + private static boolean isOurDevice(UsbDevice d) { + return d != null && d.getVendorId() == VID && d.getProductId() == PID; + } + + private boolean hasPermission(UsbDevice d) { + return d != null && usbManager != null && usbManager.hasPermission(d); + } + + private void updateStatus() { + if (statusView == null) return; + UsbDevice device = findDevice(); + boolean present = device != null; + boolean running = HeartbeatService.isRunning(); + boolean waiting = running && HeartbeatService.isWaiting(); + boolean canGrant = present && !hasPermission(device); + String heartbeat = !running ? "stopped" + : (waiting ? (canGrant ? "waiting (allow USB)" : "waiting") : "running"); + statusView.setText("Monitor: " + (present ? "connected" : "not connected") + + "\nHeartbeat: " + heartbeat); + startButton.setEnabled(!running || (waiting && canGrant)); + stopButton.setEnabled(running); + } + + private void registerPermissionReceiver() { + IntentFilter f = new IntentFilter(ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver(permissionReceiver, f, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(permissionReceiver, f); + } + receiverRegistered = true; + } + + private void toast(String s) { + Toast.makeText(this, s, Toast.LENGTH_SHORT).show(); + } + + private int dp(int v) { + return Math.round(v * getResources().getDisplayMetrics().density); + } + + @Override + protected void onStart() { + super.onStart(); + handler.post(statusTick); + } + + @Override + protected void onStop() { + handler.removeCallbacks(statusTick); + super.onStop(); + } + + @Override + protected void onDestroy() { + handler.removeCallbacks(statusTick); + if (receiverRegistered) { + try { + unregisterReceiver(permissionReceiver); + } catch (Throwable ignored) { + } + receiverRegistered = false; + } + super.onDestroy(); + } +} diff --git a/spike/src/ee/wut/xqz/enabler/MonitorTileService.java b/spike/src/ee/wut/xqz/enabler/MonitorTileService.java new file mode 100644 index 0000000..048630b --- /dev/null +++ b/spike/src/ee/wut/xqz/enabler/MonitorTileService.java @@ -0,0 +1,70 @@ +package ee.wut.xqz.enabler; + +import android.content.Intent; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbManager; +import android.os.Handler; +import android.os.Looper; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; +import android.widget.Toast; + +public final class MonitorTileService extends TileService { + private static final int VID = 0x054C; + private static final int PID = 0x0E48; + + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Override + public void onStartListening() { + super.onStartListening(); + updateTile(); + } + + @Override + public void onClick() { + super.onClick(); + if (HeartbeatService.isRunning()) { + stopService(new Intent(this, HeartbeatService.class)); + handler.postDelayed(this::updateTile, 700); + return; + } + + UsbDevice device = findDevice(); + if (device == null) { + Toast.makeText(this, "Connect the XQZ monitor", Toast.LENGTH_SHORT).show(); + return; + } + UsbManager manager = getSystemService(UsbManager.class); + if (manager == null || !manager.hasPermission(device)) { + Toast.makeText(this, "Open XQZ Enabler to allow USB access", + Toast.LENGTH_SHORT).show(); + return; + } + + try { + startForegroundService(new Intent(this, HeartbeatService.class)); + } catch (Throwable t) { + Toast.makeText(this, "Could not start: " + t.getMessage(), + Toast.LENGTH_SHORT).show(); + } + handler.postDelayed(this::updateTile, 700); + } + + private UsbDevice findDevice() { + UsbManager manager = getSystemService(UsbManager.class); + if (manager == null) return null; + for (UsbDevice d : manager.getDeviceList().values()) { + if (d.getVendorId() == VID && d.getProductId() == PID) return d; + } + return null; + } + + private void updateTile() { + Tile tile = getQsTile(); + if (tile == null) return; + tile.setState(HeartbeatService.isRunning() ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); + tile.setLabel("XQZ monitor"); + tile.updateTile(); + } +}