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();
+ }
+}