From 2f113cfb45f76c657f177b201a78eabbf60ec140 Mon Sep 17 00:00:00 2001 From: Arti Zirk Date: Wed, 4 Nov 2026 22:32:57 +0200 Subject: [PATCH] Add XQZ Enabler 1.0 with auto-reconnect and Quick Settings tile --- .gitignore | 8 +- README.md | 18 + spike/.gitignore | 4 + spike/AndroidManifest.xml | 48 +++ spike/README.md | 79 ++++ spike/XQZEnabler.apk | Bin 0 -> 21041 bytes spike/build.sh | 40 ++ spike/res/drawable/ic_launcher.xml | 22 ++ spike/res/drawable/ic_tile.xml | 13 + spike/res/values/strings.xml | 4 + .../ee/wut/xqz/enabler/HeartbeatService.java | 365 ++++++++++++++++++ .../src/ee/wut/xqz/enabler/MainActivity.java | 244 ++++++++++++ .../wut/xqz/enabler/MonitorTileService.java | 70 ++++ 13 files changed, 911 insertions(+), 4 deletions(-) create mode 100644 spike/.gitignore create mode 100644 spike/AndroidManifest.xml create mode 100644 spike/README.md create mode 100644 spike/XQZEnabler.apk create mode 100755 spike/build.sh create mode 100644 spike/res/drawable/ic_launcher.xml create mode 100644 spike/res/drawable/ic_tile.xml create mode 100644 spike/res/values/strings.xml create mode 100644 spike/src/ee/wut/xqz/enabler/HeartbeatService.java create mode 100644 spike/src/ee/wut/xqz/enabler/MainActivity.java create mode 100644 spike/src/ee/wut/xqz/enabler/MonitorTileService.java 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 0000000000000000000000000000000000000000..56289995cfcb58de7260f87d4e103e3e0e219d22 GIT binary patch literal 21041 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 Pzyb<@{n1HeKd$}<%iQN8 literal 0 HcmV?d00001 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(); + } +}