1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00

short libvpx try

This commit is contained in:
Anton Afanasyeu
2026-05-18 14:25:02 +02:00
parent 9b1afec032
commit 7a07d6de1e
33 changed files with 1400 additions and 58 deletions

View File

@@ -105,9 +105,11 @@ public final class CastDiagnosticsLabels {
case PASSTHROUGH:
return "Passthrough (debug, unimplemented)";
case LIBVPX_VP8:
return "libvpx VP8 (stub → HW)";
return com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isLibvpxAvailable()
? "libvpx VP8" : "libvpx VP8 (HW fallback)";
case LIBVPX_VP9:
return "libvpx VP9 (stub → HW)";
return com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isLibvpxAvailable()
? "libvpx VP9" : "libvpx VP9 (HW fallback)";
case AUTO:
default:
return "CodecNegotiator (HEVC→AVC→VP9→VP8)";

View File

@@ -0,0 +1,119 @@
package com.foxx.androidcast.media.codec;
import android.graphics.Bitmap;
import android.media.MediaFormat;
import android.view.Surface;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.media.RgbToYuv420;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.sender.VideoEncoder;
import java.io.IOException;
/**
* Software VP8/VP9 via libvpx when {@link NativeCodecBridge#isLibvpxAvailable()}.
* Surface capture still uses hardware {@link VideoEncoder} (libvpx is buffer-input only today).
*/
public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
private final CastSettings.VideoCodec preference;
private final VideoEncoder hwFallback = new VideoEncoder();
private Callback callback;
private long nativeHandle;
private boolean nativeActive;
private int width;
private int height;
private String mime;
private byte[] yuvScratch;
private boolean forceKeyframe;
public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) {
this.preference = preference;
}
@Override
public Surface getInputSurface() {
return hwFallback.getInputSurface();
}
@Override
public void requestKeyframe() {
if (nativeActive) {
NativeCodecBridge.vpxRequestKeyframe(nativeHandle);
forceKeyframe = true;
} else {
hwFallback.requestKeyframe();
}
}
@Override
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
nativeActive = false;
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
return hwFallback.prepare(width, height, mime, params, callback);
}
@Override
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
this.width = width;
this.height = height;
this.mime = mime;
this.callback = callback;
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
nativeActive = nativeHandle != 0L;
if (nativeActive) {
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
callback.onConfig(width, height, null, null);
return;
}
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
hwFallback.prepareBufferInput(width, height, mime, params, callback);
}
@Override
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
if (!nativeActive) {
hwFallback.queueBitmapFrame(bitmap, ptsUs);
return;
}
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420);
queueYuvFrame(yuv, ptsUs);
}
@Override
public void queueYuvFrame(byte[] yuv, long ptsUs) {
if (!nativeActive) {
hwFallback.queueYuvFrame(yuv, ptsUs);
return;
}
byte[] frame = NativeCodecBridge.vpxEncodeYuv(nativeHandle, yuv, ptsUs, forceKeyframe);
forceKeyframe = false;
if (frame != null && callback != null) {
boolean key = frame.length > 0 && (frame[0] & 0x01) == 0;
callback.onEncodedFrame(ptsUs, key, frame);
}
}
@Override
public void stop() {
if (nativeActive) {
NativeCodecBridge.vpxEncoderRelease(nativeHandle);
nativeHandle = 0L;
nativeActive = false;
}
hwFallback.stop();
}
static String mimeFor(CastSettings.VideoCodec preference) {
if (preference == CastSettings.VideoCodec.LIBVPX_VP9) {
return MediaFormat.MIMETYPE_VIDEO_VP9;
}
return MediaFormat.MIMETYPE_VIDEO_VP8;
}
}

View File

@@ -79,7 +79,12 @@ public final class PassthroughCodecPolicy {
sb.append(" · Speex FEC (when Speex enabled)");
}
if (transport != null && transport.equalsIgnoreCase("udp")) {
sb.append(" · UDP FEC/NACK stubs (app layer off)");
if (mode == null || mode == CastSettings.StreamProtection.NONE
|| mode == CastSettings.StreamProtection.AUTO) {
sb.append(" · UDP protection off (default)");
} else {
sb.append(" · UDP per-shard FEC/NACK when negotiated");
}
}
return sb.toString();
}

View File

