From 35ab5622d7a3d1426dc34fa672689a8d251724a2 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 19 May 2026 12:23:39 +0200 Subject: [PATCH] snap --- app/build.gradle | 5 +- app/src/main/AndroidManifest.xml | 5 + .../foxx/androidcast/CastSettingsBinder.java | 46 +++- .../androidcast/PictureInPictureHelper.java | 11 +- .../diagnostics/CastDiagnosticsFormatter.java | 45 +++- .../diagnostics/CastMtuSampler.java | 67 +++++ .../diagnostics/CpuLoadSampler.java | 234 ++++++++++++++++++ .../androidcast/network/CastProtocol.java | 12 +- .../foxx/androidcast/network/CastSession.java | 23 +- .../network/CastTransportFactory.java | 26 +- .../androidcast/network/UdpCastTransport.java | 71 +++++- .../network/control/NetworkStatsSnapshot.java | 4 + .../transport/CombinedProtectionEngine.java | 4 +- .../transport/FecProtectionEngine.java | 54 +++- .../ProtectionNegotiationPrompts.java | 56 +++++ .../network/transport/RetransmitCache.java | 3 +- .../transport/StreamProtectionCapability.java | 46 ++++ .../transport/StreamProtectionFactory.java | 28 ++- .../StreamProtectionNegotiation.java | 45 ++++ .../transport/StreamProtectionNegotiator.java | 232 ++++++++++++++++- .../transport/TransportLossStatsBridge.java | 2 + .../network/transport/fec/FecShardWire.java | 32 ++- .../androidcast/receiver/AudioDecoder.java | 30 ++- .../receiver/LibvpxVideoDecoder.java | 4 + .../receiver/ReceiverCastService.java | 29 ++- .../receiver/ReceiverPlaybackActivity.java | 75 +++++- .../androidcast/receiver/ReceiverSession.java | 21 +- .../foxx/androidcast/sender/AudioEncoder.java | 60 +++-- .../sender/MultiCastCoordinator.java | 18 +- .../androidcast/sender/ScreenCastService.java | 2 + .../androidcast/sender/SenderActivity.java | 6 +- .../sender/SenderCapturePreview.java | 17 +- .../sender/SenderPreviewService.java | 64 +++++ .../sender/SenderScreenPreview.java | 17 +- app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 14 +- .../diagnostics/CastMtuSamplerTest.java | 27 ++ .../network/UdpCastTransportFecTest.java | 54 +++- .../FecProtectionEngineShardTest.java | 15 ++ .../StreamProtectionFactoryTest.java | 17 ++ .../StreamProtectionNegotiatorTest.java | 99 +++++++- .../transport/fec/FecShardReassemblyTest.java | 25 ++ .../transport/fec/FecShardWireTest.java | 43 ++++ ndk/README.md | 2 +- ndk/jni/libvpx_bridge.c | 13 +- scripts/build-native-codecs.sh | 2 +- scripts/capture-vp9-fec-logs.sh | 41 +++ 47 files changed, 1598 insertions(+), 150 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/diagnostics/CastMtuSampler.java create mode 100644 app/src/main/java/com/foxx/androidcast/diagnostics/CpuLoadSampler.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/ProtectionNegotiationPrompts.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCapability.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiation.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java create mode 100644 app/src/test/java/com/foxx/androidcast/diagnostics/CastMtuSamplerTest.java create mode 100755 scripts/capture-vp9-fec-logs.sh diff --git a/app/build.gradle b/app/build.gradle index 82d8d91..4d4aeb5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,11 +49,10 @@ android { buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" externalNativeBuild { cmake { - def cmakeArgs = ["-DANDROID_STL=c++_shared"] + arguments "-DANDROID_STL=c++_shared" if (resolveCcachePath) { - cmakeArgs += "-DANDROID_CCACHE=${resolveCcachePath}" + arguments "-DANDROID_CCACHE=${resolveCcachePath}" } - arguments cmakeArgs cppFlags "" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2cd271b..e31e0a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,11 @@ android:stopWithTask="false" android:foregroundServiceType="mediaProjection|microphone" /> + + { settings.setTransport(v); - updateStreamProtectionEnabled(activity, settings); + refreshStreamProtectionUi(activity, settings); }); - updateStreamProtectionEnabled(activity, settings); + refreshStreamProtectionUi(activity, settings); + } + + private static void refreshStreamProtectionUi(AppCompatActivity activity, CastSettings settings) { + updateStreamProtectionSpinnerEnabled(activity, settings, R.id.spinner_sender_stream_protection, + R.id.text_stream_protection_hint, R.id.spinner_sender_transport); } private static void readTransport(AppCompatActivity activity, CastSettings settings, int spinnerId) { @@ -404,7 +409,16 @@ public final class CastSettingsBinder { } private static void bindStreamProtection(AppCompatActivity activity, CastSettings settings) { - Spinner spinner = activity.findViewById(R.id.spinner_sender_stream_protection); + bindStreamProtectionSpinner(activity, settings, R.id.spinner_sender_stream_protection, + R.id.text_stream_protection_hint, R.id.spinner_sender_transport); + } + + private static void bindStreamProtectionSpinner(AppCompatActivity activity, CastSettings settings, + int spinnerId, int hintId, int transportSpinnerId) { + Spinner spinner = activity.findViewById(spinnerId); + if (spinner == null) { + return; + } CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values(); String[] labels = { activity.getString(R.string.option_auto), @@ -422,7 +436,23 @@ public final class CastSettingsBinder { } SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current, settings::setStreamProtection); - updateStreamProtectionEnabled(activity, settings); + Spinner transportSpinner = activity.findViewById(transportSpinnerId); + if (transportSpinner != null) { + transportSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(android.widget.AdapterView parent, android.view.View view, + int position, long id) { + readTransport(activity, settings, transportSpinnerId); + updateStreamProtectionSpinnerEnabled(activity, settings, spinnerId, hintId, + transportSpinnerId); + } + + @Override + public void onNothingSelected(android.widget.AdapterView parent) { + } + }); + } + updateStreamProtectionSpinnerEnabled(activity, settings, spinnerId, hintId, transportSpinnerId); } private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) { @@ -430,14 +460,16 @@ public final class CastSettingsBinder { settings::setStreamProtection); } - private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) { - Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection); + private static void updateStreamProtectionSpinnerEnabled(AppCompatActivity activity, + CastSettings settings, int spinnerId, int hintId, int transportSpinnerId) { + Spinner protection = activity.findViewById(spinnerId); if (protection == null) { return; } + readTransport(activity, settings, transportSpinnerId); boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport()); protection.setEnabled(udp); - TextView hint = activity.findViewById(R.id.text_stream_protection_hint); + TextView hint = activity.findViewById(hintId); if (hint != null) { hint.setVisibility(udp ? View.VISIBLE : View.GONE); } diff --git a/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java b/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java index 3c32848..abdd8dd 100644 --- a/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java +++ b/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java @@ -45,12 +45,13 @@ public final class PictureInPictureHelper { return true; } - public static PictureInPictureParams buildParams(int videoWidth, int videoHeight) { + public static PictureInPictureParams buildParams(Context context, int videoWidth, int videoHeight) { PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder() .setAspectRatio(aspectRatio(videoWidth, videoHeight)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.setAutoEnterEnabled(true); - builder.setSeamlessResizeEnabled(true); + boolean allowed = isAllowedForApp(context); + builder.setAutoEnterEnabled(allowed); + builder.setSeamlessResizeEnabled(allowed); } return builder.build(); } @@ -59,7 +60,7 @@ public final class PictureInPictureHelper { if (!isSupported()) { return; } - activity.setPictureInPictureParams(buildParams(videoWidth, videoHeight)); + activity.setPictureInPictureParams(buildParams(activity, videoWidth, videoHeight)); } public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) { @@ -67,7 +68,7 @@ public final class PictureInPictureHelper { return false; } try { - return activity.enterPictureInPictureMode(buildParams(videoWidth, videoHeight)); + return activity.enterPictureInPictureMode(buildParams(activity, videoWidth, videoHeight)); } catch (IllegalStateException e) { return false; } 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 43d70e9..51cf901 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java @@ -22,7 +22,8 @@ public final class CastDiagnosticsFormatter { public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec, int streamW, int streamH, int renderW, int renderH, boolean idle, CastSettings localSettings, CastSettings remoteSettings, - NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) { + NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent, + CastMtuSampler.Info mtuInfo) { float inFps = metrics.getInFps(); float renderFps = metrics.getRenderFps(); int bwInKbps = metrics.getBwInKbps(); @@ -103,6 +104,7 @@ public final class CastDiagnosticsFormatter { html.append("Transport: ") .append(escape(transport != null ? transport.toUpperCase() : "?")) .append("
"); + appendMtuSection(html, mtuInfo); if (videoCodec != null && !videoCodec.isEmpty()) { html.append("Codec: ") @@ -251,6 +253,12 @@ public final class CastDiagnosticsFormatter { html.append("
"); } + CpuLoadSampler.Sample cpu = CpuLoadSampler.sample(); + html.append("CPU: ").append(vuBarHtml(cpu.loadPercent)).append(' ') + .append(colored(cpu.loadPercent + "%", cpu.loadPercent > 85)) + .append(' ').append(CpuLoadSampler.formatCoreSuffix(cpu)); + html.append("
"); + for (String hint : hints) { html.append("") .append(escape(hint)).append("
"); @@ -270,6 +278,29 @@ public final class CastDiagnosticsFormatter { return html.toString(); } + private static void appendMtuSection(StringBuilder html, CastMtuSampler.Info mtu) { + if (mtu == null) { + return; + } + html.append("MTU: "); + if (mtu.interfaceName.length() > 0) { + html.append(escape(mtu.interfaceName)).append(' '); + } + if (mtu.linkMtu > 0) { + html.append("link ").append(mtu.linkMtu); + } else { + html.append("link ?"); + } + html.append(" · dg ").append(mtu.castDatagramBytes); + html.append(" frag ").append(mtu.castFragPayloadBytes); + html.append(" · IPv4 ").append(mtu.ipv4WireBytes); + if (mtu.headroomBytes >= 0) { + html.append(" · room ").append(colored(String.valueOf(mtu.headroomBytes), + mtu.headroomBytes < 100)); + } + html.append("
"); + } + private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer, NetworkStatsSnapshot local) { long rx = pick(peer, local, true, false); @@ -314,7 +345,7 @@ public final class CastDiagnosticsFormatter { html.append(' ').append(colored("aud-dec " + aDecErr, true)); } if (aDrop > 0) { - html.append(' ').append(colored("aud-q " + aDrop, true)); + html.append(' ').append(colored("aud-drop " + aDrop, true)); } if (dropAudio > 0) { html.append(' ').append(colored("snd-aud " + dropAudio, true)); @@ -364,9 +395,15 @@ public final class CastDiagnosticsFormatter { html.append(" · ").append(escape(summary)); } long fecPkts = val(peer, local, p -> p.fecStubPackets, l -> l.fecStubPackets); + long fecOk = val(peer, local, p -> p.fecPacketsDecoded, l -> l.fecPacketsDecoded); + long fecFail = val(peer, local, p -> p.fecDecodeFailures, l -> l.fecDecodeFailures); long nackReq = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests); - if (fecPkts > 0) { - html.append("
FEC packets=").append(fecPkts); + if (fecPkts > 0 || fecOk > 0 || fecFail > 0) { + html.append("
FEC enc=").append(fecPkts).append(" dec=").append(fecOk); + if (fecFail > 0) { + html.append(" fail=").append(fecFail) + .append(""); + } } if (nackReq > 0) { html.append("
NACK events=").append(nackReq); diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastMtuSampler.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastMtuSampler.java new file mode 100644 index 0000000..6994326 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastMtuSampler.java @@ -0,0 +1,67 @@ +package com.foxx.androidcast.diagnostics; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.os.Build; + +import com.foxx.androidcast.CastConfig; + +/** + * Link MTU from the OS plus derived cast UDP sizing (see {@link com.foxx.androidcast.network.UdpCastTransport}). + */ +public final class CastMtuSampler { + /** ACUD header: magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4). */ + public static final int ACUD_HEADER_BYTES = 17; + private static final int IPV4_UDP_OVERHEAD = 20 + 8; + + public static final class Info { + public final int linkMtu; + public final String interfaceName; + public final int castDatagramBytes; + public final int castFragPayloadBytes; + public final int ipv4WireBytes; + /** {@code linkMtu - ipv4WireBytes}, or {@code -1} when link MTU is unknown. */ + public final int headroomBytes; + + Info(int linkMtu, String interfaceName, int castDatagramBytes, int castFragPayloadBytes, + int ipv4WireBytes, int headroomBytes) { + this.linkMtu = linkMtu; + this.interfaceName = interfaceName != null ? interfaceName : ""; + this.castDatagramBytes = castDatagramBytes; + this.castFragPayloadBytes = castFragPayloadBytes; + this.ipv4WireBytes = ipv4WireBytes; + this.headroomBytes = headroomBytes; + } + } + + private CastMtuSampler() {} + + public static Info sample(Context context) { + int linkMtu = 0; + String iface = ""; + if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ConnectivityManager cm = context.getSystemService(ConnectivityManager.class); + if (cm != null) { + Network network = cm.getActiveNetwork(); + LinkProperties lp = network != null ? cm.getLinkProperties(network) : null; + if (lp != null) { + linkMtu = lp.getMtu(); + if (lp.getInterfaceName() != null) { + iface = lp.getInterfaceName(); + } + } + } + } + return calculate(linkMtu, iface); + } + + public static Info calculate(int linkMtu, String interfaceName) { + int datagram = CastConfig.UDP_CHUNK_SIZE; + int fragPayload = Math.max(0, datagram - ACUD_HEADER_BYTES); + int wire = datagram + IPV4_UDP_OVERHEAD; + int headroom = linkMtu > 0 ? linkMtu - wire : -1; + return new Info(linkMtu, interfaceName, datagram, fragPayload, wire, headroom); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CpuLoadSampler.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CpuLoadSampler.java new file mode 100644 index 0000000..b38c479 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CpuLoadSampler.java @@ -0,0 +1,234 @@ +package com.foxx.androidcast.diagnostics; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** Samples device or process CPU load (0–100% smoothed). */ +public final class CpuLoadSampler { + private static final long MIN_INTERVAL_MS = 200; + private static final int CORE_BUSY_THRESHOLD_PERCENT = 12; + private static final int USER_HZ = 100; + + private static final Object LOCK = new Object(); + private static long[] prevTotals; + private static long[][] prevPerCpu; + private static long prevSelfJiffies; + private static long lastSampleMs; + private static boolean procStatReadable = true; + private static Sample cached = new Sample(0, Runtime.getRuntime().availableProcessors(), -1); + + public static final class Sample { + public final int loadPercent; + public final int totalCores; + /** Cores above {@link #CORE_BUSY_THRESHOLD_PERCENT} usage; {@code -1} if unavailable. */ + public final int activeCores; + + Sample(int loadPercent, int totalCores, int activeCores) { + this.loadPercent = Math.max(0, Math.min(100, loadPercent)); + this.totalCores = Math.max(1, totalCores); + this.activeCores = activeCores; + } + } + + private CpuLoadSampler() {} + + /** Prime baseline so the first displayed sample is not stuck at 0%. */ + public static void warmUp() { + synchronized (LOCK) { + lastSampleMs = 0; + prevTotals = null; + prevPerCpu = null; + prevSelfJiffies = 0; + } + sample(); + try { + Thread.sleep(MIN_INTERVAL_MS + 50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + sample(); + } + + public static Sample getCached() { + synchronized (LOCK) { + return cached; + } + } + + public static Sample sample() { + synchronized (LOCK) { + long now = System.currentTimeMillis(); + if (now - lastSampleMs < MIN_INTERVAL_MS && prevTotals != null) { + return cached; + } + int totalCores = Math.max(1, cached.totalCores); + int deviceLoad = -1; + int active = -1; + + if (procStatReadable) { + ProcStat current = readProcStat(); + if (current != null) { + totalCores = Math.max(1, current.perCpu.size()); + if (prevTotals != null) { + deviceLoad = percentDelta(prevTotals, current.totals); + active = countActiveCores(prevPerCpu, current.perCpu, totalCores); + } + prevTotals = current.totals; + prevPerCpu = copyPerCpu(current.perCpu); + } else { + procStatReadable = false; + } + } + + int processLoad = sampleProcessLoad(now); + int load = deviceLoad >= 0 ? deviceLoad : processLoad; + if (deviceLoad >= 0 && processLoad > load) { + load = processLoad; + } + + if (prevTotals != null || prevSelfJiffies > 0) { + cached = new Sample(load, totalCores, active); + } else if (processLoad > 0) { + cached = new Sample(processLoad, totalCores, -1); + } + lastSampleMs = now; + return cached; + } + } + + private static int sampleProcessLoad(long now) { + long jiffies = readSelfCpuJiffies(); + if (jiffies < 0) { + return 0; + } + int load = 0; + if (prevSelfJiffies > 0 && lastSampleMs > 0) { + long wallMs = now - lastSampleMs; + if (wallMs > 0) { + long delta = jiffies - prevSelfJiffies; + float pct = delta * 1000f * 100f / (USER_HZ * wallMs * cached.totalCores); + load = Math.round(Math.max(0f, Math.min(100f, pct))); + } + } + prevSelfJiffies = jiffies; + return load; + } + + private static long readSelfCpuJiffies() { + try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/stat"))) { + String line = reader.readLine(); + if (line == null) { + return -1; + } + int close = line.lastIndexOf(')'); + if (close < 0 || close + 2 >= line.length()) { + return -1; + } + String[] parts = line.substring(close + 2).split("\\s+"); + if (parts.length < 2) { + return -1; + } + return Long.parseLong(parts[0]) + Long.parseLong(parts[1]); + } catch (IOException | NumberFormatException ignored) { + return -1; + } + } + + private static int countActiveCores(long[][] prev, List curr, int totalCores) { + if (prev == null || curr.isEmpty() || prev.length != curr.size()) { + return -1; + } + int active = 0; + for (int i = 0; i < curr.size(); i++) { + if (percentDelta(prev[i], curr.get(i)) >= CORE_BUSY_THRESHOLD_PERCENT) { + active++; + } + } + return Math.min(active, totalCores); + } + + private static int percentDelta(long[] prev, long[] curr) { + long prevIdle = prev[3] + prev[4]; + long currIdle = curr[3] + curr[4]; + long prevTotal = sumTimes(prev); + long currTotal = sumTimes(curr); + long totalDelta = currTotal - prevTotal; + if (totalDelta <= 0) { + return 0; + } + long idleDelta = currIdle - prevIdle; + float busy = (totalDelta - idleDelta) * 100f / totalDelta; + return Math.round(Math.max(0f, Math.min(100f, busy))); + } + + private static long sumTimes(long[] times) { + long sum = 0; + for (long t : times) { + sum += t; + } + return sum; + } + + private static long[][] copyPerCpu(List list) { + long[][] out = new long[list.size()][]; + for (int i = 0; i < list.size(); i++) { + out[i] = list.get(i).clone(); + } + return out; + } + + private static ProcStat readProcStat() { + try (BufferedReader reader = new BufferedReader(new FileReader("/proc/stat"))) { + ProcStat stat = new ProcStat(); + String line; + while ((line = reader.readLine()) != null) { + if (line.startsWith("cpu ")) { + stat.totals = parseCpuLine(line); + } else if (line.startsWith("cpu") && line.length() > 3 && Character.isDigit(line.charAt(3))) { + long[] cpu = parseCpuLine(line); + if (cpu != null) { + stat.perCpu.add(cpu); + } + } else if (!line.startsWith("cpu")) { + break; + } + } + return stat.totals != null ? stat : null; + } catch (IOException ignored) { + return null; + } + } + + private static long[] parseCpuLine(String line) { + String[] parts = line.trim().split("\\s+"); + if (parts.length < 5) { + return null; + } + long[] times = new long[Math.min(10, parts.length - 1)]; + for (int i = 0; i < times.length; i++) { + try { + times[i] = Long.parseLong(parts[i + 1]); + } catch (NumberFormatException e) { + return null; + } + } + return times; + } + + private static final class ProcStat { + long[] totals; + final List perCpu = new ArrayList<>(); + } + + /** HTML suffix: {@code (2/8)} or {@code (8 cores)}. */ + public static String formatCoreSuffix(Sample sample) { + if (sample.activeCores >= 0 && sample.activeCores <= sample.totalCores) { + return String.format(Locale.US, "(%d/%d)", sample.activeCores, sample.totalCores); + } + return String.format(Locale.US, "(%d cores)", sample.totalCores); + } +} 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 678090b..b9d425f 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -33,6 +33,8 @@ public final class CastProtocol { public static final byte MSG_CODEC_SELECTED = 15; /** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */ public static final byte MSG_NACK = 16; + /** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */ + public static final byte MSG_RECEIVER_CAST_SETTINGS = 17; /** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */ public static final byte MSG_WIRE_PACKET = 127; @@ -268,7 +270,7 @@ public final class CastProtocol { return local.getTransport().equals(remote.getTransport()); } - private static final byte NETWORK_STATS_VERSION = 4; + private static final byte NETWORK_STATS_VERSION = 5; public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); @@ -318,6 +320,10 @@ public final class CastProtocol { dos.writeLong(s.fecStubPackets); dos.writeLong(s.nackStubRequests); } + if (NETWORK_STATS_VERSION >= 5) { + dos.writeLong(s.fecPacketsDecoded); + dos.writeLong(s.fecDecodeFailures); + } dos.flush(); return bos.toByteArray(); } @@ -372,6 +378,10 @@ public final class CastProtocol { s.fecStubPackets = dis.readLong(); s.nackStubRequests = dis.readLong(); } + if (ver >= 5 && dis.available() > 0) { + s.fecPacketsDecoded = dis.readLong(); + s.fecDecodeFailures = dis.readLong(); + } } } return s; diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java index 3d582dc..80b4a10 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java @@ -13,12 +13,17 @@ import java.util.List; public class CastSession { public static final class HandshakeResult { public final String senderName; + /** Sender cast settings from handshake. */ public final CastSettings settings; + /** Receiver advertisement ({@code AUTO} = auto-detected FEC capability). */ + public final CastSettings receiverAdvertisement; public final String negotiatedVideoMime; - public HandshakeResult(String senderName, CastSettings settings, String negotiatedVideoMime) { + public HandshakeResult(String senderName, CastSettings settings, + CastSettings receiverAdvertisement, String negotiatedVideoMime) { this.senderName = senderName; this.settings = settings; + this.receiverAdvertisement = receiverAdvertisement; this.negotiatedVideoMime = negotiatedVideoMime; } } @@ -60,13 +65,16 @@ public class CastSession { transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes())); String negotiated = null; + CastSettings receiverAdvertisement = null; long deadline = System.currentTimeMillis() + 15_000; while (System.currentTimeMillis() < deadline) { CastProtocol.Message msg = transport.receive(2_000); if (msg == null) { continue; } - if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { + if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) { + receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload); + } else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) { negotiated = CastProtocol.parseCodecSelected(msg.payload); break; } @@ -76,7 +84,7 @@ public class CastSession { } settings.setNegotiatedVideoMime(negotiated); CodecPriorityCatalog.logReceivedCodec("sender", negotiated, settings); - return new HandshakeResult(deviceName, settings, negotiated); + return new HandshakeResult(deviceName, settings, receiverAdvertisement, negotiated); } public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException { @@ -117,13 +125,20 @@ public class CastSession { break; } if (authed && remote != null && senderCaps != null) { + CastSettings receiverAdvert = new CastSettings(); + receiverAdvert.copyFrom(localSettings); + receiverAdvert.setStreamProtection( + com.foxx.androidcast.network.transport.StreamProtectionCapability + .handshakeAdvertisement()); + transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, + CastProtocol.castSettingsPayload(receiverAdvert)); List receiverCaps = CodecCatalog.localDecoderMimes(); transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps)); String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver"); transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime)); remote.setNegotiatedVideoMime(mime); - return new HandshakeResult(senderName, remote, mime); + return new HandshakeResult(senderName, remote, receiverAdvert, mime); } } throw new IOException("Handshake timeout"); 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 15e54a4..e680118 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -57,17 +57,27 @@ public final class CastTransportFactory { } } - /** Apply protection after handshake using negotiated local/remote preferences. */ - public static void applyNegotiatedUdpProtection(UdpCastTransport udp, CastSettings local, - CastSettings remote) { + /** + * Apply protection after handshake using negotiated local/remote preferences. + * + * @param localIsReceiver true when {@code local} is the receiver's capability from settings. + * @return negotiation details (prompts, agreed mode) for UI/logging. + */ + public static com.foxx.androidcast.network.transport.StreamProtectionNegotiation applyNegotiatedUdpProtection( + UdpCastTransport udp, CastSettings local, CastSettings remote, boolean localIsReceiver) { if (udp == null || local == null) { - return; + return com.foxx.androidcast.network.transport.StreamProtectionNegotiation.none(); } - CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator - .resolve(local.getStreamProtection(), remote != null ? remote.getStreamProtection() : null); + com.foxx.androidcast.network.transport.StreamProtectionNegotiation negotiated = + com.foxx.androidcast.network.transport.StreamProtectionNegotiator.negotiate( + local.getStreamProtection(), + remote != null ? remote.getStreamProtection() : null, + localIsReceiver); CastSettings effective = new CastSettings(); effective.copyFrom(local); - effective.setStreamProtection(mode); - applyUdpProtection(udp, effective); + effective.setStreamProtection(negotiated.agreed); + udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionFactory.create( + negotiated.agreed, udp.getLossStats(), negotiated.nackEnabled)); + return negotiated; } } 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 8bac0e4..d21cc13 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -1,5 +1,7 @@ package com.foxx.androidcast.network; +import android.util.Log; + import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.network.transport.CombinedProtectionEngine; import com.foxx.androidcast.network.transport.FecProtectionEngine; @@ -27,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; /** Best-effort message transport over UDP with fragmentation. */ public class UdpCastTransport implements CastTransport, TransportStatsProvider { + private static final String TAG = "UdpCastTransport"; 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; @@ -45,7 +48,12 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { private final RetransmitCache retransmitCache = new RetransmitCache(); private CastFramingContext framingContext; private CastSessionGate inboundSessionGate; - private static final long REASSEMBLY_TIMEOUT_MS = 3_000; + private static final long REASSEMBLY_TIMEOUT_MIN_MS = 3_000; + private static final long REASSEMBLY_TIMEOUT_MAX_MS = 20_000; + /** Large VP9 keyframes need many UDP fragments per FEC shard; allow extra time to collect shards. */ + private static final long FEC_GROUP_TIMEOUT_MS = 12_000; + private static final int REASSEMBLY_LOG_EVERY = 16; + private int reassemblyExpireLogCount; public boolean isListening() { return listening && socket != null && !socket.isClosed(); @@ -81,8 +89,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { } socket = new DatagramSocket(null); socket.setReuseAddress(true); - socket.setReceiveBufferSize(512 * 1024); - socket.setSendBufferSize(512 * 1024); + socket.setReceiveBufferSize(2 * 1024 * 1024); + socket.setSendBufferSize(2 * 1024 * 1024); } @Override @@ -142,9 +150,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { sendOnce(type, body, false); } - void retransmitCached(byte wireType, byte[] body) { + void retransmitCached(byte wireType, byte[] body, int messageId) { try { - sendOnce(wireType, body, false); + sendOnceWithId(wireType, body, false, messageId); } catch (IOException ignored) { } } @@ -156,12 +164,15 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { synchronized (lossStats) { lossStats.nackCacheMisses++; } + if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 1) { + Log.w(TAG, "NACK cache miss id=" + messageId); + } return; } synchronized (lossStats) { lossStats.nackRetransmits++; } - retransmitCached(entry.wireType, entry.body); + retransmitCached(entry.wireType, entry.body, messageId); } @Override @@ -199,6 +210,17 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { for (int i = 0; i < group.totalShards(); i++) { byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i); sendOnceWithId(wireType, shardWire, cache, baseId + i); + if (i + 1 < group.totalShards() && shardWire.length > 8_000) { + paceBetweenLargeShards(); + } + } + } + + private static void paceBetweenLargeShards() { + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } @@ -213,6 +235,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { } int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER; int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize); + boolean paceFragments = fragments > 24; for (int i = 0; i < fragments; i++) { int offset = i * chunkSize; int len = Math.min(chunkSize, body.length - offset); @@ -222,6 +245,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { synchronized (lossStats) { lossStats.datagramsSent++; } + if (paceFragments && i + 1 < fragments && (i & 7) == 7) { + paceBetweenLargeShards(); + } } } @@ -259,30 +285,55 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { private void expireStaleFecGroups() { if (fecEngine == null || nackEngine == null) { if (fecEngine != null) { - fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null); + fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, null); } return; } - fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack); + fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, nackEngine::requestNack); } 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) { + if (r != null && now > r.deadlineMs) { pending.remove(e.getKey()); synchronized (lossStats) { lossStats.reassemblyIncomplete++; } + maybeLogReassemblyExpired(e.getKey(), r); int messageId = e.getKey(); if (nackEngine != null) { nackEngine.requestNack(messageId); + } else if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 2) { + Log.w(TAG, "Reassembly expired without NACK id=" + messageId); } } } } + private void maybeLogReassemblyExpired(int messageId, Reassembly r) { + if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) != 1) { + return; + } + Log.w(TAG, "UDP reassembly timeout id=" + messageId + " frags=" + r.received + "/" + + r.fragCount + " bytes=" + r.totalLen); + } + + private static long reassemblyDeadlineMs(int fragCount, int totalLen) { + if (fragCount <= 1) { + return System.currentTimeMillis() + REASSEMBLY_TIMEOUT_MIN_MS; + } + int chunkSize = Math.max(1, CastConfig.UDP_CHUNK_SIZE - HEADER); + int expectedFrags = Math.max(fragCount, (totalLen + chunkSize - 1) / chunkSize); + long budget = REASSEMBLY_TIMEOUT_MIN_MS + expectedFrags * 200L + (totalLen / 2048); + if (fragCount > 1) { + budget = Math.max(8_000L, budget); + } + budget = Math.min(REASSEMBLY_TIMEOUT_MAX_MS, budget); + return System.currentTimeMillis() + budget; + } + private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException { if (length < HEADER) { synchronized (lossStats) { @@ -495,6 +546,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { final int fragCount; final int totalLen; final long startedMs; + final long deadlineMs; final byte[][] parts; final int[] sizes; int received; @@ -503,6 +555,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider { this.fragCount = fragCount; this.totalLen = totalLen; this.startedMs = System.currentTimeMillis(); + this.deadlineMs = reassemblyDeadlineMs(fragCount, totalLen); this.parts = new byte[fragCount][]; this.sizes = new int[fragCount]; } 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 465a8fd..bebb93f 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 @@ -47,6 +47,8 @@ public class NetworkStatsSnapshot { public long recvDamagedVideoFrames; public String protectionMode = "NONE"; public long fecStubPackets; + public long fecPacketsDecoded; + public long fecDecodeFailures; public long nackStubRequests; public NetworkStatsSnapshot copy() { @@ -90,6 +92,8 @@ public class NetworkStatsSnapshot { c.recvDamagedVideoFrames = recvDamagedVideoFrames; c.protectionMode = protectionMode; c.fecStubPackets = fecStubPackets; + c.fecPacketsDecoded = fecPacketsDecoded; + c.fecDecodeFailures = fecDecodeFailures; c.nackStubRequests = nackStubRequests; return c; } diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/CombinedProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/CombinedProtectionEngine.java index f7ffa04..88013f0 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/CombinedProtectionEngine.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/CombinedProtectionEngine.java @@ -20,7 +20,9 @@ public final class CombinedProtectionEngine implements StreamProtectionEngine { @Override public StreamProtectionMode mode() { - return StreamProtectionMode.FEC_NACK; + return fec.mode() == StreamProtectionMode.FEC_REED_SOLOMON + ? StreamProtectionMode.FEC_REED_SOLOMON + : StreamProtectionMode.FEC_3_4; } @Override diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java index ac33314..d9ee5bb 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java @@ -8,6 +8,8 @@ import com.foxx.androidcast.network.transport.fec.FecShardWire; import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope; import com.foxx.androidcast.network.transport.fec.ReedSolomonFec; +import android.util.Log; + import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -16,9 +18,13 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; -/** Reed-Solomon FEC: v2 sends one shard per UDP message; v1 monolithic decode still accepted inbound. */ +/** Reed-Solomon FEC: one shard per UDP message; v1 monolithic decode still accepted inbound. */ public final class FecProtectionEngine implements StreamProtectionEngine { + private static final String TAG = "FecProtection"; private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1); + private static final int LOG_EVERY_N = 32; + private int inboundShardRejectLogCount; + private int inboundDecodeFailLogCount; private final byte mode; private final int dataShards; @@ -53,11 +59,25 @@ public final class FecProtectionEngine implements StreamProtectionEngine { return protectionMode; } + /** Pick shard counts so each ACUD logical message stays small on lossy Wi‑Fi. */ + static int[] shardPlanForPayload(int payloadLength, int defaultData, int defaultParity) { + if (payloadLength > 64_000) { + return new int[] {16, 8}; + } + if (payloadLength > 16_000) { + return new int[] {8, 4}; + } + return new int[] {defaultData, defaultParity}; + } + public FecEncodedGroup encodeGroup(byte[] payload) throws IOException { if (payload == null || payload.length == 0) { throw new IOException("empty payload"); } - ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards); + int[] plan = shardPlanForPayload(payload.length, dataShards, parityShards); + int k = plan[0]; + int m = plan[1]; + ReedSolomonFec fec = new ReedSolomonFec(k, m); byte[][] shards = fec.encode(payload); int groupId = NEXT_GROUP_ID.getAndIncrement(); if (stats != null) { @@ -65,7 +85,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine { stats.fecPacketsEncoded += shards.length; } } - return new FecEncodedGroup(mode, groupId, payload.length, dataShards, parityShards, shards); + return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards); } public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex) @@ -116,6 +136,17 @@ public final class FecProtectionEngine implements StreamProtectionEngine { assembly = prev != null ? prev : created; } if (!assembly.addShard(shard)) { + maybeLogShardReject(shard, assembly); + byte[] retry = assembly.tryDecode(); + if (retry != null) { + inboundGroups.remove(shard.groupId); + if (stats != null) { + synchronized (stats) { + stats.fecPacketsDecoded++; + } + } + return new ShardDecodeResult(ShardDecodeResult.Status.OK, retry); + } return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null); } byte[] decoded = assembly.tryDecode(); @@ -126,6 +157,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine { stats.fecDecodeFailures++; } } + maybeLogDecodeFail(shard.groupId, assembly.receivedCount(), assembly.getTotalShards()); inboundGroups.remove(shard.groupId); return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null); } @@ -212,6 +244,22 @@ public final class FecProtectionEngine implements StreamProtectionEngine { return decoded.payload; } + private void maybeLogShardReject(FecShardWire.Parsed shard, FecShardReassembly assembly) { + if ((++inboundShardRejectLogCount % LOG_EVERY_N) != 1) { + return; + } + Log.w(TAG, "FEC shard reject gid=" + shard.groupId + " idx=" + shard.shardIndex + + " shardLen=" + shard.shardData.length + " recv=" + assembly.receivedCount() + + "/" + assembly.getTotalShards()); + } + + private void maybeLogDecodeFail(int groupId, int received, int total) { + if ((++inboundDecodeFailLogCount % LOG_EVERY_N) != 1) { + return; + } + Log.w(TAG, "FEC RS decode failed gid=" + groupId + " shards=" + received + "/" + total); + } + private static boolean looksLikeWireHeader(byte[] payload) { if (payload == null || payload.length < 4) { return false; diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/ProtectionNegotiationPrompts.java b/app/src/main/java/com/foxx/androidcast/network/transport/ProtectionNegotiationPrompts.java new file mode 100644 index 0000000..812cc2d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/ProtectionNegotiationPrompts.java @@ -0,0 +1,56 @@ +package com.foxx.androidcast.network.transport; + +import android.content.Context; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.R; + +/** Formats {@link StreamProtectionNegotiation.Prompt} for logs and on-screen status. */ +public final class ProtectionNegotiationPrompts { + private ProtectionNegotiationPrompts() {} + + public static String format(Context context, StreamProtectionNegotiation.Prompt prompt, + boolean localIsReceiver) { + if (context == null || prompt == null) { + return ""; + } + String local = label(context, prompt.localPreference); + String remote = label(context, prompt.remotePreference); + String role = context.getString(prompt.target == StreamProtectionNegotiation.Prompt.Target.SENDER + ? R.string.protection_negotiation_role_sender + : R.string.protection_negotiation_role_receiver); + return context.getString(prompt.messageResId, local, remote, role); + } + + public static String summarize(Context context, StreamProtectionNegotiation negotiation) { + if (context == null || negotiation == null) { + return ""; + } + String mode = label(context, negotiation.agreed); + String nack = negotiation.nackEnabled + ? context.getString(R.string.protection_negotiation_nack_on) + : context.getString(R.string.protection_negotiation_nack_off); + return context.getString(R.string.protection_negotiation_agreed, mode, nack); + } + + public static String label(Context context, CastSettings.StreamProtection mode) { + if (context == null || mode == null) { + return "?"; + } + switch (mode) { + case FEC_3_4: + return context.getString(R.string.protection_fec_34); + case FEC_REED_SOLOMON: + return context.getString(R.string.protection_fec_rs); + case NACK: + return context.getString(R.string.protection_nack); + case FEC_NACK: + return context.getString(R.string.protection_fec_nack); + case AUTO: + return context.getString(R.string.option_auto); + case NONE: + default: + return context.getString(R.string.protection_none); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/RetransmitCache.java b/app/src/main/java/com/foxx/androidcast/network/transport/RetransmitCache.java index 90230b1..80937de 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/RetransmitCache.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/RetransmitCache.java @@ -5,7 +5,8 @@ import java.util.Map; /** Ring buffer of recent UDP logical messages for NACK retransmission. */ public final class RetransmitCache { - private static final int DEFAULT_CAPACITY = 64; + /** VP9+FEC uses 7 message ids per frame; must cover several seconds of in-flight shards. */ + private static final int DEFAULT_CAPACITY = 4096; private final int capacity; private final LinkedHashMap map; diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCapability.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCapability.java new file mode 100644 index 0000000..94b8096 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCapability.java @@ -0,0 +1,46 @@ +package com.foxx.androidcast.network.transport; + +import com.foxx.androidcast.CastSettings; + +import java.util.EnumSet; + +/** Runtime FEC/NACK implementations on this device (not a user setting). */ +public final class StreamProtectionCapability { + private StreamProtectionCapability() {} + + /** Wire value: receiver supports negotiation from {@link #receiverImplementations()}. */ + public static CastSettings.StreamProtection handshakeAdvertisement() { + return CastSettings.StreamProtection.AUTO; + } + + public static boolean isAutoAdvertisement(CastSettings.StreamProtection mode) { + return mode == null || mode == CastSettings.StreamProtection.AUTO; + } + + /** Modes this build can encode/decode over UDP. */ + public static EnumSet receiverImplementations() { + return EnumSet.of( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.FEC_REED_SOLOMON, + CastSettings.StreamProtection.NACK, + CastSettings.StreamProtection.FEC_NACK, + CastSettings.StreamProtection.NONE); + } + + public static boolean supports(CastSettings.StreamProtection mode) { + CastSettings.StreamProtection n = StreamProtectionNegotiator.normalize(mode); + return receiverImplementations().contains(n); + } + + public static EnumSet resolveRemoteCapability( + CastSettings.StreamProtection remote) { + if (isAutoAdvertisement(remote)) { + return receiverImplementations(); + } + CastSettings.StreamProtection n = StreamProtectionNegotiator.normalize(remote); + if (supports(n)) { + return EnumSet.of(n); + } + return EnumSet.of(CastSettings.StreamProtection.NONE); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionFactory.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionFactory.java index cf91586..d703290 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionFactory.java @@ -1,7 +1,6 @@ package com.foxx.androidcast.network.transport; import com.foxx.androidcast.CastSettings; -import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope; @@ -16,17 +15,22 @@ public final class StreamProtectionFactory { public static StreamProtectionEngine create(CastSettings.StreamProtection setting, TransportLossStats stats) { + return create(setting, stats, wantsBundledNack(setting)); + } + + public static StreamProtectionEngine create(CastSettings.StreamProtection setting, + TransportLossStats stats, boolean nackEnabled) { CastSettings.StreamProtection mode = StreamProtectionNegotiator.normalize(setting); RetransmitCache cache = new RetransmitCache(); switch (mode) { case NONE: return StreamProtectionEngine.noop(); case FEC_3_4: - return new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34, - FEC34_DATA, FEC34_PARITY, stats); + return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34, + FEC34_DATA, FEC34_PARITY, stats), stats, cache, nackEnabled); case FEC_REED_SOLOMON: - return new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON, - RS_DATA, RS_PARITY, stats); + return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON, + RS_DATA, RS_PARITY, stats), stats, cache, nackEnabled); case NACK: return new NackProtectionEngine(stats, cache); case FEC_NACK: @@ -38,6 +42,20 @@ public final class StreamProtectionFactory { } } + private static StreamProtectionEngine wrapFec(FecProtectionEngine fec, + TransportLossStats stats, RetransmitCache cache, boolean nackEnabled) { + if (!nackEnabled) { + return fec; + } + return new CombinedProtectionEngine(fec, new NackProtectionEngine(stats, cache)); + } + + private static boolean wantsBundledNack(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.FEC_3_4 + || mode == CastSettings.StreamProtection.FEC_NACK + || mode == CastSettings.StreamProtection.FEC_REED_SOLOMON; + } + public static void wireUdp(UdpCastTransport udp, StreamProtectionEngine engine) { if (udp == null || engine == null) { return; diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiation.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiation.java new file mode 100644 index 0000000..b762331 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiation.java @@ -0,0 +1,45 @@ +package com.foxx.androidcast.network.transport; + +import com.foxx.androidcast.CastSettings; + +/** Outcome of matching local/remote {@link CastSettings.StreamProtection} preferences. */ +public final class StreamProtectionNegotiation { + public final CastSettings.StreamProtection agreed; + public final boolean nackEnabled; + public final Prompt prompt; + + public StreamProtectionNegotiation(CastSettings.StreamProtection agreed, boolean nackEnabled, + Prompt prompt) { + this.agreed = agreed != null ? agreed : CastSettings.StreamProtection.NONE; + this.nackEnabled = nackEnabled; + this.prompt = prompt; + } + + public static StreamProtectionNegotiation none() { + return new StreamProtectionNegotiation(CastSettings.StreamProtection.NONE, false, null); + } + + public static final class Prompt { + public enum Target { + /** Ask the casting phone to change stream protection in settings. */ + SENDER, + /** Ask the receiver device to change stream protection in settings. */ + RECEIVER + } + + public final Target target; + /** String resource id for a short user-visible hint. */ + public final int messageResId; + public final CastSettings.StreamProtection localPreference; + public final CastSettings.StreamProtection remotePreference; + + public Prompt(Target target, int messageResId, + CastSettings.StreamProtection localPreference, + CastSettings.StreamProtection remotePreference) { + this.target = target; + this.messageResId = messageResId; + this.localPreference = localPreference; + this.remotePreference = remotePreference; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java index c720377..14cda8f 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java @@ -1,32 +1,242 @@ package com.foxx.androidcast.network.transport; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.R; -/** Picks an agreed stream protection mode for both peers. */ +import java.util.EnumSet; + +/** Picks agreed stream protection from receiver capability vs sender preference. */ public final class StreamProtectionNegotiator { private StreamProtectionNegotiator() {} - /** - * Agreed mode for this session. Any mismatch, missing remote preference, or {@code NONE} - * on either side yields {@link CastSettings.StreamProtection#NONE} (plain UDP). - */ + /** @deprecated Use {@link #negotiate(CastSettings.StreamProtection, CastSettings.StreamProtection, boolean)}. */ + @Deprecated public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local, CastSettings.StreamProtection remote) { - CastSettings.StreamProtection a = normalize(local); - CastSettings.StreamProtection b = normalize(remote); - if (a == CastSettings.StreamProtection.NONE || b == CastSettings.StreamProtection.NONE) { - return CastSettings.StreamProtection.NONE; + return negotiate(local, remote, false).agreed; + } + + /** + * @param local sender preference when {@code localIsReceiver} is false; ignored on receiver + * (capability is auto-detected) + * @param remote sender preference when {@code localIsReceiver}; receiver wire adv. when sender + */ + public static StreamProtectionNegotiation negotiate(CastSettings.StreamProtection local, + CastSettings.StreamProtection remote, boolean localIsReceiver) { + EnumSet receiverCaps; + CastSettings.StreamProtection senderPref; + if (localIsReceiver) { + receiverCaps = StreamProtectionCapability.receiverImplementations(); + senderPref = remote; + } else { + senderPref = local; + receiverCaps = StreamProtectionCapability.resolveRemoteCapability(remote); } - if (a == b) { - return a; + return negotiateWithReceiverCaps(receiverCaps, senderPref, local, remote, localIsReceiver); + } + + private static StreamProtectionNegotiation negotiateWithReceiverCaps( + EnumSet receiverCaps, + CastSettings.StreamProtection senderPref, + CastSettings.StreamProtection localRaw, + CastSettings.StreamProtection remoteRaw, + boolean localIsReceiver) { + CastSettings.StreamProtection sender = normalize(senderPref); + CastSettings.StreamProtection receiverLabel = localIsReceiver + ? StreamProtectionCapability.handshakeAdvertisement() + : pickRepresentativeCap(receiverCaps); + + if (sender == CastSettings.StreamProtection.NONE) { + if (receiverCaps.stream().anyMatch(StreamProtectionNegotiator::isFec)) { + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + return new StreamProtectionNegotiation( + CastSettings.StreamProtection.NONE, + false, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_use_none, + receiverLabel, + senderPref)); + } + return StreamProtectionNegotiation.none(); + } + + if (receiverCaps.contains(sender)) { + return agreeExact(sender, receiverLabel, senderPref); + } + + if (isFec34(sender) && normalize(remoteRaw) == CastSettings.StreamProtection.FEC_NACK) { + return fec34WithoutNack(receiverLabel, senderPref, localIsReceiver); + } + + if (isNackOnly(sender) && receiverCaps.stream().anyMatch(StreamProtectionNegotiator::isFec)) { + CastSettings.StreamProtection fec = receiverCaps.contains(CastSettings.StreamProtection.FEC_NACK) + ? CastSettings.StreamProtection.FEC_NACK + : receiverCaps.contains(CastSettings.StreamProtection.FEC_3_4) + ? CastSettings.StreamProtection.FEC_3_4 + : CastSettings.StreamProtection.FEC_REED_SOLOMON; + return agreeFecWithNack(fec, receiverLabel, senderPref, localIsReceiver); + } + + if (isFec(sender)) { + if (isFec34(sender) && receiverCaps.contains(CastSettings.StreamProtection.FEC_REED_SOLOMON)) { + return negotiateFecFamilyMismatch( + CastSettings.StreamProtection.FEC_REED_SOLOMON, + CastSettings.StreamProtection.FEC_3_4, + receiverLabel, + senderPref, + localIsReceiver); + } + if (sender == CastSettings.StreamProtection.FEC_REED_SOLOMON + && receiverCaps.contains(CastSettings.StreamProtection.FEC_3_4)) { + return negotiateFecFamilyMismatch( + CastSettings.StreamProtection.FEC_REED_SOLOMON, + CastSettings.StreamProtection.FEC_3_4, + receiverLabel, + senderPref, + localIsReceiver); + } + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + return new StreamProtectionNegotiation( + CastSettings.StreamProtection.NONE, + false, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_use_none, + receiverLabel, + senderPref)); + } + + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + return new StreamProtectionNegotiation( + CastSettings.StreamProtection.NONE, + false, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_mismatch, + receiverLabel, + senderPref)); + } + + private static CastSettings.StreamProtection pickRepresentativeCap( + EnumSet caps) { + if (caps.contains(CastSettings.StreamProtection.FEC_REED_SOLOMON)) { + return CastSettings.StreamProtection.FEC_REED_SOLOMON; + } + if (caps.contains(CastSettings.StreamProtection.FEC_3_4)) { + return CastSettings.StreamProtection.FEC_3_4; + } + if (caps.contains(CastSettings.StreamProtection.FEC_NACK)) { + return CastSettings.StreamProtection.FEC_NACK; + } + if (caps.contains(CastSettings.StreamProtection.NACK)) { + return CastSettings.StreamProtection.NACK; } return CastSettings.StreamProtection.NONE; } + private static StreamProtectionNegotiation negotiateFecFamilyMismatch( + CastSettings.StreamProtection agreed, + CastSettings.StreamProtection other, + CastSettings.StreamProtection receiverLabel, + CastSettings.StreamProtection senderPref, + boolean localIsReceiver) { + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + boolean nack = wantsNack(other); + if (isFecNack(other)) { + nack = false; + } + return new StreamProtectionNegotiation( + agreed, + nack, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_fec_family_rs, + receiverLabel, + senderPref)); + } + + private static StreamProtectionNegotiation fec34WithoutNack( + CastSettings.StreamProtection receiverLabel, + CastSettings.StreamProtection senderPref, + boolean localIsReceiver) { + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + return new StreamProtectionNegotiation( + CastSettings.StreamProtection.FEC_3_4, + false, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_fec34_no_nack, + receiverLabel, + senderPref)); + } + + private static StreamProtectionNegotiation agreeFecWithNack( + CastSettings.StreamProtection fecMode, + CastSettings.StreamProtection receiverLabel, + CastSettings.StreamProtection senderPref, + boolean localIsReceiver) { + StreamProtectionNegotiation.Prompt.Target target = localIsReceiver + ? StreamProtectionNegotiation.Prompt.Target.SENDER + : StreamProtectionNegotiation.Prompt.Target.RECEIVER; + return new StreamProtectionNegotiation( + fecMode, + true, + new StreamProtectionNegotiation.Prompt( + target, + R.string.protection_negotiation_nack_to_fec, + receiverLabel, + senderPref)); + } + + private static StreamProtectionNegotiation agreeExact( + CastSettings.StreamProtection mode, + CastSettings.StreamProtection receiverLabel, + CastSettings.StreamProtection senderPref) { + boolean nack = wantsNack(mode); + if (mode == CastSettings.StreamProtection.FEC_3_4) { + nack = true; + } + return new StreamProtectionNegotiation(mode, nack, null); + } + public static CastSettings.StreamProtection normalize(CastSettings.StreamProtection mode) { if (mode == null || mode == CastSettings.StreamProtection.AUTO) { return CastSettings.StreamProtection.NONE; } return mode; } + + private static boolean wantsNack(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.NACK + || mode == CastSettings.StreamProtection.FEC_NACK; + } + + private static boolean isNackOnly(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.NACK; + } + + private static boolean isFec(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.FEC_3_4 + || mode == CastSettings.StreamProtection.FEC_REED_SOLOMON + || mode == CastSettings.StreamProtection.FEC_NACK; + } + + private static boolean isFec34(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.FEC_3_4; + } + + private static boolean isFecNack(CastSettings.StreamProtection mode) { + return mode == CastSettings.StreamProtection.FEC_NACK; + } } 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 index 340f4e5..192b1c7 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java @@ -33,6 +33,8 @@ public final class TransportLossStatsBridge { s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames; s.protectionMode = loss.protectionMode.name(); s.fecStubPackets = loss.fecPacketsEncoded; + s.fecPacketsDecoded = loss.fecPacketsDecoded; + s.fecDecodeFailures = loss.fecDecodeFailures; s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived; long lossEvents = loss.estimatedUdpLossEvents(); if (loss.datagramsReceived > 0 && lossEvents > 0) { diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java index 960e63a..b9bca7b 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java @@ -7,14 +7,19 @@ import java.io.DataOutputStream; import java.io.IOException; /** - * Per-datagram FEC shard (v2). Each shard is its own ACUD logical message so loss of one + * Per-datagram FEC shard. Each shard is its own ACUD logical message so loss of one * fragment does not invalidate the whole Reed–Solomon block. + * + *

