1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:57:40 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-19 12:23:39 +02:00
parent 0647e0b2a0
commit 35ab5622d7
47 changed files with 1598 additions and 150 deletions

View File

@@ -49,11 +49,10 @@ android {
buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
externalNativeBuild { externalNativeBuild {
cmake { cmake {
def cmakeArgs = ["-DANDROID_STL=c++_shared"] arguments "-DANDROID_STL=c++_shared"
if (resolveCcachePath) { if (resolveCcachePath) {
cmakeArgs += "-DANDROID_CCACHE=${resolveCcachePath}" arguments "-DANDROID_CCACHE=${resolveCcachePath}"
} }
arguments cmakeArgs
cppFlags "" cppFlags ""
} }
} }

View File

@@ -79,6 +79,11 @@
android:stopWithTask="false" android:stopWithTask="false"
android:foregroundServiceType="mediaProjection|microphone" /> android:foregroundServiceType="mediaProjection|microphone" />
<service
android:name=".sender.SenderPreviewService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service <service
android:name=".receiver.ReceiverCastService" android:name=".receiver.ReceiverCastService"
android:exported="false" android:exported="false"

View File

@@ -158,9 +158,14 @@ public final class CastSettingsBinder {
}; };
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> { bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
settings.setTransport(v); 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) { 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) { 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(); CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values();
String[] labels = { String[] labels = {
activity.getString(R.string.option_auto), activity.getString(R.string.option_auto),
@@ -422,7 +436,23 @@ public final class CastSettingsBinder {
} }
SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current, SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current,
settings::setStreamProtection); 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) { private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) {
@@ -430,14 +460,16 @@ public final class CastSettingsBinder {
settings::setStreamProtection); settings::setStreamProtection);
} }
private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) { private static void updateStreamProtectionSpinnerEnabled(AppCompatActivity activity,
Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection); CastSettings settings, int spinnerId, int hintId, int transportSpinnerId) {
Spinner protection = activity.findViewById(spinnerId);
if (protection == null) { if (protection == null) {
return; return;
} }
readTransport(activity, settings, transportSpinnerId);
boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport()); boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport());
protection.setEnabled(udp); protection.setEnabled(udp);
TextView hint = activity.findViewById(R.id.text_stream_protection_hint); TextView hint = activity.findViewById(hintId);
if (hint != null) { if (hint != null) {
hint.setVisibility(udp ? View.VISIBLE : View.GONE); hint.setVisibility(udp ? View.VISIBLE : View.GONE);
} }

View File

@@ -45,12 +45,13 @@ public final class PictureInPictureHelper {
return true; 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() PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio(videoWidth, videoHeight)); .setAspectRatio(aspectRatio(videoWidth, videoHeight));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true); boolean allowed = isAllowedForApp(context);
builder.setSeamlessResizeEnabled(true); builder.setAutoEnterEnabled(allowed);
builder.setSeamlessResizeEnabled(allowed);
} }
return builder.build(); return builder.build();
} }
@@ -59,7 +60,7 @@ public final class PictureInPictureHelper {
if (!isSupported()) { if (!isSupported()) {
return; return;
} }
activity.setPictureInPictureParams(buildParams(videoWidth, videoHeight)); activity.setPictureInPictureParams(buildParams(activity, videoWidth, videoHeight));
} }
public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) { public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) {
@@ -67,7 +68,7 @@ public final class PictureInPictureHelper {
return false; return false;
} }
try { try {
return activity.enterPictureInPictureMode(buildParams(videoWidth, videoHeight)); return activity.enterPictureInPictureMode(buildParams(activity, videoWidth, videoHeight));
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
return false; return false;
} }

View File

@@ -22,7 +22,8 @@ public final class CastDiagnosticsFormatter {
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec, public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
int streamW, int streamH, int renderW, int renderH, boolean idle, int streamW, int streamH, int renderW, int renderH, boolean idle,
CastSettings localSettings, CastSettings remoteSettings, CastSettings localSettings, CastSettings remoteSettings,
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) { NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
CastMtuSampler.Info mtuInfo) {
float inFps = metrics.getInFps(); float inFps = metrics.getInFps();
float renderFps = metrics.getRenderFps(); float renderFps = metrics.getRenderFps();
int bwInKbps = metrics.getBwInKbps(); int bwInKbps = metrics.getBwInKbps();
@@ -103,6 +104,7 @@ public final class CastDiagnosticsFormatter {
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ") html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
.append(escape(transport != null ? transport.toUpperCase() : "?")) .append(escape(transport != null ? transport.toUpperCase() : "?"))
.append("</font><br/>"); .append("</font><br/>");
appendMtuSection(html, mtuInfo);
if (videoCodec != null && !videoCodec.isEmpty()) { if (videoCodec != null && !videoCodec.isEmpty()) {
html.append("Codec: <font color='").append(COLOR_OK).append("'>") html.append("Codec: <font color='").append(COLOR_OK).append("'>")
@@ -251,6 +253,12 @@ public final class CastDiagnosticsFormatter {
html.append("<br/>"); html.append("<br/>");
} }
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("<br/>");
for (String hint : hints) { for (String hint : hints) {
html.append("<font color='").append(COLOR_WARN).append("'>") html.append("<font color='").append(COLOR_WARN).append("'>")
.append(escape(hint)).append("</font><br/>"); .append(escape(hint)).append("</font><br/>");
@@ -270,6 +278,29 @@ public final class CastDiagnosticsFormatter {
return html.toString(); return html.toString();
} }
private static void appendMtuSection(StringBuilder html, CastMtuSampler.Info mtu) {
if (mtu == null) {
return;
}
html.append("<font color='").append(COLOR_DIM).append("'>MTU:</font> ");
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("<br/>");
}
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer, private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
NetworkStatsSnapshot local) { NetworkStatsSnapshot local) {
long rx = pick(peer, local, true, false); long rx = pick(peer, local, true, false);
@@ -314,7 +345,7 @@ public final class CastDiagnosticsFormatter {
html.append(' ').append(colored("aud-dec " + aDecErr, true)); html.append(' ').append(colored("aud-dec " + aDecErr, true));
} }
if (aDrop > 0) { if (aDrop > 0) {
html.append(' ').append(colored("aud-q " + aDrop, true)); html.append(' ').append(colored("aud-drop " + aDrop, true));
} }
if (dropAudio > 0) { if (dropAudio > 0) {
html.append(' ').append(colored("snd-aud " + dropAudio, true)); html.append(' ').append(colored("snd-aud " + dropAudio, true));
@@ -364,9 +395,15 @@ public final class CastDiagnosticsFormatter {
html.append(" · ").append(escape(summary)); html.append(" · ").append(escape(summary));
} }
long fecPkts = val(peer, local, p -> p.fecStubPackets, l -> l.fecStubPackets); 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); long nackReq = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
if (fecPkts > 0) { if (fecPkts > 0 || fecOk > 0 || fecFail > 0) {
html.append("<br/>FEC packets=").append(fecPkts); html.append("<br/>FEC enc=").append(fecPkts).append(" dec=").append(fecOk);
if (fecFail > 0) {
html.append(" <font color='").append(COLOR_WARN).append("'>fail=").append(fecFail)
.append("</font>");
}
} }
if (nackReq > 0) { if (nackReq > 0) {
html.append("<br/>NACK events=").append(nackReq); html.append("<br/>NACK events=").append(nackReq);

View File

@@ -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);
}
}

View File

@@ -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 (0100% 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<long[]> 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<long[]> 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<long[]> 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);
}
}