@@ -3,6 +3,7 @@ package com.foxx.androidcast.media.codec;
/** Implementation backing a negotiated video MIME. */
public enum VideoCodecBackend {
MEDIA_CODEC_HW("MediaCodec (HW)"),
LIBVPX_NATIVE("libvpx (software)"),
LIBVPX_STUB("libvpx (HW fallback until native encode wired)"),
PASSTHROUGH("Passthrough (stub)"),
UNKNOWN("?");

View File

@@ -4,22 +4,21 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.sender.VideoEncoder;
/** Creates video encoder sinks; software libvpx will plug in here later. */
/** Creates video encoder sinks; libvpx used when native library is linked. */
public final class VideoCodecFactory {
private VideoCodecFactory() {}
public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) {
CodecSessionRegistry.setVideoPreference(preference);
VideoCodecBackend backend = PassthroughCodecPolicy.videoBackendFor(preference);
if (preference == CastSettings.VideoCodec.LIBVPX_VP8
|| preference == CastSettings.VideoCodec.LIBVPX_VP9) {
if (NativeCodecBridge.isLibvpxAvailable()) {
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_STUB, null);
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
return new LibvpxVideoEncoderSink(preference);
}
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null);
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
}
VideoCodecBackend backend = PassthroughCodecPolicy.videoBackendFor(preference);
if (backend == VideoCodecBackend.PASSTHROUGH) {
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
}

View File

@@ -39,9 +39,43 @@ public final class NativeCodecBridge {
return ensureLoaded() && nativeIsSpeexAvailable();
}
public static long vpxEncoderCreate(String mime, int width, int height, int bitrateKbps) {
if (!isLibvpxAvailable()) {
return 0L;
}
return nativeVpxEncoderCreate(mime, width, height, bitrateKbps);
}
public static byte[] vpxEncodeYuv(long handle, byte[] i420, long ptsUs, boolean forceKeyframe) {
if (handle == 0L || i420 == null) {
return null;
}
return nativeVpxEncodeYuv(handle, i420, ptsUs, forceKeyframe);
}
public static void vpxEncoderRelease(long handle) {
if (handle != 0L) {
nativeVpxEncoderRelease(handle);
}
}
public static void vpxRequestKeyframe(long handle) {
if (handle != 0L) {
nativeVpxRequestKeyframe(handle);
}
}
private static native boolean nativeIsLibvpxAvailable();
private static native boolean nativeIsOpusAvailable();
private static native boolean nativeIsSpeexAvailable();
private static native long nativeVpxEncoderCreate(String mime, int width, int height, int bitrateKbps);
private static native byte[] nativeVpxEncodeYuv(long handle, byte[] yuv, long ptsUs, boolean forceKeyframe);
private static native void nativeVpxEncoderRelease(long handle);
private static native void nativeVpxRequestKeyframe(long handle);
}

View File