v2 used a single-byte shard length (max 255), which breaks VP9 keyframes. v3 uses uint16. */ public final class FecShardWire { private static final byte[] MAGIC = {'A', 'C', 'P', 'F'}; - public static final byte VERSION_SHARD = 2; - /** Bytes after {@link #MAGIC} before shard payload. */ - private static final int HEADER_AFTER_MAGIC = 19; + /** Legacy on-wire format; shard length field is 1 byte (max 255). */ + public static final byte VERSION_SHARD_V2 = 2; + /** Current format; shard length field is uint16. */ + public static final byte VERSION_SHARD = 3; + private static final int HEADER_AFTER_MAGIC_V2 = 19; + private static final int HEADER_AFTER_MAGIC_V3 = 20; private FecShardWire() {} @@ -31,6 +36,9 @@ public final class FecShardWire { if (shardData == null) { throw new IOException("shardData required"); } + if (shardData.length > 0xFFFF) { + throw new IOException("shard too large: " + shardData.length); + } ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length); DataOutputStream dos = new DataOutputStream(bos); dos.write(MAGIC); @@ -43,18 +51,17 @@ public final class FecShardWire { dos.writeInt(originalLen); dos.writeByte(dataShards); dos.writeByte(parityShards); - dos.writeByte(shardData.length); + dos.writeShort(shardData.length); dos.write(shardData); return bos.toByteArray(); } public static boolean isShardPacket(byte[] wire) { - Parsed p = tryParse(wire); - return p != null; + return tryParse(wire) != null; } public static Parsed tryParse(byte[] wire) { - if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC) { + if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC_V2) { return null; } for (int i = 0; i < MAGIC.length; i++) { @@ -66,7 +73,7 @@ public final class FecShardWire { DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length, wire.length - MAGIC.length)); byte ver = dis.readByte(); - if (ver != VERSION_SHARD) { + if (ver != VERSION_SHARD_V2 && ver != VERSION_SHARD) { return null; } byte mode = dis.readByte(); @@ -77,14 +84,15 @@ public final class FecShardWire { int originalLen = dis.readInt(); int dataShards = dis.readUnsignedByte(); int parityShards = dis.readUnsignedByte(); - int shardSize = dis.readUnsignedByte(); + int shardSize = ver == VERSION_SHARD ? dis.readUnsignedShort() : dis.readUnsignedByte(); if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) { return null; } - if (shardIndex >= totalShards || originalLen < 1) { + if (shardIndex >= totalShards || originalLen < 1 || shardSize < 1) { return null; } - if (wire.length < MAGIC.length + HEADER_AFTER_MAGIC + shardSize) { + int headerAfterMagic = ver == VERSION_SHARD ? HEADER_AFTER_MAGIC_V3 : HEADER_AFTER_MAGIC_V2; + if (wire.length < MAGIC.length + headerAfterMagic + shardSize) { return null; } byte[] shardData = new byte[shardSize]; 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 d6eb1f9..380d78a 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -166,21 +166,31 @@ public class AudioDecoder { Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState()); } + /** Drops queued AAC input without tearing down the decoder (e.g. after video keyframe wait). */ + public void clearInputQueue() { + inputQueue.clear(); + retryFrame = null; + } + public void queueFrame(long ptsUs, byte[] data) { if (data == null || data.length == 0) { return; } stats.framesQueued++; - if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { - PendingFrame dropped = inputQueue.poll(); - if (dropped != null) { - stats.queueDrops++; - logDropBurst(); - } - if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { - stats.queueDrops++; - logDropBurst(); - } + PendingFrame frame = new PendingFrame(ptsUs, data.clone()); + if (inputQueue.offer(frame)) { + return; + } + int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0); + inputQueue.clear(); + retryFrame = null; + if (dropped > 0) { + stats.queueDrops += dropped; + logDropBurst(); + } + if (!inputQueue.offer(frame)) { + stats.queueDrops++; + logDropBurst(); } } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java index a1c3493..2773c76 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/LibvpxVideoDecoder.java @@ -11,8 +11,10 @@ import java.io.IOException; /** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */ public final class LibvpxVideoDecoder implements VideoDecoderSink { private static final String TAG = "LibvpxVideoDecoder"; + private static final int DECODE_FAIL_LOG_EVERY = 16; private final Object lock = new Object(); + private int decodeFailLogCount; private long nativeHandle; private Surface outputSurface; private Listener listener; @@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink { } if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) { notifyRenderedLocked(); + } else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) { + Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame); } } } 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 5c3e62f..d7f5a61 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -25,6 +25,8 @@ import com.foxx.androidcast.IntentExtras; import com.foxx.androidcast.R; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; +import com.foxx.androidcast.diagnostics.CastMtuSampler; +import com.foxx.androidcast.diagnostics.CpuLoadSampler; import com.foxx.androidcast.media.codec.CodecSessionRegistry; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; import com.foxx.androidcast.media.codec.VideoCodecBackend; @@ -169,6 +171,7 @@ public class ReceiverCastService extends Service { videoDecodeThread = new HandlerThread("VideoDecode"); videoDecodeThread.start(); videoDecodeHandler = new Handler(videoDecodeThread.getLooper()); + CpuLoadSampler.warmUp(); mainHandler.post(idleWatchdog); } @@ -337,7 +340,7 @@ public class ReceiverCastService extends Service { @Override public void onAudioFrame(long ptsUs, byte[] data) { - if (castEnded) { + if (castEnded || awaitingKeyframe || streamIdle) { return; } final int frameBytes = data != null ? data.length : 0; @@ -476,7 +479,7 @@ public class ReceiverCastService extends Service { return; } streamIdle = false; - awaitingKeyframe = true; + enterAwaitingKeyframe(); openPlaybackAwaiting(R.string.playback_awaiting_stream); scheduleDecoderConfigureRetries(); } @@ -485,7 +488,7 @@ public class ReceiverCastService extends Service { private void enterAwaitingMode(int messageResId, boolean fullReset) { streamIdle = true; hasPendingConfig = false; - awaitingKeyframe = true; + enterAwaitingKeyframe(); resetDecoderState(); if (videoDecoder != null) { videoDecoder.release(); @@ -500,7 +503,7 @@ public class ReceiverCastService extends Service { private void resetStreamState() { hasPendingConfig = false; - awaitingKeyframe = true; + enterAwaitingKeyframe(); pendingWidth = 0; pendingHeight = 0; displayWidth = 0; @@ -516,6 +519,19 @@ public class ReceiverCastService extends Service { decoderHeight = 0; } + private void enterAwaitingKeyframe() { + awaitingKeyframe = true; + flushAudioInput(); + } + + private void flushAudioInput() { + synchronized (audioDecoderLock) { + if (audioDecoder != null) { + audioDecoder.clearInputQueue(); + } + } + } + private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) { pendingAudioRate = sampleRate; pendingAudioChannels = channels; @@ -559,7 +575,7 @@ public class ReceiverCastService extends Service { streamMetrics.reset(); } reconfiguring = true; - awaitingKeyframe = true; + enterAwaitingKeyframe(); openPlaybackAwaiting(R.string.playback_awaiting_stream); pendingWidth = size.width; pendingHeight = size.height; @@ -796,7 +812,8 @@ public class ReceiverCastService extends Service { remoteCastSettings, peer, lastLocalStats, - PlaybackViewState.getZoomPercent()); + PlaybackViewState.getZoomPercent(), + CastMtuSampler.sample(this)); } private TransportLossStats buildReceiverLossSnapshot() { 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 48e3702..3efa09f 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -73,6 +73,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { private boolean bound; private boolean autoStartListening; private boolean listeningCancelled; + private boolean autoPipAttempted; private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() { @Override @@ -182,11 +183,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { awaitingMessage = findViewById(R.id.text_awaiting_message); pipButton = findViewById(R.id.button_pip); if (pipButton != null) { - pipButton.setOnClickListener(v -> tryEnterPictureInPicture()); + pipButton.setOnClickListener(v -> tryEnterPictureInPicture(true)); } textureView.setSurfaceTextureListener(surfaceListener); applyAspectRatio(); updatePipUi(); + if (AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported()) { + PictureInPictureHelper.applyParams(this, 16, 9); + } autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false); if (autoStartListening) { @@ -245,14 +249,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { mainHandler.post(diagnosticsRefresh); updateDiagnosticsVisibility(); updatePipUi(); + maybeAutoEnterPip(); } @Override public void onUserLeaveHint() { super.onUserLeaveHint(); - if (castSessionActive && AppPreferences.isReceiverPipEnabled(this) - && PermissionHelper.hasReceiverPlaybackReady(this)) { - tryEnterPictureInPicture(); + if (shouldAutoEnterPipOnLeave()) { + tryEnterPictureInPicture(false); } } @@ -268,6 +272,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) { updatePipUi(); + maybeAutoEnterPip(); } } @@ -546,7 +551,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { if (pipButton == null) { return; } - boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this) + boolean sessionVisible = castSessionActive || streamRendering + || (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE); + boolean show = sessionVisible && AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported() && PermissionHelper.hasReceiverPlaybackReady(this); pipButton.setVisibility(show ? View.VISIBLE : View.GONE); @@ -555,23 +562,64 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { } } - private void tryEnterPictureInPicture() { - if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) { + private boolean shouldAutoEnterPipOnLeave() { + if (!AppPreferences.isReceiverPipEnabled(this) + || !PermissionHelper.hasReceiverPlaybackReady(this)) { + return false; + } + if (castSessionActive || streamRendering) { + return true; + } + return bound && awaitingOverlay != null + && awaitingOverlay.getVisibility() == View.VISIBLE; + } + + private void maybeAutoEnterPip() { + if (!autoStartListening || autoPipAttempted || inPictureInPicture) { + return; + } + if (!AppPreferences.isReceiverPipEnabled(this) + || !PictureInPictureHelper.isSupported()) { return; } if (!PermissionHelper.hasReceiverPlaybackReady(this)) { PermissionHelper.remindForReceiverPlayback(this); return; } + autoPipAttempted = true; + mainHandler.post(() -> tryEnterPictureInPicture(false)); + } + + private void tryEnterPictureInPicture(boolean userInitiated) { + if (!AppPreferences.isReceiverPipEnabled(this)) { + return; + } + if (!castSessionActive && !streamRendering + && (awaitingOverlay == null || awaitingOverlay.getVisibility() != View.VISIBLE)) { + if (userInitiated) { + Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show(); + } + return; + } + if (!PermissionHelper.hasReceiverPlaybackReady(this)) { + if (userInitiated) { + PermissionHelper.remindForReceiverPlayback(this); + } + return; + } if (!PictureInPictureHelper.isSupported()) { - Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show(); + if (userInitiated) { + Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show(); + } return; } if (!PictureInPictureHelper.isAllowedForApp(this)) { - PictureInPictureHelper.showEnableInSettingsDialog(this); + if (userInitiated) { + PictureInPictureHelper.showEnableInSettingsDialog(this); + } return; } - if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) { + if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight) && userInitiated) { PictureInPictureHelper.showEnableInSettingsDialog(this); } } @@ -582,7 +630,12 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE); } if (pipButton != null) { - pipButton.setVisibility(inPictureInPicture || !castSessionActive ? View.GONE : View.VISIBLE); + boolean showPip = (castSessionActive || streamRendering + || (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE)) + && AppPreferences.isReceiverPipEnabled(this) + && PictureInPictureHelper.isSupported() + && PermissionHelper.hasReceiverPlaybackReady(this); + pipButton.setVisibility(inPictureInPicture || !showPip ? View.GONE : View.VISIBLE); } if (inPictureInPicture) { if (diagnosticsText != null) { 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 3660440..eb634ea 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -14,6 +14,8 @@ 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.ProtectionNegotiationPrompts; +import com.foxx.androidcast.network.transport.StreamProtectionNegotiation; import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.transport.TransportLossStats; @@ -74,6 +76,7 @@ public class ReceiverSession { private CastSession session; private CastTransport listenTransport; private final CastSessionGate inboundSessionGate = new CastSessionGate(); + private boolean loggedFirstVideoFrame; private NetworkFeedbackManager networkFeedback; private PassThroughNetworkControlPlane.StatsEnricher statsEnricher; @@ -195,6 +198,7 @@ public class ReceiverSession { listenTransport = null; } inboundSessionGate.reset(); + loggedFirstVideoFrame = false; networkFeedback = null; listener.onDisconnected(); } @@ -237,6 +241,12 @@ public class ReceiverSession { case CastProtocol.MSG_VIDEO_FRAME: CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload); recordNetworkBytes(vf.data != null ? vf.data.length : 0); + if (!loggedFirstVideoFrame) { + loggedFirstVideoFrame = true; + Log.i(TAG, "First video over network " + + (vf.keyFrame ? "KEY" : "P") + " len=" + + (vf.data != null ? vf.data.length : 0)); + } listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data); break; case CastProtocol.MSG_AUDIO_CONFIG: @@ -286,7 +296,16 @@ public class ReceiverSession { udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport(); } if (udp != null) { - CastTransportFactory.applyNegotiatedUdpProtection(udp, localSettings, remoteSettings); + StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection( + udp, localSettings, remoteSettings, true); + String summary = ProtectionNegotiationPrompts.summarize(appContext, negotiated); + Log.i(TAG, summary); + postStatus(summary); + if (negotiated.prompt != null) { + String hint = ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, true); + Log.i(TAG, hint); + postStatus(hint); + } } } 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 4b07f90..771a5c4 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java @@ -88,26 +88,27 @@ public class AudioEncoder implements AudioEncoderSink { public void stop() { running = false; - MediaCodec c; - synchronized (inputLock) { - c = codec; - codec = null; - } - if (drainThread != null) { + Thread drain = drainThread; + drainThread = null; + if (drain != null) { try { - drainThread.join(1000); + drain.join(1000); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } - if (c != null) { - try { - c.stop(); - } catch (Exception ignored) { - } - try { - c.release(); - } catch (Exception ignored) { + synchronized (inputLock) { + MediaCodec c = codec; + codec = null; + if (c != null) { + try { + c.stop(); + } catch (Exception ignored) { + } + try { + c.release(); + } catch (Exception ignored) { + } } } } @@ -115,13 +116,28 @@ public class AudioEncoder implements AudioEncoderSink { private void drainLoop() { MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); boolean configSent = false; - while (running) { - int index = codec.dequeueOutputBuffer(info, 10_000); + while (true) { + MediaCodec c; + synchronized (inputLock) { + if (!running) { + break; + } + c = codec; + if (c == null) { + break; + } + } + int index; + try { + index = c.dequeueOutputBuffer(info, 10_000); + } catch (IllegalStateException e) { + break; + } if (index == MediaCodec.INFO_TRY_AGAIN_LATER) { continue; } if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - MediaFormat fmt = codec.getOutputFormat(); + MediaFormat fmt = c.getOutputFormat(); if (!configSent && callback != null) { ByteBuffer csd = fmt.getByteBuffer("csd-0"); callback.onConfig( @@ -135,7 +151,7 @@ public class AudioEncoder implements AudioEncoderSink { if (index < 0) { continue; } - ByteBuffer buffer = codec.getOutputBuffer(index); + ByteBuffer buffer = c.getOutputBuffer(index); if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) { byte[] data = new byte[info.size]; buffer.position(info.offset); @@ -145,7 +161,11 @@ public class AudioEncoder implements AudioEncoderSink { callback.onEncodedFrame(info.presentationTimeUs, data); } } - codec.releaseOutputBuffer(index, false); + try { + c.releaseOutputBuffer(index, false); + } catch (IllegalStateException ignored) { + break; + } } } 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 8602fc1..5014c65 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java @@ -11,6 +11,8 @@ import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransportFactory; +import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts; +import com.foxx.androidcast.network.transport.StreamProtectionNegotiation; import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport; @@ -99,7 +101,7 @@ public final class MultiCastCoordinator { session = createSession(peerSettings); connectWithRetry(session, t.host, t.port); CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings); - applyNegotiatedProtection(session, peerSettings, hs.settings); + applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement); peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime)); Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime + " score=" @@ -128,7 +130,7 @@ public final class MultiCastCoordinator { CastSession session = createSession(peerSettings); connectWithRetry(session, target.host, target.port); CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings); - applyNegotiatedProtection(session, peerSettings, hs.settings); + applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement); peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime)); if (keyframeRequester != null) { keyframeRequester.requestKeyframe(); @@ -202,8 +204,8 @@ public final class MultiCastCoordinator { return session; } - private static void applyNegotiatedProtection(CastSession session, CastSettings local, - CastSettings remote) { + private void applyNegotiatedProtection(CastSession session, CastSettings local, + CastSettings receiverAdvertisement) { if (session == null) { return; } @@ -215,7 +217,13 @@ public final class MultiCastCoordinator { udp = ((QuicCronetCastTransport) transport).getUdpTransport(); } if (udp != null) { - CastTransportFactory.applyNegotiatedUdpProtection(udp, local, remote); + CastSettings remote = receiverAdvertisement; + StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection( + udp, local, remote, false); + Log.i(TAG, ProtectionNegotiationPrompts.summarize(appContext, negotiated)); + if (negotiated.prompt != null) { + Log.i(TAG, ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, false)); + } } } 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 afbb9aa..a54a462 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -158,6 +158,7 @@ public class ScreenCastService extends Service implements } CastNotifications.createChannels(this); com.foxx.androidcast.network.CastTransportFactory.init(this); + com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp(); projectionThread = new HandlerThread("CastProjection"); projectionThread.start(); projectionHandler = new Handler(projectionThread.getLooper()); @@ -411,6 +412,7 @@ public class ScreenCastService extends Service implements } private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException { + SenderPreviewService.stop(this); MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); projection = mgr.getMediaProjection(resultCode, data); 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 29dbb3c..dc2c83c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -464,10 +464,14 @@ public class SenderActivity extends DrawerHostActivity { if (resultCode == Activity.RESULT_OK && data != null) { screenPreview.setProjectionResult(resultCode, data); previewConsentDeclined = false; + findViewById(R.id.panel_preview).post(() -> { + refreshPreview(); + findViewById(R.id.panel_preview).postDelayed(this::refreshPreview, 200); + }); } else { previewConsentDeclined = true; + refreshPreview(); } - refreshPreview(); return; } if (requestCode != REQUEST_MEDIA_PROJECTION) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java index 181be5d..9dcfe22 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java @@ -19,6 +19,7 @@ import android.widget.TextView; import androidx.core.content.ContextCompat; +import com.foxx.androidcast.CastActiveState; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.R; import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator; @@ -62,6 +63,10 @@ public final class SenderCapturePreview { public void updateForMode(CastSettings.CaptureMode mode) { activeMode = mode; stopCamera(); + if (CastActiveState.isSenderCasting()) { + showCastingPlaceholder(); + return; + } if (mode == CastSettings.CaptureMode.CAMERA) { useFront = false; startCameraPreview(); @@ -71,7 +76,7 @@ public final class SenderCapturePreview { } else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) { showCalibrationPreview(); } else if (screenPreview != null && screenPreview.needsProjection(mode)) { - if (screenPreview.hasProjection()) { + if (screenPreview.hasConsent()) { labelView.setVisibility(android.view.View.GONE); textureView.setVisibility(android.view.View.VISIBLE); screenPreview.attach(textureView, mode); @@ -92,6 +97,16 @@ public final class SenderCapturePreview { labelView.setText(labelView.getContext().getString(textRes)); } + public void showCastingPlaceholder() { + if (screenPreview != null) { + screenPreview.pausePreview(); + } + stopCamera(); + textureView.setVisibility(android.view.View.GONE); + labelView.setVisibility(android.view.View.VISIBLE); + labelView.setText(labelView.getContext().getString(R.string.preview_casting_active)); + } + private void showCalibrationPreview() { if (screenPreview != null) { screenPreview.stop(); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java b/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java new file mode 100644 index 0000000..5fd649f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderPreviewService.java @@ -0,0 +1,64 @@ +package com.foxx.androidcast.sender; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +import androidx.annotation.Nullable; + +import com.foxx.androidcast.CastNotifications; + +/** + * Foreground service so {@link SenderScreenPreview} can use MediaProjection on Android 10+. + * Stopped when screen cast {@link ScreenCastService} starts (that service holds projection FGS). + */ +public final class SenderPreviewService extends Service { + private static volatile boolean running; + + public static boolean isRunning() { + return running; + } + + public static void start(Context context) { + Intent intent = new Intent(context, SenderPreviewService.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + public static void stop(Context context) { + context.stopService(new Intent(context, SenderPreviewService.class)); + } + + @Override + public void onCreate() { + super.onCreate(); + CastNotifications.createChannels(this); + running = true; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + startForeground(CastNotifications.ID_SENDER + 100, + CastNotifications.sender(this, getString(com.foxx.androidcast.R.string.preview_screen_active), + SenderPreviewService.class)); + return START_STICKY; + } + + @Override + public void onDestroy() { + running = false; + stopForeground(STOP_FOREGROUND_REMOVE); + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java index ee970a4..0941f85 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java @@ -16,6 +16,7 @@ import android.view.Surface; import android.view.TextureView; import android.view.WindowManager; +import com.foxx.androidcast.CastActiveState; import com.foxx.androidcast.CastSettings; /** @@ -105,6 +106,9 @@ public final class SenderScreenPreview { if (projection != null || resultData == null) { return; } + if (!CastActiveState.isSenderCasting() && !SenderPreviewService.isRunning()) { + SenderPreviewService.start(appContext); + } MediaProjectionManager mgr = appContext.getSystemService(MediaProjectionManager.class); projection = mgr.getMediaProjection(resultCode, resultData); @@ -133,8 +137,14 @@ public final class SenderScreenPreview { DisplayMetrics metrics = new DisplayMetrics(); WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); - int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 2); - int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 2); + int viewW = textureView.getWidth(); + int viewH = textureView.getHeight(); + if (viewW <= 0 || viewH <= 0) { + textureView.post(() -> ensureVirtualDisplay(textureView, mode)); + return; + } + int w = Math.max(320, viewW); + int h = Math.max(240, viewH); SurfaceTexture st = textureView.getSurfaceTexture(); if (st == null) { return; @@ -181,6 +191,9 @@ public final class SenderScreenPreview { } projection = null; } + if (!CastActiveState.isSenderCasting()) { + SenderPreviewService.stop(appContext); + } } /** Pause preview surface only; keep MediaProjection consent for cast START. */ diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index bfe5558..38a39d0 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -149,6 +149,8 @@ Камера (фронт) Тихо Предпросмотр\n(трансляция начнется после нажатия кнопки) + Предпросмотр экрана + Трансляция — предпросмотр приостановлен Предпросмотр с камеры Калибрационная таблица Stream options are in Android Cast settings. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0cc94f..06f0482 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,7 +133,17 @@ AUTO (fits screen) Adaptive (experimental) Stream protection (UDP) - Hint only — negotiated with receiver. UDP transports only. + Sender preference — receiver FEC/NACK is auto-detected at connect. UDP only. + sender + receiver + NACK on + NACK off + Stream protection: %1$s (%2$s) + No protection this session (receiver %1$s, sender %2$s). Set %3$s stream protection to None or match the peer. + Protection mismatch (receiver %1$s, sender %2$s). Casting without protection; align %3$s settings. + Using Reed–Solomon FEC (receiver %1$s, sender %2$s). For a direct match, set %3$s to FEC (Reed–Solomon). + Using FEC 3/4 without NACK (receiver %1$s, sender %2$s). NACK disabled for this session. + Using FEC with NACK (receiver %1$s, sender %2$s). FEC 3/4 FEC (Reed-Solomon) NACK @@ -149,6 +159,8 @@ Camera (front) Mute Source preview\n(screen capture starts after START) + Screen preview + Casting — preview paused Camera preview Calibration test pattern Stream options are in Android Cast settings. diff --git a/app/src/test/java/com/foxx/androidcast/diagnostics/CastMtuSamplerTest.java b/app/src/test/java/com/foxx/androidcast/diagnostics/CastMtuSamplerTest.java new file mode 100644 index 0000000..f7bddc3 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/diagnostics/CastMtuSamplerTest.java @@ -0,0 +1,27 @@ +package com.foxx.androidcast.diagnostics; + +import static org.junit.Assert.assertEquals; + +import com.foxx.androidcast.CastConfig; + +import org.junit.Test; + +public class CastMtuSamplerTest { + @Test + public void calculate_wifiMtu1500() { + CastMtuSampler.Info info = CastMtuSampler.calculate(1500, "wlan0"); + assertEquals(1500, info.linkMtu); + assertEquals(CastConfig.UDP_CHUNK_SIZE, info.castDatagramBytes); + assertEquals(CastConfig.UDP_CHUNK_SIZE - CastMtuSampler.ACUD_HEADER_BYTES, + info.castFragPayloadBytes); + assertEquals(CastConfig.UDP_CHUNK_SIZE + 28, info.ipv4WireBytes); + assertEquals(1500 - info.ipv4WireBytes, info.headroomBytes); + } + + @Test + public void calculate_unknownLink_noHeadroom() { + CastMtuSampler.Info info = CastMtuSampler.calculate(0, ""); + assertEquals(-1, info.headroomBytes); + assertEquals(1228, info.ipv4WireBytes); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java index 59e6cd7..0ad5c04 100644 --- a/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java @@ -7,6 +7,8 @@ import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.network.transport.StreamProtectionFactory; +import com.foxx.androidcast.network.CastFramingContext; + import org.junit.Test; import java.net.DatagramPacket; @@ -24,8 +26,8 @@ public class UdpCastTransportFecTest { receiver.prepareListen(portB); sender.connect("127.0.0.1", portB); CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4); - CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec); - CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec); + CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true); + CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false); byte[] payload = new byte[900]; for (int i = 0; i < payload.length; i++) { @@ -58,6 +60,54 @@ public class UdpCastTransportFecTest { } } + @Test + public void fecShardGroup_wireFramedLargeKeyframe_delivers() throws Exception { + int portB = 38000 + (int) (System.nanoTime() % 2000); + UdpCastTransport sender = new UdpCastTransport(); + UdpCastTransport receiver = new UdpCastTransport(); + CastFramingContext framing = new CastFramingContext(42, 1, 0, 0); + try { + receiver.prepareListen(portB); + sender.connect("127.0.0.1", portB); + sender.setFramingContext(framing); + receiver.setFramingContext(framing); + CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4); + CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true); + CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false); + + byte[] video = new byte[171_077]; + for (int i = 0; i < video.length; i++) { + video[i] = (byte) (i & 0xFF); + } + byte[] videoMsg = CastProtocol.videoFramePayload(0, true, video); + + Thread t = new Thread(() -> { + try { + sender.send(CastProtocol.MSG_VIDEO_FRAME, videoMsg); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + t.start(); + + AtomicReference received = new AtomicReference<>(); + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline && received.get() == null) { + CastProtocol.Message msg = receiver.receive(500); + if (msg != null && msg.type == CastProtocol.MSG_VIDEO_FRAME) { + CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload); + received.set(vf.data); + } + } + t.join(3000); + assertNotNull(received.get()); + assertArrayEquals(video, received.get()); + } finally { + sender.close(); + receiver.close(); + } + } + private static CastSettings settingsWith(CastSettings.StreamProtection protection) { CastSettings s = new CastSettings(); s.setTransport(CastConfig.TRANSPORT_UDP); diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java index 98054f1..33a8a24 100644 --- a/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.transport.fec.FecEncodedGroup; @@ -12,6 +13,20 @@ import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope; import org.junit.Test; public class FecProtectionEngineShardTest { + @Test + public void largePayload_usesMoreSmallerShards() throws Exception { + TransportLossStats stats = new TransportLossStats(); + FecProtectionEngine fec = new FecProtectionEngine( + ProtectionEnvelope.MODE_FEC_34, 4, 3, stats); + byte[] payload = new byte[180_000]; + FecEncodedGroup group = fec.encodeGroup(payload); + assertEquals(16, group.dataShards); + assertEquals(8, group.parityShards); + assertEquals(24, group.totalShards()); + int shardBytes = group.shards[0].length; + assertTrue(shardBytes < 16_000); + } + @Test public void encodeAndDecodeShards_viaEngine() throws Exception { TransportLossStats stats = new TransportLossStats(); diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionFactoryTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionFactoryTest.java index 8141e65..ed35222 100644 --- a/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionFactoryTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionFactoryTest.java @@ -27,6 +27,23 @@ public class StreamProtectionFactoryTest { StreamProtectionEngine engine = StreamProtectionFactory.create( CastSettings.StreamProtection.FEC_3_4, new TransportLossStats()); assertEquals(StreamProtectionMode.FEC_3_4, engine.mode()); + assertTrue(engine instanceof CombinedProtectionEngine); + } + + @Test + public void fec34_withoutNack_isFecOnly() { + StreamProtectionEngine engine = StreamProtectionFactory.create( + CastSettings.StreamProtection.FEC_3_4, new TransportLossStats(), false); + assertEquals(StreamProtectionMode.FEC_3_4, engine.mode()); + assertTrue(engine instanceof FecProtectionEngine); + } + + @Test + public void fecRs_withNack_isCombined() { + StreamProtectionEngine engine = StreamProtectionFactory.create( + CastSettings.StreamProtection.FEC_REED_SOLOMON, new TransportLossStats(), true); + assertEquals(StreamProtectionMode.FEC_REED_SOLOMON, engine.mode()); + assertTrue(engine instanceof CombinedProtectionEngine); } @Test diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiatorTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiatorTest.java index 1eb6a4a..0479d58 100644 --- a/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiatorTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiatorTest.java @@ -1,6 +1,10 @@ package com.foxx.androidcast.network.transport; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import com.foxx.androidcast.CastSettings; @@ -14,18 +18,93 @@ public class StreamProtectionNegotiatorTest { } @Test - public void mismatchFallsBackToNone() { - assertEquals(CastSettings.StreamProtection.NONE, - StreamProtectionNegotiator.resolve( - CastSettings.StreamProtection.FEC_NACK, - CastSettings.StreamProtection.NACK)); + public void bothFec34_agreesWithNack() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.FEC_3_4, + false); + assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed); + assertTrue(n.nackEnabled); + assertNull(n.prompt); } @Test - public void matchingModesPreserved() { - assertEquals(CastSettings.StreamProtection.FEC_3_4, - StreamProtectionNegotiator.resolve( - CastSettings.StreamProtection.FEC_3_4, - CastSettings.StreamProtection.FEC_3_4)); + public void senderFec34_receiverAuto_usesFec34() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.AUTO, + false); + assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed); + assertTrue(n.nackEnabled); + } + + @Test + public void receiverSide_senderFec34_usesFec34() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.AUTO, + CastSettings.StreamProtection.FEC_3_4, + true); + assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed); + assertTrue(n.nackEnabled); + } + + @Test + public void senderNone_receiverAuto_noProtectionWithPrompt() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.NONE, + CastSettings.StreamProtection.AUTO, + false); + assertEquals(CastSettings.StreamProtection.NONE, n.agreed); + assertNotNull(n.prompt); + } + + @Test + public void senderFec34_receiverRsAdvert_usesRs() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.FEC_REED_SOLOMON, + false); + assertEquals(CastSettings.StreamProtection.FEC_REED_SOLOMON, n.agreed); + assertNotNull(n.prompt); + } + + @Test + public void senderFec34_receiverAuto_usesFec34WithNack() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.AUTO, + false); + assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed); + assertTrue(n.nackEnabled); + } + + @Test + public void senderFec34_receiverAdvertFecNack_usesFec34WithoutNack() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_3_4, + CastSettings.StreamProtection.FEC_NACK, + false); + assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed); + assertFalse(n.nackEnabled); + } + + @Test + public void senderFecNack_receiverAuto_keepsFecNack() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_NACK, + CastSettings.StreamProtection.AUTO, + false); + assertEquals(CastSettings.StreamProtection.FEC_NACK, n.agreed); + assertTrue(n.nackEnabled); + } + + @Test + public void fecNackWithNackOnly_usesFecNack() { + StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate( + CastSettings.StreamProtection.FEC_NACK, + CastSettings.StreamProtection.AUTO, + false); + assertEquals(CastSettings.StreamProtection.FEC_NACK, n.agreed); + assertTrue(n.nackEnabled); } } diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java index d08c5ab..a338cef 100644 --- a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java @@ -39,6 +39,31 @@ public class FecShardReassemblyTest { assertArrayEquals(payload, recovered); } + @Test + public void vp9SizedPayload_recoversAllShards() throws Exception { + byte[] payload = new byte[54_000]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + ReedSolomonFec fec = new ReedSolomonFec(4, 3); + byte[][] shards = fec.encode(payload); + FecShardReassembly asm = null; + for (int i = 0; i < shards.length; i++) { + byte[] wire = FecShardWire.encodeShard( + ProtectionEnvelope.MODE_FEC_34, 42, 900, i, shards.length, + payload.length, 4, 3, shards[i]); + FecShardWire.Parsed p = FecShardWire.tryParse(wire); + assertNotNull(p); + if (asm == null) { + asm = new FecShardReassembly(p); + } + assertTrue(asm.addShard(p)); + } + byte[] recovered = asm.tryDecode(); + assertNotNull(recovered); + assertArrayEquals(payload, recovered); + } + @Test public void insufficientShards_returnsNull() throws Exception { byte[] payload = new byte[] {1, 2, 3, 4, 5}; diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java index 21caefc..fe769ad 100644 --- a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java @@ -5,9 +5,52 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; + import org.junit.Test; public class FecShardWireTest { + @Test + public void largeShard_roundTrip() throws Exception { + byte[] payload = new byte[54_000]; + for (int i = 0; i < payload.length; i++) { + payload[i] = (byte) (i & 0xFF); + } + ReedSolomonFec fec = new ReedSolomonFec(4, 3); + byte[][] shards = fec.encode(payload); + byte[] shard = shards[0]; + assertTrue(shard.length > 255); + byte[] wire = FecShardWire.encodeShard( + ProtectionEnvelope.MODE_FEC_34, 99, 1000, 0, shards.length, + payload.length, 4, 3, shard); + FecShardWire.Parsed p = FecShardWire.tryParse(wire); + assertNotNull(p); + assertArrayEquals(shard, p.shardData); + } + + @Test + public void v2SmallShard_stillParses() throws Exception { + byte[] shard = new byte[200]; + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.write(new byte[] {'A', 'C', 'P', 'F'}); + dos.writeByte(FecShardWire.VERSION_SHARD_V2); + dos.writeByte(ProtectionEnvelope.MODE_FEC_34); + dos.writeInt(1); + dos.writeInt(10); + dos.writeByte(0); + dos.writeByte(7); + dos.writeInt(800); + dos.writeByte(4); + dos.writeByte(3); + dos.writeByte(shard.length); + dos.write(shard); + FecShardWire.Parsed p = FecShardWire.tryParse(bos.toByteArray()); + assertNotNull(p); + assertArrayEquals(shard, p.shardData); + } + @Test public void encodeParse_roundTrip() throws Exception { byte[] shard = new byte[64]; diff --git a/ndk/README.md b/ndk/README.md index 3de1b50..1cb2bfa 100644 --- a/ndk/README.md +++ b/ndk/README.md @@ -8,7 +8,7 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft |-----------|------|--------|-------| | FEC 3/4 + RS | Yes | — | v2 **per-shard** on wire (`FecShardWire`); v1 monolithic decode still accepted | | NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled | -| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx or MediaCodec fallback | +| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx (decoder uses `VPX_CODEC_USE_ERROR_CONCEALMENT` when supported) or MediaCodec fallback | | Opus / Speex | Stub | Probe only | Submodules under `third-party/` | ## Enable libvpx (developer) diff --git a/ndk/jni/libvpx_bridge.c b/ndk/jni/libvpx_bridge.c index e5e3ed4..54ff29b 100644 --- a/ndk/jni/libvpx_bridge.c +++ b/ndk/jni/libvpx_bridge.c @@ -342,9 +342,15 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCrea return 0L; } ctx->magic = VPX_DEC_MAGIC; + vpx_codec_iface_t *iface = pick_decoder_iface(mime_utf); vpx_codec_dec_cfg_t cfg = {0}; cfg.threads = 1; - if (vpx_codec_dec_init(&ctx->codec, pick_decoder_iface(mime_utf), &cfg, 0) != VPX_CODEC_OK) { + /* vpxdec.c: dec_flags |= VPX_CODEC_USE_ERROR_CONCEALMENT when --error-concealment */ + int dec_flags = 0; + if (vpx_codec_get_caps(iface) & VPX_CODEC_CAP_ERROR_CONCEALMENT) { + dec_flags |= VPX_CODEC_USE_ERROR_CONCEALMENT; + } + if (vpx_codec_dec_init(&ctx->codec, iface, &cfg, dec_flags) != VPX_CODEC_OK) { free(ctx); (*env)->ReleaseStringUTFChars(env, mime, mime_utf); return 0L; @@ -381,9 +387,8 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecodeFrame vpx_codec_err_t err = vpx_codec_decode(&ctx->codec, (const uint8_t *) bytes, (unsigned int) len, NULL, 0); (*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT); - if (err != VPX_CODEC_OK) { - return JNI_FALSE; - } + /* With error concealment, vpx_codec_decode may warn yet still output a frame (see vpxdec.c). */ + (void) err; ANativeWindow *window = ANativeWindow_fromSurface(env, surface); if (window == NULL) { diff --git a/scripts/build-native-codecs.sh b/scripts/build-native-codecs.sh index cbd3138..efc441c 100755 --- a/scripts/build-native-codecs.sh +++ b/scripts/build-native-codecs.sh @@ -225,7 +225,7 @@ cp -f "$LIBVPX_A" "$OUT/libvpx.a" cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a" echo "" -echo "Built: $PROJECT_OUT/libvpx.a" +echo "Built: $PROJECT_OUT/libvpx.a (size $(ls -lah "${PROJECT_OUT}/libvpx.a" | awk '{print $5}'))" if [[ -n "$STAGING" ]]; then echo "(staged under $STAGING because of spaces in repo path)" fi diff --git a/scripts/capture-vp9-fec-logs.sh b/scripts/capture-vp9-fec-logs.sh new file mode 100755 index 0000000..f528c0e --- /dev/null +++ b/scripts/capture-vp9-fec-logs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Capture VP9+FEC cast logs from sender (.106) and receiver (.39). +# Usage: ./scripts/capture-vp9-fec-logs.sh [label] +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CAP="$ROOT/.log_capture" +TS="$(date +%Y%m%d_%H%M%S)" +LABEL="${1:-fec}" +mkdir -p "$CAP" + +SENDER="192.168.33.106:5555" +RECEIVER="192.168.33.39:5555" + +adb connect "$SENDER" >/dev/null 2>&1 || true +adb connect "$RECEIVER" >/dev/null 2>&1 || true + +FILTER='FEC|Fec|fec|UdpCast|StreamProtection|Protection|reasm|ReedSolomon|shard|ScreenCast|Libvpx|ReceiverCast|ReceiverSession|VideoDecoder|NativeCodec|CodecPriority|CastFanout|CastSendPump|NetworkFeedback|transportLoss|decode|keyframe|awaiting|AudioDecoder|drop' + +echo "==> Clearing logcat buffers" +adb -s "$SENDER" logcat -c 2>/dev/null || true +adb -s "$RECEIVER" logcat -c 2>/dev/null || true + +SPID=$(adb -s "$SENDER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true) +RPID=$(adb -s "$RECEIVER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true) +echo "==> PIDs sender=$SPID receiver=$RPID" + +echo "==> Recording 90s (start cast now)..." +timeout 90 adb -s "$SENDER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_sender_106_${TS}.log" & +PID_S=$! +timeout 90 adb -s "$RECEIVER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_receiver_39_${TS}.log" & +PID_R=$! +wait "$PID_S" "$PID_R" 2>/dev/null || true + +# Snapshot dumps after live capture +adb -s "$SENDER" logcat -d --pid="$SPID" -t 8000 > "$CAP/${LABEL}_sender_pid_${TS}.log" 2>&1 || true +adb -s "$RECEIVER" logcat -d --pid="$RPID" -t 8000 > "$CAP/${LABEL}_receiver_pid_${TS}.log" 2>&1 || true +adb -s "$SENDER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_sender_crash_${TS}.log" || true +adb -s "$RECEIVER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_receiver_crash_${TS}.log" || true + +echo "==> Saved under $CAP/${LABEL}_*_${TS}.log" +wc -c "$CAP"/${LABEL}_*_${TS}.log 2>/dev/null | tail -6