Add XQZ Enabler 1.0 with auto-reconnect and Quick Settings tile

This commit is contained in:
arti committed 2026-11-04 22:32:57 +02:00
1 parent 8b1c76eb90
commit 2f113cfb45
13 files changed
+911 -4

No files matched your search

+4 -4
View File
@@ -1,7 +1,7 @@
# Local analysis artifacts (proprietary / large)
apk/
out/
src/
tools/apktool.jar
/apk/
/out/
/src/
/tools/apktool.jar
__pycache__/
*.pyc
+18
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
build/
debug.keystore
release.keystore
*.idsig
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ee.wut.xqz.enabler"
android:versionCode="2"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="29" android:targetSdkVersion="35" />
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:allowBackup="false"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".HeartbeatService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
<service
android:name=".MonitorTileService"
android:exported="true"
android:icon="@drawable/ic_tile"
android:label="XQZ monitor"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>
+79
View File
@@ -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.
Binary file not shown.
Executable
+40
View File
@@ -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"
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF1565C0"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M18,30h72v40h-72z" />
<path
android:fillColor="#FF1565C0"
android:pathData="M24,36h60v28h-60z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M44,78h20v6h-20z" />
<path
android:fillColor="#FF80D8FF"
android:pathData="M48,42h12v16h-12z" />
</vector>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,4h20v13h-20z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,19h6v2h-6z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">XQZ Enabler</string>
</resources>
@@ -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;
}
}
@@ -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();
}
}
@@ -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();
}
}