View File

@@ -33,6 +33,8 @@ public final class CastProtocol {
public static final byte MSG_CODEC_SELECTED = 15; public static final byte MSG_CODEC_SELECTED = 15;
/** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */ /** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
public static final byte MSG_NACK = 16; 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. */ /** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
public static final byte MSG_WIRE_PACKET = 127; public static final byte MSG_WIRE_PACKET = 127;
@@ -268,7 +270,7 @@ public final class CastProtocol {
return local.getTransport().equals(remote.getTransport()); 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 { public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
@@ -318,6 +320,10 @@ public final class CastProtocol {
dos.writeLong(s.fecStubPackets); dos.writeLong(s.fecStubPackets);
dos.writeLong(s.nackStubRequests); dos.writeLong(s.nackStubRequests);
} }
if (NETWORK_STATS_VERSION >= 5) {
dos.writeLong(s.fecPacketsDecoded);
dos.writeLong(s.fecDecodeFailures);
}
dos.flush(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
@@ -372,6 +378,10 @@ public final class CastProtocol {
s.fecStubPackets = dis.readLong(); s.fecStubPackets = dis.readLong();
s.nackStubRequests = dis.readLong(); s.nackStubRequests = dis.readLong();
} }
if (ver >= 5 && dis.available() > 0) {
s.fecPacketsDecoded = dis.readLong();
s.fecDecodeFailures = dis.readLong();
}
} }
} }
return s; return s;

View File

@@ -13,12 +13,17 @@ import java.util.List;
public class CastSession { public class CastSession {
public static final class HandshakeResult { public static final class HandshakeResult {
public final String senderName; public final String senderName;
/** Sender cast settings from handshake. */
public final CastSettings settings; public final CastSettings settings;
/** Receiver advertisement ({@code AUTO} = auto-detected FEC capability). */
public final CastSettings receiverAdvertisement;
public final String negotiatedVideoMime; 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.senderName = senderName;
this.settings = settings; this.settings = settings;
this.receiverAdvertisement = receiverAdvertisement;
this.negotiatedVideoMime = negotiatedVideoMime; this.negotiatedVideoMime = negotiatedVideoMime;
} }
} }
@@ -60,13 +65,16 @@ public class CastSession {
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes())); transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
String negotiated = null; String negotiated = null;
CastSettings receiverAdvertisement = null;
long deadline = System.currentTimeMillis() + 15_000; long deadline = System.currentTimeMillis() + 15_000;
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
CastProtocol.Message msg = transport.receive(2_000); CastProtocol.Message msg = transport.receive(2_000);
if (msg == null) { if (msg == null) {
continue; 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); negotiated = CastProtocol.parseCodecSelected(msg.payload);
break; break;
} }
@@ -76,7 +84,7 @@ public class CastSession {
} }
settings.setNegotiatedVideoMime(negotiated); settings.setNegotiatedVideoMime(negotiated);
CodecPriorityCatalog.logReceivedCodec("sender", negotiated, settings); 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 { public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
@@ -117,13 +125,20 @@ public class CastSession {
break; break;
} }
if (authed && remote != null && senderCaps != null) { 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<String> receiverCaps = CodecCatalog.localDecoderMimes(); List<String> receiverCaps = CodecCatalog.localDecoderMimes();
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps)); transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver"); PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime)); transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
remote.setNegotiatedVideoMime(mime); remote.setNegotiatedVideoMime(mime);
return new HandshakeResult(senderName, remote, mime); return new HandshakeResult(senderName, remote, receiverAdvert, mime);
} }
} }
throw new IOException("Handshake timeout"); throw new IOException("Handshake timeout");

View File

@@ -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, * Apply protection after handshake using negotiated local/remote preferences.
CastSettings remote) { *
* @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) { if (udp == null || local == null) {
return; return com.foxx.androidcast.network.transport.StreamProtectionNegotiation.none();
} }
CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator com.foxx.androidcast.network.transport.StreamProtectionNegotiation negotiated =
.resolve(local.getStreamProtection(), remote != null ? remote.getStreamProtection() : null); com.foxx.androidcast.network.transport.StreamProtectionNegotiator.negotiate(
local.getStreamProtection(),
remote != null ? remote.getStreamProtection() : null,
localIsReceiver);
CastSettings effective = new CastSettings(); CastSettings effective = new CastSettings();
effective.copyFrom(local); effective.copyFrom(local);
effective.setStreamProtection(mode); effective.setStreamProtection(negotiated.agreed);
applyUdpProtection(udp, effective); udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionFactory.create(
negotiated.agreed, udp.getLossStats(), negotiated.nackEnabled));
return negotiated;
} }
} }

View File

