diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | 852a8a00273c9128efeed0217a8e5d3fcd8bf780 (patch) | |
| tree | c72f959731fa3c7c809cf1a0222363b6c9c9b03b /app/src/main/java | |
| parent | f379b93d52bf0dbd3816ec3f64d165314771d2a6 (diff) | |
| download | scrcpy-android-852a8a00273c9128efeed0217a8e5d3fcd8bf780.tar.gz | |
app: harden mirroring lifecycle and state
Diffstat (limited to 'app/src/main/java')
27 files changed, 1625 insertions, 834 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java index 6f755b6..77235f6 100644 --- a/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java +++ b/app/src/main/java/invalid/lena/scrcpy/AtomicFiles.java @@ -10,7 +10,8 @@ import java.nio.file.StandardCopyOption; // fsync it, then rename it over the destination. The rename is the only // mutation a concurrent reader can observe, so a reader sees either the old // file or the new file in full, never a truncated mix. A crash mid-write -// leaves at most a stale ".tmp", never a damaged destination. +// may leave a stale staging file, never a damaged destination. The next +// write removes stale staging files before creating its own. // // Deliberately no fsync of the parent directory: the rename itself may be // lost on power failure (the old content survives intact). Callers store @@ -21,20 +22,29 @@ final class AtomicFiles { private AtomicFiles() {} - static void write(File dest, byte[] data) throws IOException { + static synchronized void write(File dest, byte[] data) throws IOException { File parent = dest.getAbsoluteFile().getParentFile(); - File tmp = new File(parent, dest.getName() + ".tmp"); - try (FileOutputStream os = new FileOutputStream(tmp)) { - os.write(data); - os.flush(); - os.getFD().sync(); + if (parent == null || !parent.isDirectory()) { + throw new IOException("destination parent is not a directory: " + parent); } + String prefix = dest.getName() + ".tmp"; + File[] stale = parent.listFiles((dir, name) -> + name.equals(prefix) || name.startsWith(prefix + "-")); + if (stale == null) throw new IOException("cannot list destination parent: " + parent); + for (File file : stale) Files.deleteIfExists(file.toPath()); + File tmp = Files.createTempFile(parent.toPath(), dest.getName() + ".tmp-", null).toFile(); + boolean moved = false; try { + try (FileOutputStream os = new FileOutputStream(tmp)) { + os.write(data); + os.flush(); + os.getFD().sync(); + } Files.move(tmp.toPath(), dest.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - tmp.delete(); - throw e; + moved = true; + } finally { + if (!moved) Files.deleteIfExists(tmp.toPath()); } } } diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java index 7f09620..5507c5c 100644 --- a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java +++ b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java @@ -14,6 +14,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayDeque; import java.util.Deque; +import java.util.concurrent.atomic.AtomicBoolean; // Audio output: AudioTrack writing 48 kHz stereo 16-bit PCM. The // upstream feed is either raw PCM (passthrough) or Opus packets @@ -24,6 +25,7 @@ public final class AudioSink implements AudioFrames { private static final int CHANNEL_OUT = AudioFormat.CHANNEL_OUT_STEREO; private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; private static final int MAX_PENDING_OPUS = 16; + private static final int MAX_PACKET_BYTES = 256 * 1024; // Defaults documented at <https://developer.android.com/reference/android/media/MediaCodec#CSD>. private static final long DEFAULT_PRE_ROLL_NS = 80_000_000L; @@ -32,16 +34,17 @@ public final class AudioSink implements AudioFrames { private volatile MediaCodec opusCodec; private HandlerThread opusThread; private Handler opusHandler; - private boolean opusConfigured; + private volatile boolean opusConfigured; private int fourcc; private final Object lock = new Object(); private final Runnable onFatalError; + private final AtomicBoolean fatalReported = new AtomicBoolean(); + private final AtomicBoolean playbackReported = new AtomicBoolean(); private final Deque<Integer> freeOpusInputs = new ArrayDeque<>(); private final Deque<byte[]> pendingOpus = new ArrayDeque<>(MAX_PENDING_OPUS); private volatile boolean released; - public volatile long frames; // public read for the status overlay private long droppedBytes; private long lastDropLogMs; @@ -69,7 +72,17 @@ public final class AudioSink implements AudioFrames { track = new AudioTrack( attrs, fmt, bufSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE); + if (track.getState() != AudioTrack.STATE_INITIALIZED) { + track.release(); + track = null; + throw new IllegalStateException("audio sink: AudioTrack failed to initialize"); + } track.play(); + if (track.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) { + track.release(); + track = null; + throw new IllegalStateException("audio sink: AudioTrack failed to start"); + } Log.i("audio sink: AudioTrack started sr=%d ch=2 buf=%d", SAMPLE_RATE, bufSize); if (fourcc == Wire.CODEC_OPUS) { @@ -103,14 +116,15 @@ public final class AudioSink implements AudioFrames { out.get(pcm); writePcm(pcm, 0, pcm.length); } - } catch (IllegalStateException ignored) { + } catch (IllegalStateException e) { + reportFatal(e, "audio sink: opus output"); } finally { try { mc.releaseOutputBuffer(idx, false); } catch (IllegalStateException ignored) {} } } @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) { - Log.e(e, "audio sink: opus codec error"); + reportFatal(e, "audio sink: opus codec error"); } @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat f) { Log.i("audio sink: opus output format %s", f); @@ -121,12 +135,18 @@ public final class AudioSink implements AudioFrames { @Override public void feed(byte[] data, int off, int len, boolean isConfig) { - if (released || len <= 0) return; + if (data == null || off < 0 || len < 0 || off > data.length - len) { + throw new IndexOutOfBoundsException("invalid audio frame range"); + } + if (len > MAX_PACKET_BYTES) { + reportFatal(null, "audio sink: packet too large (" + len + " bytes)"); + return; + } + if (released || len == 0) return; if (fourcc == Wire.CODEC_OPUS) { feedOpus(data, off, len, isConfig); } else { // Raw PCM: passthrough. - frames++; writePcm(data, off, len); } } @@ -137,7 +157,7 @@ public final class AudioSink implements AudioFrames { // OpusHead is 19 bytes; pre_skip lives at bytes [10..11]. Reject // anything shorter before indexing into it. if (len < 19) { - Log.e("audio sink: opus head too short (%d bytes)", len); + reportFatal(null, "audio sink: opus head too short (" + len + " bytes)"); return; } try { @@ -156,7 +176,7 @@ public final class AudioSink implements AudioFrames { opusConfigured = true; Log.i("audio sink: opus configured, pre_skip=%d ns", preSkipNs); } catch (Exception e) { - Log.e(e, "audio sink: opus configure"); + reportFatal(e, "audio sink: opus configure"); } return; } @@ -181,16 +201,15 @@ public final class AudioSink implements AudioFrames { try { ByteBuffer in = codec.getInputBuffer(idx); if (in == null || packet.length > in.capacity()) { - Log.e("audio sink: opus packet exceeds codec input (%d bytes)", packet.length); - if (onFatalError != null) onFatalError.run(); + reportFatal(null, + "audio sink: opus packet exceeds codec input (" + packet.length + " bytes)"); return; } in.clear(); in.put(packet); codec.queueInputBuffer(idx, 0, packet.length, 0, 0); - frames++; } catch (IllegalStateException e) { - Log.w("audio sink: opus queueInputBuffer: %s", e); + reportFatal(e, "audio sink: opus queueInputBuffer"); } } @@ -206,9 +225,12 @@ public final class AudioSink implements AudioFrames { if (released || t == null || len <= 0) return; int written = t.write(data, off, len, AudioTrack.WRITE_NON_BLOCKING); if (written < 0) { - Log.w("audio sink: write rc=%d", written); + reportFatal(null, "audio sink: AudioTrack write rc=" + written); return; } + if (written > 0 && playbackReported.compareAndSet(false, true)) { + Log.i("audio sink: playback started"); + } if (written < len) { droppedBytes += (len - written); long now = System.currentTimeMillis(); @@ -219,6 +241,13 @@ public final class AudioSink implements AudioFrames { } } + private void reportFatal(Exception error, String message) { + if (!fatalReported.compareAndSet(false, true)) return; + if (error == null) Log.e("%s", message); + else Log.e(error, "%s", message); + if (onFatalError != null) onFatalError.run(); + } + @Override public void release() { MediaCodec c; diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java index 7fe33f0..13b39f5 100644 --- a/app/src/main/java/invalid/lena/scrcpy/AudioStream.java +++ b/app/src/main/java/invalid/lena/scrcpy/AudioStream.java @@ -2,6 +2,7 @@ package invalid.lena.scrcpy; import java.io.IOException; import java.io.InputStream; +import java.util.concurrent.TimeUnit; // Reads the scrcpy audio socket and drives an AudioFrames sink. // @@ -23,16 +24,34 @@ public final class AudioStream { // Generous upper bound for one audio packet (a raw PCM block or an // opus packet is a few KB). A corrupt or hostile length field must // not drive the allocation below. - private static final int MAX_FRAME_SIZE = 1024 * 1024; + private static final int MAX_FRAME_SIZE = 256 * 1024; private final InputStream source; private final AudioFrames sink; + private final Runnable onFatalError; private Thread thread; private volatile boolean stop; + // Liveness signal for Session's stall watchdog. The audio socket is + // the only one that is reliably continuous: the server captures PCM + // at 48 kHz whether or not anything on the target's screen moves, so + // silence here means the wire is gone. Video is useless for this - a + // static screen queues nothing to the encoder and legitimately + // produces no frames for minutes. + private volatile boolean started; + private volatile long lastPacketAtMs; + + public boolean isStarted() { return started; } + public long lastPacketAtMs() { return lastPacketAtMs; } + public AudioStream(InputStream source, AudioFrames sink) { + this(source, sink, null); + } + + public AudioStream(InputStream source, AudioFrames sink, Runnable onFatalError) { this.source = source; this.sink = sink; + this.onFatalError = onFatalError; } public void start() { @@ -60,16 +79,15 @@ public final class AudioStream { return; } if (fourcc == 1) { - Log.e("audio: server reports configuration error"); - return; + throw new IOException("audio: server reports configuration error"); } if (fourcc != Wire.CODEC_RAW && fourcc != Wire.CODEC_OPUS) { - Log.w("audio: unexpected codec %s - keeping silent", - Wire.fourccName(fourcc)); - return; + throw new IOException("audio: unexpected codec " + Wire.fourccName(fourcc)); } Log.i("audio meta codec=%s", Wire.fourccName(fourcc)); sink.start(fourcc); + lastPacketAtMs = monotonicMs(); + started = true; byte[] hdr = new byte[12]; byte[] payload = new byte[16 * 1024]; @@ -84,15 +102,28 @@ public final class AudioStream { } if (size > payload.length) payload = new byte[size]; Wire.readFully(source, payload, 0, size); + lastPacketAtMs = monotonicMs(); sink.feed(payload, 0, size, cfg); if (++frames == 1) Log.i("audio frame n=1 size=%d cfg=%s", size, cfg); } } catch (IOException e) { - if (!stop) Log.e(e, "audio reader"); + if (!stop) { + Log.e(e, "audio reader"); + reportFatal(); + } } catch (Exception e) { Log.e(e, "audio reader unexpected"); + if (!stop) reportFatal(); } finally { Log.i("audio reader: end"); } } + + private void reportFatal() { + if (onFatalError != null) onFatalError.run(); + } + + private static long monotonicMs() { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java index aba2ca8..6455a65 100644 --- a/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java +++ b/app/src/main/java/invalid/lena/scrcpy/ControlMessages.java @@ -8,28 +8,52 @@ import java.nio.charset.StandardCharsets; // unit-tested without android.* on the classpath. public final class ControlMessages { - static final int MAX_CLIPBOARD_BYTES = 1 << 20; + // scrcpy caps a whole control message at MESSAGE_MAX_SIZE = 256 KiB. + // For SET_CLIPBOARD that leaves 256 KiB minus the 14-byte header + // (type 1, sequence 8, paste flag 1, length 4); see the server's + // ControlMessageReader.CLIPBOARD_TEXT_MAX_LENGTH. Going over makes + // the server raise ControlProtocolException and drop the control + // connection, taking the session with it, so refuse locally instead. + static final int MAX_CLIPBOARD_BYTES = (1 << 18) - 14; + + // The reverse direction, from the server's DeviceMessageWriter: + // 256 KiB minus its 5-byte header (type 1, length 4). Used to bound + // what we are willing to read off the control socket. + static final int MAX_DEVICE_CLIPBOARD_BYTES = (1 << 18) - 5; public static final int TYPE_INJECT_KEYCODE = 0; - public static final int TYPE_INJECT_TEXT = 1; public static final int TYPE_INJECT_TOUCH_EVENT = 2; public static final int TYPE_BACK_OR_SCREEN_ON = 4; public static final int TYPE_SET_CLIPBOARD = 9; + public static final int TYPE_RESET_VIDEO = 17; // KeyEvent.ACTION_DOWN / ACTION_UP. Mirror the int values rather // than depend on android.view.KeyEvent so this stays android-free. public static final int ACTION_DOWN = 0; public static final int ACTION_UP = 1; + // AOSP keycodes, mirrored rather than imported so this stays + // android-free. KeyEvent.KEYCODE_HOME / KEYCODE_APP_SWITCH. + public static final int KEYCODE_HOME = 3; + public static final int KEYCODE_APP_SWITCH = 187; + public static final int TOUCH_MSG_LEN = 32; // 1 + 1 + 8 + 4 + 4 + 2 + 2 + 2 + 4 + 4 public static final int KEY_MSG_LEN = 14; // 1 + 1 + 4 + 4 + 4 - public static final int BACK_MSG_LEN = 2; // 1 + 1 private ControlMessages() {} public static byte[] touch(int action, long pointerId, int x, int y, int targetW, int targetH, int pressureU16, int actionButton, int buttons) { + if (targetW < 1 || targetW > 0xffff || targetH < 1 || targetH > 0xffff) { + throw new IllegalArgumentException("touch target size is out of range"); + } + if (x < 0 || x >= targetW || y < 0 || y >= targetH) { + throw new IllegalArgumentException("touch position is out of range"); + } + if (pressureU16 < 0 || pressureU16 > 0xffff) { + throw new IllegalArgumentException("touch pressure is out of range"); + } byte[] m = new byte[TOUCH_MSG_LEN]; m[0] = TYPE_INJECT_TOUCH_EVENT; m[1] = (byte) action; @@ -61,7 +85,14 @@ public final class ControlMessages { return new byte[]{(byte) TYPE_BACK_OR_SCREEN_ON, (byte) action}; } + public static byte[] resetVideo() { + return new byte[]{(byte) TYPE_RESET_VIDEO}; + } + public static byte[] setClipboard(long sequence, boolean paste, String text) { + // UTF-8 is at least one byte per char, so this rejects the + // hopeless cases without encoding a huge string first. The byte + // count below is the check that actually matters. if (text.length() > MAX_CLIPBOARD_BYTES) { throw new IllegalArgumentException("clipboard text is too large"); } diff --git a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java index a231b92..2ea2883 100644 --- a/app/src/main/java/invalid/lena/scrcpy/ControlStream.java +++ b/app/src/main/java/invalid/lena/scrcpy/ControlStream.java @@ -3,7 +3,7 @@ package invalid.lena.scrcpy; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.charset.StandardCharsets; +import java.util.Objects; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.atomic.AtomicBoolean; @@ -46,10 +46,6 @@ public final class ControlStream { private Thread reader; private volatile boolean stop; - public ControlStream(InputStream in, OutputStream out) { - this(in, out, null); - } - public ControlStream(InputStream in, OutputStream out, Runnable onFatalError) { this.in = in; this.out = out; @@ -77,6 +73,7 @@ public final class ControlStream { } public void send(byte[] msg) { + Objects.requireNonNull(msg); if (stop) return; if (outbox.offerLast(msg)) return; @@ -135,12 +132,12 @@ public final class ControlStream { case DEV_TYPE_CLIPBOARD: { Wire.readFully(in, tmp, 0, 4); int len = Wire.readBe32(tmp, 0); - if (len < 0 || len > 1 << 20) { + if (len < 0 || len > ControlMessages.MAX_DEVICE_CLIPBOARD_BYTES) { throw new IOException("clipboard len out of range: " + len); } byte[] data = new byte[len]; Wire.readFully(in, data); - String text = new String(data, StandardCharsets.UTF_8); + String text = Wire.decodeUtf8(data); InboundSink s = sink; if (s != null) s.onRemoteClipboard(text); break; diff --git a/app/src/main/java/invalid/lena/scrcpy/Controller.java b/app/src/main/java/invalid/lena/scrcpy/Controller.java index 242d6a5..3cc321c 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Controller.java +++ b/app/src/main/java/invalid/lena/scrcpy/Controller.java @@ -7,6 +7,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; // Encodes UI events into scrcpy ControlMessage byte arrays and pushes // them at ControlStream. Also mirrors the remote clipboard locally and @@ -17,22 +18,30 @@ import java.util.function.Consumer; public final class Controller implements ControlStream.InboundSink { private final Consumer<byte[]> sender; + // Null when the user has turned clipboard sync off, which disables + // both directions: nothing is read from this device and nothing the + // target sends is written to it. private final ClipboardManager clipboard; - public volatile String lastEvent = "(idle)"; + private final boolean clipboardSync; // Held once so add/removePrimaryClipChangedListener see the same // listener reference. Method references generate fresh lambdas // each call site and the remove silently no-ops otherwise. private final ClipboardManager.OnPrimaryClipChangedListener clipListener = this::onLocalClipboardChanged; - private volatile int targetW, targetH; - private volatile int viewW, viewH; + private final TouchGeometry geometry = new TouchGeometry(); - // Suppress one local clipboard change after we set it from a remote update. - private volatile boolean suppressNextClipChange; + // The latest value applied from the target. Keeping the value, rather + // than a one-shot boolean, cannot consume an unrelated user clipboard + // change when Android delays or omits our own callback. + private final AtomicReference<String> remoteClipboardText = new AtomicReference<>(); public Controller(Context ctx, Consumer<byte[]> sender) { this.sender = sender; - this.clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE); + this.clipboardSync = Settings.clipboardSync(ctx); + Context app = ctx.getApplicationContext(); + this.clipboard = clipboardSync + ? (ClipboardManager) app.getSystemService(Context.CLIPBOARD_SERVICE) + : null; if (clipboard != null) { clipboard.addPrimaryClipChangedListener(clipListener); } @@ -45,51 +54,68 @@ public final class Controller implements ControlStream.InboundSink { } } - public void setTargetSize(int w, int h) { - targetW = w; targetH = h; + public void setTargetSize(long version, int w, int h) { + geometry.setTargetSize(version, w, h); Log.i("controller: target %dx%d", w, h); } - public void setViewSize(int w, int h) { - viewW = w; viewH = h; + // The rectangle the video occupies in the activity window, which is + // the coordinate space MotionEvents arrive in. + public void setViewport(long version, int x, int y, int w, int h) { + geometry.setViewport(version, x, y, w, h); } // ---- inbound ---- @Override public void onRemoteClipboard(String text) { + if (!clipboardSync || clipboard == null) { + Log.i("clipboard from target ignored: sync is off"); + return; + } Log.i("clipboard from target: %d chars", text.length()); - if (clipboard == null) return; - suppressNextClipChange = true; + remoteClipboardText.set(text); try { clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy-android", text)); } catch (Exception e) { Log.w("clipboard set local failed: %s", e); - suppressNextClipChange = false; + remoteClipboardText.compareAndSet(text, null); } } private void onLocalClipboardChanged() { - if (suppressNextClipChange) { - suppressNextClipChange = false; - return; - } if (clipboard == null) return; ClipData data; try { data = clipboard.getPrimaryClip(); } catch (Exception e) { Log.w("clipboard get local failed: %s", e); return; } if (data == null || data.getItemCount() == 0) return; - CharSequence cs = data.getItemAt(0).coerceToText(null); + // The scrcpy control protocol carries UTF-8 text, not URI or Intent + // clipboard items. Do not coerce URI items: that invokes an arbitrary + // local ContentProvider and may materialize unbounded content before + // our wire-size check. Explicit text fails closed. + CharSequence cs = data.getItemAt(0).getText(); if (cs == null) return; - sendSetClipboard(cs.toString(), false); - Log.i("clipboard to target: %d chars", cs.length()); + String text = cs.toString(); + if (text.equals(remoteClipboardText.get())) return; + remoteClipboardText.set(null); + if (sendSetClipboard(text, false)) { + Log.i("clipboard to target: %d chars", cs.length()); + } + } + + // Android 10+ denies clipboard reads while an app is not focused. A copy + // made in another app therefore cannot be forwarded by the listener at + // copy time. Mirror calls this after regaining focus so that ordinary + // copy, return-to-mirror is reliable. + public void syncLocalClipboard() { + onLocalClipboardChanged(); } // ---- outbound ---- public void onTouch(MotionEvent ev) { - int tw = targetW, th = targetH, vw = viewW, vh = viewH; - if (tw == 0 || th == 0 || vw == 0 || vh == 0) return; + TouchGeometry.Snapshot g = geometry.snapshot(); + if (g == null) return; // scrcpy's wire protocol uses ACTION_DOWN/UP/MOVE/CANCEL with a // pointerId per message. The server tracks which pointers are @@ -97,57 +123,59 @@ public final class Controller implements ControlStream.InboundSink { // ACTION_POINTER_DOWN[i] -> ACTION_DOWN (this pointer joins) // ACTION_POINTER_UP[i] -> ACTION_UP (this pointer leaves) // ACTION_MOVE -> ACTION_MOVE for every current pointer - // ACTION_CANCEL -> ACTION_CANCEL for every current pointer + // ACTION_CANCEL -> ACTION_UP for every current pointer int action = ev.getActionMasked(); int idx = ev.getActionIndex(); int n = ev.getPointerCount(); switch (action) { case MotionEvent.ACTION_DOWN: - sendPointer(ev, 0, MotionEvent.ACTION_DOWN, tw, th, vw, vh); + sendPointer(ev, 0, MotionEvent.ACTION_DOWN, g); break; case MotionEvent.ACTION_POINTER_DOWN: - sendPointer(ev, idx, MotionEvent.ACTION_DOWN, tw, th, vw, vh); + sendPointer(ev, idx, MotionEvent.ACTION_DOWN, g); break; case MotionEvent.ACTION_UP: - sendPointer(ev, 0, MotionEvent.ACTION_UP, tw, th, vw, vh); + sendPointer(ev, 0, MotionEvent.ACTION_UP, g); break; case MotionEvent.ACTION_POINTER_UP: - sendPointer(ev, idx, MotionEvent.ACTION_UP, tw, th, vw, vh); + sendPointer(ev, idx, MotionEvent.ACTION_UP, g); break; case MotionEvent.ACTION_MOVE: - for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, tw, th, vw, vh); + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_MOVE, g); break; case MotionEvent.ACTION_CANCEL: - for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_CANCEL, tw, th, vw, vh); + // Sent as UP, not CANCEL. The server releases a pointer + // only on ACTION_UP (Controller.injectTouch calls + // pointer.setUp(action == ACTION_UP)), so a forwarded + // CANCEL leaves it down in PointersState for the rest of + // the session and every later touch behaves as an extra + // finger. Cancels are routine on the source device: an + // edge swipe or the notification shade stealing the + // gesture produces one. + for (int i = 0; i < n; i++) sendPointer(ev, i, MotionEvent.ACTION_UP, g); break; default: return; } } - // Sizes come from onTouch's snapshot of the volatile fields, so a - // concurrent resize cannot zero a divisor between check and use. private void sendPointer(MotionEvent ev, int index, int action, - int tw, int th, int vw, int vh) { + TouchGeometry.Snapshot g) { long pointerId = ev.getPointerId(index); - int x = (int) ev.getX(index); - int y = (int) ev.getY(index); - int tx = (int) ((long) x * tw / vw); - int ty = (int) ((long) y * th / vh); + int tx = TouchMap.map((int) ev.getX(index), g.x, g.w, g.targetW); + int ty = TouchMap.map((int) ev.getY(index), g.y, g.h, g.targetH); // getPressure is calibrated around 1.0 but may exceed it on some // digitizers; clamp instead of masking so hard presses don't wrap // around to a light touch. int pressure = (action == MotionEvent.ACTION_UP) ? 0 - : Math.min((int)(ev.getPressure(index) * 0xffff), 0xffff); - sender.accept(ControlMessages.touch(action, pointerId, tx, ty, tw, th, - pressure, /* actionButton */ 0, /* buttons */ 0)); - lastEvent = "touch a=" + action + " (" + tx + "," + ty + ")"; + : Math.max(0, Math.min((int)(ev.getPressure(index) * 0xffff), 0xffff)); + sender.accept(ControlMessages.touch(action, pointerId, tx, ty, + g.targetW, g.targetH, pressure, /* actionButton */ 0, /* buttons */ 0)); } public void onKey(KeyEvent ev) { - int tw = targetW, th = targetH; - if (tw == 0 || th == 0) return; + if (geometry.snapshot() == null) return; int action = ev.getAction(); // ACTION_DOWN=0, ACTION_UP=1 if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) return; sendKeycode(action, ev.getKeyCode(), ev.getRepeatCount(), ev.getMetaState()); @@ -159,22 +187,43 @@ public final class Controller implements ControlStream.InboundSink { public void onBack() { sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_DOWN)); sender.accept(ControlMessages.backOrScreenOn(ControlMessages.ACTION_UP)); - lastEvent = "back"; + } + + // Home and Recents go as keycodes, not as gestures. On a + // gesture-navigation source a swipe from the bottom edge is claimed + // by the source's own gesture detector and never reaches this app, + // and unlike the side edges that area cannot be released with + // setSystemGestureExclusionRects - the system always keeps it. So + // the target's navigation is unreachable by forwarding touches, on + // any modern source device, and has to be driven explicitly. + public void onHome() { + sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_HOME, 0, 0); + sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_HOME, 0, 0); + } + + public void onRecents() { + sendKeycode(ControlMessages.ACTION_DOWN, ControlMessages.KEYCODE_APP_SWITCH, 0, 0); + sendKeycode(ControlMessages.ACTION_UP, ControlMessages.KEYCODE_APP_SWITCH, 0, 0); + } + + public void resetVideo() { + sender.accept(ControlMessages.resetVideo()); + Log.i("controller: reset video"); } // ---- encoders (delegate to pure-java ControlMessages) ---- private void sendKeycode(int action, int keycode, int repeat, int metaState) { sender.accept(ControlMessages.keycode(action, keycode, repeat, metaState)); - lastEvent = "key a=" + action + " kc=" + keycode; } - private void sendSetClipboard(String text, boolean paste) { + private boolean sendSetClipboard(String text, boolean paste) { try { sender.accept(ControlMessages.setClipboard(/* sequence */ 0L, paste, text)); - lastEvent = "clip " + text.length() + " chars"; + return true; } catch (IllegalArgumentException e) { Log.w("clipboard not sent: %s", e.getMessage()); + return false; } } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java index 00bd3d8..705d191 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Crashlog.java +++ b/app/src/main/java/invalid/lena/scrcpy/Crashlog.java @@ -4,8 +4,11 @@ import android.content.Context; import java.io.File; import java.io.FileWriter; +import java.io.IOException; import java.io.PrintWriter; +import java.nio.file.Files; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Date; import java.util.Locale; @@ -16,6 +19,8 @@ import java.util.Locale; // fishing for it in adb logcat. public final class Crashlog { + private static final int MAX_LOGS = 5; + private Crashlog() {} public static void install(Context ctx) { @@ -24,19 +29,25 @@ public final class Crashlog { Log.w("crashlog: no external storage, skipping install"); return; } + try { + prune(dir, MAX_LOGS); + } catch (IOException e) { + Log.w("crashlog: prune failed: %s", e); + } Thread.UncaughtExceptionHandler prev = Thread.getDefaultUncaughtExceptionHandler(); Thread.setDefaultUncaughtExceptionHandler((t, e) -> { try { - String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", + String ts = new SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.ROOT).format(new Date()); - File out = new File(dir, "crash-" + ts + ".log"); + File out = File.createTempFile("crash-" + ts + "-", ".log", dir); try (PrintWriter pw = new PrintWriter(new FileWriter(out))) { pw.println("# " + new Date()); pw.println("# thread=" + t.getName()); pw.println(); e.printStackTrace(pw); } + prune(dir, MAX_LOGS); Log.e(e, "crashlog: wrote %s", out.getAbsolutePath()); } catch (Throwable ignored) { // best effort - do not mask the original crash @@ -45,4 +56,18 @@ public final class Crashlog { }); Log.i("crashlog: installed -> %s", dir.getAbsolutePath()); } + + static void prune(File dir, int keep) throws IOException { + if (keep < 0) throw new IllegalArgumentException("negative retention"); + File[] logs = dir.listFiles((parent, name) -> + name.startsWith("crash-") && name.endsWith(".log")); + if (logs == null) throw new IOException("cannot list crashlog directory: " + dir); + Arrays.sort(logs, (left, right) -> { + int modified = Long.compare(right.lastModified(), left.lastModified()); + return modified != 0 ? modified : right.getName().compareTo(left.getName()); + }); + for (int i = keep; i < logs.length; i++) { + Files.deleteIfExists(logs[i].toPath()); + } + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Devices.java b/app/src/main/java/invalid/lena/scrcpy/Devices.java index 0ba61a3..9ec308c 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Devices.java +++ b/app/src/main/java/invalid/lena/scrcpy/Devices.java @@ -5,10 +5,12 @@ import android.content.Context; import org.json.JSONArray; import org.json.JSONObject; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.ArrayList; import java.util.List; @@ -20,6 +22,8 @@ import java.util.List; public final class Devices { private static final String FILE = "devices.json"; + private static final long MAX_FILE_BYTES = 1024 * 1024; + private static final int MAX_HOST_CHARS = 255; public static final class Device { public final String host; @@ -74,7 +78,7 @@ public final class Devices { port = s.substring(colon + 1); } int p = parsePort(port); - if (host.isEmpty() || p < 0) return null; + if (!validHost(host) || p < 0) return null; return new Device(host, p); } @@ -99,13 +103,29 @@ public final class Devices { json = json.trim(); if (json.isEmpty()) return out; JSONArray arr; - try { arr = new JSONArray(json); } - catch (Exception e) { Log.e(e, "devices: not a json array"); return out; } + try { + arr = new JSONArray(json); + } catch (Exception e) { + throw new IllegalArgumentException("devices: expected a JSON array", e); + } for (int i = 0; i < arr.length(); i++) { try { JSONObject o = arr.getJSONObject(i); - out.add(new Device(o.getString("host"), o.getInt("port"))); + String host = o.getString("host"); + Object portValue = o.get("port"); + if (!(portValue instanceof Integer) && !(portValue instanceof Long)) { + throw new IllegalArgumentException("port is not an integer"); + } + long portLong = ((Number) portValue).longValue(); + if (portLong < 1 || portLong > 65535) { + throw new IllegalArgumentException("port is invalid"); + } + int port = (int) portLong; + if (!validHost(host) || port < 1 || port > 65535) { + throw new IllegalArgumentException("invalid device fields"); + } + out.add(new Device(host, port)); } catch (Exception e) { Log.w("devices: skipping malformed row %d: %s", i, e); } @@ -117,6 +137,9 @@ public final class Devices { try { JSONArray arr = new JSONArray(); for (Device d : devices) { + if (!validHost(d.host) || d.port < 1 || d.port > 65535) { + throw new IllegalArgumentException("invalid saved device"); + } JSONObject o = new JSONObject(); o.put("host", d.host); o.put("port", d.port); @@ -124,23 +147,35 @@ public final class Devices { } return arr.toString(2); } catch (Exception e) { - Log.e(e, "devices: serialize failed"); - return "[]"; + throw new IllegalStateException("devices: serialize failed", e); } } - public static List<Device> load(Context ctx) { + public static List<Device> load(Context ctx) throws IOException { File f = new File(ctx.getFilesDir(), FILE); if (!f.exists()) return new ArrayList<>(); try { - return parse(new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8)); + if (f.length() > MAX_FILE_BYTES) { + throw new IOException("device list is too large: " + f.length()); + } + byte[] data = readLimited(f); + if (data.length == 0) throw new IOException("device list is empty"); + return parse(Wire.decodeUtf8(data)); } catch (Exception e) { - Log.e(e, "devices: load failed"); - return new ArrayList<>(); + if (e instanceof IOException) throw (IOException) e; + throw new IOException("devices: load failed", e); } } - public static void save(Context ctx, List<Device> devices) throws IOException { + // The saved row for an address, or null if there is none. + public static Device find(Context ctx, String host, int port) throws IOException { + for (Device d : load(ctx)) { + if (d.port == port && d.host.equals(host)) return d; + } + return null; + } + + private static void save(Context ctx, List<Device> devices) throws IOException { File f = new File(ctx.getFilesDir(), FILE); AtomicFiles.write(f, serialize(devices).getBytes(StandardCharsets.UTF_8)); } @@ -162,4 +197,31 @@ public final class Devices { save(ctx, list); return list; } + + private static boolean validHost(String host) { + if (host == null || host.isEmpty() || host.length() > MAX_HOST_CHARS) return false; + for (int i = 0; i < host.length(); i++) { + char c = host.charAt(i); + if (Character.isWhitespace(c) || Character.isISOControl(c) || c == '[' || c == ']') { + return false; + } + } + return true; + } + + private static byte[] readLimited(File file) throws IOException { + try (InputStream in = new FileInputStream(file); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + long total = 0; + for (;;) { + int n = in.read(buf); + if (n < 0) return out.toByteArray(); + if (n == 0) throw new IOException("device list read made no progress"); + total += n; + if (total > MAX_FILE_BYTES) throw new IOException("device list is too large"); + out.write(buf, 0, n); + } + } + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Licenses.java b/app/src/main/java/invalid/lena/scrcpy/Licenses.java new file mode 100644 index 0000000..8e66912 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/Licenses.java @@ -0,0 +1,48 @@ +package invalid.lena.scrcpy; + +import android.app.Activity; +import android.os.Bundle; +import android.view.WindowInsets; +import android.widget.TextView; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +// Shows the bundled third-party notices. +// +// This is a legal requirement, not a courtesy. The APK links +// libspake2.so, which is LGPL-3.0: section 4 lets an LGPL library be +// combined into a differently-licensed work only if the combined work +// gives prominent notice that the library is used and is covered by the +// LGPL, and points at the source needed to relink it. Shipping that text +// as an asset no code reads is not notice. This screen is what makes it +// notice. +public final class Licenses extends Activity { + + private static final String ASSET = "THIRD_PARTY_NOTICES"; + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + setContentView(R.layout.licenses); + Ui.padForInsets(findViewById(R.id.root), WindowInsets.Type.systemBars()); + ((TextView) findViewById(R.id.notices)).setText(read()); + } + + private String read() { + try (InputStream in = getAssets().open(ASSET)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + for (int n; (n = in.read(buf)) >= 0; ) { + if (n == 0) throw new IOException("license asset read made no progress"); + out.write(buf, 0, n); + } + return out.toString(StandardCharsets.UTF_8.name()); + } catch (IOException e) { + Log.e(e, "licenses: cannot read %s", ASSET); + return getString(R.string.licenses_unavailable); + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Main.java b/app/src/main/java/invalid/lena/scrcpy/Main.java index c069850..24126e9 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Main.java +++ b/app/src/main/java/invalid/lena/scrcpy/Main.java @@ -7,13 +7,13 @@ import android.os.Bundle; import android.text.TextUtils; import android.view.View; import android.view.WindowInsets; -import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; -import android.widget.ListView; +import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import java.io.IOException; import java.util.List; // Pairing form + saved-device list. @@ -23,14 +23,13 @@ import java.util.List; // the target's Wireless debugging screen; pairing happens on a different, // short-lived port from the pairing dialog, against the same host. After a // successful pair() against the daemon, the row is appended to devices.json -// with the *connect* port. Tapping a saved row launches the Mirror activity -// with the target host/port; Mirror owns its own Adb instance loaded from -// the same on-disk keypair. +// with the *connect* port. Tapping a saved row launches the Mirror activity. public final class Main extends Activity { private volatile Adb adb; - private ArrayAdapter<Devices.Device> adapter; - private Button pairButton; + private LinearLayout deviceList; + private TextView devicesEmpty; + private Button pairButton; @Override protected void onCreate(Bundle saved) { @@ -38,8 +37,9 @@ public final class Main extends Activity { setContentView(R.layout.main); // The IME inset is in the mask too: the window is edge-to-edge, so - // nothing resizes it when the keyboard opens. Padding the root - // shrinks the weighted device list and keeps the form reachable. + // nothing resizes it when the keyboard opens. The page is one + // ScrollView, so padding the root keeps the form reachable behind + // the keyboard. Ui.padForInsets(findViewById(R.id.root), WindowInsets.Type.systemBars() | WindowInsets.Type.ime()); @@ -48,28 +48,34 @@ public final class Main extends Activity { EditText pairCode = findViewById(R.id.pair_code); pairButton = findViewById(R.id.pair); View settingsBtn = findViewById(R.id.settings); - ListView devices = findViewById(R.id.devices); - TextView devicesEmpty = findViewById(R.id.devices_empty); + deviceList = findViewById(R.id.devices); + devicesEmpty = findViewById(R.id.devices_empty); settingsBtn.setOnClickListener(v -> startActivity( new Intent(this, SettingsActivity.class))); - adapter = new ArrayAdapter<>(this, R.layout.device_row, R.id.device_label, Devices.load(this)); - devices.setAdapter(adapter); - devices.setEmptyView(devicesEmpty); + try { + showDevices(Devices.load(this)); + } catch (IOException e) { + Log.e(e, "devices: load failed"); + showDevices(java.util.Collections.emptyList()); + Toast.makeText(this, R.string.device_list_unreadable, Toast.LENGTH_LONG).show(); + } // RSA keygen on first launch can take 1-3 s; never on the UI thread. pairButton.setEnabled(false); new Thread(() -> { try { - Adb a = Adb.getInstance(this); + Adb a = Adb.getInstance(getApplicationContext()); runOnUiThread(() -> { + if (isFinishing() || isDestroyed()) return; adb = a; pairButton.setEnabled(true); }); } catch (Exception e) { Log.e(e, "adb init failed"); runOnUiThread(() -> { + if (isFinishing() || isDestroyed()) return; Toast.makeText(this, "adb init failed: " + e.getMessage(), Toast.LENGTH_LONG).show(); finish(); @@ -77,38 +83,6 @@ public final class Main extends Activity { } }, "adb-init").start(); - devices.setOnItemClickListener((parent, view, pos, id) -> { - Devices.Device d = adapter.getItem(pos); - Log.i("connect tap: %s", d); - Intent i = new Intent(this, Mirror.class); - i.putExtra(Mirror.EXTRA_HOST, d.host); - i.putExtra(Mirror.EXTRA_PORT, d.port); - startActivity(i); - }); - - devices.setOnItemLongClickListener((parent, view, pos, id) -> { - Devices.Device d = adapter.getItem(pos); - new AlertDialog.Builder(this) - .setTitle(R.string.forget_device) - .setMessage(d.toString()) - .setPositiveButton(android.R.string.ok, (dlg, w) -> { - Log.i("forget device: %s", d); - try { - List<Devices.Device> updated = Devices.remove(this, d); - adapter.clear(); - adapter.addAll(updated); - adapter.notifyDataSetChanged(); - } catch (Exception e) { - Log.e(e, "forget device: save failed"); - Toast.makeText(this, "could not save device list", - Toast.LENGTH_LONG).show(); - } - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - return true; - }); - pairButton.setOnClickListener(v -> { if (adb == null) return; // still initialising // The saved endpoint is the address field verbatim; pairing @@ -124,7 +98,7 @@ public final class Main extends Activity { return; } String pc = pairCode.getText().toString().trim(); - if (TextUtils.isEmpty(pc)) { + if (TextUtils.isEmpty(pc) || !pc.matches("[0-9]{6}")) { Toast.makeText(this, R.string.bad_pair_code, Toast.LENGTH_LONG).show(); return; } @@ -134,22 +108,61 @@ public final class Main extends Activity { }); } + // Rebuild the saved-device rows. There is no adapter: the whole page + // is one ScrollView, a ListView cannot measure itself inside one, and + // a handful of rows does not need recycling. + private void showDevices(List<Devices.Device> devices) { + deviceList.removeAllViews(); + devicesEmpty.setVisibility(devices.isEmpty() ? View.VISIBLE : View.GONE); + for (Devices.Device d : devices) { + View row = getLayoutInflater().inflate(R.layout.device_row, deviceList, false); + ((TextView) row.findViewById(R.id.device_label)).setText(d.toString()); + row.setOnClickListener(v -> { + Log.i("connect tap: %s", d); + Intent i = new Intent(this, Mirror.class); + i.putExtra(Mirror.EXTRA_HOST, d.host); + i.putExtra(Mirror.EXTRA_PORT, d.port); + startActivity(i); + }); + row.setOnLongClickListener(v -> { confirmForget(d); return true; }); + deviceList.addView(row); + } + } + + private void confirmForget(Devices.Device d) { + new AlertDialog.Builder(this) + .setTitle(R.string.forget_device) + .setMessage(d.toString()) + .setPositiveButton(android.R.string.ok, (dlg, w) -> { + Log.i("forget device: %s", d); + try { + showDevices(Devices.remove(this, d)); + } catch (Exception e) { + Log.e(e, "forget device: save failed"); + Toast.makeText(this, "could not save device list", + Toast.LENGTH_LONG).show(); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + private void pairAndSave(Devices.Device target, int pairPort, String code, Button btn) { try { Log.i("pair: %s:%d", target.host, pairPort); adb.pairDevice(target.host, pairPort, code); Log.i("pair ok host=%s pair_port=%d", target.host, pairPort); - List<Devices.Device> updated = Devices.upsert(this, target); + List<Devices.Device> updated = Devices.upsert(getApplicationContext(), target); runOnUiThread(() -> { - adapter.clear(); - adapter.addAll(updated); - adapter.notifyDataSetChanged(); + if (isFinishing() || isDestroyed()) return; + showDevices(updated); Toast.makeText(this, "paired and saved", Toast.LENGTH_SHORT).show(); btn.setEnabled(true); }); } catch (Exception e) { Log.e(e, "pair failed"); runOnUiThread(() -> { + if (isFinishing() || isDestroyed()) return; Toast.makeText(this, "pair failed: " + e.getMessage(), Toast.LENGTH_LONG).show(); btn.setEnabled(true); }); diff --git a/app/src/main/java/invalid/lena/scrcpy/Mirror.java b/app/src/main/java/invalid/lena/scrcpy/Mirror.java index 2a9d390..bc9db34 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Mirror.java +++ b/app/src/main/java/invalid/lena/scrcpy/Mirror.java @@ -1,41 +1,40 @@ package invalid.lena.scrcpy; import android.Manifest; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; -import android.graphics.SurfaceTexture; +import android.graphics.Insets; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; -import android.view.TextureView; import android.view.View; +import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowInsetsController; import android.view.WindowManager; +import android.window.OnBackInvokedDispatcher; import android.widget.Button; +import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.Date; import java.util.Locale; // Full-screen mirror activity. Pulls target host/port from intent // extras, starts a Sessions foreground service to keep the process // alive during brief backgrounding, and owns the Session itself. // -// Two layouts ship: src/main/res/layout/mirror.xml (release: SurfaceView -// + status bar) and src/debug/res/layout/mirror.xml (debug: TextureView -// + status bar + bottom stats overlay used by the e2e screen capture -// because emulators don't composite SurfaceView into screencap). +// Every build uses the same SurfaceView layout. Tests must exercise the +// renderer users receive, not a debug-only TextureView substitute. // // Surface lifetime is decoupled from session lifetime: when the surface // goes away (rotation, background) we swap the session's video surface @@ -60,27 +59,22 @@ public final class Mirror extends Activity { private Devices.Device target; private Session session; private Surface currentSurface; - private boolean ownsCurrentSurface; private volatile boolean destroyed; private long sessionGeneration; private boolean stoppingSession; private State state = State.CONNECTING; private int connectedW, connectedH; + private long connectedGeometryVersion; - // Only one of these is non-null per build variant. - private TextureView textureView; private SurfaceView surfaceView; // Always present (declared in both layouts). + private View root; private View statusBar; private TextView statusText; private Button reconnectBtn; - private Button recordBtn; - // Overlay TextViews - only present in the debug layout. null in release. - private TextView overlayTarget, overlayStats, overlayEvent; private final Handler ui = new Handler(Looper.getMainLooper()); - private int textureUpdates; @Override protected void onCreate(Bundle saved) { @@ -93,54 +87,71 @@ public final class Mirror extends Activity { String host = getIntent().getStringExtra(EXTRA_HOST); int port = getIntent().getIntExtra(EXTRA_PORT, -1); - if (host == null || port <= 0) { + if (host == null || port <= 0 || port > 65535) { Log.e("mirror: bad extras host=%s port=%d", host, port); finish(); return; } - target = new Devices.Device(host, port); - - View v = findViewById(R.id.surface); - if (v instanceof TextureView) { - textureView = (TextureView) v; - textureView.setSurfaceTextureListener(textureListener); - } else if (v instanceof SurfaceView) { - surfaceView = (SurfaceView) v; - surfaceView.getHolder().addCallback(holderCallback); - } else { - Log.e("mirror: layout has no SurfaceView or TextureView at R.id.surface"); + target = resolveTarget(host, port); + if (target == null) { + Log.e("mirror: refusing unsaved target %s:%d", host, port); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + View video = findViewById(R.id.surface); + if (!(video instanceof SurfaceView)) { + Log.e("mirror: layout has no SurfaceView at R.id.surface"); finish(); return; } + surfaceView = (SurfaceView) video; + surfaceView.getHolder().addCallback(holderCallback); + + root = findViewById(R.id.root); + // Rotation and insets change the container without touching the + // video surface, so re-fit from here as well. + root.addOnLayoutChangeListener( + (view, l, t, r, b, ol, ot, or, ob) -> applyLetterbox()); statusBar = findViewById(R.id.status_bar); statusText = findViewById(R.id.status_text); reconnectBtn = findViewById(R.id.reconnect); - recordBtn = findViewById(R.id.record); + insetStatusBar(); reconnectBtn.setOnClickListener(view -> reconnect()); - recordBtn.setOnClickListener(view -> toggleRecord()); + // Back, Home and Recents as explicit controls. On a + // gesture-navigation source the target's own navigation cannot be + // reached by forwarding touches: the source claims the bottom + // edge swipe for itself and, unlike the side edges, that region + // cannot be released with setSystemGestureExclusionRects. + findViewById(R.id.nav_back).setOnClickListener(view -> { + if (session != null) session.onBack(); + }); + findViewById(R.id.nav_home).setOnClickListener(view -> { + if (session != null) session.onHome(); + }); + findViewById(R.id.nav_recents).setOnClickListener(view -> { + if (session != null) session.onRecents(); + }); updateStatusBar(); - overlayTarget = findViewById(R.id.overlay_target); - overlayStats = findViewById(R.id.overlay_stats); - overlayEvent = findViewById(R.id.overlay_event); - if (overlayTarget != null) { - overlayTarget.setText("target: " + host + ":" + port); - ui.postDelayed(this::pollOverlay, 200); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, this::onBackRequested); } requestNotificationsIfNeeded(); - startForegroundService(new Intent(this, Sessions.class)); + startKeepalive(); if (!Settings.hintBackShown(this)) { - Toast.makeText(this, R.string.hint_long_press_back, - Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.hint_back, Toast.LENGTH_LONG).show(); Settings.setHintBackShown(this, true); } new Thread(() -> { try { - Adb a = Adb.getInstance(this); + Adb a = Adb.getInstance(getApplicationContext()); runOnUiThread(() -> { if (destroyed) return; adb = a; @@ -160,46 +171,18 @@ public final class Mirror extends Activity { }, "adb-init").start(); } - // ---- surface lifecycle: TextureView path ---- - - private final TextureView.SurfaceTextureListener textureListener = - new TextureView.SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) { - Log.i("mirror: texture available %dx%d", w, h); - attachSurface(new Surface(st), true, w, h); - } - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) { - Log.i("mirror: texture resized %dx%d", w, h); - if (session != null) session.setViewSize(w, h); - } - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture st) { - Log.i("mirror: texture destroyed"); - detachSurface(); - return true; - } - @Override - public void onSurfaceTextureUpdated(SurfaceTexture st) { - if (++textureUpdates == 1 || textureUpdates % 30 == 0) { - Log.i("mirror: texture updates=%d", textureUpdates); - } - } - }; - - // ---- surface lifecycle: SurfaceView path ---- + // ---- surface lifecycle ---- private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { Log.i("mirror: surface created"); - attachSurface(holder.getSurface(), false, 0, 0); + attachSurface(holder.getSurface()); } @Override public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { Log.i("mirror: surface changed %dx%d", w, h); - if (session != null) session.setViewSize(w, h); + applyLetterbox(); } @Override public void surfaceDestroyed(SurfaceHolder holder) { @@ -210,46 +193,82 @@ public final class Mirror extends Activity { // ---- session driver ---- - private void attachSurface(Surface s, boolean owned, int w, int h) { + private void attachSurface(Surface s) { if (session != null && currentSurface != null) session.swapSurface(null); - releaseOwnedSurface(); currentSurface = s; - ownsCurrentSurface = owned; if (session != null) { session.swapSurface(s); - if (w > 0) session.setViewSize(w, h); + applyLetterbox(); return; } if (adb == null) return; // adb-init thread will start the session if (stoppingSession) return; // stop worker starts the latest target if (state == State.DISCONNECTED) return; // wait for user to tap reconnect startSession(s); - if (w > 0) session.setViewSize(w, h); + applyLetterbox(); + } + + // Size the video view to the target's aspect ratio inside the root + // frame and centre it, so the black root shows through as letterbox + // bars instead of the picture being stretched to the source's screen + // shape. Also tells the session where the picture ended up, because + // touches arrive in window coordinates and must be offset by the bars. + // + // No-ops until both the container and the target geometry are known; + // every caller is a point where one of them may have just changed. + private void applyLetterbox() { + View v = surfaceView; + if (v == null || root == null || session == null) return; + int cw = root.getWidth(), ch = root.getHeight(); + int tw = connectedW, th = connectedH; + if (cw <= 0 || ch <= 0 || tw <= 0 || th <= 0) return; + + float scale = Math.min(cw / (float) tw, ch / (float) th); + int w = Math.min(cw, Math.max(1, Math.round(tw * scale))); + int h = Math.min(ch, Math.max(1, Math.round(th * scale))); + + ViewGroup.LayoutParams lp = v.getLayoutParams(); + if (lp.width != w || lp.height != h) { + lp.width = w; + lp.height = h; + v.setLayoutParams(lp); // re-layout re-enters here, then converges + Log.i("mirror: letterbox %dx%d -> %dx%d in %dx%d", tw, th, w, h, cw, ch); + } + session.setViewport(connectedGeometryVersion, + (cw - w) / 2, (ch - h) / 2, w, h); } private void detachSurface() { if (session != null) session.swapSurface(null); - releaseOwnedSurface(); currentSurface = null; } - private void releaseOwnedSurface() { - if (ownsCurrentSurface && currentSurface != null) currentSurface.release(); - ownsCurrentSurface = false; - } - private void startSession(Surface s) { if (destroyed) return; + // Re-read the row so forgetting a device invalidates stale tasks and + // notifications before they open a new session. + Devices.Device current = target; + Devices.Device resolved = resolveTarget(current.host, current.port); + if (resolved == null) { + state = State.DISCONNECTED; + updateStatusBar(); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + return; + } + target = resolved; state = State.CONNECTING; updateStatusBar(); long generation = ++sessionGeneration; session = new Session(this, adb, target, s, new Session.Listener() { - @Override public void onConnected(int w, int h) { + @Override public void onConnected(long geometryVersion, int w, int h) { runOnUiThread(() -> { if (destroyed || generation != sessionGeneration) return; state = State.CONNECTED; + connectedGeometryVersion = geometryVersion; connectedW = w; connectedH = h; updateStatusBar(); + applyLetterbox(); + if (session != null) session.syncClipboard(); }); } @Override public void onReconnecting() { @@ -263,8 +282,7 @@ public final class Mirror extends Activity { @Override public void onError(Throwable t) { runOnUiThread(() -> { if (destroyed || generation != sessionGeneration) return; - Toast.makeText(Mirror.this, - "session error: " + t.getMessage(), Toast.LENGTH_LONG).show(); + Toast.makeText(Mirror.this, describe(t), Toast.LENGTH_LONG).show(); }); } @Override public void onStopped() { @@ -279,33 +297,6 @@ public final class Mirror extends Activity { session.start(); } - private void toggleRecord() { - if (session == null) return; - if (session.isRecording()) { - // stopRecording is false when no keyframe ever landed (still - // ARMED) - no file was written, so don't claim one was saved. - boolean saved = session.stopRecording(); - Toast.makeText(this, saved ? "recording saved" : "recording discarded (no video)", - Toast.LENGTH_SHORT).show(); - } else { - File dir = getExternalFilesDir(null); - if (dir == null) { - Toast.makeText(this, "no external storage", Toast.LENGTH_LONG).show(); - return; - } - String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date()); - File out = new File(dir, "scrcpy-" + ts + ".mp4"); - session.armRecording(out); - Toast.makeText(this, "recording -> " + out.getName(), Toast.LENGTH_SHORT).show(); - } - updateRecordButton(); - } - - private void updateRecordButton() { - boolean on = session != null && session.isRecording(); - recordBtn.setText(on ? R.string.record_on : R.string.record); - } - private void reconnect() { Log.i("mirror: reconnect tapped"); state = State.CONNECTING; @@ -315,16 +306,25 @@ public final class Mirror extends Activity { Session old = session; session = null; sessionGeneration++; + connectedW = connectedH = 0; + connectedGeometryVersion = 0; // Stop the old session off the UI thread (teardown closes // sockets and joins the server's log pump), THEN start the new // one. Sequencing matters: both sessions share the singleton // Adb, so the old teardown's disconnect must finish before the // new bring-up connects. new Thread(() -> { - if (old != null) old.stop(); + boolean stopped = old == null || old.stop(); runOnUiThread(() -> { if (destroyed) return; stoppingSession = false; + if (!stopped) { + state = State.DISCONNECTED; + updateStatusBar(); + Toast.makeText(this, R.string.session_stop_timeout, + Toast.LENGTH_LONG).show(); + return; + } if (session != null) return; // another path already started one if (adb == null || currentSurface == null) return; startSession(currentSurface); @@ -343,10 +343,17 @@ public final class Mirror extends Activity { } if (target.host.equals(host) && target.port == port) return; + Devices.Device replacement = resolveTarget(host, port); + if (replacement == null) { + Log.w("mirror: refusing unsaved replacement target %s:%d", host, port); + Toast.makeText(this, R.string.device_not_paired, Toast.LENGTH_LONG).show(); + return; + } setIntent(intent); - target = new Devices.Device(host, port); + target = replacement; connectedW = connectedH = 0; - if (overlayTarget != null) overlayTarget.setText("target: " + target); + connectedGeometryVersion = 0; + startKeepalive(); // repoint the notification at the new target reconnect(); } @@ -369,8 +376,6 @@ public final class Mirror extends Activity { } statusText.setText(s); reconnectBtn.setVisibility(state == State.DISCONNECTED ? View.VISIBLE : View.GONE); - recordBtn.setEnabled(state == State.CONNECTED); - updateRecordButton(); // Hide the whole bar while actively mirroring if the user opted // out; keep it up whenever not CONNECTED so the status and the @@ -381,19 +386,6 @@ public final class Mirror extends Activity { } } - // ---- overlay (debug only - fields are null in release) ---- - - private void pollOverlay() { - if (overlayStats == null) return; - if (session != null) { - overlayStats.setText(String.format(Locale.ROOT, - "v=%-4d a=%-4d tex=%-4d", - session.videoFrames(), session.audioFrames(), textureUpdates)); - overlayEvent.setText("event: " + session.lastEvent()); - } - ui.postDelayed(this::pollOverlay, 200); - } - // ---- input ---- @Override @@ -405,29 +397,41 @@ public final class Mirror extends Activity { return super.onTouchEvent(ev); } - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_BACK) { - event.startTracking(); - return true; + // Back goes to the target; Back twice in quick succession leaves the + // mirror. + // + // It used to be long-press-Back to reach the target and short-press + // to do nothing. That stopped working twice over: gesture navigation + // has no Back key to hold, and at targetSdk 35 and later predictive back is on + // by default, so the framework routes Back through + // OnBackInvokedDispatcher and onKeyDown/onKeyLongPress are never + // called for it at all. The advertised feature was unreachable on + // every current device. + private static final long DOUBLE_BACK_MS = 600L; + private long lastBackAtMs; + + private void onBackRequested() { + long now = SystemClock.elapsedRealtime(); + if (now - lastBackAtMs < DOUBLE_BACK_MS) { + finish(); + return; } - return super.onKeyDown(keyCode, event); + lastBackAtMs = now; + if (session != null) session.onBack(); } + // Pre-33 devices (minSdk is 31) still deliver Back this way. API 33+ + // uses the OnBackInvokedDispatcher callback registered in onCreate; + // lint does not follow that version split. @Override - public boolean onKeyLongPress(int keyCode, KeyEvent event) { - if (keyCode == KeyEvent.KEYCODE_BACK && session != null) { - session.onBack(); - return true; - } - return super.onKeyLongPress(keyCode, event); + @SuppressLint("GestureBackNavigation") + @SuppressWarnings("deprecation") + public void onBackPressed() { + onBackRequested(); } @Override public boolean dispatchKeyEvent(KeyEvent ev) { - if (ev.getKeyCode() == KeyEvent.KEYCODE_BACK) { - return super.dispatchKeyEvent(ev); - } if (session != null && shouldForward(ev)) { session.onKey(ev); return true; @@ -442,23 +446,27 @@ public final class Mirror extends Activity { destroyed = true; sessionGeneration++; ui.removeCallbacksAndMessages(null); - releaseOwnedSurface(); currentSurface = null; Session s = session; session = null; if (s != null) { // Teardown blocks on socket closes and a thread join; keep // it off the UI thread. - new Thread(s::stop, "session-stop").start(); + new Thread(() -> s.stop(), "session-stop").start(); } stopService(new Intent(this, Sessions.class)); super.onDestroy(); } + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus && session != null) session.syncClipboard(); + } + // Android 13+ requires runtime grant for POST_NOTIFICATIONS. The - // foreground service notification is silently suppressed if the - // user never sees the dialog, and on Android 14+ a notification- - // less FGS can be killed at any time. + // foreground service still starts without the grant, but granting it + // keeps the active session visible in the notification drawer. private void requestNotificationsIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return; if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) @@ -467,7 +475,51 @@ public final class Mirror extends Activity { RQ_POST_NOTIFICATIONS); } + // Most of what reaches here has a null message - a bare IOException + // from the socket, an SSLHandshakeException - and "session error: + // null" is what the user was being shown for the commonest failure + // there is. + private String describe(Throwable t) { + for (Throwable c = t; c != null; c = c.getCause()) { + String m = c.getMessage(); + if (m != null && !m.isEmpty()) return getString(R.string.session_error, m); + } + return getString(R.string.session_error, + t == null ? "unknown" : t.getClass().getSimpleName()); + } + + // Missing rows fail closed. A stale notification, restored task, or + // malformed internal intent must not create a session for an unsaved row. + private Devices.Device resolveTarget(String host, int port) { + try { + return Devices.find(this, host, port); + } catch (java.io.IOException e) { + Log.e(e, "mirror: cannot read paired devices"); + return null; + } + } + + // The foreground service exists only to keep this process alive while + // mirroring. It carries the target so its notification can lead back + // here rather than somewhere that would tear the session down. + private void startKeepalive() { + Intent i = new Intent(this, Sessions.class); + i.putExtra(EXTRA_HOST, target.host); + i.putExtra(EXTRA_PORT, target.port); + startForegroundService(i); + } + + @SuppressWarnings("deprecation") private void immersive() { + // Without this the window stops at the cutout's safe area and the + // system letterboxes it, which shows up as black bands down the + // sides of what is supposed to be a full-screen mirror. Only the + // overlay controls are moved around a display cutout. + WindowManager.LayoutParams lp = getWindow().getAttributes(); + lp.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS; + getWindow().setAttributes(lp); + WindowInsetsController c = getWindow().getInsetsController(); if (c != null) { c.hide(WindowInsets.Type.systemBars()); @@ -476,6 +528,25 @@ public final class Mirror extends Activity { getWindow().setDecorFitsSystemWindows(false); } + private void insetStatusBar() { + int base = getResources().getDimensionPixelSize(R.dimen.space_sm); + statusBar.setOnApplyWindowInsetsListener((view, windowInsets) -> { + Insets cutout = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.displayCutout()); + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) view.getLayoutParams(); + int left = base + cutout.left; + int top = base + cutout.top; + int right = base + cutout.right; + if (lp.leftMargin != left || lp.topMargin != top + || lp.rightMargin != right || lp.bottomMargin != base) { + lp.setMargins(left, top, right, base); + view.setLayoutParams(lp); + } + return windowInsets; + }); + statusBar.requestApplyInsets(); + } + private static boolean shouldForward(KeyEvent ev) { int code = ev.getKeyCode(); if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true; diff --git a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java b/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java deleted file mode 100644 index 6631f66..0000000 --- a/app/src/main/java/invalid/lena/scrcpy/MuxRecorder.java +++ /dev/null @@ -1,226 +0,0 @@ -package invalid.lena.scrcpy; - -import android.media.MediaCodec; -import android.media.MediaFormat; -import android.media.MediaMuxer; - -import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; - -// MP4 muxer that taps the H.264/H.265/AV1 elementary stream coming -// straight from the scrcpy server - no re-encode, the bytes go onto -// disk verbatim. AV1 muxing into MP4 needs Android 11+ (MediaMuxer -// learned the codec there); we are minSdk 31 so that's fine. -// -// State machine: IDLE → (arm) → ARMED → (next keyframe) → RECORDING -// → (stop) → IDLE -// -// Ordering: -// onMeta(fcc, w, h) from VideoStream session-meta packets. -// onFrame(...) every frame, in wire order. Config frames -// carry SPS/PPS; we cache the most recent one -// as csd-0 for MediaMuxer.addTrack. -// -// Resize during recording closes the file (addTrack-after-start is -// illegal). User can re-arm; a fresh output gets the new dimensions. -public final class MuxRecorder implements VideoRecorder { - - private enum State { IDLE, ARMED, RECORDING } - - private final Object lock = new Object(); - - private State state = State.IDLE; - private int fourcc, w, h; - private byte[] csd; - - private File outFile; - private MediaMuxer muxer; - private int trackIdx = -1; - private long bytesWritten; - private long firstPtsUs = -1; - private long lastPtsUs = -1; - - // Externally-driven controls. - - public void arm(File out) { - synchronized (lock) { - if (state != State.IDLE) { - Log.w("rec: arm ignored in state %s", state); - return; - } - outFile = out; - state = State.ARMED; - firstPtsUs = -1; - lastPtsUs = -1; - bytesWritten = 0; - } - Log.i("rec: armed -> %s", out); - } - - // Returns true if a recording was actually written (a keyframe landed - // and the muxer ran); false if we were still ARMED, so the caller can - // tell the user the truth instead of claiming a file was saved. - public boolean stop() { - long bytes; - boolean wrote; - synchronized (lock) { - if (state == State.IDLE) return false; - wrote = state == State.RECORDING && closeMuxerLocked(); - if (!wrote) deleteOutputLocked(); - bytes = bytesWritten; - state = State.IDLE; - outFile = null; - } - Log.i("rec: stopped (%d bytes)", bytes); - return wrote; - } - - public boolean isActive() { - synchronized (lock) { - return state == State.ARMED || state == State.RECORDING; - } - } - - // VideoRecorder callbacks. - - @Override - public void onMeta(int fcc, int width, int height) { - synchronized (lock) { - // Resize during recording → close the file. addTrack after - // start is illegal; we don't try to splice tracks together. - // The user can re-arm; a fresh output gets the new dimensions. - if (state == State.RECORDING && (fcc != fourcc || width != w || height != h)) { - Log.w("rec: resize/codec change during recording - closing (%d bytes)", - bytesWritten); - closeMuxerLocked(); - state = State.IDLE; - outFile = null; - } - fourcc = fcc; w = width; h = height; - } - } - - @Override - public void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe) { - if (isConfig) { - // Cache the latest CSD (SPS/PPS for h264 - vendor packs as - // Annex-B NAL units, exactly what MediaMuxer wants in csd-0 - // for AVC/HEVC/AV1). - synchronized (lock) { - csd = data.clone(); - } - return; - } - synchronized (lock) { - switch (state) { - case IDLE: - return; - case ARMED: - if (!isKeyframe || csd == null) return; - if (!startMuxerLocked()) { - failLocked("start"); - return; - } - if (!writeSampleLocked(data, ptsUs, true)) { - failLocked("write"); - return; - } - state = State.RECORDING; - return; - case RECORDING: - if (!writeSampleLocked(data, ptsUs, isKeyframe)) failLocked("write"); - } - } - } - - @Override - public void close() { - stop(); - } - - // --- internals; all called with `lock` held --- - - private boolean startMuxerLocked() { - String mime = mimeFor(fourcc); - if (mime == null) { - Log.e("rec: unsupported codec %s", Wire.fourccName(fourcc)); - return false; - } - try { - muxer = new MediaMuxer(outFile.getAbsolutePath(), - MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); - MediaFormat fmt = MediaFormat.createVideoFormat(mime, w, h); - fmt.setByteBuffer("csd-0", ByteBuffer.wrap(csd)); - trackIdx = muxer.addTrack(fmt); - muxer.start(); - Log.i("rec: muxer start %s %dx%d -> %s", mime, w, h, outFile); - return true; - } catch (IOException | IllegalStateException e) { - Log.e(e, "rec: muxer start failed"); - try { if (muxer != null) muxer.release(); } catch (Exception ignored) {} - muxer = null; - trackIdx = -1; - return false; - } - } - - private boolean writeSampleLocked(byte[] data, long ptsUs, boolean isKeyframe) { - if (muxer == null) return false; - // PTS is rebased to zero so the file is self-contained; some - // players choke on absolute PTS that doesn't start near zero. - if (firstPtsUs < 0) firstPtsUs = ptsUs; - long rebased = ptsUs - firstPtsUs; - if (rebased < 0) rebased = 0; - // Monotonic guard - MediaMuxer fails the write if pts goes back. - if (rebased <= lastPtsUs) rebased = lastPtsUs + 1; - lastPtsUs = rebased; - - try { - ByteBuffer buf = ByteBuffer.wrap(data); - MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); - info.set(0, data.length, rebased, - isKeyframe ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0); - muxer.writeSampleData(trackIdx, buf, info); - bytesWritten += data.length; - return true; - } catch (IllegalStateException e) { - Log.w("rec: writeSampleData: %s", e); - return false; - } - } - - private boolean closeMuxerLocked() { - MediaMuxer m = muxer; - muxer = null; - trackIdx = -1; - if (m == null) return false; - boolean ok = true; - try { m.stop(); } catch (Exception e) { ok = false; Log.w("rec: muxer stop: %s", e); } - try { m.release(); } catch (Exception ignored) {} - return ok; - } - - private void failLocked(String operation) { - Log.e("rec: %s failed, discarding output", operation); - closeMuxerLocked(); - deleteOutputLocked(); - state = State.IDLE; - outFile = null; - } - - private void deleteOutputLocked() { - if (outFile != null && outFile.exists() && !outFile.delete()) { - Log.w("rec: could not delete incomplete output %s", outFile); - } - } - - private static String mimeFor(int fourcc) { - switch (fourcc) { - case Wire.CODEC_H264: return MediaFormat.MIMETYPE_VIDEO_AVC; - case Wire.CODEC_H265: return MediaFormat.MIMETYPE_VIDEO_HEVC; - case Wire.CODEC_AV1: return MediaFormat.MIMETYPE_VIDEO_AV1; - default: return null; - } - } -} diff --git a/app/src/main/java/invalid/lena/scrcpy/Server.java b/app/src/main/java/invalid/lena/scrcpy/Server.java index 284dd6c..dbc6738 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Server.java +++ b/app/src/main/java/invalid/lena/scrcpy/Server.java @@ -2,16 +2,15 @@ package invalid.lena.scrcpy; import android.content.Context; -import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.net.ConnectException; -import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; import io.github.muntashirakon.adb.AdbStream; @@ -43,17 +42,12 @@ public final class Server { public final AdbStream videoAds, audioAds, controlAds; public final InputStream videoIn, audioIn, controlIn; public final OutputStream controlOut; - public final String deviceName; - public final String scid; - public final String version; Streams(AdbStream va, AdbStream aa, AdbStream ca, - InputStream vi, InputStream ai, InputStream ci, OutputStream co, - String name, String scid, String version) { + InputStream vi, InputStream ai, InputStream ci, OutputStream co) { this.videoAds = va; this.audioAds = aa; this.controlAds = ca; this.videoIn = vi; this.audioIn = ai; this.controlIn = ci; this.controlOut = co; - this.deviceName = name; this.scid = scid; this.version = version; } } @@ -101,9 +95,8 @@ public final class Server { InputStream ci = ca.openInputStream(); OutputStream co = ca.openOutputStream(); - String name = readDeviceMeta(vi); - Log.i("device name=%s", name); - streams = new Streams(va, aa, ca, vi, ai, ci, co, name, scid, version); + Log.i("device name=%s", readDeviceMeta(vi)); + streams = new Streams(va, aa, ca, vi, ai, ci, co); committed = true; return streams; } finally { @@ -140,8 +133,11 @@ public final class Server { shellPump = null; closeQuietly(s); if (t != null) { - try { t.join(CLOSE_GRACE_MS); } - catch (InterruptedException ignored) {} + try { + t.join(CLOSE_GRACE_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } if (t.isAlive()) t.interrupt(); } } @@ -158,11 +154,19 @@ public final class Server { private String readVersion() throws IOException { try (InputStream in = ctx.getAssets().open(ASSET_VERSION)) { byte[] buf = new byte[64]; - int n = 0, r; - while ((r = in.read(buf, n, buf.length - n)) > 0) n += r; - String v = new String(buf, 0, n, StandardCharsets.UTF_8).trim(); - if (v.isEmpty()) { - throw new IOException("scrcpy-server.version is empty"); + int n = 0; + while (n < buf.length) { + int r = in.read(buf, n, buf.length - n); + if (r < 0) break; + if (r == 0) throw new IOException("scrcpy-server.version read made no progress"); + n += r; + } + if (n == buf.length && in.read() >= 0) { + throw new IOException("scrcpy-server.version is too long"); + } + String v = Wire.decodeUtf8(buf, 0, n).trim(); + if (!v.matches("[0-9]+(\\.[0-9]+)*")) { + throw new IOException("scrcpy-server.version is invalid"); } return v; } @@ -171,7 +175,7 @@ public final class Server { private static String newScid() { // 31-bit random, 8 lowercase hex chars - matches scrcpy upstream client. int v = new SecureRandom().nextInt() & 0x7fffffff; - return String.format("%08x", v); + return String.format(Locale.ROOT, "%08x", v); } private String buildCmdline(String version, String scid) { @@ -196,7 +200,9 @@ public final class Server { args.add("max_size=" + maxSize); args.add("video_bit_rate=" + videoBitR); if (maxFps > 0) args.add("max_fps=" + maxFps); - args.add("clipboard_autosync=true"); + // Off means off at the source: the server never sends the + // target's clipboard, rather than us receiving and discarding it. + args.add("clipboard_autosync=" + Settings.clipboardSync(ctx)); args.add("tunnel_forward=true"); args.add("cleanup=true"); args.add("power_on=true"); @@ -205,9 +211,9 @@ public final class Server { private AdbStream openAbstract(String scid) throws Exception { String name = "scrcpy_" + scid; - long deadline = System.currentTimeMillis() + LISTENER_DEADLINE_MS; + long deadline = monotonicMs() + LISTENER_DEADLINE_MS; ConnectException last = null; - while (System.currentTimeMillis() < deadline) { + while (monotonicMs() < deadline) { if (serverEof) { throw new IOException("server exited before opening " + name, last); } @@ -230,18 +236,27 @@ public final class Server { private static String readDeviceMeta(InputStream in) throws IOException { byte[] probe = new byte[1]; Wire.readFully(in, probe); + if (probe[0] != 0) throw new IOException("invalid scrcpy probe byte"); byte[] name = new byte[64]; Wire.readFully(in, name); int n = 0; while (n < name.length && name[n] != 0) n++; - return new String(name, 0, n, StandardCharsets.UTF_8); + for (int i = n; i < name.length; i++) { + if (name[i] != 0) throw new IOException("invalid device-name padding"); + } + return safeLogText(Wire.decodeUtf8(name, 0, n)); } private void pump(InputStream in) { - try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { - String line; - while ((line = r.readLine()) != null) { - Log.i("server: %s", line); + // Do not use BufferedReader.readLine(): a hostile target can emit an + // unterminated line of arbitrary size and make it allocate until OOM. + byte[] bytes = new byte[2048]; + try (InputStream source = in) { + int n; + while ((n = source.read(bytes)) >= 0) { + if (n == 0) throw new IOException("server stdout made no progress"); + String chunk = safeLogBytes(bytes, n).trim(); + if (!chunk.isEmpty()) Log.i("server: %s", chunk); } } catch (IOException e) { if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e); @@ -266,4 +281,29 @@ public final class Server { try { sync.close(); } catch (IOException ignored) {} } } + + private static String safeLogBytes(byte[] data, int len) { + StringBuilder out = new StringBuilder(len); + for (int i = 0; i < len; i++) { + int b = data[i] & 0xff; + if (b == '\n' || b == '\r' || b == '\t') out.append(' '); + else if (b >= 0x20 && b <= 0x7e) out.append((char) b); + else out.append('.'); + } + return out.toString(); + } + + private static String safeLogText(String text) { + StringBuilder out = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + int type = Character.getType(c); + out.append(Character.isISOControl(c) || type == Character.FORMAT ? '?' : c); + } + return out.toString(); + } + + private static long monotonicMs() { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java index a427d63..294b27d 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Session.java +++ b/app/src/main/java/invalid/lena/scrcpy/Session.java @@ -5,53 +5,86 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.Surface; -import java.io.File; import java.io.IOException; +import java.net.SocketTimeoutException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; // One mirroring session: owns the Adb connection, the spawned scrcpy // server, the three streams, sinks, and the controller. start() and // stop() are idempotent and may be called from any thread. // -// Listener contract: -// onConnected(w,h) - wire open, frames flowing. Fires on the first -// connect and again after each successful auto-reconnect. -// onReconnecting() - a live link dropped; the bring-up ladder is rerun. -// Followed by onConnected (recovered) or onError (gave up). -// onError(t) - fatal: retries exhausted, or non-retriable failure. -// Always followed by onStopped(). -// onStopped() - final state. Fires exactly once per session, whether -// user stop() or retries-exhausted. +// The Listener contract is stated once, on the interface below. // -// Auto-reconnect: after a successful connect the video socket is watched; -// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the -// session reruns the bring-up ladder instead of freezing. Only when that -// ladder is exhausted does it give up via onError + onStopped. +// Two retry budgets, because there are two different failures. +// BACKOFF_MS covers bring-up, where the target is not answering yet. +// RECONNECT_BACKOFF_MS covers a link that came up and then died (target +// sleep, Wi-Fi blip, server crash) - and also the case where it dies +// every time for the same permanent reason, which is why that budget is +// finite. Either budget running out ends the session via onError + +// onStopped rather than retrying forever. // // Surface-readiness race: Mirror.surfaceCreated builds the Session and -// calls start(), but Mirror.surfaceChanged (which carries the view -// dimensions) can fire before run() has finished constructing Controller. -// setViewSize() therefore stashes the value and applies it as soon as -// the Controller is available. +// calls start(), but the viewport can be measured before run() has +// finished constructing Controller. setViewport() therefore stashes the +// value and applies it as soon as the Controller is available. public final class Session { public interface Listener { // Fires the first time the server reports a session-meta packet // (wire open, frames about to flow), and again after each // successful auto-reconnect. Useful for the activity's status bar. - default void onConnected(int w, int h) {} + default void onConnected(long geometryVersion, int w, int h) {} // A previously-live link dropped and we are bringing it back up. // Followed by onConnected (recovered), onError (gave up), or // onStopped alone (user stop() raced the reconnect). default void onReconnecting() {} + // Fatal: a retry budget ran out, or the failure was not + // retriable. Always followed by onStopped(). void onError(Throwable t); + // Final state. Fires exactly once per session, whether from + // stop() or from a budget running out. void onStopped(); } - // Bring-up retry budget. Total worst-case wait ~ sum of these - // delays + per-attempt bring-up time. Keep below e2e.sh's deadline. - private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L}; + // Bring-up retry budget, for a target that is not answering yet. + // Total worst-case wait ~ sum of these delays + per-attempt bring-up + // time. + private static final long[] BACKOFF_MS = + {0L, 1_500L, 5_000L, 10_000L, 20_000L}; + + // Reconnect budget, for a link that dropped AFTER it came up. This is + // a different failure from bring-up: something that worked has + // stopped working, and it may be permanent (target has no decoder for + // the selected codec, frame larger than the decoder's input buffer). + // Without a budget those spin here forever at zero delay, re-pushing + // the server jar and respawning app_process on the target on every + // pass, with nothing shown to the user. + // + // A connection that stays up for HEALTHY_MS resets the budget, so a + // long session that blips occasionally never exhausts it, while a + // deterministic failure walks the ladder and then gives up. + private static final long[] RECONNECT_BACKOFF_MS = + {1_000L, 2_000L, 5_000L, 10_000L, 15_000L}; + // Comfortably longer than STALL_MS + STALL_POLL_MS. If it were not, + // a stall-detected drop would always look "healthy" (a stall is only + // declared after STALL_MS of silence, so the connection is at least + // that old by then), the budget would reset on every stall and the + // ladder would never be reached for the one failure it exists for. + private static final long HEALTHY_MS = 120_000L; + + // Stall watchdog. adb streams have no read timeout - AdbStream.read() + // waits on its queue until data arrives or the stream is closed - so a + // target that disappears without closing the socket (Wi-Fi dropping + // mid-frame, NAT rebinding, a carrier idle timeout on a VPN link) + // leaves the readers parked and the session frozen forever with no + // reconnect. Poll for silence on the audio socket instead; see + // AudioStream.isStarted() for why audio and not video. + private static final long STALL_MS = 30_000L; + private static final long STALL_POLL_MS = 5_000L; + private static final long BRING_UP_DEADLINE_MS = 90_000L; + private static final long STOP_JOIN_MS = 10_000L; private final Context ctx; private final Adb adb; @@ -59,23 +92,31 @@ public final class Session { private volatile Surface surface; private final Listener listener; - // Lightweight counters for the in-app status overlay; readers reach - // into the sinks/controller directly. - public long videoFrames() { VideoSink v = videoSink; return v == null ? 0 : v.frames; } - public long audioFrames() { AudioSink a = audioSink; return a == null ? 0 : a.frames; } - public String lastEvent() { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; } - private Server server; private VideoStream videoStream; - private VideoSink videoSink; + private volatile VideoSink videoSink; private AudioStream audioStream; private AudioSink audioSink; private ControlStream controlStream; - private Controller controller; - private MuxRecorder recorder; + private volatile Controller controller; private Thread runner; private volatile boolean stopped; - private volatile int pendingViewW, pendingViewH; + private boolean stoppedNotified; + private long geometryVersion; + private volatile Viewport pendingViewport; + + private static final class Viewport { + final long version; + final int x, y, w, h; + + Viewport(long version, int x, int y, int w, int h) { + this.version = version; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + } // Counted down by the video reader when its loop exits; the session // thread parks on it for the live duration of a connection. Swapped @@ -91,17 +132,48 @@ public final class Session { } public synchronized void start() { - if (runner != null) return; + if (runner != null || stopped) return; runner = new Thread(this::run, "session"); runner.start(); } - public synchronized void stop() { - if (stopped) return; - stopped = true; - Log.i("session: stop"); + public boolean stop() { + Thread r; + boolean notify; + synchronized (this) { + if (!stopped) { + stopped = true; + Log.i("session: stop"); + } + notify = !stoppedNotified; + stoppedNotified = true; + r = runner; + } + if (r != null && r != Thread.currentThread()) r.interrupt(); + adb.abort(); tearDownInstalled(); - if (listener != null) listener.onStopped(); + endSignal.countDown(); + boolean joined = true; + if (r != null && r != Thread.currentThread()) { + boolean interrupted = false; + long deadline = monotonicMs() + STOP_JOIN_MS; + while (r.isAlive()) { + long remaining = deadline - monotonicMs(); + if (remaining <= 0) { + joined = false; + break; + } + try { + r.join(remaining); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); + } + if (!joined) Log.e("session: runner did not stop within %d ms", STOP_JOIN_MS); + if (notify && listener != null) listener.onStopped(); + return joined; } // ---- input forwarding (Controller stays internal) ---- @@ -121,32 +193,26 @@ public final class Session { if (c != null) c.onBack(); } - public void setViewSize(int w, int h) { - pendingViewW = w; pendingViewH = h; + public void onHome() { Controller c = controller; - if (c != null) c.setViewSize(w, h); + if (c != null) c.onHome(); } - // ---- recording (delegates to MuxRecorder) ---- - - public void armRecording(File out) { - MuxRecorder r = recorder; - if (r == null) { - Log.w("session: armRecording before bring-up - ignored"); - return; - } - r.arm(out); + public void onRecents() { + Controller c = controller; + if (c != null) c.onRecents(); } - // Returns true if a file was actually written (see MuxRecorder.stop). - public boolean stopRecording() { - MuxRecorder r = recorder; - return r != null && r.stop(); + public void syncClipboard() { + Controller c = controller; + if (c != null) c.syncLocalClipboard(); } - public boolean isRecording() { - MuxRecorder r = recorder; - return r != null && r.isActive(); + public void setViewport(long version, int x, int y, int w, int h) { + Viewport viewport = new Viewport(version, x, y, w, h); + pendingViewport = viewport; + Controller c = controller; + if (c != null) c.setViewport(version, x, y, w, h); } // Swap the surface the video pipeline draws to. The audio and control @@ -154,9 +220,14 @@ public final class Session { // activity is backgrounded. Pass null to detach; pass a new Surface // (from a recreated SurfaceView) to resume rendering. public void swapSurface(Surface s) { + Surface previous = surface; surface = s; VideoSink vk = videoSink; if (vk != null) vk.setOutputSurface(s); + if (s != null && previous == null) { + Controller c = controller; + if (c != null) c.resetVideo(); + } } // ---- internals ---- @@ -165,23 +236,75 @@ public final class Session { // reconnect if the death wasn't a user stop(). Runs on one thread for // the whole session lifetime. private void run() { - while (!stopped) { + int drops = 0; // consecutive short-lived connections + while (true) { + if (stopped) return; if (!connect()) return; // gave up: onError + onStopped fired + long upAt = monotonicMs(); + CountDownLatch latch; + AudioStream as; + synchronized (this) { + if (stopped) return; + latch = endSignal; + as = audioStream; + } try { - endSignal.await(); // park until the pipeline dies or stop() + awaitEndOrStall(latch, as); } catch (InterruptedException ie) { return; } + long lived = monotonicMs() - upAt; + // Decide teardown-and-retry under the monitor so a concurrent // stop() can't interleave: without this, onReconnecting() // could fire after stop()'s onStopped(), breaking the // listener contract. + boolean exhausted; synchronized (this) { if (stopped) return; // user stop() - Log.i("session: video stream ended - link lost, reconnecting"); - tearDownInstalled(); - endSignal = new CountDownLatch(1); - if (listener != null) listener.onReconnecting(); + if (lived >= HEALTHY_MS) drops = 0; + exhausted = drops >= RECONNECT_BACKOFF_MS.length; + if (!exhausted) { + Log.i("session: link lost after %d ms - reconnecting (%d/%d)", + lived, drops + 1, RECONNECT_BACKOFF_MS.length); + tearDownInstalled(); + endSignal = new CountDownLatch(1); + if (listener != null) listener.onReconnecting(); + } + } + if (exhausted) { + Log.e("session: gave up after %d reconnects without a healthy link", + RECONNECT_BACKOFF_MS.length); + giveUp(new IOException("link kept dropping; gave up after " + + RECONNECT_BACKOFF_MS.length + " reconnects")); + return; + } + + long delay = RECONNECT_BACKOFF_MS[drops++]; + try { Thread.sleep(delay); } + catch (InterruptedException ie) { return; } + } + } + + // Park until the pipeline dies, stop() fires, or the audio socket has + // been silent long enough that the link must be gone. Returning + // without the latch firing leaves the readers parked; the caller's + // tearDownInstalled() closes their streams, which unblocks them. + // + // The watchdog only arms once audio is actually flowing. If the + // target cannot capture audio the server reports the stream disabled, + // AudioStream returns immediately, and there is no reliable idle + // signal left - in that case fall back to waiting indefinitely rather + // than inventing one from the video socket, which is legitimately + // silent whenever the target's screen is static. + private void awaitEndOrStall(CountDownLatch latch, AudioStream as) + throws InterruptedException { + while (!latch.await(STALL_POLL_MS, TimeUnit.MILLISECONDS)) { + if (stopped || as == null || !as.isStarted()) continue; + long idle = monotonicMs() - as.lastPacketAtMs(); + if (idle >= STALL_MS) { + Log.w("session: no audio for %d ms - link presumed dead", idle); + return; } } } @@ -190,7 +313,7 @@ public final class Session { // live (read threads started); false if the budget was exhausted, in // which case onError() + onStopped() have already fired. private boolean connect() { - Throwable lastErr = null; + Exception lastErr = null; for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) { if (BACKOFF_MS[attempt] > 0) { Log.i("session: retry %d/%d after %d ms", @@ -204,7 +327,7 @@ public final class Session { return true; // success; the read threads own the live session } catch (InterruptedException ie) { return false; - } catch (Throwable t) { + } catch (Exception t) { Log.w("session: bring-up attempt %d/%d failed: %s", attempt + 1, BACKOFF_MS.length, t); lastErr = t; @@ -214,12 +337,22 @@ public final class Session { } if (stopped) return false; Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length); - Throwable err = lastErr; + return giveUp(lastErr); + } + + // Terminal failure. Marks the session stopped, tears down, and fires + // the final listener pair exactly once. Must not be called while + // holding the monitor: the listener runs on the caller's thread. + // Always returns false so callers can `return giveUp(err)`. + private boolean giveUp(Throwable err) { synchronized (this) { if (stopped) return false; stopped = true; - tearDownInstalled(); + stoppedNotified = true; } + adb.abort(); + tearDownInstalled(); + endSignal.countDown(); if (listener != null) { listener.onError(err); listener.onStopped(); @@ -231,6 +364,41 @@ public final class Session { // monitor - but only if stop() hasn't already fired, in which case // we tear down the locals we just built so nothing leaks. private void bringUp() throws Exception { + CountDownLatch finished = new CountDownLatch(1); + AtomicBoolean timedOut = new AtomicBoolean(); + Thread deadline = new Thread(() -> { + try { + if (!finished.await(BRING_UP_DEADLINE_MS, TimeUnit.MILLISECONDS)) { + timedOut.set(true); + Log.e("session: bring-up exceeded %d ms", BRING_UP_DEADLINE_MS); + adb.abort(); + } + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + }, "bring-up-deadline"); + deadline.setDaemon(true); + deadline.start(); + + Exception failure = null; + try { + bringUpAttempt(); + } catch (Exception e) { + failure = e; + } finally { + finished.countDown(); + deadline.interrupt(); + } + if (timedOut.get()) { + SocketTimeoutException timeout = new SocketTimeoutException( + "session bring-up exceeded " + BRING_UP_DEADLINE_MS + " ms"); + if (failure != null) timeout.initCause(failure); + throw timeout; + } + if (failure != null) throw failure; + } + + private void bringUpAttempt() throws Exception { Log.i("session: connect %s:%d", target.host, target.port); adb.disconnect(); adb.connect(target.host, target.port); @@ -243,27 +411,32 @@ public final class Session { AudioStream as = null; VideoSink vk = null; VideoStream vs = null; - MuxRecorder rec = null; boolean installed = false; try { srv = new Server(ctx, adb); Server.Streams s = srv.bringUp(); - cs = new ControlStream(s.controlIn, s.controlOut, this::onVideoEnded); + // Bind the end callbacks to THIS generation's latch, not to + // the mutable field. Teardown joins the readers with a 1 s + // timeout, so on a black-holed link a reader can outlive its + // generation; reading the field at fire time would let it + // count down the next generation's latch and fake an + // immediate drop on a connection that was fine. + final CountDownLatch mine = endSignal; + Runnable ended = mine::countDown; + + cs = new ControlStream(s.controlIn, s.controlOut, ended); ctrl = new Controller(ctx, cs::send); cs.setInboundSink(ctrl); - ak = new AudioSink(this::onVideoEnded); - as = new AudioStream(s.audioIn, ak); + ak = new AudioSink(ended); + as = new AudioStream(s.audioIn, ak, ended); - vk = new VideoSink(surface, this::onVideoEnded); + vk = new VideoSink(surface, ended); Controller ctrlRef = ctrl; - AtomicBoolean reported = new AtomicBoolean(); vs = new VideoStream(s.videoIn, vk, - (w, h) -> reportConnected(ctrlRef, reported, w, h)); - vs.setOnEnd(this::onVideoEnded); - rec = new MuxRecorder(); - vs.setRecorder(rec); + (w, h) -> reportConnected(ctrlRef, w, h)); + vs.setOnEnd(ended); // Start locals before publishing them. stop() either tears down // a previously installed generation or marks this generation for @@ -281,35 +454,35 @@ public final class Session { audioStream = as; videoSink = vk; videoStream = vs; - recorder = rec; - if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH); + Viewport viewport = pendingViewport; + if (viewport != null && viewport.w > 0 && viewport.h > 0) { + ctrl.setViewport(viewport.version, viewport.x, viewport.y, + viewport.w, viewport.h); + } installed = true; } } finally { - if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec); + if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs); } } - private synchronized void reportConnected(Controller ctrl, AtomicBoolean reported, - int w, int h) { + // Fires for every session-meta packet, not just the first. The server + // sends a fresh one whenever the target rotates or resizes, and the + // listener needs it every time: it carries the geometry the view is + // sized to and the touch viewport is derived from. Reporting only the + // first left a rotated target drawn into a view shaped for its old + // orientation, with touches mapped through the stale rectangle. + // Repeats are harmless - the listener's handling is idempotent. + private synchronized void reportConnected(Controller ctrl, int w, int h) { if (stopped) return; - ctrl.setTargetSize(w, h); - if (reported.compareAndSet(false, true) && listener != null) { - listener.onConnected(w, h); - } - } - - // Fired on the video-reader thread when its read loop exits (EOF, - // error, or stop()). The video socket is the authoritative stream; - // its end wakes the supervisor, which either unwinds (stop) or - // reconnects. A no-op countdown after stop() is harmless. - private void onVideoEnded() { - endSignal.countDown(); + long version = ++geometryVersion; + ctrl.setTargetSize(version, w, h); + if (listener != null) listener.onConnected(version, w, h); } private synchronized void tearDownInstalled() { tearDownLocals(server, controlStream, controller, - audioSink, audioStream, videoSink, videoStream, recorder); + audioSink, audioStream, videoSink, videoStream); server = null; controlStream = null; controller = null; @@ -317,14 +490,16 @@ public final class Session { audioStream = null; videoSink = null; videoStream = null; - recorder = null; - try { adb.disconnect(); } catch (Exception ignored) {} + try { + adb.disconnect(); + } catch (IOException e) { + Log.w("session: adb disconnect failed: %s", e); + } } private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl, AudioSink ak, AudioStream as, - VideoSink vk, VideoStream vs, MuxRecorder rec) { - if (rec != null) rec.close(); + VideoSink vk, VideoStream vs) { // Closing the owning ADB streams first unblocks readers. Join them // before releasing their sinks so no callback can recreate resources // after teardown. @@ -336,4 +511,8 @@ public final class Session { if (ak != null) ak.release(); if (ctrl != null) ctrl.release(); } + + private static long monotonicMs() { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Sessions.java b/app/src/main/java/invalid/lena/scrcpy/Sessions.java index 91144be..fbf19d7 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Sessions.java +++ b/app/src/main/java/invalid/lena/scrcpy/Sessions.java @@ -33,12 +33,18 @@ public final class Sessions extends Service { @Override public int onStartCommand(Intent intent, int flags, int startId) { + // Mirror passes its target so the notification can lead back to + // the session it describes. Restarting the service with a new + // target just rebuilds the notification. + String host = intent == null ? null : intent.getStringExtra(Mirror.EXTRA_HOST); + int port = intent == null ? -1 : intent.getIntExtra(Mirror.EXTRA_PORT, -1); + ensureChannel(); Notification n = new Notification.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(getString(R.string.app_name)) .setContentText(getString(R.string.notif_session_active)) - .setContentIntent(reopenIntent()) + .setContentIntent(reopenIntent(host, port)) .setOngoing(true) .build(); startForeground(NOTIF_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); @@ -66,9 +72,19 @@ public final class Sessions extends Service { nm.createNotificationChannel(ch); } - private PendingIntent reopenIntent() { - Intent i = new Intent(this, Main.class); - i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + // Tapping an ongoing "mirroring active" notification must return to + // the mirror. It used to point at Main with FLAG_ACTIVITY_CLEAR_TOP, + // which finished Mirror on the way - the notification destroyed the + // session it was advertising. Mirror is singleTask, so a plain + // NEW_TASK launch brings the existing instance forward, and carrying + // the target means a launch after the activity died still works. + private PendingIntent reopenIntent(String host, int port) { + Intent i = new Intent(this, Mirror.class); + i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (host != null) { + i.putExtra(Mirror.EXTRA_HOST, host); + i.putExtra(Mirror.EXTRA_PORT, port); + } return PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); } diff --git a/app/src/main/java/invalid/lena/scrcpy/Settings.java b/app/src/main/java/invalid/lena/scrcpy/Settings.java index c4bb83b..fab6ed1 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Settings.java +++ b/app/src/main/java/invalid/lena/scrcpy/Settings.java @@ -18,16 +18,32 @@ public final class Settings { public static final String MAX_FPS = "max_fps"; // fps; 0 = unlimited public static final String HINT_BACK_SHOWN = "hint_back_shown"; // first-run UI hint public static final String STATUS_BAR = "status_bar"; // show status bar while mirroring + public static final String CLIPBOARD = "clipboard"; // two-way clipboard sync public static final String DEFAULT_VIDEO_CODEC = "h264"; - public static final String DEFAULT_AUDIO_CODEC = "raw"; - public static final int DEFAULT_MAX_SIZE = 0; - public static final int DEFAULT_VIDEO_BIT_RATE = 8_000_000; + public static final String DEFAULT_AUDIO_CODEC = "opus"; + // Defaults sized for the worst link this is meant to work over, not + // the best. The constraint is the target's UPLINK: an uncapped + // stream at 8 Mbit/s is fine on a LAN and unusable over a VPN or a + // home broadband uplink, which is a supported way to reach a target. + // 1080p at 4 Mbit/s looks fine on a phone and fits an ordinary + // uplink; raise both in Settings when both devices are on the LAN. + public static final int DEFAULT_MAX_SIZE = 1080; + public static final int DEFAULT_VIDEO_BIT_RATE = 4_000_000; public static final int DEFAULT_MAX_FPS = 0; - // Hidden while mirroring by default: an always-on bar over the video - // is intrusive. It still auto-shows when not CONNECTED so Reconnect - // stays reachable. Record lives in the bar, so enable this to keep it. - public static final boolean DEFAULT_STATUS_BAR = false; + // Shown by default. It carries Back, Home and Recents, which are the + // only way to reach the target's navigation: a swipe from the + // source's bottom edge is taken by the source's own gesture + // detector and never reaches us. Hiding it is a deliberate choice + // for an unobstructed picture, not the default, because a control + // the user cannot find is a control that does not exist. + public static final boolean DEFAULT_STATUS_BAR = true; + // On by default: it is a headline feature and the target is one the + // user deliberately paired with. It is a setting because that trust + // is not absolute - a compromised target + // can read whatever is copied on this device and write anything it + // likes back - and because there was previously no way to decline. + public static final boolean DEFAULT_CLIPBOARD = true; private Settings() {} @@ -36,58 +52,133 @@ public final class Settings { } public static String videoCodec(Context ctx) { - return prefs(ctx).getString(VIDEO_CODEC, DEFAULT_VIDEO_CODEC); + String value = string(ctx, VIDEO_CODEC, DEFAULT_VIDEO_CODEC); + return "h264".equals(value) || "h265".equals(value) || "av1".equals(value) + ? value : DEFAULT_VIDEO_CODEC; } public static String audioCodec(Context ctx) { - return prefs(ctx).getString(AUDIO_CODEC, DEFAULT_AUDIO_CODEC); + String value = string(ctx, AUDIO_CODEC, DEFAULT_AUDIO_CODEC); + return "opus".equals(value) || "raw".equals(value) + ? value : DEFAULT_AUDIO_CODEC; } public static int maxSize(Context ctx) { - return prefs(ctx).getInt(MAX_SIZE, DEFAULT_MAX_SIZE); + int value = integer(ctx, MAX_SIZE, DEFAULT_MAX_SIZE); + switch (value) { + case 0: case 480: case 720: case 1080: case 1440: case 2160: + return value; + default: + return DEFAULT_MAX_SIZE; + } } public static int videoBitRate(Context ctx) { - return prefs(ctx).getInt(VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE); + int value = integer(ctx, VIDEO_BIT_RATE, DEFAULT_VIDEO_BIT_RATE); + switch (value) { + case 1_000_000: case 2_000_000: case 4_000_000: + case 8_000_000: case 16_000_000: + return value; + default: + return DEFAULT_VIDEO_BIT_RATE; + } } public static int maxFps(Context ctx) { - return prefs(ctx).getInt(MAX_FPS, DEFAULT_MAX_FPS); + int value = integer(ctx, MAX_FPS, DEFAULT_MAX_FPS); + switch (value) { + case 0: case 30: case 60: case 90: case 120: + return value; + default: + return DEFAULT_MAX_FPS; + } } public static void setVideoCodec(Context ctx, String v) { + if (!"h264".equals(v) && !"h265".equals(v) && !"av1".equals(v)) { + throw new IllegalArgumentException("invalid video codec"); + } prefs(ctx).edit().putString(VIDEO_CODEC, v).apply(); } public static void setAudioCodec(Context ctx, String a) { + if (!"opus".equals(a) && !"raw".equals(a)) { + throw new IllegalArgumentException("invalid audio codec"); + } prefs(ctx).edit().putString(AUDIO_CODEC, a).apply(); } public static void setMaxSize(Context ctx, int v) { + if (v != 0 && v != 480 && v != 720 && v != 1080 && v != 1440 && v != 2160) { + throw new IllegalArgumentException("invalid maximum size"); + } prefs(ctx).edit().putInt(MAX_SIZE, v).apply(); } public static void setVideoBitRate(Context ctx, int v) { + if (v != 1_000_000 && v != 2_000_000 && v != 4_000_000 + && v != 8_000_000 && v != 16_000_000) { + throw new IllegalArgumentException("invalid video bit rate"); + } prefs(ctx).edit().putInt(VIDEO_BIT_RATE, v).apply(); } public static void setMaxFps(Context ctx, int v) { + if (v != 0 && v != 30 && v != 60 && v != 90 && v != 120) { + throw new IllegalArgumentException("invalid maximum frame rate"); + } prefs(ctx).edit().putInt(MAX_FPS, v).apply(); } public static boolean hintBackShown(Context ctx) { - return prefs(ctx).getBoolean(HINT_BACK_SHOWN, false); + return bool(ctx, HINT_BACK_SHOWN, false); } public static void setHintBackShown(Context ctx, boolean v) { prefs(ctx).edit().putBoolean(HINT_BACK_SHOWN, v).apply(); } + public static boolean clipboardSync(Context ctx) { + return bool(ctx, CLIPBOARD, DEFAULT_CLIPBOARD); + } + + public static void setClipboardSync(Context ctx, boolean v) { + prefs(ctx).edit().putBoolean(CLIPBOARD, v).apply(); + } + public static boolean showStatusBar(Context ctx) { - return prefs(ctx).getBoolean(STATUS_BAR, DEFAULT_STATUS_BAR); + return bool(ctx, STATUS_BAR, DEFAULT_STATUS_BAR); } public static void setShowStatusBar(Context ctx, boolean v) { prefs(ctx).edit().putBoolean(STATUS_BAR, v).apply(); } + + private static String string(Context ctx, String key, String fallback) { + try { + String value = prefs(ctx).getString(key, fallback); + return value == null ? fallback : value; + } catch (ClassCastException e) { + Log.w("settings: %s has the wrong type", key); + return fallback; + } + } + + private static int integer(Context ctx, String key, int fallback) { + try { + return prefs(ctx).getInt(key, fallback); + } catch (ClassCastException e) { + Log.w("settings: %s has the wrong type", key); + return fallback; + } + } + + private static boolean bool(Context ctx, String key, boolean fallback) { + try { + return prefs(ctx).getBoolean(key, fallback); + } catch (ClassCastException e) { + Log.w("settings: %s has the wrong type", key); + return fallback; + } + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java index 43b0954..b650bc4 100644 --- a/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java +++ b/app/src/main/java/invalid/lena/scrcpy/SettingsActivity.java @@ -45,9 +45,9 @@ public final class SettingsActivity extends Activity { switch (Settings.videoBitRate(this)) { case 1_000_000: ((RadioButton) findViewById(R.id.bit_rate_1m)).setChecked(true); break; case 2_000_000: ((RadioButton) findViewById(R.id.bit_rate_2m)).setChecked(true); break; - case 4_000_000: ((RadioButton) findViewById(R.id.bit_rate_4m)).setChecked(true); break; + case 8_000_000: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true); break; case 16_000_000: ((RadioButton) findViewById(R.id.bit_rate_16m)).setChecked(true); break; - default: ((RadioButton) findViewById(R.id.bit_rate_8m)).setChecked(true); + default: ((RadioButton) findViewById(R.id.bit_rate_4m)).setChecked(true); } switch (Settings.maxFps(this)) { case 30: ((RadioButton) findViewById(R.id.max_fps_30)).setChecked(true); break; @@ -83,11 +83,17 @@ public final class SettingsActivity extends Activity { }); bitRateGroup.setOnCheckedChangeListener((g, id) -> { - int v = Settings.DEFAULT_VIDEO_BIT_RATE; + // Every button maps explicitly. Falling back to + // DEFAULT_VIDEO_BIT_RATE for the unmatched one silently tied + // whichever button that was to the default's current value, + // so changing the default changed what that button stored. + int v; if (id == R.id.bit_rate_1m) v = 1_000_000; else if (id == R.id.bit_rate_2m) v = 2_000_000; else if (id == R.id.bit_rate_4m) v = 4_000_000; + else if (id == R.id.bit_rate_8m) v = 8_000_000; else if (id == R.id.bit_rate_16m) v = 16_000_000; + else return; Settings.setVideoBitRate(this, v); Log.i("settings: video_bit_rate=%d", v); }); @@ -102,6 +108,16 @@ public final class SettingsActivity extends Activity { Log.i("settings: max_fps=%d", v); }); + findViewById(R.id.licenses).setOnClickListener(v -> + startActivity(new android.content.Intent(this, Licenses.class))); + + CheckBox clipboardBox = findViewById(R.id.clipboard_sync); + clipboardBox.setChecked(Settings.clipboardSync(this)); + clipboardBox.setOnCheckedChangeListener((b, checked) -> { + Settings.setClipboardSync(this, checked); + Log.i("settings: clipboard=%b", checked); + }); + statusBarBox.setChecked(Settings.showStatusBar(this)); statusBarBox.setOnCheckedChangeListener((b, checked) -> { Settings.setShowStatusBar(this, checked); diff --git a/app/src/main/java/invalid/lena/scrcpy/Sync.java b/app/src/main/java/invalid/lena/scrcpy/Sync.java index 6e37523..b7bb4b5 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Sync.java +++ b/app/src/main/java/invalid/lena/scrcpy/Sync.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.util.Objects; // Pure-java adb sync v1 SEND framing. Extracted from Server.push() so // it can be unit-tested without an AdbStream or any android coupling. @@ -23,6 +24,7 @@ public final class Sync { // FAIL responses carry a short human-readable message; cap what a // corrupt or hostile length field can make us allocate. private static final int MAX_FAIL_MSG = 4 * 1024; + private static final int MAX_PATH_BYTES = 1024; private Sync() {} @@ -30,10 +32,22 @@ public final class Sync { // already-open `out` and `in` of an adb sync stream. Returns the // total payload byte count. public static long push(InputStream src, OutputStream out, InputStream in, - String remotePath, int mode, int mtimeSec) + String remotePath, int mode, int mtimeSec) throws IOException { + Objects.requireNonNull(src); + Objects.requireNonNull(out); + Objects.requireNonNull(in); + Objects.requireNonNull(remotePath); + if (remotePath.isEmpty() || remotePath.indexOf(',') >= 0 + || remotePath.indexOf('\0') >= 0) { + throw new IllegalArgumentException("invalid sync path"); + } + if (mode < 0) throw new IllegalArgumentException("invalid sync mode"); String header = remotePath + "," + mode; byte[] hb = header.getBytes(StandardCharsets.UTF_8); + if (hb.length > MAX_PATH_BYTES) { + throw new IllegalArgumentException("sync path and mode are too long"); + } byte[] tag = new byte[8]; putTag(tag, 0, "SEND"); @@ -46,7 +60,8 @@ public final class Sync { putTag(dataHdr, 0, "DATA"); long total = 0; int n; - while ((n = src.read(chunk)) > 0) { + while ((n = src.read(chunk)) >= 0) { + if (n == 0) throw new IOException("sync source made no progress"); Wire.writeLe32(dataHdr, 4, n); out.write(dataHdr); out.write(chunk, 0, n); @@ -63,16 +78,43 @@ public final class Sync { Wire.readFully(in, resp); String code = new String(resp, 0, 4, StandardCharsets.US_ASCII); int len = Wire.readLe32(resp, 4); - if ("OKAY".equals(code)) return total; + if ("OKAY".equals(code)) { + if (len != 0) throw new IOException("sync OKAY length is not zero: " + len); + return total; + } + if (!"FAIL".equals(code)) { + throw new IOException("unknown sync response: " + safeCode(code)); + } - byte[] msg = new byte[Math.min(Math.max(0, len), MAX_FAIL_MSG)]; + if (len < 0 || len > MAX_FAIL_MSG) { + throw new IOException("sync " + code + " response length out of range: " + len); + } + byte[] msg = new byte[len]; if (msg.length > 0) Wire.readFully(in, msg); - throw new IOException("sync " + code + ": " - + new String(msg, StandardCharsets.UTF_8)); + throw new IOException("sync FAIL: " + safeMessage(Wire.decodeUtf8(msg))); } private static void putTag(byte[] dst, int off, String tag) { byte[] b = tag.getBytes(StandardCharsets.US_ASCII); System.arraycopy(b, 0, dst, off, 4); } + + private static String safeCode(String code) { + StringBuilder out = new StringBuilder(code.length()); + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + out.append(c >= 0x20 && c <= 0x7e ? c : '?'); + } + return out.toString(); + } + + private static String safeMessage(String message) { + StringBuilder out = new StringBuilder(message.length()); + for (int i = 0; i < message.length(); i++) { + char c = message.charAt(i); + int type = Character.getType(c); + out.append(Character.isISOControl(c) || type == Character.FORMAT ? '?' : c); + } + return out.toString(); + } } diff --git a/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java b/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java new file mode 100644 index 0000000..5ee80d4 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/TouchGeometry.java @@ -0,0 +1,46 @@ +package invalid.lena.scrcpy; + +// Publishes a target size and its matching on-screen viewport as one +// immutable value. A target resize invalidates the old viewport until the UI +// lays out the new generation. +final class TouchGeometry { + + static final class Snapshot { + final int targetW, targetH; + final int x, y, w, h; + + Snapshot(int targetW, int targetH, int x, int y, int w, int h) { + this.targetW = targetW; + this.targetH = targetH; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + } + + private volatile Snapshot snapshot; + private long version; + private int targetW; + private int targetH; + + synchronized void setTargetSize(long nextVersion, int w, int h) { + if (nextVersion <= version) throw new IllegalArgumentException("stale target version"); + if (w <= 0 || h <= 0) throw new IllegalArgumentException("invalid target size"); + version = nextVersion; + targetW = w; + targetH = h; + snapshot = null; + } + + synchronized void setViewport(long expectedVersion, int x, int y, int w, int h) { + if (expectedVersion != version) return; + snapshot = version > 0 && targetW > 0 && targetH > 0 && w > 0 && h > 0 + ? new Snapshot(targetW, targetH, x, y, w, h) + : null; + } + + Snapshot snapshot() { + return snapshot; + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/TouchMap.java b/app/src/main/java/invalid/lena/scrcpy/TouchMap.java new file mode 100644 index 0000000..d1289e0 --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/TouchMap.java @@ -0,0 +1,34 @@ +package invalid.lena.scrcpy; + +// Maps a coordinate in the activity window onto the target's pixels. +// +// Android-free for the same reason Wire and ControlMessages are: this is +// the arithmetic that decides where a tap lands, and it is worth testing +// without an emulator. Controller cannot be tested directly because it +// takes MotionEvent. +// +// The video does not fill the window. It is letterboxed to the target's +// aspect ratio and centred, so a coordinate has to have the bar +// subtracted before it is scaled. +public final class TouchMap { + + private TouchMap() {} + + // coord - along one axis, in window coordinates + // viewOrigin - where the video rectangle starts on that axis + // viewSpan - how long the video rectangle is on that axis + // targetSpan - the target's size on that axis + // + // Clamped into the rectangle rather than rejected: a drag that wanders + // onto a letterbox bar and is released there must still deliver its + // UP, or the target keeps the pointer down for the rest of the + // session. Returns 0..targetSpan-1. + public static int map(int coord, int viewOrigin, int viewSpan, int targetSpan) { + if (viewSpan <= 0 || targetSpan <= 0) return 0; + long v = (long) coord - viewOrigin; + if (v < 0) v = 0; + else if (v > viewSpan - 1) v = viewSpan - 1; + // 64-bit intermediate: a 4K target times a 4K span overflows int. + return (int) (v * targetSpan / viewSpan); + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/Ui.java b/app/src/main/java/invalid/lena/scrcpy/Ui.java index 91b8b66..d750219 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Ui.java +++ b/app/src/main/java/invalid/lena/scrcpy/Ui.java @@ -5,7 +5,7 @@ import android.view.View; // Window inset plumbing for the non-fullscreen activities. // -// targetSdk 35 makes every window edge-to-edge on Android 15: the system +// targetSdk 35 and later make every window edge-to-edge on Android 15: the system // no longer insets the content view, so an unhandled layout draws under // the status and navigation bars. Mirror wants that and opts in itself; // Main and Settings do not, so they pad their root by the bar sizes. diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java index cfffe90..ec930ac 100644 --- a/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java +++ b/app/src/main/java/invalid/lena/scrcpy/VideoFrames.java @@ -11,7 +11,7 @@ public interface VideoFrames { void reconfigure(int codecFourcc, int width, int height) throws IOException; - void feed(byte[] data, long ptsUs, boolean isConfig); + void feed(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe); void release(); } diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java b/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java new file mode 100644 index 0000000..a3c308f --- /dev/null +++ b/app/src/main/java/invalid/lena/scrcpy/VideoQueue.java @@ -0,0 +1,94 @@ +package invalid.lena.scrcpy; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; + +// Bounded encoded-video queue. It never leaves delta frames queued without +// the keyframe that starts their decoder generation. +final class VideoQueue { + + static final class Frame { + final byte[] data; + final long ptsUs; + final boolean config; + final boolean keyframe; + + Frame(byte[] data, long ptsUs, boolean config, boolean keyframe) { + this.data = data; + this.ptsUs = ptsUs; + this.config = config; + this.keyframe = keyframe; + } + } + + private final int maxFrames; + private final int maxBytes; + private final Deque<Frame> frames; + private int bytes; + private boolean needsKeyframe = true; + + VideoQueue(int maxFrames, int maxBytes) { + if (maxFrames < 1 || maxBytes < 1) throw new IllegalArgumentException(); + this.maxFrames = maxFrames; + this.maxBytes = maxBytes; + frames = new ArrayDeque<>(maxFrames); + } + + boolean offer(Frame frame) { + if (frame.config) { + clear(); + return append(frame); + } + if (frame.keyframe) { + removeMediaFrames(); + needsKeyframe = false; + if (append(frame)) return true; + needsKeyframe = true; + return false; + } + if (needsKeyframe) return false; + if (append(frame)) return true; + needsKeyframe = true; + return false; + } + + Frame poll() { + Frame frame = frames.pollFirst(); + if (frame != null) bytes -= frame.data.length; + return frame; + } + + boolean isEmpty() { + return frames.isEmpty(); + } + + boolean needsKeyframe() { + return needsKeyframe; + } + + void clear() { + frames.clear(); + bytes = 0; + needsKeyframe = true; + } + + private boolean append(Frame frame) { + if (frames.size() >= maxFrames || frame.data.length > maxBytes - bytes) { + return false; + } + frames.offerLast(frame); + bytes += frame.data.length; + return true; + } + + private void removeMediaFrames() { + for (Iterator<Frame> it = frames.iterator(); it.hasNext(); ) { + Frame frame = it.next(); + if (!frame.config) { + it.remove(); + bytes -= frame.data.length; + } + } + } +} diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java b/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java deleted file mode 100644 index 98a5ca9..0000000 --- a/app/src/main/java/invalid/lena/scrcpy/VideoRecorder.java +++ /dev/null @@ -1,19 +0,0 @@ -package invalid.lena.scrcpy; - -// Optional tap on VideoStream. Receives the same frames the decoder -// gets - same bytes, same order - plus the keyframe bit so a downstream -// muxer can mark random-access samples. Android-free so VideoStream -// stays unit-testable; MuxRecorder is the concrete Android impl. -// -// Wire order: -// onMeta(fourcc, w, h) - once per session, again on resize -// onFrame(data, pts, cfg, key) - for every frame -// close() - when the stream tears down -public interface VideoRecorder { - - void onMeta(int codecFourcc, int width, int height); - - void onFrame(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe); - - void close(); -} diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoSink.java b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java index ff8f449..8c69b31 100644 --- a/app/src/main/java/invalid/lena/scrcpy/VideoSink.java +++ b/app/src/main/java/invalid/lena/scrcpy/VideoSink.java @@ -10,7 +10,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayDeque; import java.util.Deque; -import java.util.Iterator; +import java.util.concurrent.atomic.AtomicBoolean; // MediaCodec async-mode video decoder writing to a Surface. // @@ -22,67 +22,75 @@ import java.util.Iterator; // Back-pressure policy: when no input buffer is free and the pending // queue is full, replace the oldest frame of the same kind, or the oldest // frame overall. The queue remains bounded even if a peer floods CSD. -// Keyframes are not distinguishable here (the flag stays in VideoStream) -// so they drop like any delta frame; the picture heals at the next one. +// After a decoder rebuild, delta frames are dropped until the next keyframe; +// feeding them first can leave MediaCodec waiting forever for missing refs. // -// Output timing: releaseOutputBuffer is called with an absolute nano -// timestamp on the System.nanoTime clock, derived from the wire PTS so -// the surface composer interpolates frames against vsync instead of -// rendering them as fast as they decode. Anchor is set on the first -// frame: wallClockNs0 - ptsUs0 * 1000 = constant offset, then for each -// subsequent buffer renderAt = ptsUs * 1000 + offset. +// Output buffers render when decoded. The Surface compositor already +// synchronizes presentation to vsync; translating a hostile remote PTS +// into an absolute local clock can instead queue frames arbitrarily far +// into the future. public final class VideoSink implements VideoFrames { private static final int MAX_PENDING = 8; + private static final int MAX_FRAME_BYTES = 8 * 1024 * 1024; + private static final int MAX_PENDING_BYTES = 16 * 1024 * 1024; private volatile Surface surface; private final Runnable onFatalError; - public volatile long frames; // public read for the status overlay + private final AtomicBoolean fatalReported = new AtomicBoolean(); + private long renderedFrames; private volatile MediaCodec codec; private HandlerThread handlerThread; private Handler handler; private final Object lock = new Object(); private final Deque<Integer> freeInputs = new ArrayDeque<>(16); - private final Deque<Frame> pending = new ArrayDeque<>(MAX_PENDING); + private final VideoQueue pending = new VideoQueue(MAX_PENDING, MAX_PENDING_BYTES); private boolean released; - private long ptsOffsetNs; // wall-ns = ptsUs * 1000 + ptsOffsetNs - private boolean ptsAnchored; - private static final class Frame { - final byte[] data; - final long ptsUs; - final boolean isConfig; - Frame(byte[] d, long pts, boolean cfg) { data = d; ptsUs = pts; isConfig = cfg; } - } + // The format the server last announced, held so the decoder can be + // built later if there was no output surface when it arrived. + private boolean haveFormat; + private int fmtFourcc, fmtW, fmtH; + + // Most recent CSD (SPS/PPS). A decoder built late, or rebuilt on + // resize, needs it before it can decode anything, and the server only + // sends it once per run. VideoStream hands us a fresh array per frame, + // so holding the reference is enough. + private byte[] lastConfig; public VideoSink(Surface surface, Runnable onFatalError) { this.surface = surface; this.onFatalError = onFatalError; } - // Swap the output Surface without rebuilding MediaCodec. Passing null - // detaches output: the codec keeps decoding but discards frames so the - // wire stays drained while the activity is backgrounded. Passing a new - // Surface re-attaches and rendering resumes from the next decoded frame. + // Swap the output Surface. A destroyed Surface cannot remain attached to + // MediaCodec, so null tears the decoder down but retains its format and + // codec config. The reader keeps draining the wire; a new Surface rebuilds + // the decoder and replays the cached config. public void setOutputSurface(Surface newSurface) { + if (newSurface == null) { + synchronized (lock) { + if (released) return; + surface = null; + } + teardownCodec(false); + return; + } synchronized (lock) { if (released) return; this.surface = newSurface; MediaCodec c = codec; - if (c == null) return; // not yet configured; new surface will be used at configure time + if (c == null) { + if (newSurface != null && haveFormat) startDeferredLocked(); + return; + } try { - if (newSurface != null) c.setOutputSurface(newSurface); - // Calling setOutputSurface(null) is unsupported on some - // codecs; instead, ignore output buffers in the callback - // when surface is null (see onOutputBufferAvailable). - } catch (IllegalStateException e) { - Log.w("video sink: setOutputSurface: %s", e); + c.setOutputSurface(newSurface); + } catch (RuntimeException e) { + reportFatal(e, "video sink: setOutputSurface"); } - // Re-anchor PTS so frames decoded against the old surface clock - // don't drag the new surface's render time into the past. - ptsAnchored = false; } } @@ -90,78 +98,131 @@ public final class VideoSink implements VideoFrames { public void configure(int codecFourcc, int width, int height) throws IOException { String mime = mimeFor(codecFourcc); if (mime == null) throw new IOException("unsupported video codec " + Wire.fourccName(codecFourcc)); + synchronized (lock) { + if (released) return; + haveFormat = true; + fmtFourcc = codecFourcc; fmtW = width; fmtH = height; + if (surface == null) { + // No output attached yet: the activity was backgrounded + // during bring-up, or this is a reconnect that completed + // while backgrounded. MediaCodec cannot be moved from + // ByteBuffer mode to Surface mode afterwards, so + // configuring with a null surface here would black the + // session out permanently and setOutputSurface() would + // throw for the rest of the session. Wait instead; the + // wire keeps draining and frames are dropped until a + // surface arrives. + Log.i("video sink: no output surface, deferring decoder (%s %dx%d)", + mime, width, height); + return; + } + startCodecLocked(mime, width, height); + } + } + + // Build the deferred decoder once a surface finally shows up. Failure + // here is fatal to the session: without a decoder there is no picture + // and no way to ask for one again. + private void startDeferredLocked() { + try { + startCodecLocked(mimeFor(fmtFourcc), fmtW, fmtH); + } catch (Exception e) { + reportFatal(e, "video sink: deferred configure failed"); + } + } + + // Must be called with `lock` held and `surface` non-null. + private void startCodecLocked(String mime, int width, int height) throws IOException { Log.i("video sink: configure mime=%s %dx%d", mime, width, height); + renderedFrames = 0; + freeInputs.clear(); + pending.clear(); + + // Created before the HandlerThread: createDecoderByType throws + // IOException, which the RuntimeException cleanup below does not + // cover, and an orphaned thread would survive until release(). + MediaCodec c = MediaCodec.createDecoderByType(mime); handlerThread = new HandlerThread("video-mc"); handlerThread.start(); handler = new Handler(handlerThread.getLooper()); - codec = MediaCodec.createDecoderByType(mime); - codec.setCallback(new MediaCodec.Callback() { + MediaCodec.Callback callback = new MediaCodec.Callback() { @Override public void onInputBufferAvailable(MediaCodec mc, int idx) { onFreeInput(mc, idx); } @Override public void onOutputBufferAvailable(MediaCodec mc, int idx, MediaCodec.BufferInfo info) { try { - if (surface == null) { - // Activity backgrounded - discard output instead - // of rendering to a dead surface. + if (mc != codec) { mc.releaseOutputBuffer(idx, false); return; } - // PTS-honoured render: SurfaceFlinger queues the buffer - // for renderTimestampNs and interpolates against vsync, - // so bursty arrivals smooth out instead of judder. - // Config frames (pts=0) fall through to render-immediately. - long renderAtNs = renderTimeNs(info.presentationTimeUs); - if (renderAtNs == 0L) mc.releaseOutputBuffer(idx, true); - else mc.releaseOutputBuffer(idx, renderAtNs); - } catch (IllegalStateException ignored) {} + mc.releaseOutputBuffer(idx, true); + if (++renderedFrames == 1) { + Log.i("video sink: rendered frame n=1"); + } + } catch (IllegalStateException e) { + if (mc == codec) reportFatal(e, "video sink: releaseOutputBuffer"); + } } @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) { - Log.e(e, "video sink: codec error"); - if (mc == codec && onFatalError != null) onFatalError.run(); + if (mc == codec) reportFatal(e, "video sink: codec error"); } @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat fmt) { Log.i("video sink: output format %s", fmt); } - }, handler); + }; - MediaFormat fmt = MediaFormat.createVideoFormat(mime, width, height); - codec.configure(fmt, surface, null, 0); - codec.start(); + try { + c.setCallback(callback, handler); + MediaFormat fmt = MediaFormat.createVideoFormat(mime, width, height); + c.configure(fmt, surface, null, 0); + // Publish before start() so the identity checks in the + // callbacks match from the very first buffer. + codec = c; + c.start(); + } catch (RuntimeException e) { + codec = null; + try { c.release(); } catch (Exception ignored) {} + HandlerThread ht = handlerThread; + handlerThread = null; + handler = null; + if (ht != null) ht.quitSafely(); + throw e; + } + + // A decoder built after the stream started - deferred for a + // missing surface, or rebuilt on resize - has missed the CSD the + // server only sends once. Replay it ahead of everything else. + if (lastConfig != null) { + pending.offer(new VideoQueue.Frame(lastConfig, 0L, true, false)); + } } // Called by VideoStream for every encoded frame, in order. @Override - public void feed(byte[] data, long ptsUs, boolean isConfig) { - if (!isConfig) frames++; + public void feed(byte[] data, long ptsUs, boolean isConfig, boolean isKeyframe) { synchronized (lock) { if (released) return; + if (data == null || data.length == 0 || data.length > MAX_FRAME_BYTES) { + reportFatal(null, "video sink: invalid frame size"); + return; + } + if (isConfig) lastConfig = data; // Try to drain immediately if there's a free input. while (!pending.isEmpty() && !freeInputs.isEmpty()) { - submit(codec, pending.pollFirst(), freeInputs.pollFirst()); + submit(codec, pending.poll(), freeInputs.pollFirst()); } - if (!freeInputs.isEmpty()) { - submit(codec, new Frame(data, ptsUs, isConfig), freeInputs.pollFirst()); + boolean waiting = pending.needsKeyframe(); + if (!pending.offer(new VideoQueue.Frame(data, ptsUs, isConfig, isKeyframe))) { return; } - // Keep the queue strictly bounded. A newer config frame replaces - // an older one; retaining every config packet lets a hostile peer - // turn the queue into an unbounded allocation sink. - if (pending.size() >= MAX_PENDING) { - boolean removed = false; - for (Iterator<Frame> it = pending.iterator(); it.hasNext(); ) { - Frame f = it.next(); - if (f.isConfig == isConfig) { - it.remove(); - removed = true; - break; - } - } - if (!removed) pending.pollFirst(); + if (waiting && isKeyframe && !isConfig) { + Log.i("video sink: accepted keyframe after configure or overflow"); + } + while (!pending.isEmpty() && !freeInputs.isEmpty()) { + submit(codec, pending.poll(), freeInputs.pollFirst()); } - pending.offerLast(new Frame(data, ptsUs, isConfig)); } } @@ -171,32 +232,43 @@ public final class VideoSink implements VideoFrames { if (released) return; released = true; } - teardownCodec(); + teardownCodec(true); } - // Tear down the current decoder and reconfigure with new dimensions. - // The next CSD frame on the wire (server resets on resize) will prime - // the new codec instance. + // Tear down the current decoder and rebuild it at the new dimensions. + // startCodecLocked replays the cached CSD into the new instance. @Override public void reconfigure(int codecFourcc, int width, int height) throws IOException { synchronized (lock) { if (released) return; + // Disarm the deferred-start path for the window in which we + // hold no codec: a setOutputSurface() landing between the + // teardown and the configure below must not build a second + // decoder behind our back. + haveFormat = false; } - teardownCodec(); + teardownCodec(false); + configure(codecFourcc, width, height); + } + + // Claim the codec under `lock` so setOutputSurface() can never touch + // an instance that is being released, and two callers cannot both + // stop the same one. The stop/release themselves run unlocked: they + // can take a while, and they do not wait on the callback looper, so + // there is nothing to gain by holding the lock across them. + private void teardownCodec(boolean clearConfig) { + MediaCodec c; + HandlerThread ht; synchronized (lock) { + c = codec; + codec = null; + ht = handlerThread; + handlerThread = null; + handler = null; freeInputs.clear(); pending.clear(); - ptsAnchored = false; // re-anchor on the first frame of the new run + if (clearConfig) lastConfig = null; } - configure(codecFourcc, width, height); - } - - private void teardownCodec() { - MediaCodec c = codec; - codec = null; - HandlerThread ht = handlerThread; - handlerThread = null; - handler = null; if (c != null) { try { c.stop(); } catch (Exception ignored) {} try { c.release(); } catch (Exception ignored) {} @@ -208,7 +280,7 @@ public final class VideoSink implements VideoFrames { private void onFreeInput(MediaCodec mc, int idx) { synchronized (lock) { if (released || mc != codec) return; - if (!pending.isEmpty()) submit(mc, pending.pollFirst(), idx); + if (!pending.isEmpty()) submit(mc, pending.poll(), idx); else freeInputs.offerLast(idx); } } @@ -216,38 +288,29 @@ public final class VideoSink implements VideoFrames { // Must be called with `lock` held. codec can be null mid-reconfigure // (teardownCodec runs unlocked); the frame is dropped like any other // back-pressure casualty. - private void submit(MediaCodec mc, Frame f, int idx) { + private void submit(MediaCodec mc, VideoQueue.Frame f, int idx) { if (mc == null || mc != codec) return; try { ByteBuffer buf = mc.getInputBuffer(idx); if (buf == null || f.data.length > buf.capacity()) { - Log.e("video sink: frame exceeds codec input (%d bytes)", f.data.length); - if (onFatalError != null) onFatalError.run(); + reportFatal(null, "video sink: frame exceeds codec input (" + + f.data.length + " bytes)"); return; } buf.clear(); buf.put(f.data); - int flags = f.isConfig ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0; + int flags = f.config ? MediaCodec.BUFFER_FLAG_CODEC_CONFIG : 0; mc.queueInputBuffer(idx, 0, f.data.length, f.ptsUs, flags); } catch (IllegalStateException e) { - Log.w("video sink: queueInputBuffer: %s", e); + reportFatal(e, "video sink: queueInputBuffer"); } } - // Convert a wire PTS (microseconds since some scrcpy epoch) into a - // System.nanoTime value the surface composer should render at. - // First call anchors the offset to "now" so latency stays whatever - // the wire produced. PTS=0 (config frames) and unanchored state - // both return 0 → caller falls back to render-immediately. - private long renderTimeNs(long ptsUs) { - if (ptsUs <= 0L) return 0L; - synchronized (lock) { - if (!ptsAnchored) { - ptsOffsetNs = System.nanoTime() - ptsUs * 1000L; - ptsAnchored = true; - } - return ptsUs * 1000L + ptsOffsetNs; - } + private void reportFatal(Exception error, String message) { + if (!fatalReported.compareAndSet(false, true)) return; + if (error == null) Log.e("%s", message); + else Log.e(error, "%s", message); + if (onFatalError != null) onFatalError.run(); } private static String mimeFor(int fourcc) { diff --git a/app/src/main/java/invalid/lena/scrcpy/VideoStream.java b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java index 7ecb3f1..9d870e5 100644 --- a/app/src/main/java/invalid/lena/scrcpy/VideoStream.java +++ b/app/src/main/java/invalid/lena/scrcpy/VideoStream.java @@ -34,11 +34,11 @@ public final class VideoStream { private static final int FLAG_SESSION_INT_BIT = 0x80000000; private static final int MAX_FRAME_SIZE = 8 * 1024 * 1024; + private static final int MAX_DIMENSION = 16 * 1024; private final InputStream source; private final VideoFrames sink; private final SizeListener sizeListener; - private volatile VideoRecorder recorder; // optional tap private volatile Runnable onEnd; // fired once when run() exits private Thread thread; private volatile boolean stop; @@ -69,10 +69,6 @@ public final class VideoStream { catch (InterruptedException e) { Thread.currentThread().interrupt(); } } - public void setRecorder(VideoRecorder r) { - this.recorder = r; - } - // Fired exactly once, on the reader thread, when run() exits - whether // by EOF, error, or stop(). The video socket is the authoritative // stream: when it ends mid-session the link is gone, so Session uses @@ -89,6 +85,10 @@ public final class VideoStream { fourcc = Wire.readBe32(four, 0); if (fourcc == 0) throw new IOException("video: server reports stream disabled"); if (fourcc == 1) throw new IOException("video: server reports configuration error"); + if (fourcc != Wire.CODEC_H264 && fourcc != Wire.CODEC_H265 + && fourcc != Wire.CODEC_AV1) { + throw new IOException("video: unexpected codec " + Wire.fourccName(fourcc)); + } Log.i("video meta codec=%s", Wire.fourccName(fourcc)); byte[] tail8 = new byte[8]; @@ -117,9 +117,15 @@ public final class VideoStream { } private void parseSessionMeta(int hi, byte[] tail8) throws IOException { + if ((hi & ~0x80000001) != 0) { + throw new IOException("video session meta has unknown flags"); + } Wire.readFully(source, tail8); int newW = Wire.readBe32(tail8, 0); int newH = Wire.readBe32(tail8, 4); + if (newW < 1 || newW > MAX_DIMENSION || newH < 1 || newH > MAX_DIMENSION) { + throw new IOException("video size out of range: " + newW + "x" + newH); + } boolean clientResize = (hi & 1) != 0; Log.i("video session meta %dx%d client_resize=%s", newW, newH, clientResize); @@ -133,16 +139,14 @@ public final class VideoStream { sink.reconfigure(fourcc, curW, curH); } if (sizeListener != null) sizeListener.onSize(curW, curH); - VideoRecorder r = recorder; - if (r != null) r.onMeta(fourcc, curW, curH); } private void parseFrame(int hi, byte[] tail8) throws IOException { Wire.readFully(source, tail8); long pts = ((long) hi << 32) | (Wire.readBe32(tail8, 0) & 0xffffffffL); int size = Wire.readBe32(tail8, 4); - boolean cfg = (pts & FLAG_CONFIG) != 0; - boolean key = (pts & FLAG_KEYFRAME) != 0; + boolean cfg = (pts & FLAG_CONFIG) != 0; + boolean keyframe = (pts & FLAG_KEYFRAME) != 0; long ptsUs = pts & PTS_MASK; if (size <= 0 || size > MAX_FRAME_SIZE) { @@ -154,8 +158,6 @@ public final class VideoStream { byte[] payload = new byte[size]; Wire.readFully(source, payload); - sink.feed(payload, ptsUs, cfg); - VideoRecorder r = recorder; - if (r != null) r.onFrame(payload, ptsUs, cfg, key); + sink.feed(payload, ptsUs, cfg, keyframe); } } diff --git a/app/src/main/java/invalid/lena/scrcpy/Wire.java b/app/src/main/java/invalid/lena/scrcpy/Wire.java index 7ed6c6a..3d78c9b 100644 --- a/app/src/main/java/invalid/lena/scrcpy/Wire.java +++ b/app/src/main/java/invalid/lena/scrcpy/Wire.java @@ -3,6 +3,10 @@ package invalid.lena.scrcpy; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; // Byte-order helpers used everywhere we touch raw streams. // @@ -18,6 +22,7 @@ public final class Wire { // ---- big-endian (scrcpy) ---- public static int readBe32(byte[] b, int off) { + checkRange(b, off, 4); return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16) | ((b[off + 2] & 0xff) << 8) @@ -25,10 +30,12 @@ public final class Wire { } public static long readBe64(byte[] b, int off) { + checkRange(b, off, 8); return ((long)(readBe32(b, off)) << 32) | (readBe32(b, off + 4) & 0xffffffffL); } public static void writeBe32(byte[] b, int off, int v) { + checkRange(b, off, 4); b[off] = (byte)(v >>> 24); b[off + 1] = (byte)(v >>> 16); b[off + 2] = (byte)(v >>> 8); @@ -36,6 +43,7 @@ public final class Wire { } public static void writeBe64(byte[] b, int off, long v) { + checkRange(b, off, 8); writeBe32(b, off, (int)(v >>> 32)); writeBe32(b, off + 4, (int) v); } @@ -43,6 +51,7 @@ public final class Wire { // ---- little-endian (adb sync) ---- public static int readLe32(byte[] b, int off) { + checkRange(b, off, 4); return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8) | ((b[off + 2] & 0xff) << 16) @@ -50,6 +59,7 @@ public final class Wire { } public static void writeLe32(byte[] b, int off, int v) { + checkRange(b, off, 4); b[off] = (byte) v; b[off + 1] = (byte)(v >>> 8); b[off + 2] = (byte)(v >>> 16); @@ -59,11 +69,17 @@ public final class Wire { // ---- I/O helpers ---- public static void readFully(InputStream in, byte[] buf, int off, int len) throws IOException { + if (in == null || buf == null) throw new NullPointerException(); + if (off < 0 || len < 0 || off > buf.length || len > buf.length - off) { + throw new IndexOutOfBoundsException(); + } int got = 0; while (got < len) { int n = in.read(buf, off + got, len - got); if (n < 0) throw new EOFException( "short read: wanted " + len + " got " + got); + if (n == 0) throw new IOException( + "input made no progress: wanted " + len + " got " + got); got += n; } } @@ -72,28 +88,59 @@ public final class Wire { readFully(in, buf, 0, buf.length); } - // scrcpy codec identifiers, mirrored from com.genymobile.scrcpy.{video,audio}.*Codec. - // Short names (raw, aac) are NUL-padded on the left, not space-padded on the right. + public static String decodeUtf8(byte[] data, int off, int len) + throws CharacterCodingException { + checkRange(data, off, len); + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(data, off, len)).toString(); + } + + public static String decodeUtf8(byte[] data) throws CharacterCodingException { + return decodeUtf8(data, 0, data.length); + } + + // scrcpy codec identifiers, mirrored from + // vendor/scrcpy/server/src/main/java/com/genymobile/scrcpy/video/VideoCodec.java + // and .../audio/AudioCodec.java, which are the authoritative source. + // Every id is the 4-char ASCII name NUL-padded on the LEFT for names + // shorter than four characters (av1, raw, aac), never space-padded on + // the right. WireTest asserts that rule rather than copying the + // literals, so a mis-transcribed id fails the build. // Declared as int-literal constants so they can drive switch-case labels. public static final int CODEC_H264 = 0x68_32_36_34; // 'h264' public static final int CODEC_H265 = 0x68_32_36_35; // 'h265' - public static final int CODEC_AV1 = 0x61_76_30_31; // 'av01' + public static final int CODEC_AV1 = 0x00_61_76_31; // '\0av1' public static final int CODEC_OPUS = 0x6f_70_75_73; // 'opus' - public static final int CODEC_FLAC = 0x66_6c_61_63; // 'flac' - public static final int CODEC_AAC = 0x00_61_61_63; // '\0aac' public static final int CODEC_RAW = 0x00_72_61_77; // '\0raw' public static String fourccName(int v) { // Skip any leading NUL bytes - scrcpy left-pads short names like // 'raw' and 'aac' with \0, which would otherwise render as // non-printable characters in logs. - char[] cs = new char[]{ - (char)((v >>> 24) & 0xff), - (char)((v >>> 16) & 0xff), - (char)((v >>> 8) & 0xff), - (char)( v & 0xff)}; + int[] bytes = {(v >>> 24) & 0xff, (v >>> 16) & 0xff, + (v >>> 8) & 0xff, v & 0xff}; int from = 0; - while (from < cs.length && cs[from] == 0) from++; - return new String(cs, from, cs.length - from); + while (from < bytes.length && bytes[from] == 0) from++; + StringBuilder out = new StringBuilder(4); + char[] hex = "0123456789abcdef".toCharArray(); + for (int i = from; i < bytes.length; i++) { + int b = bytes[i]; + if (b >= 0x20 && b <= 0x7e) { + out.append((char) b); + } else { + out.append("\\x").append(hex[b >>> 4]).append(hex[b & 0xf]); + } + } + return out.toString(); + } + + private static void checkRange(byte[] data, int off, int len) { + if (data == null) throw new NullPointerException("data"); + if (off < 0 || len < 0 || off > data.length - len) { + throw new IndexOutOfBoundsException("off=" + off + " len=" + len + + " size=" + data.length); + } } } |