diff --git a/app/build.gradle b/app/build.gradle index 08f0fa6..cf6b57a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,10 +29,17 @@ android { buildFeatures { aidl true } + + testOptions { + unitTests.includeAndroidResources = true + } } dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'com.google.android.gms:play-services-cronet:18.1.0' + + testImplementation 'junit:junit:4.13.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d18ca3..e42917a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + = Build.VERSION_CODES.Q; + } + /** Applies default audio flag: on for Android 10+, off otherwise. */ public static void applyDefaultAudioSetting(CastSettings settings) { settings.setAudioEnabled(isAudioCaptureSupported()); diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java index 04e050c..cc8232a 100644 --- a/app/src/main/java/com/foxx/androidcast/SettingsUi.java +++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java @@ -50,10 +50,36 @@ public final class SettingsUi { Spinner resolution = activity.findViewById(R.id.spinner_resolution); bindSpinner(activity, transport, new String[]{ + activity.getString(R.string.transport_udp), activity.getString(R.string.transport_tcp), - activity.getString(R.string.transport_udp) - }, position -> settings.setTransport( - position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP)); + activity.getString(R.string.transport_quic), + activity.getString(R.string.transport_webrtc) + }, position -> { + switch (position) { + case 1: + settings.setTransport(CastConfig.TRANSPORT_TCP); + break; + case 2: + settings.setTransport(CastConfig.TRANSPORT_QUIC); + break; + case 3: + settings.setTransport(CastConfig.TRANSPORT_WEBRTC); + break; + case 0: + default: + settings.setTransport(CastConfig.TRANSPORT_UDP); + break; + } + }); + int transportIndex = 0; + if (CastConfig.TRANSPORT_TCP.equals(settings.getTransport())) { + transportIndex = 1; + } else if (CastConfig.TRANSPORT_QUIC.equals(settings.getTransport())) { + transportIndex = 2; + } else if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())) { + transportIndex = 3; + } + transport.setSelection(transportIndex); List codecs = CodecCatalog.supportedChoices(); List codecLabels = new ArrayList<>(); @@ -106,29 +132,42 @@ public final class SettingsUi { if (captureMode == null) { return; } + java.util.List modes = new java.util.ArrayList<>(); + java.util.List modeValues = new java.util.ArrayList<>(); + modes.add(activity.getString(R.string.capture_full_screen)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); if (PlatformCastSupport.isCaptureUserChoiceSupported()) { - bindSpinner(activity, captureMode, new String[]{ - activity.getString(R.string.capture_full_screen), - activity.getString(R.string.capture_user_choice) - }, position -> settings.setCaptureMode( - position == 1 ? CastSettings.CaptureMode.USER_CHOICE : CastSettings.CaptureMode.FULL_SCREEN)); - captureMode.setSelection(settings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE ? 1 : 0); - captureMode.setEnabled(true); - if (label != null) { - label.setText(R.string.label_capture_mode); - } + modes.add(activity.getString(R.string.capture_user_choice)); + modeValues.add(CastSettings.CaptureMode.USER_CHOICE); } else { - String unavailable = activity.getString(R.string.capture_user_choice_unavailable, - Build.VERSION.SDK_INT); - bindSpinner(activity, captureMode, new String[]{ - activity.getString(R.string.capture_full_screen), - unavailable - }, position -> settings.setCaptureMode(CastSettings.CaptureMode.FULL_SCREEN)); - captureMode.setSelection(0); - captureMode.setEnabled(false); - if (label != null) { - label.setText(activity.getString(R.string.label_capture_mode_sender_only)); + modes.add(activity.getString(R.string.capture_user_choice_unavailable, Build.VERSION.SDK_INT)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); + } + modes.add(activity.getString(R.string.capture_calibration_test)); + modeValues.add(CastSettings.CaptureMode.CALIBRATION_TEST); + if (PlatformCastSupport.isCameraCaptureModeSupported()) { + modes.add(activity.getString(R.string.capture_camera)); + modeValues.add(CastSettings.CaptureMode.CAMERA); + } else { + modes.add(activity.getString(R.string.capture_camera_unavailable)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); + } + bindSpinner(activity, captureMode, modes.toArray(new String[0]), position -> { + if (position >= 0 && position < modeValues.size()) { + settings.setCaptureMode(modeValues.get(position)); } + }); + int sel = 0; + for (int i = 0; i < modeValues.size(); i++) { + if (modeValues.get(i) == settings.getCaptureMode()) { + sel = i; + break; + } + } + captureMode.setSelection(sel); + captureMode.setEnabled(true); + if (label != null) { + label.setText(R.string.label_capture_mode); } } diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java index 9929806..e353561 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java @@ -135,6 +135,10 @@ public final class CastDiagnosticsFormatter { html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0)); html.append("
"); + + appendUdpLossSection(html, peer, lastLocal); + appendProtectionSection(html, peer, lastLocal); + html.append("Ctrl: ").append(escape(ctrl)); html.append("
"); html.append("Mode: ").append(escape(bitrateMode)); @@ -261,6 +265,110 @@ public final class CastDiagnosticsFormatter { return html.toString(); } + private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer, + NetworkStatsSnapshot local) { + long rx = pick(peer, local, true, false); + long tx = pick(peer, local, true, true); + long lost = val(peer, local, p -> p.udpReassemblyLost, l -> l.udpReassemblyLost); + long invalid = val(peer, local, p -> p.udpDatagramsInvalid, l -> l.udpDatagramsInvalid); + long dup = val(peer, local, p -> p.udpFragmentsDuplicate, l -> l.udpFragmentsDuplicate); + long wireDrop = val(peer, local, p -> p.udpWireDropped, l -> l.udpWireDropped); + long skipKey = val(peer, local, p -> p.recvVideoSkippedKey, l -> l.recvVideoSkippedKey); + long vDecErr = val(peer, local, p -> p.recvVideoDecodeErrors, l -> l.recvVideoDecodeErrors); + long aDecErr = val(peer, local, p -> p.recvAudioDecodeErrors, l -> l.recvAudioDecodeErrors); + long aDrop = val(peer, local, p -> p.recvAudioQueueDrops, l -> l.recvAudioQueueDrops); + long dropAudio = val(peer, local, p -> (long) p.droppedAudioFrames, l -> (long) l.droppedAudioFrames); + + if (rx == 0 && tx == 0 && lost == 0 && invalid == 0) { + return; + } + html.append("UDP: "); + html.append("rx ").append(rx).append(" / tx ").append(tx).append(" dg"); + if (lost > 0) { + html.append(" ").append(colored("reasm-lost " + lost, true)); + } + if (invalid > 0) { + html.append(" ").append(colored("bad " + invalid, true)); + } + if (dup > 0) { + html.append(" dup ").append(dup); + } + if (wireDrop > 0) { + html.append(" ").append(colored("sess-drop " + wireDrop, true)); + } + html.append("
"); + if (skipKey > 0 || vDecErr > 0 || aDecErr > 0 || aDrop > 0 || dropAudio > 0) { + html.append("App drops: "); + if (skipKey > 0) { + html.append(colored("vid-wait-key " + skipKey, skipKey > 50)); + } + if (vDecErr > 0) { + html.append(' ').append(colored("vid-dec " + vDecErr, true)); + } + if (aDecErr > 0) { + html.append(' ').append(colored("aud-dec " + aDecErr, true)); + } + if (aDrop > 0) { + html.append(' ').append(colored("aud-q " + aDrop, true)); + } + if (dropAudio > 0) { + html.append(' ').append(colored("snd-aud " + dropAudio, true)); + } + html.append("
"); + } + } + + private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer, + NetworkStatsSnapshot local) { + String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE"); + html.append("Protect: "); + html.append(escape(mode)); + html.append(" · FEC stub (off)"); + html.append(" · NACK stub (off)"); + long nack = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests); + if (nack > 0) { + html.append(" nack=").append(nack); + } + html.append("
"); + } + + private static long pick(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + boolean preferLocal, boolean tx) { + if (preferLocal && local != null) { + return tx ? local.udpDatagramsTx : local.udpDatagramsRx; + } + if (peer != null) { + return tx ? peer.udpDatagramsTx : peer.udpDatagramsRx; + } + return local != null ? (tx ? local.udpDatagramsTx : local.udpDatagramsRx) : 0; + } + + private static long val(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + java.util.function.Function peerFn, + java.util.function.Function localFn) { + long v = 0; + if (local != null) { + v = Math.max(v, localFn.apply(local)); + } + if (peer != null) { + v = Math.max(v, peerFn.apply(peer)); + } + return v; + } + + private static String str(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + java.util.function.Function peerFn, + java.util.function.Function localFn, + String def) { + if (local != null && localFn.apply(local) != null && !localFn.apply(local).isEmpty()) { + return localFn.apply(local); + } + if (peer != null && peerFn.apply(peer) != null && !peerFn.apply(peer).isEmpty()) { + return peerFn.apply(peer); + } + return def; + } + private static List buildHints(float inFps, float renderFps, int bwInKbps, int rttMs, long videoPackets) { List hints = new ArrayList<>(); diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java index b01c36f..3df4388 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java @@ -117,6 +117,10 @@ public final class CastDiagnosticsLabels { switch (mode) { case USER_CHOICE: return "Choose app or screen (Android 14+)"; + case CALIBRATION_TEST: + return "Calibration test pattern"; + case CAMERA: + return "Camera (Camera2, planned)"; case FULL_SCREEN: default: return "Entire screen"; diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 044494c..99aa02f 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -92,6 +92,10 @@ public class DiscoveryManager { this.listener = listener; } + public boolean isBrowsing() { + return running.get(); + } + public void startBrowsing() { if (!running.compareAndSet(false, true)) { return; @@ -148,7 +152,13 @@ public class DiscoveryManager { sock.setSoTimeout(300); byte[] buf = new byte[1024]; long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS; + long nextProbeMs = System.currentTimeMillis(); while (running.get() && System.currentTimeMillis() < deadline) { + long now = System.currentTimeMillis(); + if (now >= nextProbeMs) { + sendDiscoveryProbe(sock); + nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS; + } DatagramPacket packet = new DatagramPacket(buf, buf.length); try { sock.receive(packet); @@ -200,26 +210,25 @@ public class DiscoveryManager { try (DatagramSocket sock = new DatagramSocket()) { socket = sock; sock.setBroadcast(true); + sock.setSoTimeout(400); while (running.get()) { - JSONObject json = new JSONObject(); - json.put("magic", CastConfig.DISCOVERY_MAGIC); - json.put("name", deviceName); - json.put("port", port); - json.put("transport", transport); - json.put("listening", listening); - json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); - byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); - CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); - byte[] wire; - try { - wire = CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes); - } catch (Exception e) { - wire = jsonBytes; + sendAnnounce(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + long listenUntil = System.currentTimeMillis() + 2_000; + while (running.get() && System.currentTimeMillis() < listenUntil) { + try { + byte[] buf = new byte[1024]; + DatagramPacket packet = new DatagramPacket(buf, buf.length); + sock.receive(packet); + if (isDiscoverProbe(packet)) { + sendAnnounce(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask, packet.getAddress()); + } + } catch (java.net.SocketTimeoutException ignored) { + } } - InetAddress broadcast = InetAddress.getByName("255.255.255.255"); - DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); - sock.send(packet); - Thread.sleep(2000); } } catch (Exception e) { if (running.get()) { @@ -232,6 +241,73 @@ public class DiscoveryManager { } } + private static void sendDiscoveryProbe(DatagramSocket sock) { + try { + JSONObject json = new JSONObject(); + json.put("magic", CastConfig.DISCOVERY_MAGIC); + json.put("discover", true); + byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8); + InetAddress broadcast = InetAddress.getByName("255.255.255.255"); + DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } catch (Exception e) { + Log.w(TAG, "Discovery probe send failed: " + e.getMessage()); + } + } + + private static boolean isDiscoverProbe(DatagramPacket packet) { + try { + String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), + StandardCharsets.UTF_8); + JSONObject json = new JSONObject(text); + return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic")) + && json.optBoolean("discover", false); + } catch (Exception e) { + return false; + } + } + + private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port, + String transport, boolean listening, int placeholderId, int videoMask, int audioMask) + throws Exception { + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + InetAddress broadcast = InetAddress.getByName("255.255.255.255"); + DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } + + private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port, + String transport, boolean listening, int placeholderId, int videoMask, int audioMask, + InetAddress target) { + try { + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } catch (Exception e) { + Log.w(TAG, "Unicast announce failed: " + e.getMessage()); + } + } + + private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport, + boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception { + JSONObject json = new JSONObject(); + json.put("magic", CastConfig.DISCOVERY_MAGIC); + json.put("name", deviceName); + json.put("port", port); + json.put("transport", transport); + json.put("listening", listening); + json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); + byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); + CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); + try { + return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes); + } catch (Exception e) { + return jsonBytes; + } + } + private void handlePacket(DatagramPacket packet) { try { byte[] raw = packet.getData(); @@ -274,7 +350,7 @@ public class DiscoveryManager { } String name = json.optString("name", "Unknown"); int port = json.optInt("port", CastConfig.CAST_PORT); - String transport = json.optString("transport", CastConfig.TRANSPORT_TCP); + String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT); if (!json.optBoolean("listening", true)) { return; } diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java index 5509b56..21942a3 100644 --- a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java @@ -72,4 +72,8 @@ public final class CodecNegotiator { public static boolean isH264(String mime) { return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime); } + + public static boolean isHevc(String mime) { + return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime); + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java b/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java new file mode 100644 index 0000000..c4d7a54 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java @@ -0,0 +1,32 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +/** Inspects encoded bitstream headers for diagnostics. */ +public final class EncodedStreamProbe { + private EncodedStreamProbe() {} + + public static String describeKeyframe(String mime, byte[] annexB) { + if (annexB == null || annexB.length < 5) { + return "empty"; + } + int offset = 0; + if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1) { + offset = 4; + } else if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 1) { + offset = 3; + } + if (offset >= annexB.length) { + return "no start code"; + } + if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) { + int nalType = (annexB[offset] >> 1) & 0x3F; + return "HEVC NAL type " + nalType + " (" + annexB.length + " B)"; + } + if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) { + int nalType = annexB[offset] & 0x1F; + return "AVC NAL type " + nalType + " (" + annexB.length + " B)"; + } + return "encoded " + annexB.length + " B"; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java index c828793..cdd16f3 100644 --- a/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java +++ b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java @@ -2,7 +2,10 @@ package com.foxx.androidcast.media; import java.io.ByteArrayOutputStream; -/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */ +/** + * Converts H.264 / HEVC AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. + * MediaCodec encoders on Android typically emit AVCC; many decoders expect Annex-B in input buffers. + */ public final class H264Bitstream { private static final byte[] START_CODE = {0, 0, 0, 1}; diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java b/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java new file mode 100644 index 0000000..659ddcf --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java @@ -0,0 +1,52 @@ +package com.foxx.androidcast.media; + +import android.graphics.Bitmap; + +/** ARGB bitmap to NV21 for YUV420 encoder input. */ +public final class RgbToNv21 { + private RgbToNv21() {} + + public static byte[] fromBitmap(Bitmap bitmap) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int[] argb = new int[width * height]; + bitmap.getPixels(argb, 0, width, 0, 0, width, height); + return fromArgb(argb, width, height); + } + + public static byte[] fromArgb(int[] argb, int width, int height) { + int frameSize = width * height; + int ySize = frameSize; + int uvSize = frameSize / 2; + byte[] nv21 = new byte[ySize + uvSize]; + int yIndex = 0; + int uvIndex = ySize; + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + int y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; + int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; + int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; + nv21[yIndex++] = (byte) clamp(y); + if ((j & 1) == 0 && (i & 1) == 0) { + nv21[uvIndex++] = (byte) clamp(v); + nv21[uvIndex++] = (byte) clamp(u); + } + } + } + return nv21; + } + + private static int clamp(int v) { + if (v < 0) { + return 0; + } + if (v > 255) { + return 255; + } + return v; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java new file mode 100644 index 0000000..4fffee9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java @@ -0,0 +1,115 @@ +package com.foxx.androidcast.media; + +import android.graphics.Bitmap; + +/** + * ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input. + */ +public final class RgbToYuv420 { + public enum Layout { + I420, + NV12 + } + + private RgbToYuv420() {} + + public static byte[] fromBitmap(Bitmap bitmap, Layout layout) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int[] argb = new int[width * height]; + bitmap.getPixels(argb, 0, width, 0, 0, width, height); + return fromArgb(argb, width, height, layout); + } + + public static byte[] fromArgb(int[] argb, int width, int height, Layout layout) { + if (layout == Layout.NV12) { + return toNv12(argb, width, height); + } + return toI420(argb, width, height); + } + + public static int bufferSize(int width, int height) { + return width * height + (width * height) / 2; + } + + private static byte[] toI420(int[] argb, int width, int height) { + int frameSize = width * height; + int chromaW = (width + 1) / 2; + int chromaH = (height + 1) / 2; + int chromaLen = chromaW * chromaH; + byte[] out = new byte[frameSize + chromaLen * 2]; + int uOffset = frameSize; + int vOffset = frameSize + chromaLen; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + int y = yFromRgb(r, g, b); + out[j * width + i] = (byte) y; + if ((j & 1) == 0 && (i & 1) == 0) { + int u = uFromRgb(r, g, b); + int v = vFromRgb(r, g, b); + int cx = i >> 1; + int cy = j >> 1; + int cIndex = cy * chromaW + cx; + out[uOffset + cIndex] = (byte) u; + out[vOffset + cIndex] = (byte) v; + } + } + } + return out; + } + + private static byte[] toNv12(int[] argb, int width, int height) { + int frameSize = width * height; + int chromaW = (width + 1) / 2; + int chromaH = (height + 1) / 2; + byte[] out = new byte[frameSize + chromaW * chromaH * 2]; + int uvOffset = frameSize; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + out[j * width + i] = (byte) yFromRgb(r, g, b); + if ((j & 1) == 0 && (i & 1) == 0) { + int u = uFromRgb(r, g, b); + int v = vFromRgb(r, g, b); + int cx = i >> 1; + int cy = j >> 1; + int uvIndex = uvOffset + (cy * chromaW + cx) * 2; + out[uvIndex] = (byte) u; + out[uvIndex + 1] = (byte) v; + } + } + } + return out; + } + + private static int yFromRgb(int r, int g, int b) { + return clamp(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16); + } + + private static int uFromRgb(int r, int g, int b) { + return clamp(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128); + } + + private static int vFromRgb(int r, int g, int b) { + return clamp(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128); + } + + private static int clamp(int v) { + if (v < 0) { + return 0; + } + if (v > 255) { + return 255; + } + return v; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java index 9fd50bc..e375237 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java @@ -24,14 +24,22 @@ public final class CastPacketFramer { return out; } - public static CastProtocol.Message unframe(byte[] wire) throws IOException { - if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) { + public static CastProtoHeader readHeader(byte[] wire) throws IOException { + if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1) { throw new IOException("Frame too short"); } CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length); if (!hdr.verifyChecksum()) { throw new IOException("Header checksum mismatch"); } + return hdr; + } + + public static CastProtocol.Message unframe(byte[] wire) throws IOException { + if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) { + throw new IOException("Frame too short"); + } + CastProtoHeader hdr = readHeader(wire); int innerOff = hdr.headerSize; if (wire.length < innerOff + 5) { throw new IOException("Missing message body"); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index a685620..d9a6b52 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -255,7 +255,7 @@ public final class CastProtocol { return local.getTransport().equals(remote.getTransport()); } - private static final byte NETWORK_STATS_VERSION = 3; + private static final byte NETWORK_STATS_VERSION = 4; public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); @@ -283,6 +283,28 @@ public final class CastProtocol { dos.writeFloat(s.audioCapturePeakDb); dos.writeFloat(s.audioCaptureRmsDb); } + if (NETWORK_STATS_VERSION >= 4) { + dos.writeLong(s.udpDatagramsRx); + dos.writeLong(s.udpDatagramsTx); + dos.writeLong(s.udpDatagramsInvalid); + dos.writeLong(s.udpFragmentsRx); + dos.writeLong(s.udpFragmentsDuplicate); + dos.writeLong(s.udpReassemblyOk); + dos.writeLong(s.udpReassemblyLost); + dos.writeLong(s.udpWireDropped); + dos.writeLong(s.udpMalformed); + dos.writeInt(s.droppedAudioFrames); + dos.writeLong(s.recvVideoPackets); + dos.writeLong(s.recvAudioPackets); + dos.writeLong(s.recvVideoSkippedKey); + dos.writeLong(s.recvVideoDecodeErrors); + dos.writeLong(s.recvAudioDecodeErrors); + dos.writeLong(s.recvAudioQueueDrops); + dos.writeLong(s.recvDamagedVideoFrames); + dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE"); + dos.writeLong(s.fecStubPackets); + dos.writeLong(s.nackStubRequests); + } dos.flush(); return bos.toByteArray(); } @@ -315,6 +337,28 @@ public final class CastProtocol { s.audioCapturePeakDb = dis.readFloat(); s.audioCaptureRmsDb = dis.readFloat(); } + if (ver >= 4 && dis.available() > 0) { + s.udpDatagramsRx = dis.readLong(); + s.udpDatagramsTx = dis.readLong(); + s.udpDatagramsInvalid = dis.readLong(); + s.udpFragmentsRx = dis.readLong(); + s.udpFragmentsDuplicate = dis.readLong(); + s.udpReassemblyOk = dis.readLong(); + s.udpReassemblyLost = dis.readLong(); + s.udpWireDropped = dis.readLong(); + s.udpMalformed = dis.readLong(); + s.droppedAudioFrames = dis.readInt(); + s.recvVideoPackets = dis.readLong(); + s.recvAudioPackets = dis.readLong(); + s.recvVideoSkippedKey = dis.readLong(); + s.recvVideoDecodeErrors = dis.readLong(); + s.recvAudioDecodeErrors = dis.readLong(); + s.recvAudioQueueDrops = dis.readLong(); + s.recvDamagedVideoFrames = dis.readLong(); + s.protectionMode = dis.readUTF(); + s.fecStubPackets = dis.readLong(); + s.nackStubRequests = dis.readLong(); + } } } return s; @@ -324,7 +368,7 @@ public final class CastProtocol { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeInt(advice.suggestedBitrateKbps); - dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP); + dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT); dos.writeByte(advice.switchTransport ? 1 : 0); dos.writeInt(advice.congestionLevel); dos.flush(); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java b/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java new file mode 100644 index 0000000..853e565 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java @@ -0,0 +1,37 @@ +package com.foxx.androidcast.network; + +/** + * Binds to the first non-zero session id on a connection and drops stale packets from other casts. + */ +public final class CastSessionGate { + private static final int UNBOUND = Integer.MIN_VALUE; + + private int boundSessionId = UNBOUND; + + public boolean accept(CastProtoHeader header) { + if (header == null) { + return true; + } + int sid = header.sessionId; + if (sid == 0) { + return true; + } + if (boundSessionId == UNBOUND) { + boundSessionId = sid; + return true; + } + return boundSessionId == sid; + } + + public void reset() { + boundSessionId = UNBOUND; + } + + public boolean isBound() { + return boundSessionId != UNBOUND; + } + + public int getBoundSessionId() { + return boundSessionId == UNBOUND ? 0 : boundSessionId; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java index c59354b..a4b4f90 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java @@ -1,5 +1,7 @@ package com.foxx.androidcast.network; +import com.foxx.androidcast.network.transport.TransportLossStats; + import java.io.Closeable; import java.io.IOException; @@ -7,6 +9,9 @@ import java.io.IOException; public interface CastTransport extends Closeable { /** Optional proto header context; ignored when null. */ default void setFramingContext(CastFramingContext context) {} + + /** Drops inbound wire packets whose session id does not match the active cast. */ + default void setInboundSessionGate(CastSessionGate gate) {} /** Server: bind and wait for first peer datagram / connection. */ void listen(int port) throws IOException; @@ -28,6 +33,11 @@ public interface CastTransport extends Closeable { String getModeLabel(); + /** Loss/damage counters when supported (UDP); empty stats otherwise. */ + default TransportLossStats getLossStats() { + return new TransportLossStats(); + } + /** Server: drop the active client socket but keep the listen socket bound. */ default void releaseConnection() { close(); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java index cec3ff8..6bc1cc1 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -1,15 +1,35 @@ package com.foxx.androidcast.network; +import android.content.Context; + import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.quic.QuicCronetCastTransport; +import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory; import java.io.IOException; public final class CastTransportFactory { + private static Context appContext; + private CastTransportFactory() {} + public static void init(Context context) { + appContext = context.getApplicationContext(); + } + public static CastTransport create(CastSettings settings) throws IOException { - if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) { + String mode = settings.getTransport(); + if (CastConfig.TRANSPORT_QUIC.equals(mode)) { + if (appContext == null) { + throw new IOException("CastTransportFactory.init(context) required for QUIC"); + } + return new QuicCronetCastTransport(appContext); + } + if (WebRtcTransportFactory.isTransportId(mode)) { + return WebRtcTransportFactory.create(); + } + if (CastConfig.TRANSPORT_UDP.equals(mode)) { return new UdpCastTransport(); } return new TcpCastTransport(); diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java index 0d6383a..2f13863 100644 --- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -5,6 +5,7 @@ import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; @@ -18,6 +19,7 @@ public class TcpCastTransport implements CastTransport { private final Object sendLock = new Object(); private CastFramingContext framingContext; + private CastSessionGate inboundSessionGate; private final Object receiveLock = new Object(); private ServerSocket serverSocket; @@ -83,6 +85,11 @@ public class TcpCastTransport implements CastTransport { this.framingContext = context; } + @Override + public void setInboundSessionGate(CastSessionGate gate) { + this.inboundSessionGate = gate; + } + @Override public void send(byte type, byte[] payload) throws IOException { synchronized (sendLock) { @@ -133,7 +140,83 @@ public class TcpCastTransport implements CastTransport { } private CastProtocol.Message readFramedMessage() throws IOException { - return CastPacketFramer.readFramedMessage(bufferedIn); + if (inboundSessionGate == null) { + return CastPacketFramer.readFramedMessage(bufferedIn); + } + byte[] magicBuf = readFully(bufferedIn, 4); + if (magicBuf == null) { + return null; + } + int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8) + | ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24); + if (magic != CastProtoHeader.MAGIC) { + throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic)); + } + byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4); + if (restHdr == null) { + return null; + } + byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1]; + System.arraycopy(magicBuf, 0, headerBytes, 0, 4); + System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length); + CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length); + if (!hdr.verifyChecksum()) { + throw new IOException("Header checksum mismatch"); + } + if (!inboundSessionGate.accept(hdr)) { + skipMessageBody(in); + return null; + } + byte type = (byte) in.read(); + if (type < 0) { + return null; + } + byte[] lenBuf = readFully(bufferedIn, 4); + if (lenBuf == null) { + return null; + } + int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16) + | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff); + if (len < 0 || len > 4 * 1024 * 1024) { + throw new IOException("Invalid payload length: " + len); + } + byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len); + if (payload == null && len > 0) { + return null; + } + return new CastProtocol.Message(type, payload); + } + + private static void skipMessageBody(DataInputStream in) throws IOException { + byte type = (byte) in.read(); + if (type < 0) { + return; + } + byte[] lenBuf = readFully(in, 4); + if (lenBuf == null) { + return; + } + int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16) + | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff); + if (len > 0 && len <= 4 * 1024 * 1024) { + in.skipBytes(len); + } + } + + private static byte[] readFully(InputStream in, int n) throws IOException { + byte[] buf = new byte[n]; + int off = 0; + while (off < n) { + int r = in.read(buf, off, n - off); + if (r < 0) { + if (off == 0) { + return null; + } + throw new java.io.EOFException("Short read"); + } + off += r; + } + return buf; } private boolean waitForHeader(int timeoutMs) throws IOException { diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java index 4e36078..ddc1594 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -1,6 +1,9 @@ package com.foxx.androidcast.network; import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.transport.StreamProtectionEngine; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportStatsProvider; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -14,7 +17,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** Best-effort message transport over UDP with fragmentation. */ -public class UdpCastTransport implements CastTransport { +public class UdpCastTransport implements CastTransport, TransportStatsProvider { private static final byte[] MAGIC = {'A', 'C', 'U', 'D'}; /** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */ private static final int HEADER = 17; @@ -26,7 +29,11 @@ public class UdpCastTransport implements CastTransport { private volatile boolean listening; private final Map pending = new ConcurrentHashMap<>(); + private final TransportLossStats lossStats = new TransportLossStats(); + private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop(); private CastFramingContext framingContext; + private CastSessionGate inboundSessionGate; + private static final long REASSEMBLY_TIMEOUT_MS = 3_000; public boolean isListening() { return listening && socket != null && !socket.isClosed(); @@ -71,6 +78,24 @@ public class UdpCastTransport implements CastTransport { this.framingContext = context; } + @Override + public void setInboundSessionGate(CastSessionGate gate) { + this.inboundSessionGate = gate; + } + + /** Select protection stub (default none). FEC/NACK are not active yet. */ + public void setProtectionEngine(StreamProtectionEngine engine) { + protectionEngine = engine != null ? engine : StreamProtectionEngine.noop(); + synchronized (lossStats) { + lossStats.protectionMode = protectionEngine.mode(); + } + } + + @Override + public TransportLossStats getLossStats() { + return lossStats.copy(); + } + @Override public void send(byte type, byte[] payload) throws IOException { if (peer == null) { @@ -85,6 +110,7 @@ public class UdpCastTransport implements CastTransport { } else { body = payload == null ? new byte[0] : payload; } + body = protectionEngine.onOutboundPayload(type, body); if (body.length <= MAX_RELIABLE_BYTES) { sendOnce(wireType, body); sendOnce(wireType, body); @@ -103,6 +129,9 @@ public class UdpCastTransport implements CastTransport { byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length); DatagramPacket dp = new DatagramPacket(packet, packet.length, peer); socket.send(dp); + synchronized (lossStats) { + lossStats.datagramsSent++; + } } } @@ -113,12 +142,16 @@ public class UdpCastTransport implements CastTransport { } long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { + expireStaleReassembly(); int remaining = (int) Math.max(1, deadline - System.currentTimeMillis()); socket.setSoTimeout(remaining); try { byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64]; DatagramPacket dp = new DatagramPacket(buf, buf.length); socket.receive(dp); + synchronized (lossStats) { + lossStats.datagramsReceived++; + } if (peer == null) { peer = new InetSocketAddress(dp.getAddress(), dp.getPort()); } @@ -132,15 +165,38 @@ public class UdpCastTransport implements CastTransport { return null; } + private void expireStaleReassembly() { + long now = System.currentTimeMillis(); + for (Map.Entry e : pending.entrySet()) { + Reassembly r = e.getValue(); + if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) { + pending.remove(e.getKey()); + synchronized (lossStats) { + lossStats.reassemblyIncomplete++; + } + protectionEngine.onNackStub(e.getKey()); + } + } + } + private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException { if (length < HEADER) { + synchronized (lossStats) { + lossStats.datagramsInvalid++; + } return null; } for (int i = 0; i < 4; i++) { if (data[i] != MAGIC[i]) { + synchronized (lossStats) { + lossStats.datagramsInvalid++; + } return null; } } + synchronized (lossStats) { + lossStats.fragmentsReceived++; + } byte type = data[4]; int messageId = readInt(data, 5); int fragIndex = readShort(data, 9); @@ -152,32 +208,68 @@ public class UdpCastTransport implements CastTransport { CastProtocol.Message raw; if (fragCount <= 1) { + byte[] body; if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) { - byte[] trimmed = new byte[totalLen]; - System.arraycopy(chunk, 0, trimmed, 0, totalLen); - raw = new CastProtocol.Message(type, trimmed); + body = new byte[totalLen]; + System.arraycopy(chunk, 0, body, 0, totalLen); } else { - raw = new CastProtocol.Message(type, chunk); + body = chunk; } + body = protectionEngine.onInboundPayload(type, body); + raw = new CastProtocol.Message(type, body); return unwrapWire(raw); } - Reassembly r = pending.get(messageId); - if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) { + Reassembly existing = pending.get(messageId); + if (existing != null && (existing.fragCount != fragCount || existing.totalLen != totalLen)) { + pending.remove(messageId); + synchronized (lossStats) { + lossStats.reassemblySuperseded++; + } + existing = null; + } + Reassembly r = existing; + if (r == null) { r = new Reassembly(fragCount, totalLen); pending.put(messageId, r); } - r.put(fragIndex, chunk, payloadLen); + if (!r.put(fragIndex, chunk, payloadLen)) { + synchronized (lossStats) { + lossStats.fragmentsDuplicate++; + } + return null; + } if (!r.complete()) { return null; } pending.remove(messageId); - return unwrapWire(new CastProtocol.Message(type, r.assemble())); + synchronized (lossStats) { + lossStats.reassemblyCompleted++; + } + byte[] assembled = r.assemble(); + assembled = protectionEngine.onInboundPayload(type, assembled); + return unwrapWire(new CastProtocol.Message(type, assembled)); } - private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException { + private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException { if (raw.type == CastProtocol.MSG_WIRE_PACKET) { - return CastPacketFramer.unframe(raw.payload); + if (inboundSessionGate != null) { + CastProtoHeader hdr = CastPacketFramer.readHeader(raw.payload); + if (!inboundSessionGate.accept(hdr)) { + synchronized (lossStats) { + lossStats.wirePacketsDropped++; + } + return null; + } + } + try { + return CastPacketFramer.unframe(raw.payload); + } catch (IOException e) { + synchronized (lossStats) { + lossStats.malformedPackets++; + } + throw e; + } } return raw; } @@ -242,6 +334,7 @@ public class UdpCastTransport implements CastTransport { private static final class Reassembly { final int fragCount; final int totalLen; + final long startedMs; final byte[][] parts; final int[] sizes; int received; @@ -249,19 +342,21 @@ public class UdpCastTransport implements CastTransport { Reassembly(int fragCount, int totalLen) { this.fragCount = fragCount; this.totalLen = totalLen; + this.startedMs = System.currentTimeMillis(); this.parts = new byte[fragCount][]; this.sizes = new int[fragCount]; } - void put(int index, byte[] data, int len) { + boolean put(int index, byte[] data, int len) { if (index < 0 || index >= fragCount || parts[index] != null) { - return; + return false; } byte[] copy = new byte[len]; System.arraycopy(data, 0, copy, 0, len); parts[index] = copy; sizes[index] = len; received++; + return true; } boolean complete() { diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java index 3a6b9ee..f199bd2 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java @@ -7,7 +7,7 @@ public class NetworkControlAdvice { public boolean sendStats; public boolean sendControl; public int suggestedBitrateKbps; - public String suggestedTransport = CastConfig.TRANSPORT_TCP; + public String suggestedTransport = CastConfig.DEFAULT_TRANSPORT; public boolean switchTransport; public int congestionLevel; diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java index 74cdb70..465a8fd 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java @@ -27,6 +27,28 @@ public class NetworkStatsSnapshot { public float audioCapturePeakDb; public float audioCaptureRmsDb; + /** v4: UDP + app-layer loss (see {@link com.foxx.androidcast.network.transport.TransportLossStats}). */ + public long udpDatagramsRx; + public long udpDatagramsTx; + public long udpDatagramsInvalid; + public long udpFragmentsRx; + public long udpFragmentsDuplicate; + public long udpReassemblyOk; + public long udpReassemblyLost; + public long udpWireDropped; + public long udpMalformed; + public int droppedAudioFrames; + public long recvVideoPackets; + public long recvAudioPackets; + public long recvVideoSkippedKey; + public long recvVideoDecodeErrors; + public long recvAudioDecodeErrors; + public long recvAudioQueueDrops; + public long recvDamagedVideoFrames; + public String protectionMode = "NONE"; + public long fecStubPackets; + public long nackStubRequests; + public NetworkStatsSnapshot copy() { NetworkStatsSnapshot c = new NetworkStatsSnapshot(); c.sequence = sequence; @@ -49,6 +71,26 @@ public class NetworkStatsSnapshot { c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks; c.audioCapturePeakDb = audioCapturePeakDb; c.audioCaptureRmsDb = audioCaptureRmsDb; + c.udpDatagramsRx = udpDatagramsRx; + c.udpDatagramsTx = udpDatagramsTx; + c.udpDatagramsInvalid = udpDatagramsInvalid; + c.udpFragmentsRx = udpFragmentsRx; + c.udpFragmentsDuplicate = udpFragmentsDuplicate; + c.udpReassemblyOk = udpReassemblyOk; + c.udpReassemblyLost = udpReassemblyLost; + c.udpWireDropped = udpWireDropped; + c.udpMalformed = udpMalformed; + c.droppedAudioFrames = droppedAudioFrames; + c.recvVideoPackets = recvVideoPackets; + c.recvAudioPackets = recvAudioPackets; + c.recvVideoSkippedKey = recvVideoSkippedKey; + c.recvVideoDecodeErrors = recvVideoDecodeErrors; + c.recvAudioDecodeErrors = recvAudioDecodeErrors; + c.recvAudioQueueDrops = recvAudioQueueDrops; + c.recvDamagedVideoFrames = recvDamagedVideoFrames; + c.protectionMode = protectionMode; + c.fecStubPackets = fecStubPackets; + c.nackStubRequests = nackStubRequests; return c; } } diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java index 8a24de6..052fb67 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java @@ -33,7 +33,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS; outbound.sendControl = false; outbound.suggestedBitrateKbps = 0; - outbound.suggestedTransport = CastConfig.TRANSPORT_TCP; + outbound.suggestedTransport = CastConfig.DEFAULT_TRANSPORT; outbound.switchTransport = false; outbound.congestionLevel = 0; if (outbound.sendStats) { diff --git a/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java b/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java new file mode 100644 index 0000000..03014e0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java @@ -0,0 +1,50 @@ +package com.foxx.androidcast.network.quic; + +import android.content.Context; + +import com.google.android.gms.net.CronetProviderInstaller; +import com.google.android.gms.tasks.Tasks; + +import org.chromium.net.CronetEngine; +import org.chromium.net.ExperimentalCronetEngine; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Shared Cronet engine with QUIC / HTTP3 enabled for experimental cast transport. */ +public final class CronetEngineProvider { + private static final AtomicReference ENGINE = new AtomicReference<>(); + + private CronetEngineProvider() {} + + public static CronetEngine getEngine(Context context) throws Exception { + CronetEngine existing = ENGINE.get(); + if (existing != null) { + return existing; + } + synchronized (CronetEngineProvider.class) { + existing = ENGINE.get(); + if (existing != null) { + return existing; + } + Context app = context.getApplicationContext(); + Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS); + ExperimentalCronetEngine.Builder builder = + new ExperimentalCronetEngine.Builder(app) + .enableQuic(true) + .enableHttp2(true) + .setStoragePath(app.getCacheDir().getAbsolutePath()) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024); + CronetEngine built = builder.build(); + ENGINE.set(built); + return built; + } + } + + public static void shutdown() { + CronetEngine e = ENGINE.getAndSet(null); + if (e != null) { + e.shutdown(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java new file mode 100644 index 0000000..f706009 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java @@ -0,0 +1,138 @@ +package com.foxx.androidcast.network.quic; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.CastFramingContext; +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastTransport; +import com.foxx.androidcast.network.TcpCastTransport; +import com.foxx.androidcast.network.UdpCastTransport; + +import org.chromium.net.CronetEngine; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Experimental QUIC cast transport (Cronet stack). + *

+ * Cronet on Android is a QUIC/HTTP3 client only. This transport uses: + *

    + *
  • {@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})
  • + *
  • Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)
  • + *
  • TCP fallback on the same port if UDP bind fails
  • + *