@@ -1,5 +1,7 @@
package com.foxx.androidcast.network; package com.foxx.androidcast.network;
import android.util.Log;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.transport.CombinedProtectionEngine; import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
import com.foxx.androidcast.network.transport.FecProtectionEngine; import com.foxx.androidcast.network.transport.FecProtectionEngine;
@@ -27,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
/** Best-effort message transport over UDP with fragmentation. */ /** Best-effort message transport over UDP with fragmentation. */
public class UdpCastTransport implements CastTransport, TransportStatsProvider { public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private static final String TAG = "UdpCastTransport";
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'}; private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */ /** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
private static final int HEADER = 17; private static final int HEADER = 17;
@@ -45,7 +48,12 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private final RetransmitCache retransmitCache = new RetransmitCache(); private final RetransmitCache retransmitCache = new RetransmitCache();
private CastFramingContext framingContext; private CastFramingContext framingContext;
private CastSessionGate inboundSessionGate; 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() { public boolean isListening() {
return listening && socket != null && !socket.isClosed(); return listening && socket != null && !socket.isClosed();
@@ -81,8 +89,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} }
socket = new DatagramSocket(null); socket = new DatagramSocket(null);
socket.setReuseAddress(true); socket.setReuseAddress(true);
socket.setReceiveBufferSize(512 * 1024); socket.setReceiveBufferSize(2 * 1024 * 1024);
socket.setSendBufferSize(512 * 1024); socket.setSendBufferSize(2 * 1024 * 1024);
} }
@Override @Override
@@ -142,9 +150,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
sendOnce(type, body, false); sendOnce(type, body, false);
} }
void retransmitCached(byte wireType, byte[] body) { void retransmitCached(byte wireType, byte[] body, int messageId) {
try { try {
sendOnce(wireType, body, false); sendOnceWithId(wireType, body, false, messageId);
} catch (IOException ignored) { } catch (IOException ignored) {
} }
} }
@@ -156,12 +164,15 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
synchronized (lossStats) { synchronized (lossStats) {
lossStats.nackCacheMisses++; lossStats.nackCacheMisses++;
} }
if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 1) {
Log.w(TAG, "NACK cache miss id=" + messageId);
}
return; return;
} }
synchronized (lossStats) { synchronized (lossStats) {
lossStats.nackRetransmits++; lossStats.nackRetransmits++;
} }
retransmitCached(entry.wireType, entry.body); retransmitCached(entry.wireType, entry.body, messageId);
} }
@Override @Override
@@ -199,6 +210,17 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
for (int i = 0; i < group.totalShards(); i++) { for (int i = 0; i < group.totalShards(); i++) {
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i); byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
sendOnceWithId(wireType, shardWire, cache, 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 chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize); int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
boolean paceFragments = fragments > 24;
for (int i = 0; i < fragments; i++) { for (int i = 0; i < fragments; i++) {
int offset = i * chunkSize; int offset = i * chunkSize;
int len = Math.min(chunkSize, body.length - offset); int len = Math.min(chunkSize, body.length - offset);
@@ -222,6 +245,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
synchronized (lossStats) { synchronized (lossStats) {
lossStats.datagramsSent++; lossStats.datagramsSent++;
} }
if (paceFragments && i + 1 < fragments && (i & 7) == 7) {
paceBetweenLargeShards();
}
} }
} }
@@ -259,30 +285,55 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private void expireStaleFecGroups() { private void expireStaleFecGroups() {
if (fecEngine == null || nackEngine == null) { if (fecEngine == null || nackEngine == null) {
if (fecEngine != null) { if (fecEngine != null) {
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null); fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, null);
} }
return; return;
} }
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack); fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, nackEngine::requestNack);
} }
private void expireStaleReassembly() { private void expireStaleReassembly() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) { for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
Reassembly r = e.getValue(); Reassembly r = e.getValue();
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) { if (r != null && now > r.deadlineMs) {
pending.remove(e.getKey()); pending.remove(e.getKey());
synchronized (lossStats) { synchronized (lossStats) {
lossStats.reassemblyIncomplete++; lossStats.reassemblyIncomplete++;
} }
maybeLogReassemblyExpired(e.getKey(), r);
int messageId = e.getKey(); int messageId = e.getKey();
if (nackEngine != null) { if (nackEngine != null) {
nackEngine.requestNack(messageId); 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 { private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
if (length < HEADER) { if (length < HEADER) {
synchronized (lossStats) { synchronized (lossStats) {
@@ -495,6 +546,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
final int fragCount; final int fragCount;
final int totalLen; final int totalLen;
final long startedMs; final long startedMs;
final long deadlineMs;
final byte[][] parts; final byte[][] parts;
final int[] sizes; final int[] sizes;
int received; int received;
@@ -503,6 +555,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
this.fragCount = fragCount; this.fragCount = fragCount;
this.totalLen = totalLen; this.totalLen = totalLen;
this.startedMs = System.currentTimeMillis(); this.startedMs = System.currentTimeMillis();
this.deadlineMs = reassemblyDeadlineMs(fragCount, totalLen);
this.parts = new byte[fragCount][]; this.parts = new byte[fragCount][];
this.sizes = new int[fragCount]; this.sizes = new int[fragCount];
} }

View File

@@ -47,6 +47,8 @@ public class NetworkStatsSnapshot {
public long recvDamagedVideoFrames; public long recvDamagedVideoFrames;
public String protectionMode = "NONE"; public String protectionMode = "NONE";
public long fecStubPackets; public long fecStubPackets;
public long fecPacketsDecoded;
public long fecDecodeFailures;
public long nackStubRequests; public long nackStubRequests;
public NetworkStatsSnapshot copy() { public NetworkStatsSnapshot copy() {
@@ -90,6 +92,8 @@ public class NetworkStatsSnapshot {
c.recvDamagedVideoFrames = recvDamagedVideoFrames; c.recvDamagedVideoFrames = recvDamagedVideoFrames;
c.protectionMode = protectionMode; c.protectionMode = protectionMode;
c.fecStubPackets = fecStubPackets; c.fecStubPackets = fecStubPackets;
c.fecPacketsDecoded = fecPacketsDecoded;
c.fecDecodeFailures = fecDecodeFailures;
c.nackStubRequests = nackStubRequests; c.nackStubRequests = nackStubRequests;
return c; return c;
} }

View File

@@ -20,7 +20,9 @@ public final class CombinedProtectionEngine implements StreamProtectionEngine {
@Override @Override
public StreamProtectionMode mode() { public StreamProtectionMode mode() {
return StreamProtectionMode.FEC_NACK; return fec.mode() == StreamProtectionMode.FEC_REED_SOLOMON
? StreamProtectionMode.FEC_REED_SOLOMON
: StreamProtectionMode.FEC_3_4;
} }
@Override @Override

View File

@@ -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.ProtectionEnvelope;
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec; import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
import android.util.Log;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
@@ -16,9 +18,13 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger; 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 { 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 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 byte mode;
private final int dataShards; private final int dataShards;
@@ -53,11 +59,25 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
return protectionMode; return protectionMode;
} }
/** Pick shard counts so each ACUD logical message stays small on lossy WiFi. */
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 { public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
if (payload == null || payload.length == 0) { if (payload == null || payload.length == 0) {
throw new IOException("empty payload"); 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); byte[][] shards = fec.encode(payload);
int groupId = NEXT_GROUP_ID.getAndIncrement(); int groupId = NEXT_GROUP_ID.getAndIncrement();
if (stats != null) { if (stats != null) {
@@ -65,7 +85,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
stats.fecPacketsEncoded += shards.length; 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) public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
@@ -116,6 +136,17 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
assembly = prev != null ? prev : created; assembly = prev != null ? prev : created;
} }
if (!assembly.addShard(shard)) { 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); return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
} }
byte[] decoded = assembly.tryDecode(); byte[] decoded = assembly.tryDecode();
@@ -126,6 +157,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
stats.fecDecodeFailures++; stats.fecDecodeFailures++;
} }
} }
maybeLogDecodeFail(shard.groupId, assembly.receivedCount(), assembly.getTotalShards());
inboundGroups.remove(shard.groupId); inboundGroups.remove(shard.groupId);
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null); return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
} }
@@ -212,6 +244,22 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
return decoded.payload; 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) { private static boolean looksLikeWireHeader(byte[] payload) {
if (payload == null || payload.length < 4) { if (payload == null || payload.length < 4) {
return false; return false;

View File

@@ -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);
}
}
}

View File

@@ -5,7 +5,8 @@ import java.util.Map;
/** Ring buffer of recent UDP logical messages for NACK retransmission. */ /** Ring buffer of recent UDP logical messages for NACK retransmission. */
public final class RetransmitCache { 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 int capacity;
private final LinkedHashMap<Integer, CachedMessage> map; private final LinkedHashMap<Integer, CachedMessage> map;

View File

@@ -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<CastSettings.StreamProtection> 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<CastSettings.StreamProtection> 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);
}
}

