1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:18:09 +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('\"', '\\\\\"')}\""
externalNativeBuild {
cmake {
def cmakeArgs = ["-DANDROID_STL=c++_shared"]
arguments "-DANDROID_STL=c++_shared"
if (resolveCcachePath) {
cmakeArgs += "-DANDROID_CCACHE=${resolveCcachePath}"
arguments "-DANDROID_CCACHE=${resolveCcachePath}"
}
arguments cmakeArgs
cppFlags ""
}
}

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,8 @@ public final class CastDiagnosticsFormatter {
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
int streamW, int streamH, int renderW, int renderH, boolean idle,
CastSettings localSettings, CastSettings remoteSettings,
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) {
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
CastMtuSampler.Info mtuInfo) {
float inFps = metrics.getInFps();
float renderFps = metrics.getRenderFps();
int bwInKbps = metrics.getBwInKbps();
@@ -103,6 +104,7 @@ public final class CastDiagnosticsFormatter {
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
.append(escape(transport != null ? transport.toUpperCase() : "?"))
.append("</font><br/>");
appendMtuSection(html, mtuInfo);
if (videoCodec != null && !videoCodec.isEmpty()) {
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
@@ -251,6 +253,12 @@ public final class CastDiagnosticsFormatter {
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) {
html.append("<font color='").append(COLOR_WARN).append("'>")
.append(escape(hint)).append("</font><br/>");
@@ -270,6 +278,29 @@ public final class CastDiagnosticsFormatter {
return html.toString();
}
private static void appendMtuSection(StringBuilder html, CastMtuSampler.Info mtu) {
if (mtu == null) {
return;
}
html.append("<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,
NetworkStatsSnapshot local) {
long rx = pick(peer, local, true, false);
@@ -314,7 +345,7 @@ public final class CastDiagnosticsFormatter {
html.append(' ').append(colored("aud-dec " + aDecErr, true));
}
if (aDrop > 0) {
html.append(' ').append(colored("aud-q " + aDrop, true));
html.append(' ').append(colored("aud-drop " + aDrop, true));
}
if (dropAudio > 0) {
html.append(' ').append(colored("snd-aud " + dropAudio, true));
@@ -364,9 +395,15 @@ public final class CastDiagnosticsFormatter {
html.append(" · ").append(escape(summary));
}
long fecPkts = val(peer, local, p -> p.fecStubPackets, l -> l.fecStubPackets);
long fecOk = val(peer, local, p -> p.fecPacketsDecoded, l -> l.fecPacketsDecoded);
long fecFail = val(peer, local, p -> p.fecDecodeFailures, l -> l.fecDecodeFailures);
long nackReq = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
if (fecPkts > 0) {
html.append("<br/>FEC packets=").append(fecPkts);
if (fecPkts > 0 || fecOk > 0 || fecFail > 0) {
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) {
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;
/** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
public static final byte MSG_NACK = 16;
/** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */
public static final byte MSG_RECEIVER_CAST_SETTINGS = 17;
/** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
public static final byte MSG_WIRE_PACKET = 127;
@@ -268,7 +270,7 @@ public final class CastProtocol {
return local.getTransport().equals(remote.getTransport());
}
private static final byte NETWORK_STATS_VERSION = 4;
private static final byte NETWORK_STATS_VERSION = 5;
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
@@ -318,6 +320,10 @@ public final class CastProtocol {
dos.writeLong(s.fecStubPackets);
dos.writeLong(s.nackStubRequests);
}
if (NETWORK_STATS_VERSION >= 5) {
dos.writeLong(s.fecPacketsDecoded);
dos.writeLong(s.fecDecodeFailures);
}
dos.flush();
return bos.toByteArray();
}
@@ -372,6 +378,10 @@ public final class CastProtocol {
s.fecStubPackets = dis.readLong();
s.nackStubRequests = dis.readLong();
}
if (ver >= 5 && dis.available() > 0) {
s.fecPacketsDecoded = dis.readLong();
s.fecDecodeFailures = dis.readLong();
}
}
}
return s;

View File

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

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,
CastSettings remote) {
/**
* Apply protection after handshake using negotiated local/remote preferences.
*
* @param localIsReceiver true when {@code local} is the receiver's capability from settings.
* @return negotiation details (prompts, agreed mode) for UI/logging.
*/
public static com.foxx.androidcast.network.transport.StreamProtectionNegotiation applyNegotiatedUdpProtection(
UdpCastTransport udp, CastSettings local, CastSettings remote, boolean localIsReceiver) {
if (udp == null || local == null) {
return;
return com.foxx.androidcast.network.transport.StreamProtectionNegotiation.none();
}
CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator
.resolve(local.getStreamProtection(), remote != null ? remote.getStreamProtection() : null);
com.foxx.androidcast.network.transport.StreamProtectionNegotiation negotiated =
com.foxx.androidcast.network.transport.StreamProtectionNegotiator.negotiate(
local.getStreamProtection(),
remote != null ? remote.getStreamProtection() : null,
localIsReceiver);
CastSettings effective = new CastSettings();
effective.copyFrom(local);
effective.setStreamProtection(mode);
applyUdpProtection(udp, effective);
effective.setStreamProtection(negotiated.agreed);
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionFactory.create(
negotiated.agreed, udp.getLossStats(), negotiated.nackEnabled));
return negotiated;
}
}

View File

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

View File

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

View File

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

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.ReedSolomonFec;
import android.util.Log;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -16,9 +18,13 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/** Reed-Solomon FEC: v2 sends one shard per UDP message; v1 monolithic decode still accepted inbound. */
/** Reed-Solomon FEC: one shard per UDP message; v1 monolithic decode still accepted inbound. */
public final class FecProtectionEngine implements StreamProtectionEngine {
private static final String TAG = "FecProtection";
private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1);
private static final int LOG_EVERY_N = 32;
private int inboundShardRejectLogCount;
private int inboundDecodeFailLogCount;
private final byte mode;
private final int dataShards;
@@ -53,11 +59,25 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
return protectionMode;
}
/** Pick shard counts so each ACUD logical message stays small on lossy 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 {
if (payload == null || payload.length == 0) {
throw new IOException("empty payload");
}
ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
int[] plan = shardPlanForPayload(payload.length, dataShards, parityShards);
int k = plan[0];
int m = plan[1];
ReedSolomonFec fec = new ReedSolomonFec(k, m);
byte[][] shards = fec.encode(payload);
int groupId = NEXT_GROUP_ID.getAndIncrement();
if (stats != null) {
@@ -65,7 +85,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
stats.fecPacketsEncoded += shards.length;
}
}
return new FecEncodedGroup(mode, groupId, payload.length, dataShards, parityShards, shards);
return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards);
}
public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
@@ -116,6 +136,17 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
assembly = prev != null ? prev : created;
}
if (!assembly.addShard(shard)) {
maybeLogShardReject(shard, assembly);
byte[] retry = assembly.tryDecode();
if (retry != null) {
inboundGroups.remove(shard.groupId);
if (stats != null) {
synchronized (stats) {
stats.fecPacketsDecoded++;
}
}
return new ShardDecodeResult(ShardDecodeResult.Status.OK, retry);
}
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
}
byte[] decoded = assembly.tryDecode();
@@ -126,6 +157,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
stats.fecDecodeFailures++;
}
}
maybeLogDecodeFail(shard.groupId, assembly.receivedCount(), assembly.getTotalShards());
inboundGroups.remove(shard.groupId);
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
}
@@ -212,6 +244,22 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
return decoded.payload;
}
private void maybeLogShardReject(FecShardWire.Parsed shard, FecShardReassembly assembly) {
if ((++inboundShardRejectLogCount % LOG_EVERY_N) != 1) {
return;
}
Log.w(TAG, "FEC shard reject gid=" + shard.groupId + " idx=" + shard.shardIndex
+ " shardLen=" + shard.shardData.length + " recv=" + assembly.receivedCount()
+ "/" + assembly.getTotalShards());
}
private void maybeLogDecodeFail(int groupId, int received, int total) {
if ((++inboundDecodeFailLogCount % LOG_EVERY_N) != 1) {
return;
}
Log.w(TAG, "FEC RS decode failed gid=" + groupId + " shards=" + received + "/" + total);
}
private static boolean looksLikeWireHeader(byte[] payload) {
if (payload == null || payload.length < 4) {
return false;

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. */
public final class RetransmitCache {
private static final int DEFAULT_CAPACITY = 64;
/** VP9+FEC uses 7 message ids per frame; must cover several seconds of in-flight shards. */
private static final int DEFAULT_CAPACITY = 4096;
private final int capacity;
private final LinkedHashMap<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;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
@@ -16,17 +15,22 @@ public final class StreamProtectionFactory {
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
TransportLossStats stats) {
return create(setting, stats, wantsBundledNack(setting));
}
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
TransportLossStats stats, boolean nackEnabled) {
CastSettings.StreamProtection mode = StreamProtectionNegotiator.normalize(setting);
RetransmitCache cache = new RetransmitCache();
switch (mode) {
case NONE:
return StreamProtectionEngine.noop();
case FEC_3_4:
return new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
FEC34_DATA, FEC34_PARITY, stats);
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
FEC34_DATA, FEC34_PARITY, stats), stats, cache, nackEnabled);
case FEC_REED_SOLOMON:
return new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
RS_DATA, RS_PARITY, stats);
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
RS_DATA, RS_PARITY, stats), stats, cache, nackEnabled);
case NACK:
return new NackProtectionEngine(stats, cache);
case FEC_NACK:
@@ -38,6 +42,20 @@ public final class StreamProtectionFactory {
}
}
private static StreamProtectionEngine wrapFec(FecProtectionEngine fec,
TransportLossStats stats, RetransmitCache cache, boolean nackEnabled) {
if (!nackEnabled) {
return fec;
}
return new CombinedProtectionEngine(fec, new NackProtectionEngine(stats, cache));
}
private static boolean wantsBundledNack(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.FEC_3_4
|| mode == CastSettings.StreamProtection.FEC_NACK
|| mode == CastSettings.StreamProtection.FEC_REED_SOLOMON;
}
public static void wireUdp(UdpCastTransport udp, StreamProtectionEngine engine) {
if (udp == null || engine == null) {
return;

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;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.R;
/** Picks an agreed stream protection mode for both peers. */
import java.util.EnumSet;
/** Picks agreed stream protection from receiver capability vs sender preference. */
public final class StreamProtectionNegotiator {
private StreamProtectionNegotiator() {}
/**
* Agreed mode for this session. Any mismatch, missing remote preference, or {@code NONE}
* on either side yields {@link CastSettings.StreamProtection#NONE} (plain UDP).
*/
/** @deprecated Use {@link #negotiate(CastSettings.StreamProtection, CastSettings.StreamProtection, boolean)}. */
@Deprecated
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
CastSettings.StreamProtection remote) {
CastSettings.StreamProtection a = normalize(local);
CastSettings.StreamProtection b = normalize(remote);
if (a == CastSettings.StreamProtection.NONE || b == CastSettings.StreamProtection.NONE) {
return CastSettings.StreamProtection.NONE;
return negotiate(local, remote, false).agreed;
}
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;
}
private static StreamProtectionNegotiation negotiateFecFamilyMismatch(
CastSettings.StreamProtection agreed,
CastSettings.StreamProtection other,
CastSettings.StreamProtection receiverLabel,
CastSettings.StreamProtection senderPref,
boolean localIsReceiver) {
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
? StreamProtectionNegotiation.Prompt.Target.SENDER
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
boolean nack = wantsNack(other);
if (isFecNack(other)) {
nack = false;
}
return new StreamProtectionNegotiation(
agreed,
nack,
new StreamProtectionNegotiation.Prompt(
target,
R.string.protection_negotiation_fec_family_rs,
receiverLabel,
senderPref));
}
private static StreamProtectionNegotiation fec34WithoutNack(
CastSettings.StreamProtection receiverLabel,
CastSettings.StreamProtection senderPref,
boolean localIsReceiver) {
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
? StreamProtectionNegotiation.Prompt.Target.SENDER
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
return new StreamProtectionNegotiation(
CastSettings.StreamProtection.FEC_3_4,
false,
new StreamProtectionNegotiation.Prompt(
target,
R.string.protection_negotiation_fec34_no_nack,
receiverLabel,
senderPref));
}
private static StreamProtectionNegotiation agreeFecWithNack(
CastSettings.StreamProtection fecMode,
CastSettings.StreamProtection receiverLabel,
CastSettings.StreamProtection senderPref,
boolean localIsReceiver) {
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
? StreamProtectionNegotiation.Prompt.Target.SENDER
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
return new StreamProtectionNegotiation(
fecMode,
true,
new StreamProtectionNegotiation.Prompt(
target,
R.string.protection_negotiation_nack_to_fec,
receiverLabel,
senderPref));
}
private static StreamProtectionNegotiation agreeExact(
CastSettings.StreamProtection mode,
CastSettings.StreamProtection receiverLabel,
CastSettings.StreamProtection senderPref) {
boolean nack = wantsNack(mode);
if (mode == CastSettings.StreamProtection.FEC_3_4) {
nack = true;
}
return new StreamProtectionNegotiation(mode, nack, null);
}
public static CastSettings.StreamProtection normalize(CastSettings.StreamProtection mode) {
if (mode == null || mode == CastSettings.StreamProtection.AUTO) {
return CastSettings.StreamProtection.NONE;
}
return mode;
}
private static boolean wantsNack(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.NACK
|| mode == CastSettings.StreamProtection.FEC_NACK;
}
private static boolean isNackOnly(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.NACK;
}
private static boolean isFec(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.FEC_3_4
|| mode == CastSettings.StreamProtection.FEC_REED_SOLOMON
|| mode == CastSettings.StreamProtection.FEC_NACK;
}
private static boolean isFec34(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.FEC_3_4;
}
private static boolean isFecNack(CastSettings.StreamProtection mode) {
return mode == CastSettings.StreamProtection.FEC_NACK;
}
}

View File

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

View File

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

View File

@@ -166,23 +166,33 @@ public class AudioDecoder {
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
}
/** Drops queued AAC input without tearing down the decoder (e.g. after video keyframe wait). */
public void clearInputQueue() {
inputQueue.clear();
retryFrame = null;
}
public void queueFrame(long ptsUs, byte[] data) {
if (data == null || data.length == 0) {
return;
}
stats.framesQueued++;
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
PendingFrame dropped = inputQueue.poll();
if (dropped != null) {
stats.queueDrops++;
PendingFrame frame = new PendingFrame(ptsUs, data.clone());
if (inputQueue.offer(frame)) {
return;
}
int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
inputQueue.clear();
retryFrame = null;
if (dropped > 0) {
stats.queueDrops += dropped;
logDropBurst();
}
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
if (!inputQueue.offer(frame)) {
stats.queueDrops++;
logDropBurst();
}
}
}
private void logDropBurst() {
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}. */
public final class LibvpxVideoDecoder implements VideoDecoderSink {
private static final String TAG = "LibvpxVideoDecoder";
private static final int DECODE_FAIL_LOG_EVERY = 16;
private final Object lock = new Object();
private int decodeFailLogCount;
private long nativeHandle;
private Surface outputSurface;
private Listener listener;
@@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
}
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
notifyRenderedLocked();
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
}
}
}

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.transport.TransportLossStats;
@@ -74,6 +76,7 @@ public class ReceiverSession {
private CastSession session;
private CastTransport listenTransport;
private final CastSessionGate inboundSessionGate = new CastSessionGate();
private boolean loggedFirstVideoFrame;
private NetworkFeedbackManager networkFeedback;
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
@@ -195,6 +198,7 @@ public class ReceiverSession {
listenTransport = null;
}
inboundSessionGate.reset();
loggedFirstVideoFrame = false;
networkFeedback = null;
listener.onDisconnected();
}
@@ -237,6 +241,12 @@ public class ReceiverSession {
case CastProtocol.MSG_VIDEO_FRAME:
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
if (!loggedFirstVideoFrame) {
loggedFirstVideoFrame = true;
Log.i(TAG, "First video over network "
+ (vf.keyFrame ? "KEY" : "P") + " len="
+ (vf.data != null ? vf.data.length : 0));
}
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
break;
case CastProtocol.MSG_AUDIO_CONFIG:
@@ -286,7 +296,16 @@ public class ReceiverSession {
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
}
if (udp != null) {
CastTransportFactory.applyNegotiatedUdpProtection(udp, localSettings, remoteSettings);
StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
udp, localSettings, remoteSettings, true);
String summary = ProtectionNegotiationPrompts.summarize(appContext, negotiated);
Log.i(TAG, summary);
postStatus(summary);
if (negotiated.prompt != null) {
String hint = ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, true);
Log.i(TAG, hint);
postStatus(hint);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -149,6 +149,8 @@
<string name="capture_camera_front">Камера (фронт)</string>
<string name="label_mute">Тихо</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_calibration">Калибрационная таблица</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_adaptive">Adaptive (experimental)</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_rs">FEC (Reed-Solomon)</string>
<string name="protection_nack">NACK</string>
@@ -149,6 +159,8 @@
<string name="capture_camera_front">Camera (front)</string>
<string name="label_mute">Mute</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_calibration">Calibration test pattern</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.network.transport.StreamProtectionFactory;
import com.foxx.androidcast.network.CastFramingContext;
import org.junit.Test;
import java.net.DatagramPacket;
@@ -24,8 +26,8 @@ public class UdpCastTransportFecTest {
receiver.prepareListen(portB);
sender.connect("127.0.0.1", portB);
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec);
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec);
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
byte[] payload = new byte[900];
for (int i = 0; i < payload.length; i++) {
@@ -58,6 +60,54 @@ public class UdpCastTransportFecTest {
}
}
@Test
public void fecShardGroup_wireFramedLargeKeyframe_delivers() throws Exception {
int portB = 38000 + (int) (System.nanoTime() % 2000);
UdpCastTransport sender = new UdpCastTransport();
UdpCastTransport receiver = new UdpCastTransport();
CastFramingContext framing = new CastFramingContext(42, 1, 0, 0);
try {
receiver.prepareListen(portB);
sender.connect("127.0.0.1", portB);
sender.setFramingContext(framing);
receiver.setFramingContext(framing);
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
byte[] video = new byte[171_077];
for (int i = 0; i < video.length; i++) {
video[i] = (byte) (i & 0xFF);
}
byte[] videoMsg = CastProtocol.videoFramePayload(0, true, video);
Thread t = new Thread(() -> {
try {
sender.send(CastProtocol.MSG_VIDEO_FRAME, videoMsg);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
t.start();
AtomicReference<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) {
CastSettings s = new CastSettings();
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.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
@@ -12,6 +13,20 @@ import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
import org.junit.Test;
public class FecProtectionEngineShardTest {
@Test
public void largePayload_usesMoreSmallerShards() throws Exception {
TransportLossStats stats = new TransportLossStats();
FecProtectionEngine fec = new FecProtectionEngine(
ProtectionEnvelope.MODE_FEC_34, 4, 3, stats);
byte[] payload = new byte[180_000];
FecEncodedGroup group = fec.encodeGroup(payload);
assertEquals(16, group.dataShards);
assertEquals(8, group.parityShards);
assertEquals(24, group.totalShards());
int shardBytes = group.shards[0].length;
assertTrue(shardBytes < 16_000);
}
@Test
public void encodeAndDecodeShards_viaEngine() throws Exception {
TransportLossStats stats = new TransportLossStats();

View File

@@ -27,6 +27,23 @@ public class StreamProtectionFactoryTest {
StreamProtectionEngine engine = StreamProtectionFactory.create(
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats());
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode());
assertTrue(engine instanceof CombinedProtectionEngine);
}
@Test
public void fec34_withoutNack_isFecOnly() {
StreamProtectionEngine engine = StreamProtectionFactory.create(
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats(), false);
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode());
assertTrue(engine instanceof FecProtectionEngine);
}
@Test
public void fecRs_withNack_isCombined() {
StreamProtectionEngine engine = StreamProtectionFactory.create(
CastSettings.StreamProtection.FEC_REED_SOLOMON, new TransportLossStats(), true);
assertEquals(StreamProtectionMode.FEC_REED_SOLOMON, engine.mode());
assertTrue(engine instanceof CombinedProtectionEngine);
}
@Test

View File

@@ -1,6 +1,10 @@
package com.foxx.androidcast.network.transport;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.CastSettings;
@@ -14,18 +18,93 @@ public class StreamProtectionNegotiatorTest {
}
@Test
public void mismatchFallsBackToNone() {
assertEquals(CastSettings.StreamProtection.NONE,
StreamProtectionNegotiator.resolve(
CastSettings.StreamProtection.FEC_NACK,
CastSettings.StreamProtection.NACK));
public void bothFec34_agreesWithNack() {
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
CastSettings.StreamProtection.FEC_3_4,
CastSettings.StreamProtection.FEC_3_4,
false);
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
assertTrue(n.nackEnabled);
assertNull(n.prompt);
}
@Test
public void matchingModesPreserved() {
assertEquals(CastSettings.StreamProtection.FEC_3_4,
StreamProtectionNegotiator.resolve(
public void senderFec34_receiverAuto_usesFec34() {
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
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);
}
@Test
public void vp9SizedPayload_recoversAllShards() throws Exception {
byte[] payload = new byte[54_000];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i & 0xFF);
}
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
byte[][] shards = fec.encode(payload);
FecShardReassembly asm = null;
for (int i = 0; i < shards.length; i++) {
byte[] wire = FecShardWire.encodeShard(
ProtectionEnvelope.MODE_FEC_34, 42, 900, i, shards.length,
payload.length, 4, 3, shards[i]);
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
assertNotNull(p);
if (asm == null) {
asm = new FecShardReassembly(p);
}
assertTrue(asm.addShard(p));
}
byte[] recovered = asm.tryDecode();
assertNotNull(recovered);
assertArrayEquals(payload, recovered);
}
@Test
public void insufficientShards_returnsNull() throws Exception {
byte[] payload = new byte[] {1, 2, 3, 4, 5};

View File

@@ -5,9 +5,52 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import org.junit.Test;
public class FecShardWireTest {
@Test
public void largeShard_roundTrip() throws Exception {
byte[] payload = new byte[54_000];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i & 0xFF);
}
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
byte[][] shards = fec.encode(payload);
byte[] shard = shards[0];
assertTrue(shard.length > 255);
byte[] wire = FecShardWire.encodeShard(
ProtectionEnvelope.MODE_FEC_34, 99, 1000, 0, shards.length,
payload.length, 4, 3, shard);
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
assertNotNull(p);
assertArrayEquals(shard, p.shardData);
}
@Test
public void v2SmallShard_stillParses() throws Exception {
byte[] shard = new byte[200];
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.write(new byte[] {'A', 'C', 'P', 'F'});
dos.writeByte(FecShardWire.VERSION_SHARD_V2);
dos.writeByte(ProtectionEnvelope.MODE_FEC_34);
dos.writeInt(1);
dos.writeInt(10);
dos.writeByte(0);
dos.writeByte(7);
dos.writeInt(800);
dos.writeByte(4);
dos.writeByte(3);
dos.writeByte(shard.length);
dos.write(shard);
FecShardWire.Parsed p = FecShardWire.tryParse(bos.toByteArray());
assertNotNull(p);
assertArrayEquals(shard, p.shardData);
}
@Test
public void encodeParse_roundTrip() throws Exception {
byte[] shard = new byte[64];

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 |
| NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled |
| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx or MediaCodec fallback |
| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx (decoder uses `VPX_CODEC_USE_ERROR_CONCEALMENT` when supported) or MediaCodec fallback |
| Opus / Speex | Stub | Probe only | Submodules under `third-party/` |
## Enable libvpx (developer)

View File

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

View File

@@ -225,7 +225,7 @@ cp -f "$LIBVPX_A" "$OUT/libvpx.a"
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
echo ""
echo "Built: $PROJECT_OUT/libvpx.a"
echo "Built: $PROJECT_OUT/libvpx.a (size $(ls -lah "${PROJECT_OUT}/libvpx.a" | awk '{print $5}'))"
if [[ -n "$STAGING" ]]; then
echo "(staged under $STAGING because of spaces in repo path)"
fi

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