@@ -25,7 +25,7 @@ public final class CastTransportFactory {
throw new IOException("CastTransportFactory.init(context) required for QUIC");
}
QuicCronetCastTransport quic = new QuicCronetCastTransport(appContext);
applyUdpProtection(quic.getUdpTransport(), settings);
applyUdpProtectionDeferred(quic.getUdpTransport());
return quic;
}
if (WebRtcTransportFactory.isTransportId(mode)) {
@@ -33,7 +33,7 @@ public final class CastTransportFactory {
}
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
UdpCastTransport udp = new UdpCastTransport();
applyUdpProtection(udp, settings);
applyUdpProtectionDeferred(udp);
return udp;
}
return new TcpCastTransport();
@@ -43,13 +43,20 @@ public final class CastTransportFactory {
if (udp == null) {
return;
}
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionEngine.noop());
if (settings == null || !CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionEngine.noop());
return;
}
udp.applyStreamProtection(settings);
}
/** UDP transports start with protection off until {@link #applyNegotiatedUdpProtection}. */
public static void applyUdpProtectionDeferred(UdpCastTransport udp) {
if (udp != null) {
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionEngine.noop());
}
}
/** Apply protection after handshake using negotiated local/remote preferences. */
public static void applyNegotiatedUdpProtection(UdpCastTransport udp, CastSettings local,
CastSettings remote) {

View File

@@ -2,11 +2,17 @@ package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
import com.foxx.androidcast.network.transport.FecProtectionEngine;
import com.foxx.androidcast.network.transport.NackProtectionEngine;
import com.foxx.androidcast.network.transport.RetransmitCache;
import com.foxx.androidcast.network.transport.StreamProtectionEngine;
import com.foxx.androidcast.network.transport.StreamProtectionCompat;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.StreamProtectionScopes;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportStatsProvider;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.FecShardWire;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -34,6 +40,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
private final TransportLossStats lossStats = new TransportLossStats();
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
private FecProtectionEngine fecEngine;
private NackProtectionEngine nackEngine;
private final RetransmitCache retransmitCache = new RetransmitCache();
private CastFramingContext framingContext;
@@ -97,6 +104,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
/** Select stream protection engine (FEC / NACK). */
public void setProtectionEngine(StreamProtectionEngine engine) {
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
fecEngine = FecProtectionEngine.extract(protectionEngine);
nackEngine = extractNackEngine(protectionEngine);
if (nackEngine != null) {
nackEngine.setNackSender(messageId -> sendRaw(CastProtocol.MSG_NACK,
@@ -170,6 +178,10 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} else {
body = payload == null ? new byte[0] : payload;
}
if (fecEngine != null && StreamProtectionScopes.protectsUdpPayload(type)) {
sendFecShardGroup(wireType, body);
return;
}
body = protectionEngine.onOutboundPayload(type, body);
if (body.length <= MAX_RELIABLE_BYTES) {
sendOnce(wireType, body, true);
@@ -179,8 +191,23 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
}
}
private void sendFecShardGroup(byte wireType, byte[] payload) throws IOException {
FecEncodedGroup group = fecEngine.encodeGroup(payload);
int baseId = nextMessageId;
nextMessageId += group.totalShards();
boolean cache = nackEngine != null;
for (int i = 0; i < group.totalShards(); i++) {
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
sendOnceWithId(wireType, shardWire, cache, baseId + i);
}
}
private void sendOnce(byte type, byte[] body, boolean cacheForNack) throws IOException {
int messageId = nextMessageId++;
sendOnceWithId(type, body, cacheForNack, nextMessageId++);
}
private void sendOnceWithId(byte type, byte[] body, boolean cacheForNack, int messageId)
throws IOException {
if (cacheForNack) {
retransmitCache.put(messageId, type, body);
}
@@ -206,6 +233,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
expireStaleReassembly();
expireStaleFecGroups();
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
socket.setSoTimeout(remaining);
try {
@@ -228,6 +256,16 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
return null;
}
private void expireStaleFecGroups() {
if (fecEngine == null || nackEngine == null) {
if (fecEngine != null) {
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null);
}
return;
}
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack);
}
private void expireStaleReassembly() {
long now = System.currentTimeMillis();
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
@@ -268,6 +306,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
int fragIndex = readShort(data, 9);
int fragCount = readShort(data, 11);
if (type == CastProtocol.MSG_NACK && fragCount <= 1) {
if (nackEngine == null) {
return null;
}
int payloadLenNack = length - HEADER;
byte[] nackBody = payloadLenNack > 0 ? new byte[payloadLenNack] : new byte[0];
if (payloadLenNack > 0) {
@@ -293,11 +334,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} else {
body = chunk;
}
body = protectionEngine.onInboundPayload(type, body);
body = decodeInboundBody(type, body, messageId);
if (body == null) {
if (nackEngine != null) {
nackEngine.requestNack(messageId);
}
return null;
}
raw = new CastProtocol.Message(type, body);
@@ -331,16 +369,48 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
lossStats.reassemblyCompleted++;
}
byte[] assembled = r.assemble();
assembled = protectionEngine.onInboundPayload(type, assembled);
assembled = decodeInboundBody(type, assembled, messageId);
if (assembled == null) {
if (nackEngine != null) {
nackEngine.requestNack(messageId);
}
return null;
}
return unwrapWire(new CastProtocol.Message(type, assembled));
}
private byte[] decodeInboundBody(byte type, byte[] body, int messageId) {
StreamProtectionMode active = protectionEngine.mode();
if (StreamProtectionCompat.shouldIgnoreInbound(body, active)) {
synchronized (lossStats) {
lossStats.unsupportedProtectionDropped++;
}
return null;
}
if (fecEngine != null && FecShardWire.isShardPacket(body)) {
FecProtectionEngine.ShardDecodeResult shard = fecEngine.onInboundShard(type, body);
if (shard.status == FecProtectionEngine.ShardDecodeResult.Status.PENDING) {
return null;
}
if (shard.status == FecProtectionEngine.ShardDecodeResult.Status.FAILED) {
if (nackEngine != null) {
nackEngine.requestNack(messageId);
}
return null;
}
return shard.payload;
}
byte[] decoded = protectionEngine.onInboundPayload(type, body);
if (decoded != null
&& StreamProtectionCompat.shouldIgnoreInbound(decoded, active)) {
synchronized (lossStats) {
lossStats.unsupportedProtectionDropped++;
}
return null;
}
if (decoded == null && nackEngine != null && active != StreamProtectionMode.NONE) {
nackEngine.requestNack(messageId);
}
return decoded;
}
private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
if (inboundSessionGate != null) {

View File

@@ -2,19 +2,30 @@ package com.foxx.androidcast.network.transport;
import com.foxx.androidcast.network.CastProtoHeader;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.FecShardReassembly;
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 java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/** Reed-Solomon FEC wrapper for UDP logical payloads. */
/** Reed-Solomon FEC: v2 sends one shard per UDP message; v1 monolithic decode still accepted inbound. */
public final class FecProtectionEngine implements StreamProtectionEngine {
private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1);
private final byte mode;
private final int dataShards;
private final int parityShards;
private final TransportLossStats stats;
private final StreamProtectionMode protectionMode;
private final Map<Integer, FecShardReassembly> inboundGroups = new ConcurrentHashMap<>();
public FecProtectionEngine(byte envelopeMode, int dataShards, int parityShards,
TransportLossStats stats) {
@@ -27,32 +38,142 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
: StreamProtectionMode.FEC_3_4;
}
public static FecProtectionEngine extract(StreamProtectionEngine engine) {
if (engine instanceof FecProtectionEngine) {
return (FecProtectionEngine) engine;
}
if (engine instanceof CombinedProtectionEngine) {
return ((CombinedProtectionEngine) engine).fec();
}
return null;
}
@Override
public StreamProtectionMode mode() {
return protectionMode;
}
@Override
public byte[] onOutboundPayload(byte type, byte[] payload) {
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
return payload;
}
public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
if (payload == null || payload.length == 0) {
return payload;
throw new IOException("empty payload");
}
try {
byte[] out = ProtectionEnvelope.encode(mode, dataShards, parityShards, payload);
ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
byte[][] shards = fec.encode(payload);
int groupId = NEXT_GROUP_ID.getAndIncrement();
if (stats != null) {
synchronized (stats) {
stats.fecPacketsEncoded += shards.length;
}
}
return new FecEncodedGroup(mode, groupId, payload.length, dataShards, parityShards, shards);
}
public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
throws IOException {
return FecShardWire.encodeShard(
group.mode,
group.groupId,
baseMessageId,
shardIndex,
group.totalShards(),
group.originalLen,
group.dataShards,
group.parityShards,
group.shards[shardIndex]);
}
public static final class ShardDecodeResult {
public enum Status { PENDING, OK, FAILED }
public final Status status;
public final byte[] payload;
ShardDecodeResult(Status status, byte[] payload) {
this.status = status;
this.payload = payload;
}
}
public ShardDecodeResult onInboundShard(byte type, byte[] payload) {
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
return new ShardDecodeResult(ShardDecodeResult.Status.OK, payload);
}
if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) {
return new ShardDecodeResult(ShardDecodeResult.Status.OK, payload);
}
FecShardWire.Parsed shard = FecShardWire.tryParse(payload);
if (shard == null) {
byte[] legacy = onInboundPayload(type, payload);
if (legacy == null) {
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
}
return new ShardDecodeResult(ShardDecodeResult.Status.OK, legacy);
}
FecShardReassembly assembly = inboundGroups.get(shard.groupId);
if (assembly == null) {
FecShardReassembly created = new FecShardReassembly(shard);
FecShardReassembly prev = inboundGroups.putIfAbsent(shard.groupId, created);
assembly = prev != null ? prev : created;
}
if (!assembly.addShard(shard)) {
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
}
byte[] decoded = assembly.tryDecode();
if (decoded == null) {
if (assembly.receivedCount() >= assembly.getTotalShards()) {
if (stats != null) {
synchronized (stats) {
stats.fecDecodeFailures++;
}
}
inboundGroups.remove(shard.groupId);
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
}
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
}
inboundGroups.remove(shard.groupId);
if (stats != null) {
synchronized (stats) {
stats.fecPacketsDecoded++;
}
}
return new ShardDecodeResult(ShardDecodeResult.Status.OK, decoded);
}
public void expireStaleGroups(long maxAgeMs, ShardNackRequester nack) {
long now = System.currentTimeMillis();
Iterator<Map.Entry<Integer, FecShardReassembly>> it = inboundGroups.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Integer, FecShardReassembly> e = it.next();
FecShardReassembly asm = e.getValue();
if (now - asm.getStartedMs() <= maxAgeMs) {
continue;
}
it.remove();
if (nack != null) {
for (int i = 0; i < asm.getTotalShards(); i++) {
if (!asm.hasShard(i)) {
nack.requestNack(asm.nackMessageIdForIndex(i));
}
}
}
if (stats != null) {
synchronized (stats) {
stats.fecPacketsEncoded++;
stats.fecDecodeFailures++;
}
}
return out;
} catch (IOException e) {
return payload;
}
}
public interface ShardNackRequester {
void requestNack(int messageId);
}
@Override
public byte[] onOutboundPayload(byte type, byte[] payload) {
return payload;
}
@Override
public byte[] onInboundPayload(byte type, byte[] payload) {
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
@@ -61,6 +182,13 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) {
return payload;
}
if (FecShardWire.isShardPacket(payload)) {
ShardDecodeResult shard = onInboundShard(type, payload);
if (shard.status == ShardDecodeResult.Status.OK) {
return shard.payload;
}
return null;
}
if (!ProtectionEnvelope.isProtected(payload)) {
return payload;
}