View File

@@ -1,7 +1,6 @@
package com.foxx.androidcast.network.transport; package com.foxx.androidcast.network.transport;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.UdpCastTransport; import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope; import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
@@ -16,17 +15,22 @@ public final class StreamProtectionFactory {
public static StreamProtectionEngine create(CastSettings.StreamProtection setting, public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
TransportLossStats stats) { 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); CastSettings.StreamProtection mode = StreamProtectionNegotiator.normalize(setting);
RetransmitCache cache = new RetransmitCache(); RetransmitCache cache = new RetransmitCache();
switch (mode) { switch (mode) {
case NONE: case NONE:
return StreamProtectionEngine.noop(); return StreamProtectionEngine.noop();
case FEC_3_4: case FEC_3_4:
return new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34, return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
FEC34_DATA, FEC34_PARITY, stats); FEC34_DATA, FEC34_PARITY, stats), stats, cache, nackEnabled);
case FEC_REED_SOLOMON: case FEC_REED_SOLOMON:
return new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON, return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
RS_DATA, RS_PARITY, stats); RS_DATA, RS_PARITY, stats), stats, cache, nackEnabled);
case NACK: case NACK:
return new NackProtectionEngine(stats, cache); return new NackProtectionEngine(stats, cache);
case FEC_NACK: 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) { public static void wireUdp(UdpCastTransport udp, StreamProtectionEngine engine) {
if (udp == null || engine == null) { if (udp == null || engine == null) {
return; return;

View File

@@ -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;
}
}
}

View File

@@ -1,32 +1,242 @@
package com.foxx.androidcast.network.transport; package com.foxx.androidcast.network.transport;
import com.foxx.androidcast.CastSettings; 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 { public final class StreamProtectionNegotiator {
private StreamProtectionNegotiator() {} private StreamProtectionNegotiator() {}
/** /** @deprecated Use {@link #negotiate(CastSettings.StreamProtection, CastSettings.StreamProtection, boolean)}. */
* Agreed mode for this session. Any mismatch, missing remote preference, or {@code NONE} @Deprecated
* on either side yields {@link CastSettings.StreamProtection#NONE} (plain UDP).
*/
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local, public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
CastSettings.StreamProtection remote) { CastSettings.StreamProtection remote) {
CastSettings.StreamProtection a = normalize(local); return negotiate(local, remote, false).agreed;
CastSettings.StreamProtection b = normalize(remote);
if (a == CastSettings.StreamProtection.NONE || b == CastSettings.StreamProtection.NONE) {
return CastSettings.StreamProtection.NONE;
} }
if (a == b) {
return a; /**
* @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<CastSettings.StreamProtection> receiverCaps;
CastSettings.StreamProtection senderPref;
if (localIsReceiver) {
receiverCaps = StreamProtectionCapability.receiverImplementations();
senderPref = remote;
} else {
senderPref = local;
receiverCaps = StreamProtectionCapability.resolveRemoteCapability(remote);
}
return negotiateWithReceiverCaps(receiverCaps, senderPref, local, remote, localIsReceiver);
}
private static StreamProtectionNegotiation negotiateWithReceiverCaps(
EnumSet<CastSettings.StreamProtection> 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<CastSettings.StreamProtection> 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; 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) { public static CastSettings.StreamProtection normalize(CastSettings.StreamProtection mode) {
if (mode == null || mode == CastSettings.StreamProtection.AUTO) { if (mode == null || mode == CastSettings.StreamProtection.AUTO) {
return CastSettings.StreamProtection.NONE; return CastSettings.StreamProtection.NONE;
} }
return mode; 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;
}
} }

View File

@@ -33,6 +33,8 @@ public final class TransportLossStatsBridge {
s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames; s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
s.protectionMode = loss.protectionMode.name(); s.protectionMode = loss.protectionMode.name();
s.fecStubPackets = loss.fecPacketsEncoded; s.fecStubPackets = loss.fecPacketsEncoded;
s.fecPacketsDecoded = loss.fecPacketsDecoded;
s.fecDecodeFailures = loss.fecDecodeFailures;
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived; s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived;
long lossEvents = loss.estimatedUdpLossEvents(); long lossEvents = loss.estimatedUdpLossEvents();
if (loss.datagramsReceived > 0 && lossEvents > 0) { if (loss.datagramsReceived > 0 && lossEvents > 0) {

View File

@@ -7,14 +7,19 @@ import java.io.DataOutputStream;
import java.io.IOException; 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 ReedSolomon block. * fragment does not invalidate the whole ReedSolomon block.
*
* <p>v2 used a single-byte shard length (max 255), which breaks VP9 keyframes. v3 uses uint16.
*/ */
public final class FecShardWire { public final class FecShardWire {
private static final byte[] MAGIC = {'A', 'C', 'P', 'F'}; private static final byte[] MAGIC = {'A', 'C', 'P', 'F'};
public static final byte VERSION_SHARD = 2; /** Legacy on-wire format; shard length field is 1 byte (max 255). */
/** Bytes after {@link #MAGIC} before shard payload. */ public static final byte VERSION_SHARD_V2 = 2;
private static final int HEADER_AFTER_MAGIC = 19; /** 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() {} private FecShardWire() {}
@@ -31,6 +36,9 @@ public final class FecShardWire {
if (shardData == null) { if (shardData == null) {
throw new IOException("shardData required"); throw new IOException("shardData required");
} }
if (shardData.length > 0xFFFF) {
throw new IOException("shard too large: " + shardData.length);
}
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length); ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
DataOutputStream dos = new DataOutputStream(bos); DataOutputStream dos = new DataOutputStream(bos);
dos.write(MAGIC); dos.write(MAGIC);
@@ -43,18 +51,17 @@ public final class FecShardWire {
dos.writeInt(originalLen); dos.writeInt(originalLen);
dos.writeByte(dataShards); dos.writeByte(dataShards);
dos.writeByte(parityShards); dos.writeByte(parityShards);
dos.writeByte(shardData.length); dos.writeShort(shardData.length);
dos.write(shardData); dos.write(shardData);
return bos.toByteArray(); return bos.toByteArray();
} }
public static boolean isShardPacket(byte[] wire) { public static boolean isShardPacket(byte[] wire) {
Parsed p = tryParse(wire); return tryParse(wire) != null;
return p != null;
} }
public static Parsed tryParse(byte[] wire) { 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; return null;
} }
for (int i = 0; i < MAGIC.length; i++) { 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, DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length,
wire.length - MAGIC.length)); wire.length - MAGIC.length));
byte ver = dis.readByte(); byte ver = dis.readByte();
if (ver != VERSION_SHARD) { if (ver != VERSION_SHARD_V2 && ver != VERSION_SHARD) {
return null; return null;
} }
byte mode = dis.readByte(); byte mode = dis.readByte();
@@ -77,14 +84,15 @@ public final class FecShardWire {
int originalLen = dis.readInt(); int originalLen = dis.readInt();
int dataShards = dis.readUnsignedByte(); int dataShards = dis.readUnsignedByte();
int parityShards = 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) { if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) {
return null; return null;
} }
if (shardIndex >= totalShards || originalLen < 1) { if (shardIndex >= totalShards || originalLen < 1 || shardSize < 1) {
return null; 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; return null;
} }
byte[] shardData = new byte[shardSize]; byte[] shardData = new byte[shardSize];

View File

@@ -166,23 +166,33 @@ public class AudioDecoder {
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState()); 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) { public void queueFrame(long ptsUs, byte[] data) {
if (data == null || data.length == 0) { if (data == null || data.length == 0) {
return; return;
} }
stats.framesQueued++; stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { PendingFrame frame = new PendingFrame(ptsUs, data.clone());
PendingFrame dropped = inputQueue.poll(); if (inputQueue.offer(frame)) {
if (dropped != null) { return;
stats.queueDrops++; }
int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
inputQueue.clear();
retryFrame = null;
if (dropped > 0) {
stats.queueDrops += dropped;
logDropBurst(); logDropBurst();
} }
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { if (!inputQueue.offer(frame)) {
stats.queueDrops++; stats.queueDrops++;
logDropBurst(); logDropBurst();
} }
} }
}
private void logDropBurst() { private void logDropBurst() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();

View File

@@ -11,8 +11,10 @@ import java.io.IOException;
/** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */ /** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */
public final class LibvpxVideoDecoder implements VideoDecoderSink { public final class LibvpxVideoDecoder implements VideoDecoderSink {
private static final String TAG = "LibvpxVideoDecoder"; private static final String TAG = "LibvpxVideoDecoder";
private static final int DECODE_FAIL_LOG_EVERY = 16;
private final Object lock = new Object(); private final Object lock = new Object();
private int decodeFailLogCount;
private long nativeHandle; private long nativeHandle;
private Surface outputSurface; private Surface outputSurface;
private Listener listener; private Listener listener;
@@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
} }
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) { if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
notifyRenderedLocked(); notifyRenderedLocked();
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
} }
} }
} }

