Add XQZ Enabler 1.0 with auto-reconnect and Quick Settings tile
This commit is contained in:
1 parent
8b1c76eb90
commit
2f113cfb45
13 files changed
+911
-4
No files matched your search
+4
-4
@@ -1,7 +1,7 @@
|
||||
# Local analysis artifacts (proprietary / large)
|
||||
apk/
|
||||
out/
|
||||
src/
|
||||
tools/apktool.jar
|
||||
/apk/
|
||||
/out/
|
||||
/src/
|
||||
/tools/apktool.jar
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
debug.keystore
|
||||
release.keystore
|
||||
*.idsig
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user