View File

@@ -10,6 +10,7 @@ import java.io.IOException;
public final class NackProtectionEngine implements StreamProtectionEngine {
private final TransportLossStats stats;
private final RetransmitCache cache;
private final NackRateLimiter rateLimiter = new NackRateLimiter();
private NackSender nackSender;
public interface NackSender {
@@ -54,6 +55,9 @@ public final class NackProtectionEngine implements StreamProtectionEngine {
}
public void requestNack(int messageId) {
if (!rateLimiter.allow(messageId)) {
return;
}
if (stats != null) {
synchronized (stats) {
stats.nackRequestsSent++;

View File

@@ -0,0 +1,46 @@
package com.foxx.androidcast.network.transport;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Limits outbound NACK storms per message id. */
public final class NackRateLimiter {
private static final long MIN_INTERVAL_MS = 40L;
private static final int MAX_NACKS_PER_MESSAGE = 6;
private static final long ENTRY_TTL_MS = 30_000L;
private final Map<Integer, Entry> entries = new ConcurrentHashMap<>();
public boolean allow(int messageId) {
long now = System.currentTimeMillis();
pruneExpired(now);
Entry e = entries.computeIfAbsent(messageId, id -> new Entry());
synchronized (e) {
if (e.count >= MAX_NACKS_PER_MESSAGE) {
return false;
}
if (now - e.lastRequestMs < MIN_INTERVAL_MS) {
return false;
}
e.lastRequestMs = now;
e.count++;
return true;
}
}
public void pruneExpired(long nowMs) {
Iterator<Map.Entry<Integer, Entry>> it = entries.entrySet().iterator();
while (it.hasNext()) {
Entry e = it.next().getValue();
if (nowMs - e.lastRequestMs > ENTRY_TTL_MS) {
it.remove();
}
}
}
private static final class Entry {
long lastRequestMs;
int count;
}
}

View File

@@ -0,0 +1,26 @@
package com.foxx.androidcast.network.transport;
import com.foxx.androidcast.network.transport.fec.FecShardWire;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
/**
* Backward-compatible handling of optional UDP stream protection (FEC/NACK).
* Peers that did not negotiate protection must ignore protected payloads without
* disconnecting or feeding decoders.
*/
public final class StreamProtectionCompat {
private StreamProtectionCompat() {}
/** True when the payload uses FEC wrapping this peer is not applying. */
public static boolean isForeignProtectionPayload(byte[] body) {
if (body == null || body.length < 4) {
return false;
}
return FecShardWire.isShardPacket(body) || ProtectionEnvelope.isProtected(body);
}
/** Drop foreign protection silently (no NACK) when {@code activeMode} is {@link StreamProtectionMode#NONE}. */
public static boolean shouldIgnoreInbound(byte[] body, StreamProtectionMode activeMode) {
return activeMode == StreamProtectionMode.NONE && isForeignProtectionPayload(body);
}
}

View File

@@ -12,6 +12,14 @@ public enum StreamProtectionMode {
return this != NONE;
}
public boolean usesFec() {
return this == FEC_3_4 || this == FEC_REED_SOLOMON || this == FEC_NACK;
}
public boolean usesNack() {
return this == NACK || this == FEC_NACK;
}
public String wireLabel() {
switch (this) {
case FEC_3_4:

View File

@@ -6,6 +6,10 @@ import com.foxx.androidcast.CastSettings;
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).
*/
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
CastSettings.StreamProtection remote) {
CastSettings.StreamProtection a = normalize(local);

View File

@@ -36,6 +36,8 @@ public final class TransportLossStats {
public long nackRequestsReceived;
public long nackRetransmits;
public long nackCacheMisses;
/** Inbound FEC/protection payloads dropped (peer sent protection we did not negotiate). */
public long unsupportedProtectionDropped;
/** Legacy wire stats (mirrors real counters). */
public long fecPacketsStub;
public long nackRequestsStub;
@@ -71,6 +73,7 @@ public final class TransportLossStats {
nackRequestsReceived += other.nackRequestsReceived;
nackRetransmits += other.nackRetransmits;
nackCacheMisses += other.nackCacheMisses;
unsupportedProtectionDropped += other.unsupportedProtectionDropped;
fecPacketsStub += other.fecPacketsStub;
nackRequestsStub += other.nackRequestsStub;
nackRetransmitsStub += other.nackRetransmitsStub;
@@ -106,6 +109,7 @@ public final class TransportLossStats {
c.nackRequestsReceived = nackRequestsReceived;
c.nackRetransmits = nackRetransmits;
c.nackCacheMisses = nackCacheMisses;
c.unsupportedProtectionDropped = unsupportedProtectionDropped;
c.fecPacketsStub = fecPacketsStub;
c.nackRequestsStub = nackRequestsStub;
c.nackRetransmitsStub = nackRetransmitsStub;

View File

@@ -0,0 +1,25 @@
package com.foxx.androidcast.network.transport.fec;
/** ReedSolomon shards ready for per-datagram transmission. */
public final class FecEncodedGroup {
public final byte mode;
public final int groupId;
public final int originalLen;
public final int dataShards;
public final int parityShards;
public final byte[][] shards;
public FecEncodedGroup(byte mode, int groupId, int originalLen, int dataShards, int parityShards,
byte[][] shards) {
this.mode = mode;
this.groupId = groupId;
this.originalLen = originalLen;
this.dataShards = dataShards;
this.parityShards = parityShards;
this.shards = shards;
}
public int totalShards() {
return shards.length;
}
}

View File

@@ -0,0 +1,86 @@
package com.foxx.androidcast.network.transport.fec;
import java.util.Arrays;
/** Collects per-datagram FEC shards until ReedSolomon decode succeeds. */
public final class FecShardReassembly {
private final int groupId;
private final byte mode;
private final int originalLen;
private final int dataShards;
private final int parityShards;
private final int totalShards;
private final int shardSize;
private final int baseMessageId;
private final long startedMs;
private final byte[][] shards;
private int received;
public FecShardReassembly(FecShardWire.Parsed first) {
groupId = first.groupId;
mode = first.mode;
originalLen = first.originalLen;
dataShards = first.dataShards;
parityShards = first.parityShards;
totalShards = first.totalShards;
shardSize = first.shardData.length;
baseMessageId = first.baseMessageId;
startedMs = System.currentTimeMillis();
shards = new byte[totalShards][];
}
public int getGroupId() {
return groupId;
}
public long getStartedMs() {
return startedMs;
}
public int getBaseMessageId() {
return baseMessageId;
}
public int getTotalShards() {
return totalShards;
}
/** @return true if this shard was new */
public boolean addShard(FecShardWire.Parsed shard) {
if (shard.groupId != groupId || shard.originalLen != originalLen
|| shard.dataShards != dataShards || shard.parityShards != parityShards
|| shard.baseMessageId != baseMessageId) {
return false;
}
if (shard.shardData.length != shardSize) {
return false;
}
int idx = shard.shardIndex;
if (idx < 0 || idx >= totalShards || shards[idx] != null) {
return false;
}
shards[idx] = Arrays.copyOf(shard.shardData, shard.shardData.length);
received++;
return true;
}
public int receivedCount() {
return received;
}
public boolean hasShard(int index) {
return index >= 0 && index < totalShards && shards[index] != null;
}
public int nackMessageIdForIndex(int shardIndex) {
return baseMessageId + shardIndex;
}
public byte[] tryDecode() {
if (received < dataShards) {
return null;
}
ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
return fec.decode(shards, originalLen);
}
}

View File

@@ -0,0 +1,127 @@
package com.foxx.androidcast.network.transport.fec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
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
* fragment does not invalidate the whole ReedSolomon block.
*/
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;
private FecShardWire() {}
public static byte[] encodeShard(
byte mode,
int groupId,
int baseMessageId,
int shardIndex,
int totalShards,
int originalLen,
int dataShards,
int parityShards,
byte[] shardData) throws IOException {
if (shardData == null) {
throw new IOException("shardData required");
}
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
DataOutputStream dos = new DataOutputStream(bos);
dos.write(MAGIC);
dos.writeByte(VERSION_SHARD);
dos.writeByte(mode);
dos.writeInt(groupId);
dos.writeInt(baseMessageId);
dos.writeByte(shardIndex);
dos.writeByte(totalShards);
dos.writeInt(originalLen);
dos.writeByte(dataShards);
dos.writeByte(parityShards);
dos.writeByte(shardData.length);
dos.write(shardData);
return bos.toByteArray();
}
public static boolean isShardPacket(byte[] wire) {
Parsed p = tryParse(wire);
return p != null;
}
public static Parsed tryParse(byte[] wire) {
if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC) {
return null;
}
for (int i = 0; i < MAGIC.length; i++) {
if (wire[i] != MAGIC[i]) {
return null;
}
}
try {
DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length,
wire.length - MAGIC.length));
byte ver = dis.readByte();
if (ver != VERSION_SHARD) {
return null;
}
byte mode = dis.readByte();
int groupId = dis.readInt();
int baseMessageId = dis.readInt();
int shardIndex = dis.readUnsignedByte();
int totalShards = dis.readUnsignedByte();
int originalLen = dis.readInt();
int dataShards = dis.readUnsignedByte();
int parityShards = dis.readUnsignedByte();
int shardSize = dis.readUnsignedByte();
if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) {
return null;
}
if (shardIndex >= totalShards || originalLen < 1) {
return null;
}
if (wire.length < MAGIC.length + HEADER_AFTER_MAGIC + shardSize) {
return null;
}
byte[] shardData = new byte[shardSize];
dis.readFully(shardData);
return new Parsed(mode, groupId, baseMessageId, shardIndex, totalShards, originalLen,
dataShards, parityShards, shardData);
} catch (IOException e) {
return null;
}
}
public static final class Parsed {
public final byte mode;
public final int groupId;
public final int baseMessageId;
public final int shardIndex;
public final int totalShards;
public final int originalLen;
public final int dataShards;
public final int parityShards;
public final byte[] shardData;
Parsed(byte mode, int groupId, int baseMessageId, int shardIndex, int totalShards,
int originalLen, int dataShards, int parityShards, byte[] shardData) {
this.mode = mode;
this.groupId = groupId;
this.baseMessageId = baseMessageId;
this.shardIndex = shardIndex;
this.totalShards = totalShards;
this.originalLen = originalLen;
this.dataShards = dataShards;
this.parityShards = parityShards;
this.shardData = shardData;
}
public int messageIdForShard() {
return baseMessageId + shardIndex;
}
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.TransportLossStats;
import org.json.JSONObject;
@@ -173,9 +174,17 @@ public final class SessionStatsRecorder {
}
JSONObject protection = new JSONObject();
protection.put("mode", lossTotals.protectionMode.name());
protection.put("fec", "stub_disabled");
protection.put("nack", "stub_disabled");
StreamProtectionMode protMode = lossTotals.protectionMode != null
? lossTotals.protectionMode : StreamProtectionMode.NONE;
protection.put("mode", protMode.name());
protection.put("fec_enabled", protMode.usesFec());
protection.put("nack_enabled", protMode.usesNack());
protection.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
protection.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
protection.put("fec_decode_failures", lossTotals.fecDecodeFailures);
protection.put("nack_requests_sent", lossTotals.nackRequestsSent);
protection.put("nack_requests_received", lossTotals.nackRequestsReceived);
protection.put("nack_retransmits", lossTotals.nackRetransmits);
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
root.put("stream_protection", protection);

View File

@@ -0,0 +1,53 @@
package com.foxx.androidcast.network;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import org.junit.Test;
public class UdpCastTransportBackwardCompatTest {
@Test
public void create_startsWithProtectionOff() throws Exception {
UdpCastTransport udp = new UdpCastTransport();
CastTransportFactory.applyUdpProtectionDeferred(udp);
assertEquals(StreamProtectionMode.NONE, udp.getLossStats().protectionMode);
}
@Test
public void foreignFecShard_isIgnoredWithoutNack() throws Exception {
int port = 38000 + (int) (System.nanoTime() % 2000);
UdpCastTransport receiver = new UdpCastTransport();
UdpCastTransport sender = new UdpCastTransport();
try {
receiver.prepareListen(port);
sender.connect("127.0.0.1", port);
assertEquals(StreamProtectionMode.NONE, receiver.getLossStats().protectionMode);
CastSettings fec = new CastSettings();
fec.setTransport(CastConfig.TRANSPORT_UDP);
fec.setStreamProtection(CastSettings.StreamProtection.FEC_3_4);
sender.applyStreamProtection(fec);
byte[] payload = new byte[] {1, 2, 3, 4, 5, 6, 7, 8};
sender.send(CastProtocol.MSG_VIDEO_FRAME, payload);
CastProtocol.Message msg = receiver.receive(1000);
assertNull(msg);
assertTrue(receiver.getLossStats().unsupportedProtectionDropped >= 1);
} finally {
sender.close();
receiver.close();
}
}
@Test
public void negotiatedMismatch_usesPlainUdp() {
CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator
.resolve(CastSettings.StreamProtection.FEC_NACK, CastSettings.StreamProtection.NONE);
assertEquals(CastSettings.StreamProtection.NONE, mode);
}
}

View File

@@ -0,0 +1,67 @@
package com.foxx.androidcast.network;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.transport.StreamProtectionFactory;
import org.junit.Test;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicReference;
public class UdpCastTransportFecTest {
@Test
public void fecShardGroup_deliversPayloadOverLoopback() throws Exception {
int portB = 37000 + (int) (System.nanoTime() % 2000);
UdpCastTransport sender = new UdpCastTransport();
UdpCastTransport receiver = new UdpCastTransport();
try {
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);
byte[] payload = new byte[900];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i & 0xFF);
}
Thread t = new Thread(() -> {
try {
sender.send(CastProtocol.MSG_VIDEO_FRAME, payload);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
t.start();
AtomicReference<byte[]> received = new AtomicReference<>();
long deadline = System.currentTimeMillis() + 5000;
while (System.currentTimeMillis() < deadline && received.get() == null) {
CastProtocol.Message msg = receiver.receive(200);
if (msg != null && msg.type == CastProtocol.MSG_VIDEO_FRAME) {
received.set(msg.payload);
}
}
t.join(2000);
assertNotNull(received.get());
assertArrayEquals(payload, received.get());
} finally {
sender.close();
receiver.close();
}
}
private static CastSettings settingsWith(CastSettings.StreamProtection protection) {
CastSettings s = new CastSettings();
s.setTransport(CastConfig.TRANSPORT_UDP);
s.setStreamProtection(protection);
return s;
}
}

View File

@@ -0,0 +1,41 @@
package com.foxx.androidcast.network.transport;
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 com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
import org.junit.Test;
public class FecProtectionEngineShardTest {
@Test
public void encodeAndDecodeShards_viaEngine() throws Exception {
TransportLossStats stats = new TransportLossStats();
FecProtectionEngine fec = new FecProtectionEngine(
ProtectionEnvelope.MODE_FEC_34, 4, 3, stats);
byte[] payload = "fec-shard-engine-roundtrip".getBytes();
FecEncodedGroup group = fec.encodeGroup(payload);
assertEquals(7, group.totalShards());
int baseId = 100;
FecProtectionEngine.ShardDecodeResult decoded = null;
for (int i = 0; i < group.totalShards(); i++) {
if (i == 3) {
continue;
}
byte[] wire = fec.encodeShardWire(group, baseId, i);
FecProtectionEngine.ShardDecodeResult r =
fec.onInboundShard(CastProtocol.MSG_VIDEO_FRAME, wire);
if (r.status == FecProtectionEngine.ShardDecodeResult.Status.OK) {
decoded = r;
break;
}
}
assertNotNull(decoded);
assertArrayEquals(payload, decoded.payload);
}
}

View File

@@ -0,0 +1,25 @@
package com.foxx.androidcast.network.transport;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class NackRateLimiterTest {
@Test
public void capsPerMessage() throws InterruptedException {
NackRateLimiter limiter = new NackRateLimiter();
for (int i = 0; i < 6; i++) {
assertTrue(limiter.allow(42));
Thread.sleep(45);
}
assertFalse(limiter.allow(42));
}
@Test
public void rateLimitsRapidRepeats() {
NackRateLimiter limiter = new NackRateLimiter();
assertTrue(limiter.allow(1));
assertFalse(limiter.allow(1));
}
}

View File

@@ -0,0 +1,36 @@
package com.foxx.androidcast.network.transport;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.FecShardWire;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
import org.junit.Test;
public class StreamProtectionCompatTest {
@Test
public void detectsFecShardAndMonolithic() throws Exception {
byte[] payload = "compat-probe".getBytes();
FecEncodedGroup group = new FecProtectionEngine(
ProtectionEnvelope.MODE_FEC_34, 4, 3, null).encodeGroup(payload);
byte[] shard = FecShardWire.encodeShard(
group.mode, group.groupId, 10, 0, group.totalShards(),
group.originalLen, group.dataShards, group.parityShards, group.shards[0]);
assertTrue(StreamProtectionCompat.isForeignProtectionPayload(shard));
byte[] mono = ProtectionEnvelope.encode(
ProtectionEnvelope.MODE_FEC_34, 4, 3, payload);
assertTrue(StreamProtectionCompat.isForeignProtectionPayload(mono));
assertFalse(StreamProtectionCompat.isForeignProtectionPayload(payload));
}
@Test
public void shouldIgnore_whenProtectionNotNegotiated() throws Exception {
byte[] payload = "x".getBytes();
byte[] mono = ProtectionEnvelope.encode(
ProtectionEnvelope.MODE_FEC_34, 4, 3, payload);
assertTrue(StreamProtectionCompat.shouldIgnoreInbound(mono, StreamProtectionMode.NONE));
assertFalse(StreamProtectionCompat.shouldIgnoreInbound(mono, StreamProtectionMode.FEC_3_4));
}
}

View File

@@ -0,0 +1,56 @@
package com.foxx.androidcast.network.transport.fec;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class FecShardReassemblyTest {
@Test
public void perShardDelivery_recoversOneLoss() throws Exception {
byte[] payload = new byte[512];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i & 0xFF);
}
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
byte[][] shards = fec.encode(payload);
int groupId = 7;
int baseId = 500;
FecShardReassembly asm = null;
for (int i = 0; i < shards.length; i++) {
if (i == 1) {
continue;
}
byte[] wire = FecShardWire.encodeShard(
ProtectionEnvelope.MODE_FEC_34, groupId, baseId, 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));
}
assertNotNull(asm);
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};
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
byte[][] shards = fec.encode(payload);
byte[] wire = FecShardWire.encodeShard(
ProtectionEnvelope.MODE_FEC_34, 1, 10, 0, shards.length,
payload.length, 4, 3, shards[0]);
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
assertNotNull(p);
FecShardReassembly asm = new FecShardReassembly(p);
assertTrue(asm.addShard(p));
assertNull(asm.tryDecode());
}
}

View File

@@ -0,0 +1,27 @@
package com.foxx.androidcast.network.transport.fec;
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 org.junit.Test;
public class FecShardWireTest {
@Test
public void encodeParse_roundTrip() throws Exception {
byte[] shard = new byte[64];
for (int i = 0; i < shard.length; i++) {
shard[i] = (byte) i;
}
byte[] wire = FecShardWire.encodeShard(
ProtectionEnvelope.MODE_FEC_34, 42, 1000, 2, 7, 200, 4, 3, shard);
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
assertNotNull(p);
assertEquals(42, p.groupId);
assertEquals(1000, p.baseMessageId);
assertEquals(2, p.shardIndex);
assertArrayEquals(shard, p.shardData);
assertTrue(FecShardWire.isShardPacket(wire));
}
}