View File

@@ -25,6 +25,8 @@ import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; 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.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy; import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecBackend; import com.foxx.androidcast.media.codec.VideoCodecBackend;
@@ -169,6 +171,7 @@ public class ReceiverCastService extends Service {
videoDecodeThread = new HandlerThread("VideoDecode"); videoDecodeThread = new HandlerThread("VideoDecode");
videoDecodeThread.start(); videoDecodeThread.start();
videoDecodeHandler = new Handler(videoDecodeThread.getLooper()); videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
CpuLoadSampler.warmUp();
mainHandler.post(idleWatchdog); mainHandler.post(idleWatchdog);
} }
@@ -337,7 +340,7 @@ public class ReceiverCastService extends Service {
@Override @Override
public void onAudioFrame(long ptsUs, byte[] data) { public void onAudioFrame(long ptsUs, byte[] data) {
if (castEnded) { if (castEnded || awaitingKeyframe || streamIdle) {
return; return;
} }
final int frameBytes = data != null ? data.length : 0; final int frameBytes = data != null ? data.length : 0;
@@ -476,7 +479,7 @@ public class ReceiverCastService extends Service {
return; return;
} }
streamIdle = false; streamIdle = false;
awaitingKeyframe = true; enterAwaitingKeyframe();
openPlaybackAwaiting(R.string.playback_awaiting_stream); openPlaybackAwaiting(R.string.playback_awaiting_stream);
scheduleDecoderConfigureRetries(); scheduleDecoderConfigureRetries();
} }
@@ -485,7 +488,7 @@ public class ReceiverCastService extends Service {
private void enterAwaitingMode(int messageResId, boolean fullReset) { private void enterAwaitingMode(int messageResId, boolean fullReset) {
streamIdle = true; streamIdle = true;
hasPendingConfig = false; hasPendingConfig = false;
awaitingKeyframe = true; enterAwaitingKeyframe();
resetDecoderState(); resetDecoderState();
if (videoDecoder != null) { if (videoDecoder != null) {
videoDecoder.release(); videoDecoder.release();
@@ -500,7 +503,7 @@ public class ReceiverCastService extends Service {
private void resetStreamState() { private void resetStreamState() {
hasPendingConfig = false; hasPendingConfig = false;
awaitingKeyframe = true; enterAwaitingKeyframe();
pendingWidth = 0; pendingWidth = 0;
pendingHeight = 0; pendingHeight = 0;
displayWidth = 0; displayWidth = 0;
@@ -516,6 +519,19 @@ public class ReceiverCastService extends Service {
decoderHeight = 0; 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) { private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
pendingAudioRate = sampleRate; pendingAudioRate = sampleRate;
pendingAudioChannels = channels; pendingAudioChannels = channels;
@@ -559,7 +575,7 @@ public class ReceiverCastService extends Service {
streamMetrics.reset(); streamMetrics.reset();
} }
reconfiguring = true; reconfiguring = true;
awaitingKeyframe = true; enterAwaitingKeyframe();
openPlaybackAwaiting(R.string.playback_awaiting_stream); openPlaybackAwaiting(R.string.playback_awaiting_stream);
pendingWidth = size.width; pendingWidth = size.width;
pendingHeight = size.height; pendingHeight = size.height;
@@ -796,7 +812,8 @@ public class ReceiverCastService extends Service {
remoteCastSettings, remoteCastSettings,
peer, peer,
lastLocalStats, lastLocalStats,
PlaybackViewState.getZoomPercent()); PlaybackViewState.getZoomPercent(),
CastMtuSampler.sample(this));
} }
private TransportLossStats buildReceiverLossSnapshot() { private TransportLossStats buildReceiverLossSnapshot() {

View File

@@ -73,6 +73,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
private boolean bound; private boolean bound;
private boolean autoStartListening; private boolean autoStartListening;
private boolean listeningCancelled; private boolean listeningCancelled;
private boolean autoPipAttempted;
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() { private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
@Override @Override
@@ -182,11 +183,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
awaitingMessage = findViewById(R.id.text_awaiting_message); awaitingMessage = findViewById(R.id.text_awaiting_message);
pipButton = findViewById(R.id.button_pip); pipButton = findViewById(R.id.button_pip);
if (pipButton != null) { if (pipButton != null) {
pipButton.setOnClickListener(v -> tryEnterPictureInPicture()); pipButton.setOnClickListener(v -> tryEnterPictureInPicture(true));
} }
textureView.setSurfaceTextureListener(surfaceListener); textureView.setSurfaceTextureListener(surfaceListener);
applyAspectRatio(); applyAspectRatio();
updatePipUi(); updatePipUi();
if (AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported()) {
PictureInPictureHelper.applyParams(this, 16, 9);
}
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false); autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
if (autoStartListening) { if (autoStartListening) {
@@ -245,14 +249,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
mainHandler.post(diagnosticsRefresh); mainHandler.post(diagnosticsRefresh);
updateDiagnosticsVisibility(); updateDiagnosticsVisibility();
updatePipUi(); updatePipUi();
maybeAutoEnterPip();
} }
@Override @Override
public void onUserLeaveHint() { public void onUserLeaveHint() {
super.onUserLeaveHint(); super.onUserLeaveHint();
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this) if (shouldAutoEnterPipOnLeave()) {
&& PermissionHelper.hasReceiverPlaybackReady(this)) { tryEnterPictureInPicture(false);
tryEnterPictureInPicture();
} }
} }
@@ -268,6 +272,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) { if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
updatePipUi(); updatePipUi();
maybeAutoEnterPip();
} }
} }
@@ -546,7 +551,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
if (pipButton == null) { if (pipButton == null) {
return; 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() && PictureInPictureHelper.isSupported()
&& PermissionHelper.hasReceiverPlaybackReady(this); && PermissionHelper.hasReceiverPlaybackReady(this);
pipButton.setVisibility(show ? View.VISIBLE : View.GONE); pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
@@ -555,23 +562,64 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
} }
} }
private void tryEnterPictureInPicture() { private boolean shouldAutoEnterPipOnLeave() {
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) { 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; return;
} }
if (!PermissionHelper.hasReceiverPlaybackReady(this)) { if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
PermissionHelper.remindForReceiverPlayback(this); PermissionHelper.remindForReceiverPlayback(this);
return; return;
} }
if (!PictureInPictureHelper.isSupported()) { 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(); 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()) {
if (userInitiated) {
Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
}
return; return;
} }
if (!PictureInPictureHelper.isAllowedForApp(this)) { if (!PictureInPictureHelper.isAllowedForApp(this)) {
if (userInitiated) {
PictureInPictureHelper.showEnableInSettingsDialog(this); PictureInPictureHelper.showEnableInSettingsDialog(this);
}
return; return;
} }
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) { if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight) && userInitiated) {
PictureInPictureHelper.showEnableInSettingsDialog(this); PictureInPictureHelper.showEnableInSettingsDialog(this);
} }
} }
@@ -582,7 +630,12 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE); handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
} }
if (pipButton != null) { 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 (inPictureInPicture) {
if (diagnosticsText != null) { if (diagnosticsText != null) {

View File

@@ -14,6 +14,8 @@ import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastSessionGate; import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory; 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.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStats;
@@ -74,6 +76,7 @@ public class ReceiverSession {
private CastSession session; private CastSession session;
private CastTransport listenTransport; private CastTransport listenTransport;
private final CastSessionGate inboundSessionGate = new CastSessionGate(); private final CastSessionGate inboundSessionGate = new CastSessionGate();
private boolean loggedFirstVideoFrame;
private NetworkFeedbackManager networkFeedback; private NetworkFeedbackManager networkFeedback;
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher; private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
@@ -195,6 +198,7 @@ public class ReceiverSession {
listenTransport = null; listenTransport = null;
} }
inboundSessionGate.reset(); inboundSessionGate.reset();
loggedFirstVideoFrame = false;
networkFeedback = null; networkFeedback = null;
listener.onDisconnected(); listener.onDisconnected();
} }
@@ -237,6 +241,12 @@ public class ReceiverSession {
case CastProtocol.MSG_VIDEO_FRAME: case CastProtocol.MSG_VIDEO_FRAME:
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload); CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
recordNetworkBytes(vf.data != null ? vf.data.length : 0); 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); listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
break; break;
case CastProtocol.MSG_AUDIO_CONFIG: case CastProtocol.MSG_AUDIO_CONFIG:
@@ -286,7 +296,16 @@ public class ReceiverSession {
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport(); udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
} }
if (udp != null) { 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);
}
} }
} }