+ * A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3 + * terminator is available on the receiver. + */ +public final class QuicCronetCastTransport implements CastTransport { + private static final String TAG = "QuicCronetTransport"; + + private final Context appContext; + private final UdpCastTransport udp = new UdpCastTransport(); + private final TcpCastTransport tcpFallback = new TcpCastTransport(); + private final AtomicBoolean usingTcp = new AtomicBoolean(false); + private CronetEngine engine; + + public QuicCronetCastTransport(Context context) { + this.appContext = context.getApplicationContext(); + } + + private CastTransport active() { + return usingTcp.get() ? tcpFallback : udp; + } + + @Override + public void setFramingContext(CastFramingContext context) { + udp.setFramingContext(context); + tcpFallback.setFramingContext(context); + } + + @Override + public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) { + udp.setInboundSessionGate(gate); + tcpFallback.setInboundSessionGate(gate); + } + + private void ensureCronet() { + if (engine != null) { + return; + } + try { + engine = CronetEngineProvider.getEngine(appContext); + Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)"); + } catch (Exception e) { + Log.w(TAG, "Cronet engine unavailable: " + e.getMessage()); + } + } + + @Override + public void prepareListen(int port) throws IOException { + ensureCronet(); + int quicPort = port > 0 ? port : CastConfig.QUIC_PORT; + try { + udp.prepareListen(quicPort); + usingTcp.set(false); + Log.i(TAG, "QUIC listen UDP :" + quicPort); + } catch (IOException e) { + Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e); + tcpFallback.prepareListen(quicPort); + usingTcp.set(true); + } + } + + @Override + public void acceptClient() throws IOException { + active().acceptClient(); + } + + @Override + public void listen(int port) throws IOException { + prepareListen(port); + acceptClient(); + } + + @Override + public void connect(String host, int port) throws IOException { + ensureCronet(); + int quicPort = port > 0 ? port : CastConfig.QUIC_PORT; + try { + udp.connect(host, quicPort); + usingTcp.set(false); + Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort); + } catch (IOException e) { + Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e); + tcpFallback.connect(host, quicPort); + usingTcp.set(true); + } + } + + @Override + public void send(byte type, byte[] payload) throws IOException { + active().send(type, payload); + } + + @Override + public CastProtocol.Message receive(int timeoutMs) throws IOException { + return active().receive(timeoutMs); + } + + @Override + public String getModeLabel() { + String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)"; + return engine != null ? base + "+Cronet" : base; + } + + @Override + public void releaseConnection() { + active().releaseConnection(); + } + + @Override + public void close() { + udp.close(); + tcpFallback.close(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java new file mode 100644 index 0000000..dffb504 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java @@ -0,0 +1,95 @@ +package com.foxx.androidcast.network.transport; + +/** + * Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only. + */ +public interface StreamProtectionEngine { + StreamProtectionMode mode(); + + /** Called before UDP payload is fragmented (outbound). */ + byte[] onOutboundPayload(byte type, byte[] payload); + + /** Called when a reassembled UDP message is ready (inbound). */ + byte[] onInboundPayload(byte type, byte[] payload); + + /** Future: process FEC repair block (stub). */ + default void onFecBlockStub(byte[] block) { + } + + /** Future: sender handles NACK for messageId (stub). */ + default void onNackStub(int messageId) { + } + + static StreamProtectionEngine noop() { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.NONE; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + }; + } + + /** Placeholder for upcoming FEC — counts invocations only. */ + static StreamProtectionEngine fecStub(TransportLossStats stats) { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.FEC_STUB; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + if (stats != null) { + synchronized (stats) { + stats.fecPacketsStub++; + } + } + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + }; + } + + /** Placeholder for upcoming NACK — no retransmit yet. */ + static StreamProtectionEngine nackStub(TransportLossStats stats) { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.NACK_STUB; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public void onNackStub(int messageId) { + if (stats != null) { + synchronized (stats) { + stats.nackRequestsStub++; + } + } + } + }; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java new file mode 100644 index 0000000..142561c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java @@ -0,0 +1,26 @@ +package com.foxx.androidcast.network.transport; + +/** Planned LAN cast reliability features (stubs until implemented). */ +public enum StreamProtectionMode { + NONE, + /** Forward-error correction over UDP (not implemented). */ + FEC_STUB, + /** Negative acknowledgement / retransmit requests (not implemented). */ + NACK_STUB; + + public boolean isStub() { + return this == FEC_STUB || this == NACK_STUB; + } + + public String overlayLabel() { + switch (this) { + case FEC_STUB: + return "FEC (stub)"; + case NACK_STUB: + return "NACK (stub)"; + case NONE: + default: + return "none"; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java new file mode 100644 index 0000000..ddfa402 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java @@ -0,0 +1,97 @@ +package com.foxx.androidcast.network.transport; + +/** + * Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops). + * Thread-safe increments; copy for stats exchange. + */ +public final class TransportLossStats { + public long datagramsSent; + public long datagramsReceived; + public long datagramsInvalid; + public long fragmentsReceived; + public long fragmentsDuplicate; + public long reassemblyCompleted; + public long reassemblyIncomplete; + public long reassemblySuperseded; + public long wirePacketsDropped; + public long malformedPackets; + + public int sendDroppedVideoP; + public int sendDroppedAudio; + public int sendQueueDepth; + + public long recvVideoPackets; + public long recvAudioPackets; + public long recvVideoSkippedAwaitingKey; + public long recvVideoDecodeErrors; + public long recvAudioDecodeErrors; + public long recvAudioQueueDrops; + public long recvDamagedVideoFrames; + + public StreamProtectionMode protectionMode = StreamProtectionMode.NONE; + public long fecPacketsStub; + public long nackRequestsStub; + public long nackRetransmitsStub; + + public synchronized void addFrom(TransportLossStats other) { + if (other == null) { + return; + } + datagramsSent += other.datagramsSent; + datagramsReceived += other.datagramsReceived; + datagramsInvalid += other.datagramsInvalid; + fragmentsReceived += other.fragmentsReceived; + fragmentsDuplicate += other.fragmentsDuplicate; + reassemblyCompleted += other.reassemblyCompleted; + reassemblyIncomplete += other.reassemblyIncomplete; + reassemblySuperseded += other.reassemblySuperseded; + wirePacketsDropped += other.wirePacketsDropped; + malformedPackets += other.malformedPackets; + sendDroppedVideoP += other.sendDroppedVideoP; + sendDroppedAudio += other.sendDroppedAudio; + recvVideoPackets += other.recvVideoPackets; + recvAudioPackets += other.recvAudioPackets; + recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey; + recvVideoDecodeErrors += other.recvVideoDecodeErrors; + recvAudioDecodeErrors += other.recvAudioDecodeErrors; + recvAudioQueueDrops += other.recvAudioQueueDrops; + recvDamagedVideoFrames += other.recvDamagedVideoFrames; + fecPacketsStub += other.fecPacketsStub; + nackRequestsStub += other.nackRequestsStub; + nackRetransmitsStub += other.nackRetransmitsStub; + } + + public synchronized TransportLossStats copy() { + TransportLossStats c = new TransportLossStats(); + c.datagramsSent = datagramsSent; + c.datagramsReceived = datagramsReceived; + c.datagramsInvalid = datagramsInvalid; + c.fragmentsReceived = fragmentsReceived; + c.fragmentsDuplicate = fragmentsDuplicate; + c.reassemblyCompleted = reassemblyCompleted; + c.reassemblyIncomplete = reassemblyIncomplete; + c.reassemblySuperseded = reassemblySuperseded; + c.wirePacketsDropped = wirePacketsDropped; + c.malformedPackets = malformedPackets; + c.sendDroppedVideoP = sendDroppedVideoP; + c.sendDroppedAudio = sendDroppedAudio; + c.sendQueueDepth = sendQueueDepth; + c.recvVideoPackets = recvVideoPackets; + c.recvAudioPackets = recvAudioPackets; + c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey; + c.recvVideoDecodeErrors = recvVideoDecodeErrors; + c.recvAudioDecodeErrors = recvAudioDecodeErrors; + c.recvAudioQueueDrops = recvAudioQueueDrops; + c.recvDamagedVideoFrames = recvDamagedVideoFrames; + c.protectionMode = protectionMode; + c.fecPacketsStub = fecPacketsStub; + c.nackRequestsStub = nackRequestsStub; + c.nackRetransmitsStub = nackRetransmitsStub; + return c; + } + + /** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */ + public synchronized long estimatedUdpLossEvents() { + return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java new file mode 100644 index 0000000..ee90f8b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.network.transport; + +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + +/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */ +public final class TransportLossStatsBridge { + private TransportLossStatsBridge() {} + + public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) { + if (s == null || loss == null) { + return; + } + synchronized (loss) { + s.udpDatagramsRx = loss.datagramsReceived; + s.udpDatagramsTx = loss.datagramsSent; + s.udpDatagramsInvalid = loss.datagramsInvalid; + s.udpFragmentsRx = loss.fragmentsReceived; + s.udpFragmentsDuplicate = loss.fragmentsDuplicate; + s.udpReassemblyOk = loss.reassemblyCompleted; + s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded; + s.udpWireDropped = loss.wirePacketsDropped; + s.udpMalformed = loss.malformedPackets; + s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP); + s.droppedAudioFrames = loss.sendDroppedAudio; + s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth); + s.recvVideoPackets = loss.recvVideoPackets; + s.recvAudioPackets = loss.recvAudioPackets; + s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey; + s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors; + s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors; + s.recvAudioQueueDrops = loss.recvAudioQueueDrops; + s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames; + s.protectionMode = loss.protectionMode.name(); + s.fecStubPackets = loss.fecPacketsStub; + s.nackStubRequests = loss.nackRequestsStub; + long lossEvents = loss.estimatedUdpLossEvents(); + if (loss.datagramsReceived > 0 && lossEvents > 0) { + s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived); + } + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java new file mode 100644 index 0000000..002e0d9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java @@ -0,0 +1,6 @@ +package com.foxx.androidcast.network.transport; + +/** Transports that expose {@link TransportLossStats}. */ +public interface TransportStatsProvider { + TransportLossStats getLossStats(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java new file mode 100644 index 0000000..79cf591 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java @@ -0,0 +1,62 @@ +package com.foxx.androidcast.network.webrtc; + +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastTransport; + +import java.io.IOException; + +/** + * Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}. + */ +public interface WebRtcCastTransport extends CastTransport { + + /** Signaling / ICE configuration for upcoming integration. */ + interface SignalingConfig { + String getSignalingUrl(); + + String[] getIceServers(); + } + + /** Attach when integrating real PeerConnection / DataChannel. */ + void attachPeer(WebRtcPeerAdapter peer); + + /** + * Placeholder until libwebrtc or pion transport is connected. + */ + static WebRtcCastTransport createStub() { + return new WebRtcCastTransportStub(); + } + + final class WebRtcCastTransportStub implements WebRtcCastTransport { + @Override + public void attachPeer(WebRtcPeerAdapter peer) {} + + @Override + public void listen(int port) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public void connect(String host, int port) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public void send(byte type, byte[] payload) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public CastProtocol.Message receive(int timeoutMs) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public String getModeLabel() { + return "WebRTC (stub)"; + } + + @Override + public void close() {} + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java new file mode 100644 index 0000000..fdacb5e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java @@ -0,0 +1,37 @@ +package com.foxx.androidcast.network.webrtc; + +/** + * Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs. + * Implementations will map cast frames to SCTP DataChannel or encoded tracks. + */ +public interface WebRtcPeerAdapter { + + interface Observer { + void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate); + + void onConnectionStateChanged(WebRtcConnectionState state); + + void onDataChannelMessage(byte[] payload); + + void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data); + } + + enum WebRtcConnectionState { + NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED + } + + void setObserver(Observer observer); + + void createOffer(); + + void createAnswer(String remoteDescription); + + void setRemoteDescription(String sdp, boolean isOffer); + + void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate); + + /** Send opaque cast protocol bytes on a labeled DataChannel. */ + void sendDataChannel(byte[] payload); + + void close(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java new file mode 100644 index 0000000..8aec38f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java @@ -0,0 +1,19 @@ +package com.foxx.androidcast.network.webrtc; + +import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.CastTransport; + +/** + * Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion). + */ +public final class WebRtcTransportFactory { + private WebRtcTransportFactory() {} + + public static boolean isTransportId(String transport) { + return CastConfig.TRANSPORT_WEBRTC.equals(transport); + } + + public static CastTransport create() { + return WebRtcCastTransport.createStub(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java index 9abe17d..79fb2f5 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -7,6 +7,7 @@ import android.media.AudioManager; import android.media.AudioTrack; import android.media.MediaCodec; import android.media.MediaFormat; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.util.Log; @@ -36,6 +37,7 @@ public class AudioDecoder { public long pcmSilentBlocks; public long pcmAudibleBlocks; public long decodeErrors; + public long queueDrops; public float lastPeak; public float lastRms; } @@ -43,7 +45,8 @@ public class AudioDecoder { private final Context appContext; private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl"); private Handler worker; - private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(128); + private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(256); + private long playbackEpochUs = -1; private final Stats stats = new Stats(); private MediaCodec codec; @@ -130,6 +133,7 @@ public class AudioDecoder { private void startDrainThread() { drainThread = new Thread(this::drainLoop, "AudioDecoderDrain"); + drainThread.setPriority(Thread.MAX_PRIORITY - 2); drainThread.start(); } @@ -138,7 +142,7 @@ public class AudioDecoder { ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); - int bufSize = Math.max(minBuf * 8, 65536); + int bufSize = Math.max(minBuf * 16, 131072); track = new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) @@ -164,8 +168,11 @@ public class AudioDecoder { } stats.framesQueued++; if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { + stats.queueDrops++; inputQueue.poll(); - inputQueue.offer(new PendingFrame(ptsUs, data.clone())); + if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { + stats.queueDrops++; + } } } @@ -241,7 +248,8 @@ public class AudioDecoder { } else { stats.pcmAudibleBlocks++; } - int written = writePcm(pcm); + pacePlayback(info.presentationTimeUs); + int written = writePcmBlocking(pcm); if (written > 0) { stats.pcmBytesWritten += written; if (renderCallback != null) { @@ -253,16 +261,42 @@ public class AudioDecoder { } } - private int writePcm(byte[] pcm) { + private void pacePlayback(long ptsUs) { + long nowUs = System.nanoTime() / 1000L; + if (playbackEpochUs < 0) { + playbackEpochUs = nowUs - ptsUs; + return; + } + long dueUs = playbackEpochUs + ptsUs; + long aheadUs = dueUs - nowUs; + if (aheadUs > 120_000L) { + try { + Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private int writePcmBlocking(byte[] pcm) { int total = 0; int offset = 0; - while (offset < pcm.length) { + int idle = 0; + while (offset < pcm.length && idle < 80) { int w = track.write(pcm, offset, pcm.length - offset); - if (w <= 0) { + if (w > 0) { + total += w; + offset += w; + idle = 0; + continue; + } + idle++; + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); break; } - total += w; - offset += w; } return total; } @@ -275,7 +309,11 @@ public class AudioDecoder { } } - public void release() { + /** Stops playback immediately and drops queued AAC (sender ended cast). */ + public void flushAndStop() { + draining = false; + configured = false; + inputQueue.clear(); final Object lock = new Object(); worker.post(() -> { releaseOnWorker(); @@ -285,21 +323,26 @@ public class AudioDecoder { }); synchronized (lock) { try { - lock.wait(2000); + lock.wait(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } + public void release() { + flushAndStop(); + } + private void releaseOnWorker() { draining = false; configured = false; + playbackEpochUs = -1; inputQueue.clear(); if (drainThread != null) { drainThread.interrupt(); try { - drainThread.join(1500); + drainThread.join(400); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } @@ -315,12 +358,26 @@ public class AudioDecoder { } if (track != null) { try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + track.pause(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + track.flush(); + } track.stop(); } catch (Exception ignored) { } track.release(); track = null; } + abandonAudioFocus(); + } + + private void abandonAudioFocus() { + AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + if (am != null) { + am.abandonAudioFocus(null); + } } public void releaseAndQuit() { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 6b93eea..5fd1ac7 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -22,7 +22,11 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; import com.foxx.androidcast.discovery.DiscoveryManager; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportLossStatsBridge; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.stats.SessionStatsRecorder; +import com.foxx.androidcast.stats.SessionStatsStore; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import java.util.Arrays; @@ -80,7 +84,10 @@ public class ReceiverCastService extends Service { private CastSettings remoteCastSettings; private NetworkStatsSnapshot lastLocalStats; private boolean streamIdle; + private volatile boolean castEnded; private long lastVideoFrameMs; + private final TransportLossStats transportLoss = new TransportLossStats(); + private SessionStatsRecorder sessionStatsRecorder; private final Runnable idleWatchdog = new Runnable() { @Override @@ -136,6 +143,7 @@ public class ReceiverCastService extends Service { public void onCreate() { super.onCreate(); CastNotifications.createChannels(this); + SessionStatsStore.pruneOnAppStart(this); mainHandler.post(idleWatchdog); } @@ -175,7 +183,11 @@ public class ReceiverCastService extends Service { private void restartListeningLocked(String pin) { stopSessionOnly(); + castEnded = false; resetStreamState(); + pendingWidth = 0; + pendingHeight = 0; + playbackLaunched = false; phase = Phase.LISTENING; acquireWakeLock(); startForeground(CastNotifications.ID_RECEIVER, @@ -220,10 +232,10 @@ public class ReceiverCastService extends Service { @Override public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) { - if (reconfiguring) { - return; - } if (awaitingKeyframe && !keyFrame) { + synchronized (transportLoss) { + transportLoss.recvVideoSkippedAwaitingKey++; + } return; } if (keyFrame) { @@ -237,7 +249,10 @@ public class ReceiverCastService extends Service { onStreamResumedAfterIdle(); } streamMetrics.onVideoPacket(keyFrame, frameBytes); - if (videoDecoder != null && !reconfiguring) { + synchronized (transportLoss) { + transportLoss.recvVideoPackets++; + } + if (videoDecoder != null) { videoDecoder.queueFrame(ptsUs, keyFrame, data); } }); @@ -271,6 +286,9 @@ public class ReceiverCastService extends Service { @Override public void onAudioFrame(long ptsUs, byte[] data) { + if (castEnded) { + return; + } final int frameBytes = data != null ? data.length : 0; final boolean accept; synchronized (ReceiverCastService.this) { @@ -279,7 +297,17 @@ public class ReceiverCastService extends Service { if (accept && audioDecoder != null) { audioDecoder.queueFrame(ptsUs, data); } - mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes)); + mainHandler.post(() -> { + streamMetrics.onAudioPacket(frameBytes); + synchronized (transportLoss) { + transportLoss.recvAudioPackets++; + } + }); + } + + @Override + public void onCastEnded() { + mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate); } @Override @@ -304,6 +332,13 @@ public class ReceiverCastService extends Service { private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) { phase = Phase.CONNECTED; + castEnded = false; + if (sessionStatsRecorder == null) { + sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport()); + sessionStatsRecorder.setClientCount(1); + } + sessionStatsRecorder.mergeSettings(remoteSettings); + sessionStatsRecorder.setCodecs(videoMime, "AAC"); negotiatedVideoMime = videoMime; remoteCastSettings = remoteSettings; streamIdle = false; @@ -332,25 +367,32 @@ public class ReceiverCastService extends Service { }, "AudioConsent").start(); } + private void stopAudioPlaybackImmediate() { + castEnded = true; + if (audioDecoder != null) { + audioDecoder.flushAndStop(); + audioDecoder = new AudioDecoder(this); + audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered( + pcmBytes, levels.peak, levels.rms, levels.silent)); + } + Log.i(TAG, "Audio playback stopped"); + } + private void onSenderDisconnected() { + stopAudioPlaybackImmediate(); phase = Phase.LISTENING; audioAccepted = null; remoteCastSettings = null; streamIdle = false; lastVideoFrameMs = 0; negotiatedVideoMime = null; - enterAwaitingMode(R.string.playback_stream_ended, true); - updateStatus(getString(R.string.waiting_sender)); - closePlaybackUi(); - openReceiverSetupUi(); - Log.i(TAG, "Sender disconnected"); - } - - private void openReceiverSetupUi() { - Intent intent = new Intent(this, ReceiverActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP - | Intent.FLAG_ACTIVITY_SINGLE_TOP); - startActivity(intent); + pendingWidth = 0; + pendingHeight = 0; + hasPendingConfig = false; + resetDecoderState(); + enterAwaitingMode(R.string.playback_listening, true); + updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase())); + Log.i(TAG, "Sender disconnected — listening again"); } private void checkStreamIdle() { @@ -360,6 +402,9 @@ public class ReceiverCastService extends Service { long idleMs = System.currentTimeMillis() - lastVideoFrameMs; if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) { Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting"); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.noteIdleTimeout(); + } enterAwaitingMode(R.string.playback_awaiting_stream, false); } } @@ -459,6 +504,9 @@ public class ReceiverCastService extends Service { decoderActive = false; notifyPlaybackDimensions(pendingWidth, pendingHeight); scheduleDecoderConfigureRetries(); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.setResolution(pendingWidth, pendingHeight); + } Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight); } @@ -504,9 +552,11 @@ public class ReceiverCastService extends Service { return; } discovery = new DiscoveryManager(this); - discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, + com.foxx.androidcast.network.CastTransportFactory.init(this); + int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); + discovery.startAnnouncing(android.os.Build.MODEL, announcePort, settings.getTransport(), true); - Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT); + Log.i(TAG, "Discovery announcing on port " + announcePort); } private void stopSessionOnly() { @@ -545,6 +595,7 @@ public class ReceiverCastService extends Service { if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) { Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig + " surface=" + (attachedSurface != null && attachedSurface.isValid())); + reconfiguring = false; return; } if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) { @@ -569,16 +620,24 @@ public class ReceiverCastService extends Service { public void onFrameRendered() { ReceiverCastService.this.onFrameRendered(); } + + @Override + public void onOutputSizeChanged(int width, int height) { + ReceiverCastService.this.onDecoderOutputSize(width, height); + } }); decoderActive = true; reconfiguring = false; decoderWidth = pendingWidth; decoderHeight = pendingHeight; - updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); + updateStatus(getString(R.string.playback_awaiting_stream)); broadcastStreamStarted(pendingWidth, pendingHeight); Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); } catch (Exception e) { decoderActive = false; + synchronized (transportLoss) { + transportLoss.recvVideoDecodeErrors++; + } Log.e(TAG, "Video configure failed", e); updateStatus("Video: " + e.getMessage()); } @@ -589,6 +648,9 @@ public class ReceiverCastService extends Service { streamMetrics.onRenderedFrame(); Log.i(TAG, "First frame on screen"); streamIdle = false; + if (pendingWidth > 0 && pendingHeight > 0) { + updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); + } broadcastVideoRendering(); }); } @@ -597,8 +659,27 @@ public class ReceiverCastService extends Service { mainHandler.post(() -> streamMetrics.onRenderedFrame()); } + private void onDecoderOutputSize(int width, int height) { + mainHandler.post(() -> { + if (width <= 0 || height <= 0) { + return; + } + pendingWidth = width; + pendingHeight = height; + decoderWidth = width; + decoderHeight = height; + notifyPlaybackDimensions(width, height); + Log.i(TAG, "Decoder output size " + width + "x" + height); + }); + } + private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) { s.encodeFps = streamMetrics.getDecodeFps(); + TransportLossStats snap = buildReceiverLossSnapshot(); + TransportLossStatsBridge.applyTo(s, snap); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer()); + } NetworkStatsSnapshot peer = streamMetrics.getLastPeer(); CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings; com.foxx.androidcast.CastTuningEngine.EffectiveParams eff = @@ -634,6 +715,34 @@ public class ReceiverCastService extends Service { PlaybackViewState.getZoomPercent()); } + private TransportLossStats buildReceiverLossSnapshot() { + TransportLossStats snap = session != null + ? session.getTransportLossStats() : new TransportLossStats(); + synchronized (transportLoss) { + snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey; + snap.recvVideoPackets = transportLoss.recvVideoPackets; + snap.recvAudioPackets = transportLoss.recvAudioPackets; + snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors; + } + if (audioDecoder != null) { + AudioDecoder.Stats ast = audioDecoder.getStats(); + snap.recvAudioDecodeErrors = ast.decodeErrors; + snap.recvAudioQueueDrops = ast.queueDrops; + } + return snap; + } + + private void finishSessionStatsAsync() { + final SessionStatsRecorder recorder = sessionStatsRecorder; + sessionStatsRecorder = null; + if (recorder == null) { + return; + } + recorder.setResolution(pendingWidth, pendingHeight); + recorder.mergeLoss(buildReceiverLossSnapshot()); + new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start(); + } + private void attachSurface(Surface surface) { if (surface == null || !surface.isValid()) { Log.w(TAG, "attachSurface: invalid surface"); @@ -732,6 +841,7 @@ public class ReceiverCastService extends Service { } private void releaseAll() { + finishSessionStatsAsync(); phase = Phase.IDLE; resetStreamState(); playbackLaunched = false; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index ffbc216..ab92e8b 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -9,8 +9,9 @@ import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.RemoteException; -import android.view.SurfaceHolder; -import android.view.SurfaceView; +import android.graphics.SurfaceTexture; +import android.view.Surface; +import android.view.TextureView; import android.view.View; import android.view.WindowManager; import android.animation.AnimatorSet; @@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.R; import com.foxx.androidcast.ui.AspectRatioFrameLayout; +import com.foxx.androidcast.ui.PlaybackTextureTransform; import com.foxx.androidcast.ui.ZoomPanContainer; import androidx.core.text.HtmlCompat; @@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat; * Fullscreen display: dancing-robot overlay while listening / waiting; * video surface shown only after the first decoded frame. */ -public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback { +public class ReceiverPlaybackActivity extends AppCompatActivity { public static final String EXTRA_WIDTH = "video_width"; public static final String EXTRA_HEIGHT = "video_height"; public static final String EXTRA_FINISH = "finish_playback"; @@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message"; private AspectRatioFrameLayout aspectContainer; - private SurfaceView surfaceView; + private TextureView textureView; private View awaitingOverlay; private TextView diagnosticsText; private ImageView robotView; private TextView awaitingMessage; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private SurfaceHolder surfaceHolder; + private SurfaceTexture surfaceTexture; private int videoWidth; private int videoHeight; private boolean streamRendering; @@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa runOnUiThread(() -> { streamRendering = true; hideAwaitingOverlay(); + ZoomPanContainer zoom = findViewById(R.id.zoom_container); + if (zoom != null) { + zoom.resetTransform(); + } + applyTextureFitCenter(); updateDiagnosticsVisibility(); }); } @@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container); aspectContainer = findViewById(R.id.aspect_container); - surfaceView = findViewById(R.id.surface_playback); + textureView = findViewById(R.id.surface_playback); zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent); awaitingOverlay = findViewById(R.id.overlay_awaiting); diagnosticsText = findViewById(R.id.text_diagnostics); robotView = findViewById(R.id.image_robot); awaitingMessage = findViewById(R.id.text_awaiting_message); - surfaceHolder = surfaceView.getHolder(); - surfaceHolder.addCallback(this); - surfaceView.setZOrderMediaOverlay(false); + textureView.setSurfaceTextureListener(surfaceListener); applyAspectRatio(); if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { @@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa videoWidth = width; videoHeight = height; applyAspectRatio(); - if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) { - surfaceHolder.setFixedSize(videoWidth, videoHeight); + applyTextureFitCenter(); + if (surfaceTexture != null) { + surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight); attachSurfaceIfReady(); } } + private void applyTextureFitCenter() { + if (textureView == null || videoWidth <= 0 || videoHeight <= 0) { + return; + } + if (textureView.getWidth() == 0 || textureView.getHeight() == 0) { + textureView.post(this::applyTextureFitCenter); + return; + } + PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight); + } + private void applyFullscreen() { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().getDecorView().setSystemUiVisibility( @@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa } private void applyAspectRatio() { - if (videoWidth > 0 && videoHeight > 0) { - aspectContainer.setAspectRatio(videoWidth, videoHeight); - } + // Full-screen TextureView; letterboxing via transform only (avoids double-scaling). + aspectContainer.setAspectRatio(0, 0); } - @Override - public void surfaceCreated(SurfaceHolder holder) { - if (videoWidth > 0 && videoHeight > 0) { - holder.setFixedSize(videoWidth, videoHeight); - } - surfaceSentToService = false; - attachSurfaceIfReady(); - } + private final TextureView.SurfaceTextureListener surfaceListener = + new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) { + surfaceTexture = texture; + if (videoWidth > 0 && videoHeight > 0) { + texture.setDefaultBufferSize(videoWidth, videoHeight); + } + surfaceSentToService = false; + attachSurfaceIfReady(); + } - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - if (videoWidth > 0 && videoHeight > 0) { - holder.setFixedSize(videoWidth, videoHeight); - } - } + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) { + if (videoWidth > 0 && videoHeight > 0) { + texture.setDefaultBufferSize(videoWidth, videoHeight); + } + applyTextureFitCenter(); + } - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - surfaceSentToService = false; - detachSurface(); - } + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) { + surfaceSentToService = false; + detachSurface(); + surfaceTexture = null; + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture texture) { + } + }; private void attachSurfaceIfReady() { - if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) { - return; - } - if (!surfaceHolder.getSurface().isValid()) { + if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) { return; } try { - receiverService.attachVideoSurface(surfaceHolder.getSurface()); + receiverService.attachVideoSurface(new Surface(surfaceTexture)); surfaceSentToService = true; } catch (RemoteException ignored) { } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 837a313..40acb61 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags; import com.foxx.androidcast.network.CastFramingContext; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; +import com.foxx.androidcast.network.CastSessionGate; import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransportFactory; +import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; @@ -41,6 +43,9 @@ public class ReceiverSession { void onDisconnected(); + /** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */ + void onCastEnded(); + /** TCP/UDP listen socket is bound; safe to advertise on the LAN. */ void onListening(); @@ -58,6 +63,7 @@ public class ReceiverSession { private CastSession session; private CastTransport listenTransport; + private final CastSessionGate inboundSessionGate = new CastSessionGate(); private NetworkFeedbackManager networkFeedback; private PassThroughNetworkControlPlane.StatsEnricher statsEnricher; @@ -94,6 +100,14 @@ public class ReceiverSession { listenTransport.close(); listenTransport = null; } + inboundSessionGate.reset(); + } + + public TransportLossStats getTransportLossStats() { + if (listenTransport != null) { + return listenTransport.getLossStats(); + } + return new TransportLossStats(); } public void sendAudioConsent(boolean accept) { @@ -111,17 +125,21 @@ public class ReceiverSession { while (running.get()) { try { if (listenTransport == null) { + com.foxx.androidcast.network.CastTransportFactory.init(appContext); listenTransport = CastTransportFactory.create(localSettings); + inboundSessionGate.reset(); + listenTransport.setInboundSessionGate(inboundSessionGate); listenTransport.setFramingContext(new CastFramingContext( 0, DeviceInfo.getPlaceholderId(appContext), CastCodecFlags.localDecoderVideoMask(), CastCodecFlags.localAudioMask())); session = new CastSession(listenTransport); - session.prepareListen(CastConfig.CAST_PORT); + int listenPort = CastConfig.portForTransport(localSettings.getTransport()); + session.prepareListen(listenPort); listener.onListening(); postStatus("Listening (" + listenTransport.getModeLabel() + ") port " - + CastConfig.CAST_PORT); + + listenPort); } else if (session == null) { session = new CastSession(listenTransport); } @@ -158,7 +176,13 @@ public class ReceiverSession { } finally { if (session != null) { session.releaseConnection(); + session = null; } + if (listenTransport != null) { + listenTransport.close(); + listenTransport = null; + } + inboundSessionGate.reset(); networkFeedback = null; listener.onDisconnected(); } @@ -214,6 +238,7 @@ public class ReceiverSession { break; case CastProtocol.MSG_GOODBYE: postStatus("Sender stopped casting"); + listener.onCastEnded(); return; default: break; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index 857f825..dd11685 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -22,6 +22,9 @@ public class VideoDecoder { /** Called for each frame after the first. */ default void onFrameRendered() {} + + /** Decoder output size (may differ from stream config crop). */ + default void onOutputSizeChanged(int width, int height) {} } private final Object lock = new Object(); @@ -66,7 +69,7 @@ public class VideoDecoder { if (released || data == null || data.length == 0) { return; } - byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data; + byte[] annexB = needsAnnexB(mime) ? H264Bitstream.toAnnexB(data) : data; if (!configured || codec == null) { pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); return; @@ -143,7 +146,9 @@ public class VideoDecoder { break; } if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - Log.i(TAG, "Output format: " + c.getOutputFormat()); + MediaFormat fmt = c.getOutputFormat(); + Log.i(TAG, "Output format: " + fmt); + notifyOutputSizeLocked(fmt); continue; } if (outIndex < 0) { @@ -161,6 +166,23 @@ public class VideoDecoder { } } + private void notifyOutputSizeLocked(MediaFormat format) { + if (listener == null || format == null) { + return; + } + int w = format.getInteger(MediaFormat.KEY_WIDTH); + int h = format.getInteger(MediaFormat.KEY_HEIGHT); + if (format.containsKey("crop-right") && format.containsKey("crop-left")) { + w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left"); + } + if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) { + h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top"); + } + if (w > 0 && h > 0) { + listener.onOutputSizeChanged(w, h); + } + } + private void notifyFirstFrameLocked() { if (listener == null) { return; @@ -175,6 +197,10 @@ public class VideoDecoder { } } + private static boolean needsAnnexB(String mime) { + return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime); + } + private static final class PendingFrame { final long ptsUs; final boolean keyFrame; diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java index 6c07075..9cabc34 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java @@ -50,6 +50,10 @@ public class AudioCapture { Log.w(TAG, "Audio capture requires API 29+"); return; } + if (projection == null) { + startMicOnly(callback); + return; + } if (!startPlayback(projection)) { Log.e(TAG, "Playback capture unavailable"); return; @@ -72,6 +76,59 @@ public class AudioCapture { thread.start(); } + private void startMicOnly(Callback callback) { + micRecord = canUseMic() ? buildMicRecord() : null; + if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) { + micRecord = null; + Log.w(TAG, "Mic-only capture unavailable"); + return; + } + running = true; + micRecord.startRecording(); + captureMode = "mic"; + if (callback != null) { + callback.onCaptureModeChanged(captureMode); + } + Log.i(TAG, "Mic-only capture for calibration VU"); + thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic"); + thread.start(); + } + + private void micOnlyLoop(Callback callback) { + int frameBytes = pcmFrameBytes(); + byte[] micBuf = new byte[frameBytes]; + byte[] copy = new byte[frameBytes]; + long frameIndex = 0; + while (running) { + if (!readFully(micRecord, micBuf, frameBytes)) { + continue; + } + System.arraycopy(micBuf, 0, copy, 0, frameBytes); + long ptsUs = frameIndex * 20_000L; + frameIndex++; + if (callback != null) { + callback.onPcm(copy, frameBytes, ptsUs, + com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes)); + } + } + } + + private static int pcmFrameBytes() { + return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50; + } + + private static boolean readFully(AudioRecord record, byte[] buf, int length) { + int off = 0; + while (off < length && record != null) { + int n = record.read(buf, off, length - off); + if (n <= 0) { + return false; + } + off += n; + } + return true; + } + private boolean startPlayback(MediaProjection projection) { try { playbackRecord = buildPlaybackRecord(projection); @@ -138,40 +195,32 @@ public class AudioCapture { } private void mixLoop(Callback callback) { - byte[] playBuf = new byte[4096]; - byte[] micBuf = new byte[4096]; - long startNs = System.nanoTime(); + int frameBytes = pcmFrameBytes(); + byte[] playBuf = new byte[frameBytes]; + byte[] micBuf = new byte[frameBytes]; + byte[] mixOut = new byte[frameBytes]; + long frameIndex = 0; while (running) { - int pRead = playbackRecord.read(playBuf, 0, playBuf.length); - int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0; - if (pRead < 0 && mRead < 0) { - Log.w(TAG, "Audio read error"); + if (!readFully(playbackRecord, playBuf, frameBytes)) { continue; } - byte[] out; - int outLen; - if (pRead > 0) { - out = playBuf; - outLen = pRead; - String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet"; - if (!mode.equals(captureMode)) { - captureMode = mode; - notifyMode(callback, captureMode); + int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0; + System.arraycopy(playBuf, 0, mixOut, 0, frameBytes); + if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) { + for (int i = 0; i < frameBytes; i++) { + mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff); } - } else if (mRead > 0 && micRecord != null) { - out = micBuf; - outLen = mRead; - if (!"mic".equals(captureMode)) { - captureMode = "mic"; - notifyMode(callback, captureMode); - } - } else { - continue; } - long ptsUs = (System.nanoTime() - startNs) / 1000; + String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet"; + if (!mode.equals(captureMode)) { + captureMode = mode; + notifyMode(callback, captureMode); + } + long ptsUs = frameIndex * 20_000L; + frameIndex++; if (callback != null) { - PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen); - callback.onPcm(out, outLen, ptsUs, levels); + PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes); + callback.onPcm(mixOut, frameBytes, ptsUs, levels); } } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java index dc13ed9..017a356 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java @@ -85,6 +85,8 @@ public class AudioEncoder { public void stop() { running = false; + MediaCodec c = codec; + codec = null; if (drainThread != null) { try { drainThread.join(1000); @@ -92,13 +94,15 @@ public class AudioEncoder { Thread.currentThread().interrupt(); } } - if (codec != null) { + if (c != null) { try { - codec.stop(); + c.stop(); + } catch (Exception ignored) { + } + try { + c.release(); } catch (Exception ignored) { } - codec.release(); - codec = null; } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java index 8c4ee0d..a7da65c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java @@ -11,25 +11,32 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */ +/** Background send queue: audio has its own lane so video backlog cannot starve it. */ public final class CastSendPump { private static final String TAG = "CastSendPump"; - private static final int MAX_QUEUE = 48; + private static final int MAX_VIDEO_QUEUE = 40; + private static final int MAX_AUDIO_QUEUE = 96; - private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE); + private final LinkedBlockingQueue videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE); + private final LinkedBlockingQueue audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE); private final AtomicBoolean running = new AtomicBoolean(false); private Thread thread; private CastSession session; private Runnable onSendFailed; private PassThroughNetworkControlPlane statsPlane; private volatile int droppedPFrames; + private volatile int droppedAudioFrames; public int getDroppedPFrames() { return droppedPFrames; } + public int getDroppedAudioFrames() { + return droppedAudioFrames; + } + public int getQueueDepth() { - return queue.size(); + return videoQueue.size() + audioQueue.size(); } public void start(CastSession session, Runnable onSendFailed) { @@ -44,21 +51,23 @@ public final class CastSendPump { return; } thread = new Thread(this::run, "CastSendPump"); + thread.setPriority(Thread.NORM_PRIORITY + 1); thread.start(); } public void stop() { running.set(false); + videoQueue.clear(); + audioQueue.clear(); if (thread != null) { thread.interrupt(); try { - thread.join(1500); + thread.join(500); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } thread = null; } - queue.clear(); session = null; } @@ -67,17 +76,12 @@ public final class CastSendPump { return; } Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload); - if (!queue.offer(item)) { + if (!videoQueue.offer(item)) { if (!keyFrame) { - Outgoing dropped = queue.poll(); - if (dropped != null && !dropped.keyFrame) { - droppedPFrames++; - } + dropOneVideoPFrame(); } - if (!queue.offer(item)) { - if (!keyFrame) { - droppedPFrames++; - } + if (!videoQueue.offer(item) && !keyFrame) { + droppedPFrames++; } } } @@ -87,15 +91,43 @@ public final class CastSendPump { return; } Outgoing item = Outgoing.raw(type, payload); - while (running.get() && !queue.offer(item)) { - queue.poll(); + boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG; + if (audio) { + if (!audioQueue.offer(item)) { + Outgoing dropped = audioQueue.poll(); + if (dropped != null) { + droppedAudioFrames++; + } + audioQueue.offer(item); + } + return; + } + while (running.get() && !videoQueue.offer(item)) { + dropOneVideoPFrame(); + } + } + + private void dropOneVideoPFrame() { + for (Outgoing o : videoQueue) { + if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) { + videoQueue.remove(o); + droppedPFrames++; + return; + } + } + Outgoing head = videoQueue.poll(); + if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) { + droppedPFrames++; } } private void run() { while (running.get()) { try { - Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS); + Outgoing item = audioQueue.poll(); + if (item == null) { + item = videoQueue.poll(50, TimeUnit.MILLISECONDS); + } if (item == null || session == null) { continue; } diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java index b28b997..1384a33 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java @@ -149,18 +149,27 @@ public final class MultiCastCoordinator { return any; } - public void closeAll() { + public void sendGoodbyeAll() { for (ActivePeer p : peers) { try { p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]); } catch (IOException ignored) { } - p.session.close(); + } + } + + public void closeAll() { + for (ActivePeer p : peers) { + try { + p.session.close(); + } catch (Exception ignored) { + } } peers.clear(); } private CastSession createSession(CastSettings settings) throws IOException { + CastTransportFactory.init(appContext); CastSession session = new CastSession(CastTransportFactory.create(settings)); session.getTransport().setFramingContext(framingContext); return session; diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 8275113..c7d849c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -36,6 +36,10 @@ import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportLossStatsBridge; +import com.foxx.androidcast.stats.SessionStatsRecorder; +import com.foxx.androidcast.stats.SessionStatsStore; import java.io.IOException; import java.util.ArrayList; @@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; /** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */ -public class ScreenCastService extends Service { +public class ScreenCastService extends Service implements + com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost { private static final String TAG = "ScreenCastService"; private static final int CONNECT_RETRIES = 20; private static final long CONNECT_RETRY_MS = 1000; @@ -58,12 +63,14 @@ public class ScreenCastService extends Service { public static final String EXTRA_PIN = "pin"; public static final String EXTRA_DEVICE_NAME = "device_name"; public static final String EXTRA_TARGETS = "cast_targets"; + public static final String EXTRA_CALIBRATION = "calibration_mode"; public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND"; private final RemoteCallbackList callbacks = new RemoteCallbackList<>(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final AtomicBoolean stopping = new AtomicBoolean(false); private final AtomicBoolean casting = new AtomicBoolean(false); + private final AtomicBoolean resourcesReleased = new AtomicBoolean(false); private MediaProjection projection; private VirtualDisplay virtualDisplay; @@ -92,6 +99,11 @@ public class ScreenCastService extends Service { private int captureDensity; private DisplayManager displayManager; private DisplayManager.DisplayListener displayListener; + private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver; + private volatile boolean calibrationMode; + private volatile boolean cameraMode; + private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture; + private SessionStatsRecorder sessionStatsRecorder; private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { @Override @@ -128,7 +140,9 @@ public class ScreenCastService extends Service { @Override public void onCreate() { super.onCreate(); + SessionStatsStore.pruneOnAppStart(this); CastNotifications.createChannels(this); + com.foxx.androidcast.network.CastTransportFactory.init(this); } @Override @@ -149,6 +163,8 @@ public class ScreenCastService extends Service { } private void runCast(Intent intent) { + resourcesReleased.set(false); + stopping.set(false); if (intent == null) { failAndStop(getString(R.string.error_no_intent)); return; @@ -161,7 +177,11 @@ public class ScreenCastService extends Service { String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME); CastSettings settings = IntentExtras.getCastSettings(intent); - if (data == null) { + boolean calibration = settings != null + && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; + boolean camera = settings != null + && settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + if (!calibration && !camera && data == null) { failAndStop(getString(R.string.error_projection_data)); return; } @@ -169,6 +189,16 @@ public class ScreenCastService extends Service { failAndStop(getString(R.string.error_missing_params)); return; } + cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; + if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) { + failAndStop(getString(R.string.capture_camera_not_implemented)); + return; + } + int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); + if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) { + port = castPort; + } acquireWakeLock(); try { @@ -210,7 +240,17 @@ public class ScreenCastService extends Service { casting.set(true); CastActiveState.setSenderCasting(true); activeSettings = settings; - startCaptureOnMainThread(resultCode, new Intent(data), settings); + sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport()); + sessionStatsRecorder.mergeSettings(settings); + sessionStatsRecorder.setClientCount(peers.size()); + sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC"); + if (calibration) { + startCalibrationCaptureOnMainThread(settings); + } else if (camera) { + startCameraCaptureOnMainThread(settings); + } else { + startCaptureOnMainThread(resultCode, new Intent(data), settings); + } scheduleTuningTick(); while (!stopping.get()) { try { @@ -285,6 +325,83 @@ public class ScreenCastService extends Service { throw new IOException(getString(R.string.error_connect_failed, host, port), last); } + private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException { + if (Looper.myLooper() != mainHandler.getLooper()) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + mainHandler.post(() -> { + try { + startCalibrationCaptureOnMainThread(settings); + } catch (Exception e) { + error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e)); + } finally { + latch.countDown(); + } + }); + try { + if (!latch.await(15, TimeUnit.SECONDS)) { + throw new IOException("Calibration setup timeout"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + if (error.get() != null) { + throw error.get(); + } + return; + } + CastResolution.Size size = CastResolution.calibrationTestSize(); + setupVideoPipeline(size, settings, true); + startAudioPipeline(settings, true); + updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight)); + } + + private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException { + if (Looper.myLooper() != mainHandler.getLooper()) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + mainHandler.post(() -> { + try { + startCameraCaptureOnMainThread(settings); + } catch (Exception e) { + error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e)); + } finally { + latch.countDown(); + } + }); + try { + if (!latch.await(20, TimeUnit.SECONDS)) { + throw new IOException("Camera setup timeout"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + if (error.get() != null) { + throw error.get(); + } + return; + } + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(metrics); + CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); + setupVideoPipeline(size, settings, false); + Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null; + if (surface == null) { + throw new IOException("Encoder surface unavailable for camera"); + } + cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this); + try { + cameraCapture.start(surface, captureWidth, captureHeight); + } catch (android.hardware.camera2.CameraAccessException e) { + throw new IOException("Camera open failed: " + e.getMessage(), e); + } + startAudioPipeline(settings, true); + updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight)); + } + private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException { if (Looper.myLooper() != mainHandler.getLooper()) { CountDownLatch latch = new CountDownLatch(1); @@ -335,16 +452,17 @@ public class ScreenCastService extends Service { WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); - setupVideoPipeline(size, settings); + setupVideoPipeline(size, settings, false); registerDisplayListener(); - startAudioPipeline(settings); + startAudioPipeline(settings, false); String mode = session.getTransport().getModeLabel(); updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); } - private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { + private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration) + throws IOException { if (settings.isPassthrough()) { throw new IOException("Passthrough video is not implemented yet (debug only)"); } @@ -376,45 +494,29 @@ public class ScreenCastService extends Service { } videoEncoder = new VideoEncoder(); - VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { - @Override - public void onConfig(int w, int h, byte[] csd0, byte[] csd1) { - if (stopping.get()) { - return; - } - sendSafe(CastProtocol.MSG_STREAM_CONFIG, - () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1)); - } + VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime); - @Override - public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { - if ((sendPump == null && fanoutPump == null) || frameData == null) { - return; - } - boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames - && !keyFrame - && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES; - if (tiny) { - return; - } - lastSignificantFrameMs = System.currentTimeMillis(); - senderMetrics.onEncodedFrame(keyFrame); - enqueueVideoFrame(ptsUs, keyFrame, frameData); - } - }; + if (calibration) { + startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true); + return; + } Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb); encoderKeepalive = new EncoderKeepalive(); encoderKeepalive.start(videoEncoder); - virtualDisplay = projection.createVirtualDisplay( - "AndroidCast", - captureWidth, - captureHeight, - captureDensity, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null); + if (projection != null) { + virtualDisplay = projection.createVirtualDisplay( + "AndroidCast", + captureWidth, + captureHeight, + captureDensity, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null); + } else { + throw new IOException("MediaProjection required for screen capture"); + } videoEncoder.requestKeyframe(); Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight); } @@ -450,7 +552,8 @@ public class ScreenCastService extends Service { } private void maybeReconfigureForRotation() { - if (!casting.get() || projection == null || activeSettings == null || stopping.get()) { + if (calibrationMode || cameraMode || !casting.get() || projection == null + || activeSettings == null || stopping.get()) { return; } DisplayMetrics metrics = new DisplayMetrics(); @@ -461,7 +564,7 @@ public class ScreenCastService extends Service { return; } try { - setupVideoPipeline(size, activeSettings); + setupVideoPipeline(size, activeSettings, false); updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); } catch (IOException e) { Log.e(TAG, "Rotation reconfigure failed", e); @@ -484,6 +587,11 @@ public class ScreenCastService extends Service { s.encodeQueueDepth = sendPump.getQueueDepth(); s.droppedPFrames = sendPump.getDroppedPFrames(); } + TransportLossStats snap = buildSenderLossSnapshot(); + TransportLossStatsBridge.applyTo(s, snap); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.sampleNetwork(s, getPeerStats()); + } s.audioActive = audioEncoder != null && audioFramesSent > 0; s.audioFramesSent = audioFramesSent; s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent(); @@ -543,7 +651,7 @@ public class ScreenCastService extends Service { } private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) { - if (activeSettings == null + if (calibrationMode || cameraMode || activeSettings == null || activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) { return; } @@ -557,19 +665,19 @@ public class ScreenCastService extends Service { return; } try { - setupVideoPipeline(size, activeSettings); + setupVideoPipeline(size, activeSettings, false); Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight); } catch (IOException e) { Log.w(TAG, "Adaptive resize failed", e); } } - private void startAudioPipeline(CastSettings settings) { + private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) { if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { Log.i(TAG, "Audio capture disabled for this session"); return; } - if (projection == null) { + if (projection == null && !calibrationOnlyMic) { Log.w(TAG, "Audio skipped — no MediaProjection"); return; } @@ -609,11 +717,15 @@ public class ScreenCastService extends Service { senderAudioMetrics.reset(); audioCaptureMode = "playback"; audioCapture = new AudioCapture(this); - audioCapture.start(projection, new AudioCapture.Callback() { + MediaProjection audioProjection = calibrationOnlyMic ? null : projection; + audioCapture.start(audioProjection, new AudioCapture.Callback() { @Override public void onPcm(byte[] pcm, int length, long ptsUs, com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) { senderAudioMetrics.onPcm(levels); + if (calibrationDriver != null) { + calibrationDriver.setVuFromPcm(pcm, length); + } if (audioEncoder != null) { audioEncoder.queuePcm(pcm, length, ptsUs); } @@ -634,7 +746,8 @@ public class ScreenCastService extends Service { private void handleAudioConsent(boolean accept) { if (accept) { if (audioEncoder == null && activeSettings != null) { - startAudioPipeline(activeSettings); + startAudioPipeline(activeSettings, activeSettings != null + && activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST); } Log.i(TAG, "Receiver accepted audio"); } else { @@ -695,6 +808,10 @@ public class ScreenCastService extends Service { } private void releaseAll() { + if (!resourcesReleased.compareAndSet(false, true)) { + return; + } + finishSessionStatsAsync(); stopping.set(true); casting.set(false); CastActiveState.setSenderCasting(false); @@ -708,10 +825,16 @@ public class ScreenCastService extends Service { encoderKeepalive.stop(); encoderKeepalive = null; } - if (videoEncoder != null) { - videoEncoder.stop(); - videoEncoder = null; + if (cameraCapture != null) { + cameraCapture.stop(); + cameraCapture = null; } + if (calibrationDriver != null) { + calibrationDriver.release(); + calibrationDriver = null; + } + calibrationMode = false; + cameraMode = false; if (audioCapture != null) { audioCapture.stop(); audioCapture = null; @@ -720,28 +843,36 @@ public class ScreenCastService extends Service { audioEncoder.stop(); audioEncoder = null; } + if (videoEncoder != null) { + videoEncoder.stop(); + videoEncoder = null; + } if (projection != null) { projection.stop(); projection = null; } if (multiCoordinator != null) { - multiCoordinator.closeAll(); - multiCoordinator = null; + multiCoordinator.sendGoodbyeAll(); } else if (session != null) { try { session.send(CastProtocol.MSG_GOODBYE, new byte[0]); } catch (Exception ignored) { } - session.close(); - session = null; + } + if (sendPump != null) { + sendPump.stop(); + sendPump = null; } if (fanoutPump != null) { fanoutPump.stop(); fanoutPump = null; } - if (sendPump != null) { - sendPump.stop(); - sendPump = null; + if (multiCoordinator != null) { + multiCoordinator.closeAll(); + multiCoordinator = null; + } else if (session != null) { + session.close(); + session = null; } releaseWakeLock(); } @@ -752,10 +883,17 @@ public class ScreenCastService extends Service { } private void stopSelfSafely() { - stopping.set(true); - releaseAll(); - stopForeground(STOP_FOREGROUND_REMOVE); - stopSelf(); + if (!stopping.compareAndSet(false, true)) { + return; + } + Thread stopper = new Thread(() -> { + releaseAll(); + mainHandler.post(() -> { + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(); + }); + }, "CastStop"); + stopper.start(); } private void startSenderForeground(String text, boolean withAudio) { @@ -771,6 +909,41 @@ public class ScreenCastService extends Service { } } + private TransportLossStats buildSenderLossSnapshot() { + TransportLossStats snap = session != null + ? session.getTransport().getLossStats() : new TransportLossStats(); + if (sendPump != null) { + snap.sendDroppedVideoP = sendPump.getDroppedPFrames(); + snap.sendDroppedAudio = sendPump.getDroppedAudioFrames(); + snap.sendQueueDepth = sendPump.getQueueDepth(); + } else if (fanoutPump != null) { + snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames(); + snap.sendQueueDepth = fanoutPump.getQueueDepth(); + } + return snap; + } + + private void finishSessionStatsAsync() { + final SessionStatsRecorder recorder = sessionStatsRecorder; + sessionStatsRecorder = null; + if (recorder == null) { + return; + } + if (activeSettings != null) { + recorder.setResolution(captureWidth, captureHeight); + recorder.setCaptureMode(activeSettings.getCaptureMode() != null + ? activeSettings.getCaptureMode().name() : ""); + } + recorder.mergeLoss(buildSenderLossSnapshot()); + new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start(); + } + + private void logEncodedCodecSample(String mime, byte[] frameData) { + String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData); + Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime) + + " — " + probe); + } + private void updateStatus(String text) { status = text; broadcastStatus(text); @@ -790,6 +963,90 @@ public class ScreenCastService extends Service { callbacks.finishBroadcast(); } + private VideoEncoder.Callback buildVideoEncoderCallback(String mime) { + return new VideoEncoder.Callback() { + @Override + public void onConfig(int w, int h, byte[] csd0, byte[] csd1) { + if (stopping.get()) { + return; + } + sendSafe(CastProtocol.MSG_STREAM_CONFIG, + () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1)); + } + + @Override + public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { + if ((sendPump == null && fanoutPump == null) || frameData == null) { + return; + } + boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames + && !keyFrame + && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES; + if (tiny) { + return; + } + lastSignificantFrameMs = System.currentTimeMillis(); + senderMetrics.onEncodedFrame(keyFrame); + if (keyFrame) { + logEncodedCodecSample(mime, frameData); + } + enqueueVideoFrame(ptsUs, keyFrame, frameData); + } + }; + } + + private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params, + VideoEncoder.Callback videoCb, boolean tryGles) throws IOException { + if (encoderKeepalive != null) { + encoderKeepalive.stop(); + encoderKeepalive = null; + } + if (calibrationDriver != null) { + calibrationDriver.release(); + calibrationDriver = null; + } + if (videoEncoder != null) { + videoEncoder.stop(); + } + videoEncoder = new VideoEncoder(); + float density = getResources().getDisplayMetrics().density; + calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this); + if (tryGles) { + Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb); + encoderKeepalive = new EncoderKeepalive(); + encoderKeepalive.start(videoEncoder); + calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density); + videoEncoder.requestKeyframe(); + Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime); + } else { + videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb); + encoderKeepalive = new EncoderKeepalive(); + encoderKeepalive.start(videoEncoder); + calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density); + videoEncoder.requestKeyframe(); + Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime); + } + } + + @Override + public void onCalibrationNeedBufferEncoder() { + if (!calibrationMode || stopping.get() || activeSettings == null) { + return; + } + String mime = activeSettings.getNegotiatedVideoMime(); + if (mime == null || mime.isEmpty()) { + return; + } + mainHandler.post(() -> { + try { + startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false); + } catch (IOException e) { + Log.e(TAG, "Calibration buffer fallback failed", e); + failAndStop("Calibration video failed"); + } + }); + } + @Override public IBinder onBind(Intent intent) { return binder; diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index 2e9098e..140e49c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -14,6 +14,7 @@ import android.provider.Settings; import android.text.TextUtils; import android.view.View; import android.view.WindowManager; +import android.widget.Button; import android.widget.EditText; import android.widget.ListView; import android.widget.ProgressBar; @@ -35,7 +36,6 @@ import com.foxx.androidcast.discovery.DiscoveryManager; public class SenderActivity extends AppCompatActivity { private static final int REQUEST_MEDIA_PROJECTION = 2001; - private static final long DISCOVERY_INTERVAL_MS = 30_000; private static final long BACK_STOP_INTERVAL_MS = 2_000; private static final int DEV_TAP_UNLOCK = 7; @@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity { private TextView statusText; private TextView titleText; private ProgressBar discoveryProgress; + private Button refreshButton; private View advancedPanel; + + private enum DiscoveryPhase { + IDLE, SCANNING, PAUSED + } + + private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE; + private int discoverySecondsLeft; + private final Runnable autoScanRunnable = () -> beginScanPass(false); + private final Runnable discoveryCountdownTick = new Runnable() { + @Override + public void run() { + if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { + if (discoverySecondsLeft > 0) { + discoverySecondsLeft--; + } + updateRefreshButtonLabel(); + handler.postDelayed(this, 1000); + } + } + }; private EditText pinInput; private EditText manualHostInput; private EditText manualPortInput; @@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity { private boolean useManualConnect; private long lastBackPressMs; - private final Runnable discoveryWatchdog = new Runnable() { - @Override - public void run() { - if (discovery != null) { - restartDiscoveryInternal(false); - } - handler.postDelayed(this, DISCOVERY_INTERVAL_MS); - } - }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity { pinInput.setText(CastConfig.DEFAULT_PIN); statusText = findViewById(R.id.text_status); discoveryProgress = findViewById(R.id.progress_discovery); + refreshButton = findViewById(R.id.btn_refresh); advancedPanel = findViewById(R.id.panel_advanced); manualHostInput = findViewById(R.id.edit_manual_host); manualPortInput = findViewById(R.id.edit_manual_port); @@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity { @Override public void onScanStarted() { + discoveryPhase = DiscoveryPhase.SCANNING; + discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000); + updateRefreshButtonLabel(); + handler.removeCallbacks(discoveryCountdownTick); + handler.post(discoveryCountdownTick); showDiscoveryProgress(); } @Override public void onScanEnded() { hideDiscoveryProgress(); + discoveryPhase = DiscoveryPhase.PAUSED; + discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000); + updateRefreshButtonLabel(); + handler.removeCallbacks(autoScanRunnable); + handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS); } }); @@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity { } }); - findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery()); + refreshButton.setOnClickListener(v -> onRefreshClicked()); findViewById(R.id.btn_start_cast).setOnClickListener(v -> PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); findViewById(R.id.btn_manual_cast).setOnClickListener(v -> { @@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity { @Override protected void onStart() { super.onStart(); - restartDiscovery(); - handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); + beginScanPass(false); } @Override protected void onStop() { - handler.removeCallbacks(discoveryWatchdog); + handler.removeCallbacks(discoveryCountdownTick); + handler.removeCallbacks(autoScanRunnable); if (discovery != null) { discovery.stop(); } + discoveryPhase = DiscoveryPhase.IDLE; + updateRefreshButtonLabel(); hideDiscoveryProgress(); super.onStop(); } - private void restartDiscovery() { - handler.removeCallbacks(discoveryWatchdog); - restartDiscoveryInternal(true); - handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); + private void onRefreshClicked() { + if (discoveryPhase == DiscoveryPhase.SCANNING) { + return; + } + handler.removeCallbacks(autoScanRunnable); + beginScanPass(true); } - private void restartDiscoveryInternal(boolean userRefresh) { - deviceAdapter.clearDevices(); - statusText.setText(userRefresh ? R.string.rescanning : R.string.searching); + private void beginScanPass(boolean userInitiated) { + if (discovery != null && discovery.isBrowsing()) { + return; + } + if (userInitiated) { + deviceAdapter.clearDevices(); + statusText.setText(R.string.rescanning); + } else if (deviceAdapter.getCount() == 0) { + statusText.setText(R.string.searching); + } if (discovery != null) { discovery.stop(); - discovery.clearDevices(); + if (userInitiated) { + discovery.clearDevices(); + } } discovery.startBrowsing(); } + private void updateRefreshButtonLabel() { + if (refreshButton == null) { + return; + } + if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { + refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft))); + } else { + refreshButton.setText(R.string.refresh); + } + } + private void showDiscoveryProgress() { discoveryProgress.setVisibility(View.VISIBLE); discoveryProgress.bringToFront(); @@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity { } pendingPin = pin; + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { + launchCastService(null, Activity.RESULT_OK); + return; + } + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) { + if (androidx.core.content.ContextCompat.checkSelfPermission(this, + android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + androidx.core.app.ActivityCompat.requestPermissions(this, + new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE); + pendingCameraLaunch = true; + return; + } + launchCastService(null, Activity.RESULT_OK); + return; + } MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION); } @@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity { return mgr.createScreenCaptureIntent(); } + private boolean pendingCameraLaunch; + private String pendingPin; private String pendingHost; private int pendingPort; private String pendingDeviceName; private java.util.ArrayList pendingTargets; + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) { + pendingCameraLaunch = false; + if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) { + launchCastService(null, Activity.RESULT_OK); + } else { + Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show(); + } + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode != REQUEST_MEDIA_PROJECTION) { return; } - if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) { + if (resultCode != Activity.RESULT_OK || pendingHost == null) { Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); return; } + launchCastService(data, resultCode); + } + + private void launchCastService(Intent projectionData, int resultCode) { String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); if (deviceName == null) { deviceName = Build.MODEL; } - Intent serviceIntent = new Intent(this, ScreenCastService.class); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); - serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data)); + if (projectionData != null) { + serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData)); + } serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); serviceIntent.putExtra(CastSettings.EXTRA, castSettings); + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { + serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true); + } if (pendingTargets != null && !pendingTargets.isEmpty()) { serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent); } else { diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java index 7066c04..015f952 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -1,5 +1,6 @@ package com.foxx.androidcast.sender; +import android.graphics.Bitmap; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; @@ -9,6 +10,7 @@ import android.util.Log; import android.view.Surface; import com.foxx.androidcast.CastTuningEngine; +import com.foxx.androidcast.media.RgbToYuv420; import java.io.IOException; import java.nio.ByteBuffer; @@ -25,6 +27,9 @@ public class VideoEncoder { private MediaCodec codec; private Surface inputSurface; + private boolean bufferInput; + private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420; + private byte[] yuvScratch; private Callback callback; private int width; private int height; @@ -32,7 +37,10 @@ public class VideoEncoder { private Thread drainThread; private String mime; - /** Request an IDR (keyframe) on the next opportunity. */ + public Surface getInputSurface() { + return inputSurface; + } + public void requestKeyframe() { if (codec == null || !running) { return; @@ -48,19 +56,36 @@ public class VideoEncoder { public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, Callback callback) throws IOException { + return prepareInternal(width, height, mime, params, callback, false); + } + + /** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */ + public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params, + Callback callback) throws IOException { + prepareInternal(width, height, mime, params, callback, true); + } + + private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params, + Callback callback, boolean buffer) throws IOException { this.width = width; this.height = height; this.mime = mime; this.callback = callback; - - MediaFormat tuned = buildFormat(width, height, mime, params, true); - MediaFormat basic = buildFormat(width, height, mime, params, false); + this.bufferInput = buffer; + yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null; codec = MediaCodec.createEncoderByType(mime); + yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420; + + MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout); + MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout); + if (!tryConfigure(codec, tuned)) { Log.w(TAG, "Tuned encoder format rejected; retrying with basic format"); codec.release(); codec = MediaCodec.createEncoderByType(mime); + yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420; + basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout); if (!tryConfigure(codec, basic)) { codec.release(); codec = null; @@ -68,20 +93,73 @@ public class VideoEncoder { } } - inputSurface = codec.createInputSurface(); + if (bufferInput) { + inputSurface = null; + } else { + inputSurface = codec.createInputSurface(); + } codec.start(); running = true; drainThread = new Thread(this::drainLoop, "VideoEncoderDrain"); drainThread.start(); - Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps " - + (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec); + Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime + + " " + width + "x" + height + " @ " + params.videoFps + "fps"); return inputSurface; } + private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) { + MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime); + for (int fmt : caps.colorFormats) { + if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) { + return RgbToYuv420.Layout.NV12; + } + } + return RgbToYuv420.Layout.I420; + } + + public void queueBitmapFrame(Bitmap bitmap, long ptsUs) { + if (!bufferInput || codec == null || !running || bitmap == null) { + return; + } + byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout); + queueYuvFrame(yuv, ptsUs); + } + + public void queueYuvFrame(byte[] yuv, long ptsUs) { + if (!bufferInput || codec == null || !running || yuv == null) { + return; + } + try { + int index = codec.dequeueInputBuffer(0); + if (index < 0) { + return; + } + ByteBuffer buffer = codec.getInputBuffer(index); + if (buffer == null) { + return; + } + buffer.clear(); + int len = Math.min(buffer.remaining(), yuv.length); + buffer.put(yuv, 0, len); + codec.queueInputBuffer(index, 0, len, ptsUs, 0); + } catch (Exception e) { + Log.w(TAG, "queueYuvFrame: " + e.getMessage()); + } + } + private static MediaFormat buildFormat(int width, int height, String mime, - CastTuningEngine.EffectiveParams params, boolean tuned) { + CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer, + RgbToYuv420.Layout layout) { MediaFormat format = MediaFormat.createVideoFormat(mime, width, height); - format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + int color; + if (!buffer) { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; + } else if (layout == RgbToYuv420.Layout.NV12) { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } else { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar; + } + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color); format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate); format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec); @@ -117,6 +195,7 @@ public class VideoEncoder { public void stop() { running = false; + bufferInput = false; if (drainThread != null) { try { drainThread.join(1500); @@ -136,25 +215,30 @@ public class VideoEncoder { inputSurface.release(); inputSurface = null; } + yuvScratch = null; } private void drainLoop() { MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); boolean configSent = false; while (running) { - int index = codec.dequeueOutputBuffer(info, 5_000); + MediaCodec c = codec; + if (c == null) { + break; + } + int index = c.dequeueOutputBuffer(info, 5_000); if (index == MediaCodec.INFO_TRY_AGAIN_LATER) { continue; } if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - sendConfig(codec.getOutputFormat(), configSent); + sendConfig(c.getOutputFormat(), configSent); configSent = true; continue; } if (index < 0) { continue; } - ByteBuffer buffer = codec.getOutputBuffer(index); + ByteBuffer buffer = c.getOutputBuffer(index); if (buffer != null && info.size > 0) { byte[] data = new byte[info.size]; buffer.position(info.offset); @@ -166,7 +250,7 @@ public class VideoEncoder { callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data); } } - codec.releaseOutputBuffer(index, false); + c.releaseOutputBuffer(index, false); if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { break; } diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java new file mode 100644 index 0000000..95cced0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java @@ -0,0 +1,199 @@ +package com.foxx.androidcast.sender.calibration; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.view.Surface; + +import com.foxx.androidcast.media.PcmLevelAnalyzer; +import com.foxx.androidcast.sender.VideoEncoder; + +/** + * Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback). + */ +public final class CalibrationCastDriver { + private static final String TAG = "CalibrationCastDriver"; + private static final int EGL_FAIL_THRESHOLD = 5; + + public interface FallbackHost { + /** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */ + void onCalibrationNeedBufferEncoder(); + } + + private enum FeedMode { + GLES_SURFACE, + YUV_BUFFER + } + + private final Context appContext; + private final HandlerThread thread; + private final Handler handler; + private final FallbackHost fallbackHost; + private CalibrationEglRenderer eglRenderer; + private VideoEncoder videoEncoder; + private Bitmap chartBitmap; + private Bitmap frameBitmap; + private Canvas frameCanvas; + private int width; + private int height; + private float density; + private int scrollPx; + private volatile float vuLevel; + private volatile boolean running; + private Runnable tick; + private long encodeStartNs; + private FeedMode feedMode = FeedMode.YUV_BUFFER; + private int eglFailures; + private boolean fallbackRequested; + + public CalibrationCastDriver(Context context) { + this(context, null); + } + + public CalibrationCastDriver(Context context, FallbackHost fallbackHost) { + appContext = context.getApplicationContext(); + this.fallbackHost = fallbackHost; + thread = new HandlerThread("CalibrationCast"); + thread.start(); + handler = new Handler(thread.getLooper()); + } + + /** Preferred path: GLES into encoder {@link Surface}. */ + public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) { + this.videoEncoder = encoder; + initBitmaps(width, height, density); + eglFailures = 0; + fallbackRequested = false; + try { + eglRenderer = new CalibrationEglRenderer(); + eglRenderer.start(encoderSurface, width, height); + feedMode = FeedMode.GLES_SURFACE; + Log.i(TAG, "Calibration GLES " + width + "x" + height); + } catch (Exception e) { + Log.w(TAG, "GLES init failed: " + e.getMessage()); + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + requestBufferEncoderFallback(); + } + running = true; + encodeStartNs = System.nanoTime(); + startTick(); + } + + /** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */ + public void attachBuffer(VideoEncoder encoder, int width, int height, float density) { + this.videoEncoder = encoder; + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + initBitmaps(width, height, density); + running = true; + encodeStartNs = System.nanoTime(); + startTick(); + Log.i(TAG, "Calibration buffer feed " + width + "x" + height); + } + + private void initBitmaps(int w, int h, float density) { + this.width = w; + this.height = h; + this.density = density; + scrollPx = 0; + chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas chartCanvas = new Canvas(chartBitmap); + TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext); + frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + frameCanvas = new Canvas(frameBitmap); + } + + public void setVuFromPcm(byte[] pcm, int length) { + PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length); + float instant = Math.max(levels.peak, levels.rms); + if (levels.silent) { + vuLevel *= 0.88f; + } else { + vuLevel = instant * 0.7f + vuLevel * 0.3f; + } + } + + private void startTick() { + tick = new Runnable() { + @Override + public void run() { + if (!running || frameCanvas == null) { + return; + } + long frameStart = System.nanoTime(); + drawFrame(); + scrollPx += Math.max(1, Math.round(1f * density)); + long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L; + long delay = feedMode == FeedMode.GLES_SURFACE + ? Math.max(1L, 33L - elapsedMs) + : Math.max(1L, 50L - elapsedMs); + handler.postDelayed(this, delay); + } + }; + handler.post(tick); + } + + private void drawFrame() { + frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null); + TvCalibrationGenerator.drawBorderOverlay( + frameCanvas, width, height, density, scrollPx, vuLevel); + long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L; + if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) { + if (eglRenderer.draw(frameBitmap, System.nanoTime())) { + eglFailures = 0; + return; + } + eglFailures++; + if (eglFailures >= EGL_FAIL_THRESHOLD) { + Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer"); + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + requestBufferEncoderFallback(); + } + return; + } + if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) { + videoEncoder.queueBitmapFrame(frameBitmap, ptsUs); + } + } + + private void requestBufferEncoderFallback() { + if (fallbackRequested || fallbackHost == null) { + return; + } + fallbackRequested = true; + handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder()); + } + + private void releaseEgl() { + if (eglRenderer != null) { + eglRenderer.release(); + eglRenderer = null; + } + } + + public void stop() { + running = false; + handler.removeCallbacksAndMessages(null); + releaseEgl(); + if (chartBitmap != null) { + chartBitmap.recycle(); + chartBitmap = null; + } + if (frameBitmap != null) { + frameBitmap.recycle(); + frameBitmap = null; + } + frameCanvas = null; + videoEncoder = null; + } + + public void release() { + stop(); + thread.quitSafely(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java new file mode 100644 index 0000000..53a3009 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java @@ -0,0 +1,236 @@ +package com.foxx.androidcast.sender.calibration; + +import android.graphics.Bitmap; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.opengl.GLES20; +import android.opengl.GLUtils; +import android.util.Log; +import android.view.Surface; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */ +final class CalibrationEglRenderer { + private static final String TAG = "CalibrationEglRenderer"; + + private static final String VERT = + "attribute vec4 aPosition;\n" + + "attribute vec2 aTexCoord;\n" + + "varying vec2 vTexCoord;\n" + + "void main() {\n" + + " gl_Position = aPosition;\n" + + " vTexCoord = aTexCoord;\n" + + "}\n"; + + private static final String FRAG = + "precision mediump float;\n" + + "varying vec2 vTexCoord;\n" + + "uniform sampler2D uTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(uTexture, vTexCoord);\n" + + "}\n"; + + private static final float[] QUAD = { + -1f, -1f, 0f, 1f, + 1f, -1f, 1f, 1f, + -1f, 1f, 0f, 0f, + 1f, 1f, 1f, 0f, + }; + + private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY; + private EGLContext eglContext = EGL14.EGL_NO_CONTEXT; + private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE; + private int program; + private int textureId; + private int aPosition; + private int aTexCoord; + private int uTexture; + private FloatBuffer quadBuffer; + private int width; + private int height; + /** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */ + private static final int EGL_RECORDABLE_ANDROID = 0x3142; + + boolean isReady() { + return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE; + } + + void start(Surface surface, int width, int height) { + this.width = width; + this.height = height; + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (eglDisplay == EGL14.EGL_NO_DISPLAY) { + throw new IllegalStateException("eglGetDisplay failed"); + } + int[] version = new int[2]; + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { + throw new IllegalStateException("eglInitialize failed"); + } + EGLConfig eglConfig = chooseConfig(eglDisplay); + int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; + eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0); + if (eglContext == EGL14.EGL_NO_CONTEXT) { + throw new IllegalStateException("eglCreateContext failed"); + } + int[] surfaceAttribs = {EGL14.EGL_NONE}; + eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0); + if (eglSurface == EGL14.EGL_NO_SURFACE) { + throw new IllegalStateException("eglCreateWindowSurface failed"); + } + if (!makeCurrent()) { + throw new IllegalStateException("eglMakeCurrent failed: 0x" + + Integer.toHexString(EGL14.eglGetError())); + } + program = buildProgram(VERT, FRAG); + aPosition = GLES20.glGetAttribLocation(program, "aPosition"); + aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord"); + uTexture = GLES20.glGetUniformLocation(program, "uTexture"); + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + textureId = textures[0]; + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + quadBuffer.put(QUAD).position(0); + GLES20.glViewport(0, 0, width, height); + Log.i(TAG, "EGL calibration renderer " + width + "x" + height); + } + + /** @return false if EGL could not present this frame (caller should fall back to buffer input). */ + boolean draw(Bitmap bitmap, long ptsNs) { + if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) { + return false; + } + if (!makeCurrent()) { + return false; + } + GLES20.glViewport(0, 0, width, height); + GLES20.glClearColor(0f, 0f, 0f, 1f); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLES20.glUseProgram(program); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); + GLES20.glUniform1i(uTexture, 0); + quadBuffer.position(0); + GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer); + quadBuffer.position(2); + GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer); + GLES20.glEnableVertexAttribArray(aPosition); + GLES20.glEnableVertexAttribArray(aTexCoord); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLES20.glDisableVertexAttribArray(aPosition); + GLES20.glDisableVertexAttribArray(aTexCoord); + EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs); + if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) { + Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError())); + return false; + } + return true; + } + + private boolean makeCurrent() { + if (eglContext.equals(EGL14.eglGetCurrentContext()) + && eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) { + return true; + } + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError())); + return false; + } + return true; + } + + void release() { + if (textureId != 0) { + int[] t = new int[] {textureId}; + GLES20.glDeleteTextures(1, t, 0); + textureId = 0; + } + if (program != 0) { + GLES20.glDeleteProgram(program); + program = 0; + } + if (eglDisplay != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); + if (eglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(eglDisplay, eglSurface); + } + if (eglContext != EGL14.EGL_NO_CONTEXT) { + EGL14.eglDestroyContext(eglDisplay, eglContext); + } + EGL14.eglTerminate(eglDisplay); + } + eglDisplay = EGL14.EGL_NO_DISPLAY; + eglContext = EGL14.EGL_NO_CONTEXT; + eglSurface = EGL14.EGL_NO_SURFACE; + } + + private static EGLConfig chooseConfig(EGLDisplay display) { + int[] attribList = { + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL_RECORDABLE_ANDROID, 1, + EGL14.EGL_NONE + }; + EGLConfig[] configs = new EGLConfig[16]; + int[] numConfigs = new int[1]; + if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0) + || numConfigs[0] <= 0) { + throw new IllegalStateException("eglChooseConfig failed (recordable)"); + } + for (int i = 0; i < numConfigs[0]; i++) { + int[] recordable = new int[1]; + if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0) + && recordable[0] != 0) { + return configs[i]; + } + } + return configs[0]; + } + + private static int buildProgram(String vertexSource, String fragmentSource) { + int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource); + int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + int program = GLES20.glCreateProgram(); + GLES20.glAttachShader(program, vs); + GLES20.glAttachShader(program, fs); + GLES20.glLinkProgram(program); + int[] link = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0); + if (link[0] == 0) { + String err = GLES20.glGetProgramInfoLog(program); + GLES20.glDeleteProgram(program); + throw new IllegalStateException("Program link failed: " + err); + } + GLES20.glDeleteShader(vs); + GLES20.glDeleteShader(fs); + return program; + } + + private static int compileShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] compiled = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); + if (compiled[0] == 0) { + String err = GLES20.glGetShaderInfoLog(shader); + GLES20.glDeleteShader(shader); + throw new IllegalStateException("Shader compile failed: " + err); + } + return shader; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java new file mode 100644 index 0000000..11e839e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java @@ -0,0 +1,159 @@ +package com.foxx.androidcast.sender.calibration; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; + +import com.foxx.androidcast.R; + +/** + * Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent. + */ +public final class TvCalibrationGenerator { + private static final int[] BAR_COLORS = { + Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN, + Color.MAGENTA, Color.RED, Color.BLUE + }; + + private TvCalibrationGenerator() {} + + public static void drawChart(Canvas canvas, int width, int height, Context context) { + if (context == null) { + drawProceduralChart(canvas, width, height); + return; + } + canvas.drawColor(Color.BLACK); + Bitmap ref = loadReference(context); + if (ref != null) { + drawReferenceWithTransparentGray(canvas, ref, width, height); + ref.recycle(); + return; + } + drawProceduralChart(canvas, width, height); + } + + private static Bitmap loadReference(Context context) { + try { + return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference); + } catch (Exception e) { + return null; + } + } + + private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) { + Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true); + Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true); + scaled.recycle(); + int[] pixels = new int[w * h]; + keyed.getPixels(pixels, 0, w, 0, 0, w, h); + for (int i = 0; i < pixels.length; i++) { + int p = pixels[i]; + int r = (p >> 16) & 0xff; + int g = (p >> 8) & 0xff; + int b = p & 0xff; + if (isGray(r, g, b)) { + pixels[i] = 0; + } + } + keyed.setPixels(pixels, 0, w, 0, 0, w, h); + canvas.drawBitmap(keyed, 0, 0, null); + keyed.recycle(); + } + + private static boolean isGray(int r, int g, int b) { + return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200; + } + + private static void drawProceduralChart(Canvas canvas, int width, int height) { + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + int barH = (int) (height * 0.55f); + int barW = width / BAR_COLORS.length; + for (int i = 0; i < BAR_COLORS.length; i++) { + paint.setColor(BAR_COLORS[i]); + canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint); + } + int lowerTop = barH; + int cols = 8; + int cellW = width / cols; + for (int c = 0; c < cols; c++) { + int level = 255 - (c * 255 / (cols - 1)); + if (c == 0 || c == cols - 1) { + continue; + } + paint.setColor(Color.rgb(level, level, level)); + canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint); + } + paint.setColor(Color.WHITE); + canvas.drawRect(0, lowerTop, cellW, height, paint); + paint.setColor(Color.BLACK); + canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint); + } + + /** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */ + public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density, + int scrollPx, float vuLevel, Context context) { + drawChart(canvas, width, height, context); + drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel); + } + + /** Draws only the animated border (chart must already be on the canvas / bitmap). */ + public static void drawBorderOverlay(Canvas canvas, int width, int height, float density, + int scrollPx, float vuLevel) { + int borderPx = Math.max(2, Math.round(8f * density)); + int stripe = Math.max(1, Math.round(1f * density)); + int maxScroll = width + height; + int offset = scrollPx % Math.max(1, maxScroll); + float vu = Math.max(0f, Math.min(1f, vuLevel)); + int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f)); + drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset); + } + + private static void drawStripeFrame(Canvas canvas, int width, int height, int border, + int stripe, int blueWidth, int offset) { + Paint paint = new Paint(); + int cycle = stripe + blueWidth; + RectF top = new RectF(0, 0, width, border); + RectF bottom = new RectF(0, height - border, width, height); + RectF left = new RectF(0, 0, border, height); + RectF right = new RectF(width - border, 0, width, height); + fillStripes(canvas, paint, top, true, offset, cycle, blueWidth); + fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth); + fillStripes(canvas, paint, left, false, offset, cycle, blueWidth); + fillStripes(canvas, paint, right, false, offset, cycle, blueWidth); + } + + private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal, + int offset, int cycle, int blueWidth) { + if (horizontal) { + int h = Math.max(1, Math.round(area.height())); + for (float x = area.left; x < area.right; ) { + int pos = (int) (x + area.top + offset); + int color = stripeColor(pos, cycle, blueWidth); + float next = Math.min(area.right, x + 1f); + paint.setColor(color); + canvas.drawRect(x, area.top, next, area.top + h, paint); + x = next; + } + } else { + int w = Math.max(1, Math.round(area.width())); + for (float y = area.top; y < area.bottom; ) { + int pos = (int) (area.left + y + offset); + int color = stripeColor(pos, cycle, blueWidth); + float next = Math.min(area.bottom, y + 1f); + paint.setColor(color); + canvas.drawRect(area.left, y, area.left + w, next, paint); + y = next; + } + } + } + + private static int stripeColor(int pos, int cycle, int blueWidth) { + int m = pos % cycle; + return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java new file mode 100644 index 0000000..434b4b7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java @@ -0,0 +1,156 @@ +package com.foxx.androidcast.sender.camera; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.util.Size; +import android.view.Surface; + +import java.util.Collections; + +/** Camera2 preview into the video encoder input {@link Surface}. */ +public final class CameraCastCapture { + private static final String TAG = "CameraCastCapture"; + + private final Context appContext; + private HandlerThread thread; + private Handler handler; + private CameraDevice camera; + private CameraCaptureSession session; + private String cameraId; + + public CameraCastCapture(Context context) { + appContext = context.getApplicationContext(); + } + + public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException { + stop(); + thread = new HandlerThread("CameraCast"); + thread.start(); + handler = new Handler(thread.getLooper()); + CameraManager mgr = appContext.getSystemService(CameraManager.class); + cameraId = chooseCameraId(mgr); + if (cameraId == null) { + throw new CameraAccessException(CameraAccessException.CAMERA_ERROR, + "No back camera available"); + } + Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight); + Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight()); + mgr.openCamera(cameraId, new CameraDevice.StateCallback() { + @Override + public void onOpened(CameraDevice device) { + camera = device; + try { + createSession(device, encoderSurface, preview); + } catch (CameraAccessException e) { + Log.e(TAG, "Session failed", e); + stop(); + } + } + + @Override + public void onDisconnected(CameraDevice device) { + device.close(); + camera = null; + } + + @Override + public void onError(CameraDevice device, int error) { + Log.e(TAG, "Camera error " + error); + device.close(); + camera = null; + } + }, handler); + } + + private void createSession(CameraDevice device, Surface encoderSurface, Size preview) + throws CameraAccessException { + CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + builder.addTarget(encoderSurface); + device.createCaptureSession(Collections.singletonList(encoderSurface), + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(CameraCaptureSession captureSession) { + session = captureSession; + try { + builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO); + captureSession.setRepeatingRequest(builder.build(), null, handler); + Log.i(TAG, "Camera preview running"); + } catch (CameraAccessException e) { + Log.e(TAG, "Repeating request failed", e); + } + } + + @Override + public void onConfigureFailed(CameraCaptureSession captureSession) { + Log.e(TAG, "Camera session configure failed"); + } + }, handler); + } + + private static String chooseCameraId(CameraManager mgr) throws CameraAccessException { + for (String id : mgr.getCameraIdList()) { + CameraCharacteristics chars = mgr.getCameraCharacteristics(id); + Integer facing = chars.get(CameraCharacteristics.LENS_FACING); + if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { + return id; + } + } + String[] ids = mgr.getCameraIdList(); + return ids.length > 0 ? ids[0] : null; + } + + private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH) + throws CameraAccessException { + CameraCharacteristics chars = mgr.getCameraCharacteristics(id); + StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (map == null) { + return new Size(targetW, targetH); + } + Size[] sizes = map.getOutputSizes(SurfaceTexture.class); + if (sizes == null || sizes.length == 0) { + return new Size(targetW, targetH); + } + Size best = sizes[0]; + long bestScore = Long.MAX_VALUE; + for (Size s : sizes) { + long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH); + if (score < bestScore) { + bestScore = score; + best = s; + } + } + return best; + } + + public void stop() { + if (session != null) { + try { + session.close(); + } catch (Exception ignored) { + } + session = null; + } + if (camera != null) { + try { + camera.close(); + } catch (Exception ignored) { + } + camera = null; + } + if (thread != null) { + thread.quitSafely(); + thread = null; + } + handler = null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java new file mode 100644 index 0000000..a9f2ac4 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -0,0 +1,204 @@ +package com.foxx.androidcast.stats; + +import android.content.Context; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.transport.TransportLossStats; + +import org.json.JSONObject; + +/** + * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}. + */ +public final class SessionStatsRecorder { + private final long startedMs = System.currentTimeMillis(); + private final String role; + private final String transport; + private String videoCodec = ""; + private String audioCodec = "AAC"; + private String captureMode = ""; + private String resolution = ""; + private int clientCount = 1; + private int disruptCount; + private int idleTimeouts; + private int networkSamples; + + private long sumRttMs; + private long sumLossRatioMicro; // loss * 1e6 + private long sumVideoBitrateKbps; + private long sumAudioBitrateKbps; + private long sumEncodeFpsMicro; + private long sumDecodeFpsMicro; + private int bitrateSamples; + private int fpsSamples; + + private final TransportLossStats lossTotals = new TransportLossStats(); + + public SessionStatsRecorder(String role, String transport) { + this.role = role; + this.transport = transport != null ? transport : "unknown"; + } + + public void setClientCount(int count) { + if (count > 0) { + clientCount = count; + } + } + + public void setCodecs(String videoMime, String audioMime) { + if (videoMime != null) { + videoCodec = videoMime; + } + if (audioMime != null) { + audioCodec = audioMime; + } + } + + public void setCaptureMode(String mode) { + captureMode = mode != null ? mode : ""; + } + + public void setResolution(int w, int h) { + if (w > 0 && h > 0) { + resolution = w + "x" + h; + } + } + + public void noteDisrupt(String reason) { + disruptCount++; + } + + public void noteIdleTimeout() { + idleTimeouts++; + disruptCount++; + } + + public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) { + networkSamples++; + NetworkStatsSnapshot s = local != null ? local : peer; + if (s == null) { + return; + } + if (s.rttMs > 0) { + sumRttMs += s.rttMs; + } + if (s.lossRatio > 0) { + sumLossRatioMicro += (long) (s.lossRatio * 1_000_000); + } + if (s.sendBitrateKbps > 0) { + sumVideoBitrateKbps += s.sendBitrateKbps; + bitrateSamples++; + } else if (s.recvBitrateKbps > 0) { + sumVideoBitrateKbps += s.recvBitrateKbps; + bitrateSamples++; + } + if (s.encodeFps > 0) { + sumEncodeFpsMicro += (long) (s.encodeFps * 1000); + fpsSamples++; + } + if (s.targetBitrateKbps > 0) { + sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256); + } + } + + /** Final transport/app loss snapshot for this session (not accumulated over time). */ + public void mergeLoss(TransportLossStats loss) { + if (loss == null) { + return; + } + synchronized (lossTotals) { + lossTotals.datagramsSent = loss.datagramsSent; + lossTotals.datagramsReceived = loss.datagramsReceived; + lossTotals.datagramsInvalid = loss.datagramsInvalid; + lossTotals.fragmentsReceived = loss.fragmentsReceived; + lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate; + lossTotals.reassemblyCompleted = loss.reassemblyCompleted; + lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete; + lossTotals.reassemblySuperseded = loss.reassemblySuperseded; + lossTotals.wirePacketsDropped = loss.wirePacketsDropped; + lossTotals.malformedPackets = loss.malformedPackets; + lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP; + lossTotals.sendDroppedAudio = loss.sendDroppedAudio; + lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey; + lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors; + lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors; + lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops; + lossTotals.protectionMode = loss.protectionMode; + } + } + + public void mergeSettings(CastSettings settings) { + if (settings == null) { + return; + } + captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode; + if (settings.getNegotiatedVideoMime() != null) { + videoCodec = settings.getNegotiatedVideoMime(); + } + } + + public void finish(Context context) { + long endedMs = System.currentTimeMillis(); + try { + JSONObject root = new JSONObject(); + root.put("schema_version", 1); + root.put("started_at_epoch_ms", startedMs); + root.put("ended_at_epoch_ms", endedMs); + root.put("duration_ms", endedMs - startedMs); + root.put("role", role); + root.put("transport", transport); + root.put("client_count", clientCount); + root.put("disrupt_count", disruptCount); + root.put("idle_timeout_count", idleTimeouts); + root.put("video_codec", videoCodec); + root.put("audio_codec", audioCodec); + root.put("capture_mode", captureMode); + root.put("resolution", resolution); + root.put("network_samples", networkSamples); + + if (networkSamples > 0) { + root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples)); + root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples))); + } + if (bitrateSamples > 0) { + root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples); + } + if (fpsSamples > 0) { + root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples)); + } + + JSONObject protection = new JSONObject(); + protection.put("mode", lossTotals.protectionMode.name()); + protection.put("fec", "stub_disabled"); + protection.put("nack", "stub_disabled"); + protection.put("fec_stub_packets", lossTotals.fecPacketsStub); + protection.put("nack_stub_requests", lossTotals.nackRequestsStub); + root.put("stream_protection", protection); + + JSONObject udp = new JSONObject(); + synchronized (lossTotals) { + udp.put("datagrams_tx", lossTotals.datagramsSent); + udp.put("datagrams_rx", lossTotals.datagramsReceived); + udp.put("datagrams_invalid", lossTotals.datagramsInvalid); + udp.put("fragments_rx", lossTotals.fragmentsReceived); + udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate); + udp.put("reassembly_ok", lossTotals.reassemblyCompleted); + udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded); + udp.put("wire_dropped", lossTotals.wirePacketsDropped); + udp.put("malformed", lossTotals.malformedPackets); + udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP); + udp.put("send_dropped_audio", lossTotals.sendDroppedAudio); + udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey); + udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors); + udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors); + udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops); + udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames); + } + root.put("udp", udp); + + SessionStatsStore.saveSession(context, startedMs, root); + } catch (Exception ignored) { + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java new file mode 100644 index 0000000..c0a4f24 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java @@ -0,0 +1,73 @@ +package com.foxx.androidcast.stats; + +import android.content.Context; + +import com.foxx.androidcast.CastConfig; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** Rotating flat JSON session logs in app-private storage. */ +public final class SessionStatsStore { + private static final String DIR_NAME = "session_stats"; + private static final String PREFIX = "session_"; + private static final String SUFFIX = ".json"; + + private SessionStatsStore() {} + + public static File statsDir(Context context) { + return new File(context.getApplicationContext().getFilesDir(), DIR_NAME); + } + + /** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */ + public static void pruneOnAppStart(Context context) { + pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES); + } + + /** Deletes oldest session JSON files until count is below {@code maxFiles}. */ + static void pruneDirectory(File dir, int maxFiles) { + if (dir == null || !dir.exists()) { + return; + } + File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); + if (files == null || files.length == 0) { + return; + } + List list = new ArrayList<>(); + Collections.addAll(list, files); + Collections.sort(list, Comparator.comparing(File::getName)); + while (list.size() >= maxFiles) { + File oldest = list.remove(0); + if (!oldest.delete()) { + break; + } + } + } + + public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception { + File dir = statsDir(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IllegalStateException("Cannot create session_stats dir"); + } + String name = PREFIX + formatTimestamp(startedMs) + SUFFIX; + File out = new File(dir, name); + byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(bytes); + } + } + + private static String formatTimestamp(long epochMs) { + return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs)); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java index 93f48d7..bf3f6c5 100644 --- a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java +++ b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java @@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout { @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (aspectRatio <= 0f || getChildCount() == 0) { - return; - } int parentW = MeasureSpec.getSize(widthMeasureSpec); int parentH = MeasureSpec.getSize(heightMeasureSpec); + if (getChildCount() == 0) { + setMeasuredDimension(parentW, parentH); + return; + } + if (aspectRatio <= 0f) { + int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY); + int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY); + measureChild(getChildAt(0), fullW, fullH); + setMeasuredDimension(parentW, parentH); + return; + } int childW; int childH; float parentRatio = (float) parentW / parentH; @@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout { if (getChildCount() == 0) { return; } + if (aspectRatio <= 0f) { + getChildAt(0).layout(left, top, right, bottom); + return; + } int parentW = right - left; int parentH = bottom - top; int childW = getChildAt(0).getMeasuredWidth(); diff --git a/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java new file mode 100644 index 0000000..80b05f6 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java @@ -0,0 +1,41 @@ +package com.foxx.androidcast.ui; + +import android.graphics.Matrix; +import android.view.TextureView; + +/** + * Letterboxes decoded video in a {@link TextureView}. + * Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}. + */ +public final class PlaybackTextureTransform { + private PlaybackTextureTransform() {} + + public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) { + if (view == null || videoWidth <= 0 || videoHeight <= 0) { + return; + } + int viewW = view.getWidth(); + int viewH = view.getHeight(); + if (viewW <= 0 || viewH <= 0) { + return; + } + float videoAspect = (float) videoWidth / videoHeight; + float viewAspect = (float) viewW / viewH; + float scaleX = 1f; + float scaleY = 1f; + if (videoAspect > viewAspect) { + scaleY = viewAspect / videoAspect; + } else { + scaleX = videoAspect / viewAspect; + } + Matrix matrix = new Matrix(); + matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f); + view.setTransform(matrix); + } + + public static void clearTransform(TextureView view) { + if (view != null) { + view.setTransform(new Matrix()); + } + } +} diff --git a/app/src/main/res/drawable-nodpi/calibration_chart_reference.png b/app/src/main/res/drawable-nodpi/calibration_chart_reference.png new file mode 100644 index 0000000..6d0fa7b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/calibration_chart_reference.png differ diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml index 6186e97..38ce9eb 100644 --- a/app/src/main/res/layout/activity_playback.xml +++ b/app/src/main/res/layout/activity_playback.xml @@ -16,7 +16,7 @@ android:layout_height="match_parent" android:layout_gravity="center"> - Android Cast
Send screen (tablet / phone) Receive on TV / projector - Same Wi‑Fi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices. + Same Wi‑Fi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices. Cast to receiver Waiting for cast PIN - Refresh + REFRESH + REFRESH (%1$d) Start cast Searching for receivers… Waiting for sender… @@ -25,8 +26,16 @@ Video only This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only. Continue (video only) - TCP (reliable) - UDP (best effort) + UDP (default, best effort) + TCP (reliable fallback) + QUIC experimental (Cronet) + WebRTC (stub, future) + Calibration test pattern + Camera (rear Camera2) + Camera source (requires Android 10+) + Camera capture is not implemented yet. Use screen or calibration test. + Calibration cast %1$dx%2$d + Camera cast %1$dx%2$d Low (~1.5 Mbps, 24 fps) Medium (~5 Mbps, 30 fps) — recommended High (~10 Mbps, 30 fps) diff --git a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java new file mode 100644 index 0000000..7f54b08 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java @@ -0,0 +1,17 @@ +package com.foxx.androidcast; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CastResolutionTest { + + @Test + public void calibrationTestSizeIsLandscape16x9() { + CastResolution.Size size = CastResolution.calibrationTestSize(); + assertEquals(960, size.width); + assertEquals(540, size.height); + assertTrue(size.width > size.height); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java b/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java new file mode 100644 index 0000000..f2d9a9b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java @@ -0,0 +1,16 @@ +package com.foxx.androidcast.media; + +import static org.junit.Assert.assertTrue; + +import android.media.MediaFormat; + +import org.junit.Test; + +public class EncodedStreamProbeTest { + @Test + public void hevcKeyframeReportsNalType() { + byte[] vps = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01}; + String desc = EncodedStreamProbe.describeKeyframe(MediaFormat.MIMETYPE_VIDEO_HEVC, vps); + assertTrue(desc.contains("HEVC")); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java b/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java new file mode 100644 index 0000000..88bcb03 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java @@ -0,0 +1,16 @@ +package com.foxx.androidcast.media; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class H264BitstreamHevcTest { + @Test + public void avccHevcNalGetsStartCode() { + byte[] vps = {0, 0, 0, 4, 0x40, 0x01, 0x0c, 0x01}; + byte[] annexB = H264Bitstream.toAnnexB(vps); + assertTrue(annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1); + assertEquals(0x40, annexB[4] & 0xff); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java b/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java new file mode 100644 index 0000000..a70c014 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.media; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class RgbToNv21Test { + + @Test + public void whitePixelProducesHighLuma() { + int[] argb = {0xFFFFFFFF, 0, 0, 0xFFFFFFFF}; + byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2); + int y = nv21[0] & 0xff; + assertTrue(y > 220); + } + + @Test + public void blackPixelProducesLowLuma() { + int[] argb = {0xFF000000, 0, 0, 0xFF000000}; + byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2); + int y = nv21[0] & 0xff; + assertEquals(16, y); + } + + @Test + public void bufferSizeMatchesNv21Layout() { + int w = 16; + int h = 16; + int[] argb = new int[w * h]; + byte[] nv21 = RgbToNv21.fromArgb(argb, w, h); + assertEquals(w * h + w * h / 2, nv21.length); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java b/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java new file mode 100644 index 0000000..7d50095 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java @@ -0,0 +1,25 @@ +package com.foxx.androidcast.media; + +import org.junit.Test; + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertEquals; + +public class RgbToYuv420Test { + + @Test + public void redPixelDiffersBetweenYAndChromaPlanes() { + int[] argb = {0xFFFF0000, 0, 0, 0xFFFF0000}; + byte[] i420 = RgbToYuv420.fromArgb(argb, 2, 2, RgbToYuv420.Layout.I420); + int y = i420[0] & 0xff; + int u = i420[4] & 0xff; + int v = i420[5] & 0xff; + assertNotEquals(y, u); + assertNotEquals(y, v); + } + + @Test + public void bufferSizeMatchesI420Layout() { + assertEquals(960 * 540 + (960 * 540) / 2, RgbToYuv420.bufferSize(960, 540)); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java b/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java new file mode 100644 index 0000000..0481fd4 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.network; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CastProtoHeaderTest { + + @Test + public void encodeDecodeRoundTrip() throws Exception { + CastProtoHeader original = CastProtoHeader.forCast(0x12345678, 0xABCDEF01, 0x0F, 0xF0); + byte[] wire = original.encode(); + CastProtoHeader decoded = CastProtoHeader.decode(wire, 0, wire.length); + assertEquals(original.sessionId, decoded.sessionId); + assertEquals(original.placeholderId, decoded.placeholderId); + assertEquals(original.videoCodecs, decoded.videoCodecs); + assertEquals(original.audioCodecs, decoded.audioCodecs); + assertTrue(decoded.verifyChecksum()); + } + + @Test + public void frameUnframePreservesPayload() throws Exception { + CastProtoHeader hdr = CastProtoHeader.forCast(99, 1, 2, 3); + byte[] payload = new byte[] {1, 2, 3, 4}; + byte[] wire = CastPacketFramer.frame(hdr, (byte) 7, payload); + CastProtoHeader fromWire = CastPacketFramer.readHeader(wire); + assertEquals(99, fromWire.sessionId); + CastProtocol.Message msg = CastPacketFramer.unframe(wire); + assertEquals(7, msg.type); + assertArrayEquals(payload, msg.payload); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java b/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java new file mode 100644 index 0000000..9a0dca5 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java @@ -0,0 +1,49 @@ +package com.foxx.androidcast.network; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CastSessionGateTest { + + @Test + public void bindsFirstNonZeroSession() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader first = new CastProtoHeader(); + first.sessionId = 42; + assertTrue(gate.accept(first)); + assertEquals(42, gate.getBoundSessionId()); + + CastProtoHeader stale = new CastProtoHeader(); + stale.sessionId = 99; + assertFalse(gate.accept(stale)); + + CastProtoHeader same = new CastProtoHeader(); + same.sessionId = 42; + assertTrue(gate.accept(same)); + } + + @Test + public void zeroSessionAlwaysAccepted() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader zero = new CastProtoHeader(); + zero.sessionId = 0; + assertTrue(gate.accept(zero)); + assertFalse(gate.isBound()); + } + + @Test + public void resetAllowsRebind() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader a = new CastProtoHeader(); + a.sessionId = 1; + gate.accept(a); + gate.reset(); + CastProtoHeader b = new CastProtoHeader(); + b.sessionId = 2; + assertTrue(gate.accept(b)); + assertEquals(2, gate.getBoundSessionId()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java new file mode 100644 index 0000000..fd79f1d --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.stats; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.foxx.androidcast.CastConfig; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; + +public class SessionStatsStoreTest { + + @Test + public void pruneDirectory_keepsAtMostMaxMinusOne() throws IOException { + File dir = Files.createTempDirectory("session_stats_test").toFile(); + int max = CastConfig.SESSION_STATS_MAX_FILES; + for (int i = 0; i < max + 5; i++) { + File f = new File(dir, String.format("session_20260101_%06d.json", i)); + assertTrue(f.createNewFile()); + Files.write(f.toPath(), "{}".getBytes(StandardCharsets.UTF_8)); + } + assertEquals(max + 5, dir.list((d, n) -> n.endsWith(".json")).length); + + SessionStatsStore.pruneDirectory(dir, max); + + String[] remaining = dir.list((d, n) -> n.endsWith(".json")); + Arrays.sort(remaining); + assertEquals(max - 1, remaining.length); + assertEquals("session_20260101_000006.json", remaining[0]); + assertEquals("session_20260101_000024.json", remaining[remaining.length - 1]); + + for (File f : dir.listFiles()) { + assertTrue(f.delete()); + } + assertTrue(dir.delete()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java new file mode 100644 index 0000000..d03b14b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.ui; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PlaybackTextureTransformTest { + @Test + public void wideVideoOnWideViewIsUnityScale() { + float[] scales = scalesFor(1920, 1080, 960, 540); + assertEquals(1f, scales[0], 0.01f); + assertEquals(1f, scales[1], 0.01f); + } + + @Test + public void tallVideoOnWideViewLetterboxesHorizontally() { + float[] scales = scalesFor(1920, 1080, 540, 960); + assertEquals(0.316f, scales[0], 0.02f); + assertEquals(1f, scales[1], 0.01f); + } + + private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) { + float videoAspect = (float) videoW / videoH; + float viewAspect = (float) viewW / viewH; + float scaleX = 1f; + float scaleY = 1f; + if (videoAspect > viewAspect) { + scaleY = viewAspect / videoAspect; + } else { + scaleX = videoAspect / viewAspect; + } + return new float[] {scaleX, scaleY}; + } +}