View File

@@ -88,18 +88,18 @@ public class AudioEncoder implements AudioEncoderSink {
public void stop() { public void stop() {
running = false; running = false;
MediaCodec c; Thread drain = drainThread;
synchronized (inputLock) { drainThread = null;
c = codec; if (drain != null) {
codec = null;
}
if (drainThread != null) {
try { try {
drainThread.join(1000); drain.join(1000);
} catch (InterruptedException ignored) { } catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
synchronized (inputLock) {
MediaCodec c = codec;
codec = null;
if (c != null) { if (c != null) {
try { try {
c.stop(); c.stop();
@@ -111,17 +111,33 @@ public class AudioEncoder implements AudioEncoderSink {
} }
} }
} }
}
private void drainLoop() { private void drainLoop() {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false; boolean configSent = false;
while (running) { while (true) {
int index = codec.dequeueOutputBuffer(info, 10_000); 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) { if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue; continue;
} }
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
MediaFormat fmt = codec.getOutputFormat(); MediaFormat fmt = c.getOutputFormat();
if (!configSent && callback != null) { if (!configSent && callback != null) {
ByteBuffer csd = fmt.getByteBuffer("csd-0"); ByteBuffer csd = fmt.getByteBuffer("csd-0");
callback.onConfig( callback.onConfig(
@@ -135,7 +151,7 @@ public class AudioEncoder implements AudioEncoderSink {
if (index < 0) { if (index < 0) {
continue; 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) { if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
byte[] data = new byte[info.size]; byte[] data = new byte[info.size];
buffer.position(info.offset); buffer.position(info.offset);
@@ -145,7 +161,11 @@ public class AudioEncoder implements AudioEncoderSink {
callback.onEncodedFrame(info.presentationTimeUs, data); callback.onEncodedFrame(info.presentationTimeUs, data);
} }
} }
codec.releaseOutputBuffer(index, false); try {
c.releaseOutputBuffer(index, false);
} catch (IllegalStateException ignored) {
break;
}
} }
} }

View File

@@ -11,6 +11,8 @@ import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession; import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory; 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.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport; import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
@@ -99,7 +101,7 @@ public final class MultiCastCoordinator {
session = createSession(peerSettings); session = createSession(peerSettings);
connectWithRetry(session, t.host, t.port); connectWithRetry(session, t.host, t.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings); 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)); peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime
+ " score=" + " score="
@@ -128,7 +130,7 @@ public final class MultiCastCoordinator {
CastSession session = createSession(peerSettings); CastSession session = createSession(peerSettings);
connectWithRetry(session, target.host, target.port); connectWithRetry(session, target.host, target.port);
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings); 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)); peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
if (keyframeRequester != null) { if (keyframeRequester != null) {
keyframeRequester.requestKeyframe(); keyframeRequester.requestKeyframe();
@@ -202,8 +204,8 @@ public final class MultiCastCoordinator {
return session; return session;
} }
private static void applyNegotiatedProtection(CastSession session, CastSettings local, private void applyNegotiatedProtection(CastSession session, CastSettings local,
CastSettings remote) { CastSettings receiverAdvertisement) {
if (session == null) { if (session == null) {
return; return;
} }
@@ -215,7 +217,13 @@ public final class MultiCastCoordinator {
udp = ((QuicCronetCastTransport) transport).getUdpTransport(); udp = ((QuicCronetCastTransport) transport).getUdpTransport();
} }
if (udp != null) { 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));
}
} }
} }

View File

@@ -158,6 +158,7 @@ public class ScreenCastService extends Service implements
} }
CastNotifications.createChannels(this); CastNotifications.createChannels(this);
com.foxx.androidcast.network.CastTransportFactory.init(this); com.foxx.androidcast.network.CastTransportFactory.init(this);
com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp();
projectionThread = new HandlerThread("CastProjection"); projectionThread = new HandlerThread("CastProjection");
projectionThread.start(); projectionThread.start();
projectionHandler = new Handler(projectionThread.getLooper()); 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 { private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
SenderPreviewService.stop(this);
MediaProjectionManager mgr = MediaProjectionManager mgr =
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
projection = mgr.getMediaProjection(resultCode, data); projection = mgr.getMediaProjection(resultCode, data);

View File

@@ -464,10 +464,14 @@ public class SenderActivity extends DrawerHostActivity {
if (resultCode == Activity.RESULT_OK && data != null) { if (resultCode == Activity.RESULT_OK && data != null) {
screenPreview.setProjectionResult(resultCode, data); screenPreview.setProjectionResult(resultCode, data);
previewConsentDeclined = false; previewConsentDeclined = false;
findViewById(R.id.panel_preview).post(() -> {
refreshPreview();
findViewById(R.id.panel_preview).postDelayed(this::refreshPreview, 200);
});
} else { } else {
previewConsentDeclined = true; previewConsentDeclined = true;
}
refreshPreview(); refreshPreview();
}
return; return;
} }
if (requestCode != REQUEST_MEDIA_PROJECTION) { if (requestCode != REQUEST_MEDIA_PROJECTION) {

View File

@@ -19,6 +19,7 @@ import android.widget.TextView;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator; import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
@@ -62,6 +63,10 @@ public final class SenderCapturePreview {
public void updateForMode(CastSettings.CaptureMode mode) { public void updateForMode(CastSettings.CaptureMode mode) {
activeMode = mode; activeMode = mode;
stopCamera(); stopCamera();
if (CastActiveState.isSenderCasting()) {
showCastingPlaceholder();
return;
}
if (mode == CastSettings.CaptureMode.CAMERA) { if (mode == CastSettings.CaptureMode.CAMERA) {
useFront = false; useFront = false;
startCameraPreview(); startCameraPreview();
@@ -71,7 +76,7 @@ public final class SenderCapturePreview {
} else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) { } else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
showCalibrationPreview(); showCalibrationPreview();
} else if (screenPreview != null && screenPreview.needsProjection(mode)) { } else if (screenPreview != null && screenPreview.needsProjection(mode)) {
if (screenPreview.hasProjection()) { if (screenPreview.hasConsent()) {
labelView.setVisibility(android.view.View.GONE); labelView.setVisibility(android.view.View.GONE);
textureView.setVisibility(android.view.View.VISIBLE); textureView.setVisibility(android.view.View.VISIBLE);
screenPreview.attach(textureView, mode); screenPreview.attach(textureView, mode);
@@ -92,6 +97,16 @@ public final class SenderCapturePreview {
labelView.setText(labelView.getContext().getString(textRes)); 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() { private void showCalibrationPreview() {
if (screenPreview != null) { if (screenPreview != null) {
screenPreview.stop(); screenPreview.stop();

View File

@@ -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;
}
}

View File

@@ -16,6 +16,7 @@ import android.view.Surface;
import android.view.TextureView; import android.view.TextureView;
import android.view.WindowManager; import android.view.WindowManager;
import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
/** /**
@@ -105,6 +106,9 @@ public final class SenderScreenPreview {
if (projection != null || resultData == null) { if (projection != null || resultData == null) {
return; return;
} }
if (!CastActiveState.isSenderCasting() && !SenderPreviewService.isRunning()) {
SenderPreviewService.start(appContext);
}
MediaProjectionManager mgr = MediaProjectionManager mgr =
appContext.getSystemService(MediaProjectionManager.class); appContext.getSystemService(MediaProjectionManager.class);
projection = mgr.getMediaProjection(resultCode, resultData); projection = mgr.getMediaProjection(resultCode, resultData);
@@ -133,8 +137,14 @@ public final class SenderScreenPreview {
DisplayMetrics metrics = new DisplayMetrics(); DisplayMetrics metrics = new DisplayMetrics();
WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE); WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
wm.getDefaultDisplay().getRealMetrics(metrics); wm.getDefaultDisplay().getRealMetrics(metrics);
int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 2); int viewW = textureView.getWidth();
int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 2); 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(); SurfaceTexture st = textureView.getSurfaceTexture();
if (st == null) { if (st == null) {
return; return;
@@ -181,6 +191,9 @@ public final class SenderScreenPreview {
} }
projection = null; projection = null;
} }
if (!CastActiveState.isSenderCasting()) {
SenderPreviewService.stop(appContext);
}
} }
/** Pause preview surface only; keep MediaProjection consent for cast START. */ /** Pause preview surface only; keep MediaProjection consent for cast START. */

View File

@@ -149,6 +149,8 @@
<string name="capture_camera_front">Камера (фронт)</string> <string name="capture_camera_front">Камера (фронт)</string>
<string name="label_mute">Тихо</string> <string name="label_mute">Тихо</string>
<string name="preview_placeholder">Предпросмотр\n(трансляция начнется после нажатия кнопки)</string> <string name="preview_placeholder">Предпросмотр\n(трансляция начнется после нажатия кнопки)</string>
<string name="preview_screen_active">Предпросмотр экрана</string>
<string name="preview_casting_active">Трансляция — предпросмотр приостановлен</string>
<string name="preview_camera">Предпросмотр с камеры</string> <string name="preview_camera">Предпросмотр с камеры</string>
<string name="preview_calibration">Калибрационная таблица</string> <string name="preview_calibration">Калибрационная таблица</string>
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string> <string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string>

View File

@@ -133,7 +133,17 @@
<string name="receiver_display_auto_fit">AUTO (fits screen)</string> <string name="receiver_display_auto_fit">AUTO (fits screen)</string>
<string name="receiver_display_adaptive">Adaptive (experimental)</string> <string name="receiver_display_adaptive">Adaptive (experimental)</string>
<string name="label_stream_protection">Stream protection (UDP)</string> <string name="label_stream_protection">Stream protection (UDP)</string>
<string name="stream_protection_hint">Hint only — negotiated with receiver. UDP transports only.</string> <string name="stream_protection_hint">Sender preference — receiver FEC/NACK is auto-detected at connect. UDP only.</string>
<string name="protection_negotiation_role_sender">sender</string>
<string name="protection_negotiation_role_receiver">receiver</string>
<string name="protection_negotiation_nack_on">NACK on</string>
<string name="protection_negotiation_nack_off">NACK off</string>
<string name="protection_negotiation_agreed">Stream protection: %1$s (%2$s)</string>
<string name="protection_negotiation_use_none">No protection this session (receiver %1$s, sender %2$s). Set %3$s stream protection to None or match the peer.</string>
<string name="protection_negotiation_mismatch">Protection mismatch (receiver %1$s, sender %2$s). Casting without protection; align %3$s settings.</string>
<string name="protection_negotiation_fec_family_rs">Using ReedSolomon FEC (receiver %1$s, sender %2$s). For a direct match, set %3$s to FEC (ReedSolomon).</string>
<string name="protection_negotiation_fec34_no_nack">Using FEC 3/4 without NACK (receiver %1$s, sender %2$s). NACK disabled for this session.</string>
<string name="protection_negotiation_nack_to_fec">Using FEC with NACK (receiver %1$s, sender %2$s).</string>
<string name="protection_fec_34">FEC 3/4</string> <string name="protection_fec_34">FEC 3/4</string>
<string name="protection_fec_rs">FEC (Reed-Solomon)</string> <string name="protection_fec_rs">FEC (Reed-Solomon)</string>
<string name="protection_nack">NACK</string> <string name="protection_nack">NACK</string>
@@ -149,6 +159,8 @@
<string name="capture_camera_front">Camera (front)</string> <string name="capture_camera_front">Camera (front)</string>
<string name="label_mute">Mute</string> <string name="label_mute">Mute</string>
<string name="preview_placeholder">Source preview\n(screen capture starts after START)</string> <string name="preview_placeholder">Source preview\n(screen capture starts after START)</string>
<string name="preview_screen_active">Screen preview</string>
<string name="preview_casting_active">Casting — preview paused</string>
<string name="preview_camera">Camera preview</string> <string name="preview_camera">Camera preview</string>
<string name="preview_calibration">Calibration test pattern</string> <string name="preview_calibration">Calibration test pattern</string>
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string> <string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string>

View File

@@ -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);
}
}

View File

@@ -7,6 +7,8 @@ import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.transport.StreamProtectionFactory; import com.foxx.androidcast.network.transport.StreamProtectionFactory;
import com.foxx.androidcast.network.CastFramingContext;
import org.junit.Test; import org.junit.Test;
import java.net.DatagramPacket; import java.net.DatagramPacket;
@@ -24,8 +26,8 @@ public class UdpCastTransportFecTest {
receiver.prepareListen(portB); receiver.prepareListen(portB);
sender.connect("127.0.0.1", portB); sender.connect("127.0.0.1", portB);
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4); CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec); CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec); CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
byte[] payload = new byte[900]; byte[] payload = new byte[900];
for (int i = 0; i < payload.length; i++) { 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<byte[]> 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) { private static CastSettings settingsWith(CastSettings.StreamProtection protection) {
CastSettings s = new CastSettings(); CastSettings s = new CastSettings();
s.setTransport(CastConfig.TRANSPORT_UDP); s.setTransport(CastConfig.TRANSPORT_UDP);

View File

@@ -4,6 +4,7 @@ import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup; import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
@@ -12,6 +13,20 @@ import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
import org.junit.Test; import org.junit.Test;
public class FecProtectionEngineShardTest { 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 @Test
public void encodeAndDecodeShards_viaEngine() throws Exception { public void encodeAndDecodeShards_viaEngine() throws Exception {
TransportLossStats stats = new TransportLossStats(); TransportLossStats stats = new TransportLossStats();

View File

@@ -27,6 +27,23 @@ public class StreamProtectionFactoryTest {
StreamProtectionEngine engine = StreamProtectionFactory.create( StreamProtectionEngine engine = StreamProtectionFactory.create(
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats()); CastSettings.StreamProtection.FEC_3_4, new TransportLossStats());
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode()); 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 @Test

View File

@@ -1,6 +1,10 @@
package com.foxx.androidcast.network.transport; package com.foxx.androidcast.network.transport;
import static org.junit.Assert.assertEquals; 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; import com.foxx.androidcast.CastSettings;
@@ -14,18 +18,93 @@ public class StreamProtectionNegotiatorTest {
} }
@Test @Test
public void mismatchFallsBackToNone() { public void bothFec34_agreesWithNack() {
assertEquals(CastSettings.StreamProtection.NONE, StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
StreamProtectionNegotiator.resolve( CastSettings.StreamProtection.FEC_3_4,
CastSettings.StreamProtection.FEC_NACK, CastSettings.StreamProtection.FEC_3_4,
CastSettings.StreamProtection.NACK)); false);
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
assertTrue(n.nackEnabled);
assertNull(n.prompt);
} }
@Test @Test
public void matchingModesPreserved() { public void senderFec34_receiverAuto_usesFec34() {
assertEquals(CastSettings.StreamProtection.FEC_3_4, StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
StreamProtectionNegotiator.resolve(
CastSettings.StreamProtection.FEC_3_4, CastSettings.StreamProtection.FEC_3_4,
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);
} }
} }

View File

@@ -39,6 +39,31 @@ public class FecShardReassemblyTest {
assertArrayEquals(payload, recovered); 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 @Test
public void insufficientShards_returnsNull() throws Exception { public void insufficientShards_returnsNull() throws Exception {
byte[] payload = new byte[] {1, 2, 3, 4, 5}; byte[] payload = new byte[] {1, 2, 3, 4, 5};

View File

@@ -5,9 +5,52 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import org.junit.Test; import org.junit.Test;
public class FecShardWireTest { 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 @Test
public void encodeParse_roundTrip() throws Exception { public void encodeParse_roundTrip() throws Exception {
byte[] shard = new byte[64]; byte[] shard = new byte[64];

View File

@@ -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 | | 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 | | 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/` | | Opus / Speex | Stub | Probe only | Submodules under `third-party/` |
## Enable libvpx (developer) ## Enable libvpx (developer)

View File

@@ -342,9 +342,15 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCrea
return 0L; return 0L;
} }
ctx->magic = VPX_DEC_MAGIC; ctx->magic = VPX_DEC_MAGIC;
vpx_codec_iface_t *iface = pick_decoder_iface(mime_utf);
vpx_codec_dec_cfg_t cfg = {0}; vpx_codec_dec_cfg_t cfg = {0};
cfg.threads = 1; 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); free(ctx);
(*env)->ReleaseStringUTFChars(env, mime, mime_utf); (*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L; 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, vpx_codec_err_t err = vpx_codec_decode(&ctx->codec, (const uint8_t *) bytes, (unsigned int) len,
NULL, 0); NULL, 0);
(*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT); (*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT);
if (err != VPX_CODEC_OK) { /* With error concealment, vpx_codec_decode may warn yet still output a frame (see vpxdec.c). */
return JNI_FALSE; (void) err;
}
ANativeWindow *window = ANativeWindow_fromSurface(env, surface); ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
if (window == NULL) { if (window == NULL) {

View File

@@ -225,7 +225,7 @@ cp -f "$LIBVPX_A" "$OUT/libvpx.a"
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a" cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
echo "" 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 if [[ -n "$STAGING" ]]; then
echo "(staged under $STAGING because of spaces in repo path)" echo "(staged under $STAGING because of spaces in repo path)"
fi fi

41
scripts/capture-vp9-fec-logs.sh Executable file
View File

@@ -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