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

10
.gitmodules vendored Normal file
View File

@@ -0,0 +1,10 @@
[submodule "third-party/libvpx"]
path = third-party/libvpx
url = https://chromium.googlesource.com/webm/libvpx
branch = main
[submodule "third-party/opus"]
path = third-party/opus
url = https://github.com/xiph/opus.git
[submodule "third-party/speex"]
path = third-party/speex
url = https://github.com/xiph/speex.git

View File

@@ -105,9 +105,11 @@ public final class CastDiagnosticsLabels {
case PASSTHROUGH: case PASSTHROUGH:
return "Passthrough (debug, unimplemented)"; return "Passthrough (debug, unimplemented)";
case LIBVPX_VP8: 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: 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: case AUTO:
default: default:
return "CodecNegotiator (HEVC→AVC→VP9→VP8)"; 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)"); sb.append(" · Speex FEC (when Speex enabled)");
} }
if (transport != null && transport.equalsIgnoreCase("udp")) { 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(); return sb.toString();
} }

View File

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

View File

@@ -4,22 +4,21 @@ import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.sender.VideoEncoder; 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 { public final class VideoCodecFactory {
private VideoCodecFactory() {} private VideoCodecFactory() {}
public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) { public static VideoEncoderSink createEncoder(CastSettings.VideoCodec preference) {
CodecSessionRegistry.setVideoPreference(preference); CodecSessionRegistry.setVideoPreference(preference);
VideoCodecBackend backend = PassthroughCodecPolicy.videoBackendFor(preference);
if (preference == CastSettings.VideoCodec.LIBVPX_VP8 if (preference == CastSettings.VideoCodec.LIBVPX_VP8
|| preference == CastSettings.VideoCodec.LIBVPX_VP9) { || preference == CastSettings.VideoCodec.LIBVPX_VP9) {
if (NativeCodecBridge.isLibvpxAvailable()) { if (NativeCodecBridge.isLibvpxAvailable()) {
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_STUB, null); return new LibvpxVideoEncoderSink(preference);
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
} }
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null); CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, null);
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference); return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
} }
VideoCodecBackend backend = PassthroughCodecPolicy.videoBackendFor(preference);
if (backend == VideoCodecBackend.PASSTHROUGH) { if (backend == VideoCodecBackend.PASSTHROUGH) {
return new PassthroughVideoEncoderSink(new VideoEncoder(), preference); return new PassthroughVideoEncoderSink(new VideoEncoder(), preference);
} }

View File

@@ -39,9 +39,43 @@ public final class NativeCodecBridge {
return ensureLoaded() && nativeIsSpeexAvailable(); 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 nativeIsLibvpxAvailable();
private static native boolean nativeIsOpusAvailable(); private static native boolean nativeIsOpusAvailable();
private static native boolean nativeIsSpeexAvailable(); 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"); throw new IOException("CastTransportFactory.init(context) required for QUIC");
} }
QuicCronetCastTransport quic = new QuicCronetCastTransport(appContext); QuicCronetCastTransport quic = new QuicCronetCastTransport(appContext);
applyUdpProtection(quic.getUdpTransport(), settings); applyUdpProtectionDeferred(quic.getUdpTransport());
return quic; return quic;
} }
if (WebRtcTransportFactory.isTransportId(mode)) { if (WebRtcTransportFactory.isTransportId(mode)) {
@@ -33,7 +33,7 @@ public final class CastTransportFactory {
} }
if (CastConfig.TRANSPORT_UDP.equals(mode)) { if (CastConfig.TRANSPORT_UDP.equals(mode)) {
UdpCastTransport udp = new UdpCastTransport(); UdpCastTransport udp = new UdpCastTransport();
applyUdpProtection(udp, settings); applyUdpProtectionDeferred(udp);
return udp; return udp;
} }
return new TcpCastTransport(); return new TcpCastTransport();
@@ -43,13 +43,20 @@ public final class CastTransportFactory {
if (udp == null) { if (udp == null) {
return; return;
} }
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionEngine.noop());
if (settings == null || !CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) { if (settings == null || !CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionEngine.noop());
return; return;
} }
udp.applyStreamProtection(settings); 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. */ /** Apply protection after handshake using negotiated local/remote preferences. */
public static void applyNegotiatedUdpProtection(UdpCastTransport udp, CastSettings local, public static void applyNegotiatedUdpProtection(UdpCastTransport udp, CastSettings local,
CastSettings remote) { CastSettings remote) {

View File

@@ -2,11 +2,17 @@ package com.foxx.androidcast.network;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.network.transport.CombinedProtectionEngine; import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
import com.foxx.androidcast.network.transport.FecProtectionEngine;
import com.foxx.androidcast.network.transport.NackProtectionEngine; import com.foxx.androidcast.network.transport.NackProtectionEngine;
import com.foxx.androidcast.network.transport.RetransmitCache; import com.foxx.androidcast.network.transport.RetransmitCache;
import com.foxx.androidcast.network.transport.StreamProtectionEngine; 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.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportStatsProvider; 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.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@@ -34,6 +40,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>(); private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
private final TransportLossStats lossStats = new TransportLossStats(); private final TransportLossStats lossStats = new TransportLossStats();
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop(); private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
private FecProtectionEngine fecEngine;
private NackProtectionEngine nackEngine; private NackProtectionEngine nackEngine;
private final RetransmitCache retransmitCache = new RetransmitCache(); private final RetransmitCache retransmitCache = new RetransmitCache();
private CastFramingContext framingContext; private CastFramingContext framingContext;
@@ -97,6 +104,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
/** Select stream protection engine (FEC / NACK). */ /** Select stream protection engine (FEC / NACK). */
public void setProtectionEngine(StreamProtectionEngine engine) { public void setProtectionEngine(StreamProtectionEngine engine) {
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop(); protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
fecEngine = FecProtectionEngine.extract(protectionEngine);
nackEngine = extractNackEngine(protectionEngine); nackEngine = extractNackEngine(protectionEngine);
if (nackEngine != null) { if (nackEngine != null) {
nackEngine.setNackSender(messageId -> sendRaw(CastProtocol.MSG_NACK, nackEngine.setNackSender(messageId -> sendRaw(CastProtocol.MSG_NACK,
@@ -170,6 +178,10 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} else { } else {
body = payload == null ? new byte[0] : payload; body = payload == null ? new byte[0] : payload;
} }
if (fecEngine != null && StreamProtectionScopes.protectsUdpPayload(type)) {
sendFecShardGroup(wireType, body);
return;
}
body = protectionEngine.onOutboundPayload(type, body); body = protectionEngine.onOutboundPayload(type, body);
if (body.length <= MAX_RELIABLE_BYTES) { if (body.length <= MAX_RELIABLE_BYTES) {
sendOnce(wireType, body, true); 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 { 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) { if (cacheForNack) {
retransmitCache.put(messageId, type, body); retransmitCache.put(messageId, type, body);
} }
@@ -206,6 +233,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
long deadline = System.currentTimeMillis() + timeoutMs; long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
expireStaleReassembly(); expireStaleReassembly();
expireStaleFecGroups();
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis()); int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
socket.setSoTimeout(remaining); socket.setSoTimeout(remaining);
try { try {
@@ -228,6 +256,16 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
return null; 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() { private void expireStaleReassembly() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) { for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
@@ -268,6 +306,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
int fragIndex = readShort(data, 9); int fragIndex = readShort(data, 9);
int fragCount = readShort(data, 11); int fragCount = readShort(data, 11);
if (type == CastProtocol.MSG_NACK && fragCount <= 1) { if (type == CastProtocol.MSG_NACK && fragCount <= 1) {
if (nackEngine == null) {
return null;
}
int payloadLenNack = length - HEADER; int payloadLenNack = length - HEADER;
byte[] nackBody = payloadLenNack > 0 ? new byte[payloadLenNack] : new byte[0]; byte[] nackBody = payloadLenNack > 0 ? new byte[payloadLenNack] : new byte[0];
if (payloadLenNack > 0) { if (payloadLenNack > 0) {
@@ -293,11 +334,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
} else { } else {
body = chunk; body = chunk;
} }
body = protectionEngine.onInboundPayload(type, body); body = decodeInboundBody(type, body, messageId);
if (body == null) { if (body == null) {
if (nackEngine != null) {
nackEngine.requestNack(messageId);
}
return null; return null;
} }
raw = new CastProtocol.Message(type, body); raw = new CastProtocol.Message(type, body);
@@ -331,16 +369,48 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
lossStats.reassemblyCompleted++; lossStats.reassemblyCompleted++;
} }
byte[] assembled = r.assemble(); byte[] assembled = r.assemble();
assembled = protectionEngine.onInboundPayload(type, assembled); assembled = decodeInboundBody(type, assembled, messageId);
if (assembled == null) { if (assembled == null) {
if (nackEngine != null) {
nackEngine.requestNack(messageId);
}
return null; return null;
} }
return unwrapWire(new CastProtocol.Message(type, assembled)); 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 { private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
if (raw.type == CastProtocol.MSG_WIRE_PACKET) { if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
if (inboundSessionGate != null) { 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.CastProtoHeader;
import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.FecShardReassembly;
import com.foxx.androidcast.network.transport.fec.FecShardWire;
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope; import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.ByteOrder; 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 { public final class FecProtectionEngine implements StreamProtectionEngine {
private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1);
private final byte mode; private final byte mode;
private final int dataShards; private final int dataShards;
private final int parityShards; private final int parityShards;
private final TransportLossStats stats; private final TransportLossStats stats;
private final StreamProtectionMode protectionMode; private final StreamProtectionMode protectionMode;
private final Map<Integer, FecShardReassembly> inboundGroups = new ConcurrentHashMap<>();
public FecProtectionEngine(byte envelopeMode, int dataShards, int parityShards, public FecProtectionEngine(byte envelopeMode, int dataShards, int parityShards,
TransportLossStats stats) { TransportLossStats stats) {
@@ -27,32 +38,142 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
: StreamProtectionMode.FEC_3_4; : 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 @Override
public StreamProtectionMode mode() { public StreamProtectionMode mode() {
return protectionMode; return protectionMode;
} }
@Override public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
public byte[] onOutboundPayload(byte type, byte[] payload) {
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
return payload;
}
if (payload == null || payload.length == 0) { if (payload == null || payload.length == 0) {
return payload; throw new IOException("empty payload");
} }
try { ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
byte[] out = ProtectionEnvelope.encode(mode, dataShards, parityShards, payload); 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) { if (stats != null) {
synchronized (stats) { 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 @Override
public byte[] onInboundPayload(byte type, byte[] payload) { public byte[] onInboundPayload(byte type, byte[] payload) {
if (!StreamProtectionScopes.protectsUdpPayload(type)) { if (!StreamProtectionScopes.protectsUdpPayload(type)) {
@@ -61,6 +182,13 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) { if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) {
return 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)) { if (!ProtectionEnvelope.isProtected(payload)) {
return payload; return payload;
} }

View File

@@ -10,6 +10,7 @@ import java.io.IOException;
public final class NackProtectionEngine implements StreamProtectionEngine { public final class NackProtectionEngine implements StreamProtectionEngine {
private final TransportLossStats stats; private final TransportLossStats stats;
private final RetransmitCache cache; private final RetransmitCache cache;
private final NackRateLimiter rateLimiter = new NackRateLimiter();
private NackSender nackSender; private NackSender nackSender;
public interface NackSender { public interface NackSender {
@@ -54,6 +55,9 @@ public final class NackProtectionEngine implements StreamProtectionEngine {
} }
public void requestNack(int messageId) { public void requestNack(int messageId) {
if (!rateLimiter.allow(messageId)) {
return;
}
if (stats != null) { if (stats != null) {
synchronized (stats) { synchronized (stats) {
stats.nackRequestsSent++; 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; 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() { public String wireLabel() {
switch (this) { switch (this) {
case FEC_3_4: case FEC_3_4:

View File

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

View File

@@ -36,6 +36,8 @@ public final class TransportLossStats {
public long nackRequestsReceived; public long nackRequestsReceived;
public long nackRetransmits; public long nackRetransmits;
public long nackCacheMisses; public long nackCacheMisses;
/** Inbound FEC/protection payloads dropped (peer sent protection we did not negotiate). */
public long unsupportedProtectionDropped;
/** Legacy wire stats (mirrors real counters). */ /** Legacy wire stats (mirrors real counters). */
public long fecPacketsStub; public long fecPacketsStub;
public long nackRequestsStub; public long nackRequestsStub;
@@ -71,6 +73,7 @@ public final class TransportLossStats {
nackRequestsReceived += other.nackRequestsReceived; nackRequestsReceived += other.nackRequestsReceived;
nackRetransmits += other.nackRetransmits; nackRetransmits += other.nackRetransmits;
nackCacheMisses += other.nackCacheMisses; nackCacheMisses += other.nackCacheMisses;
unsupportedProtectionDropped += other.unsupportedProtectionDropped;
fecPacketsStub += other.fecPacketsStub; fecPacketsStub += other.fecPacketsStub;
nackRequestsStub += other.nackRequestsStub; nackRequestsStub += other.nackRequestsStub;
nackRetransmitsStub += other.nackRetransmitsStub; nackRetransmitsStub += other.nackRetransmitsStub;
@@ -106,6 +109,7 @@ public final class TransportLossStats {
c.nackRequestsReceived = nackRequestsReceived; c.nackRequestsReceived = nackRequestsReceived;
c.nackRetransmits = nackRetransmits; c.nackRetransmits = nackRetransmits;
c.nackCacheMisses = nackCacheMisses; c.nackCacheMisses = nackCacheMisses;
c.unsupportedProtectionDropped = unsupportedProtectionDropped;
c.fecPacketsStub = fecPacketsStub; c.fecPacketsStub = fecPacketsStub;
c.nackRequestsStub = nackRequestsStub; c.nackRequestsStub = nackRequestsStub;
c.nackRetransmitsStub = nackRetransmitsStub; 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.AppPreferences;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStats;
import org.json.JSONObject; import org.json.JSONObject;
@@ -173,9 +174,17 @@ public final class SessionStatsRecorder {
} }
JSONObject protection = new JSONObject(); JSONObject protection = new JSONObject();
protection.put("mode", lossTotals.protectionMode.name()); StreamProtectionMode protMode = lossTotals.protectionMode != null
protection.put("fec", "stub_disabled"); ? lossTotals.protectionMode : StreamProtectionMode.NONE;
protection.put("nack", "stub_disabled"); 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("fec_stub_packets", lossTotals.fecPacketsStub);
protection.put("nack_stub_requests", lossTotals.nackRequestsStub); protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
root.put("stream_protection", protection); 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));
}
}

31
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,31 @@
# Android Cast — implementation roadmap
## Stream protection (FEC / NACK)
| Item | Status |
|------|--------|
| GF(256) ReedSolomon + `ProtectionEnvelope` v1 (monolithic) | Done |
| **v2 per-shard FEC** (`FecShardWire`, one ACUD message per RS shard) | Done |
| NACK + `RetransmitCache` + `MSG_NACK` | Done |
| Post-handshake negotiation | Done |
| Backward compat (defer FEC until negotiated; ignore foreign FEC/NACK) | Done |
| Default protection **NONE** (stable cast) | Done |
| Integration tests (`FecShard*`, `UdpCastTransportFecTest`) | Done |
| Lab soak under loss (HY300 / BL6000) | TODO (manual) |
| NACK rate limit / backoff | Done (`NackRateLimiter`) |
| `MSG_FEC_NACK` by groupId (optional) | Deferred |
Enable in **Settings → Stream protection (UDP)** on both sender and receiver, then reconnect.
## libvpx / Opus / Speex
| Item | Status |
|------|--------|
| `libandroidcast_codecs.so` + JNI probes | Done |
| Cross-compile `libvpx.a` (`scripts/build-native-codecs.sh`) | Script ready (needs submodule + NDK build) |
| JNI encode in `libvpx_bridge.c` | Done (when `ANDROIDCAST_LIBVPX_LIB` set) |
| `LibvpxVideoEncoderSink` (buffer / calibration path) | Done |
| libvpx decoder on receiver | TODO |
| Opus / Speex build + sinks | TODO |
See [ndk/README.md](../ndk/README.md).

View File

@@ -21,6 +21,9 @@ if(DEFINED ENV{ANDROIDCAST_LIBVPX_LIB} AND EXISTS "$ENV{ANDROIDCAST_LIBVPX_LIB}"
message(STATUS "Linking libvpx from $ENV{ANDROIDCAST_LIBVPX_LIB}") message(STATUS "Linking libvpx from $ENV{ANDROIDCAST_LIBVPX_LIB}")
target_compile_definitions(androidcast_codecs PRIVATE ANDROIDCAST_HAVE_LIBVPX=1) target_compile_definitions(androidcast_codecs PRIVATE ANDROIDCAST_HAVE_LIBVPX=1)
target_link_libraries(androidcast_codecs "$ENV{ANDROIDCAST_LIBVPX_LIB}") target_link_libraries(androidcast_codecs "$ENV{ANDROIDCAST_LIBVPX_LIB}")
if(EXISTS "${THIRD_PARTY_ROOT}/libvpx")
target_include_directories(androidcast_codecs PRIVATE "${THIRD_PARTY_ROOT}/libvpx")
endif()
elseif(EXISTS "${THIRD_PARTY_ROOT}/libvpx") elseif(EXISTS "${THIRD_PARTY_ROOT}/libvpx")
message(STATUS "libvpx submodule present — set ANDROIDCAST_LIBVPX_LIB to link (see ndk/README.md)") message(STATUS "libvpx submodule present — set ANDROIDCAST_LIBVPX_LIB to link (see ndk/README.md)")
endif() endif()

View File

@@ -6,7 +6,7 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft
| Component | Java | Native | Notes | | Component | Java | Native | Notes |
|-----------|------|--------|-------| |-----------|------|--------|-------|
| FEC 3/4 + RS | Yes | — | `ProtectionEnvelope`, handshake-safe scopes | | FEC 3/4 + RS | Yes | — | v2 **per-shard** on wire (`FecShardWire`); v1 monolithic decode still accepted |
| NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled | | NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled |
| libvpx VP8/VP9 | Stub | Probe only | HW passthrough until `ANDROIDCAST_LIBVPX_LIB` is linked | | libvpx VP8/VP9 | Stub | Probe only | HW passthrough until `ANDROIDCAST_LIBVPX_LIB` is linked |
| Opus / Speex | Stub | Probe only | Submodules under `third-party/` | | Opus / Speex | Stub | Probe only | Submodules under `third-party/` |
@@ -14,7 +14,8 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft
## Enable libvpx (developer) ## Enable libvpx (developer)
1. `git submodule update --init third-party/libvpx` 1. `git submodule update --init third-party/libvpx`
2. Build `libvpx.a` for your ABI (script stub: `scripts/build-native-codecs.sh`) 2. Build `libvpx.a` for your ABI: `./scripts/build-native-codecs.sh arm64-v8a`
(If the repo path has **spaces**, the script uses `/tmp/androidcast-libvpx-$USER` automatically.)
3. Rebuild the APK with the static library path: 3. Rebuild the APK with the static library path:
```bash ```bash
@@ -28,4 +29,7 @@ CMake defines `ANDROIDCAST_HAVE_LIBVPX=1` and links the archive when the variabl
- Default: **None** (no FEC/NACK) - Default: **None** (no FEC/NACK)
- Enable in **Global settings → Stream protection (UDP)** - Enable in **Global settings → Stream protection (UDP)**
- After handshake, sender and receiver negotiate the effective mode - After handshake, sender and receiver negotiate the effective mode (no protocol version bump)
- Until negotiation completes, UDP protection stays **off**; peers without FEC support **ignore** protected frames
Full checklist: [docs/ROADMAP.md](../docs/ROADMAP.md)

View File

@@ -1,15 +1,222 @@
#include <jni.h> #include <jni.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
/* VP8/VP9 via libvpx — linked when third-party/libvpx is built into this library. */
#ifndef ANDROIDCAST_HAVE_LIBVPX #ifndef ANDROIDCAST_HAVE_LIBVPX
#define ANDROIDCAST_HAVE_LIBVPX 0 #define ANDROIDCAST_HAVE_LIBVPX 0
#endif #endif
#if ANDROIDCAST_HAVE_LIBVPX
#include <vpx/vpx_encoder.h>
#include <vpx/vp8cx.h>
#include <vpx/vpx_decoder.h>
#include <vpx/vp8dx.h>
#endif
#define VPX_ENC_MAGIC 0x56505845L /* VPXE */
#if ANDROIDCAST_HAVE_LIBVPX
typedef struct {
unsigned long magic;
vpx_codec_ctx_t codec;
vpx_image_t img;
int width;
int height;
int frame_count;
int force_keyframe;
} VpxEncoderCtx;
static vpx_codec_iface_t *pick_encoder_iface(const char *mime) {
if (mime != NULL && strstr(mime, "VP9") != NULL) {
return vpx_codec_vp9_cx();
}
return vpx_codec_vp8_cx();
}
static vpx_img_fmt_t img_fmt(void) {
return VPX_IMG_FMT_I420;
}
static void copy_i420_to_image(const uint8_t *yuv, vpx_image_t *img, int width, int height) {
int y_stride = img->stride[VPX_PLANE_Y];
int uv_stride = img->stride[VPX_PLANE_U];
const uint8_t *src_y = yuv;
const uint8_t *src_u = yuv + width * height;
const uint8_t *src_v = src_u + (width / 2) * (height / 2);
for (int row = 0; row < height; row++) {
memcpy(img->planes[VPX_PLANE_Y] + row * y_stride, src_y + row * width, (size_t) width);
}
int chroma_h = height / 2;
int chroma_w = width / 2;
for (int row = 0; row < chroma_h; row++) {
memcpy(img->planes[VPX_PLANE_U] + row * uv_stride, src_u + row * chroma_w, (size_t) chroma_w);
memcpy(img->planes[VPX_PLANE_V] + row * uv_stride, src_v + row * chroma_w, (size_t) chroma_w);
}
}
static VpxEncoderCtx *enc_from_handle(jlong handle) {
VpxEncoderCtx *ctx = (VpxEncoderCtx *) (intptr_t) handle;
if (ctx == NULL || ctx->magic != VPX_ENC_MAGIC) {
return NULL;
}
return ctx;
}
#endif /* ANDROIDCAST_HAVE_LIBVPX */
JNIEXPORT jboolean JNICALL JNIEXPORT jboolean JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsLibvpxAvailable( Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsLibvpxAvailable(
JNIEnv *env, jclass clazz) { JNIEnv *env, jclass clazz) {
(void)env; (void) env;
(void)clazz; (void) clazz;
return ANDROIDCAST_HAVE_LIBVPX ? JNI_TRUE : JNI_FALSE; return ANDROIDCAST_HAVE_LIBVPX ? JNI_TRUE : JNI_FALSE;
} }
JNIEXPORT jlong JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderCreate(
JNIEnv *env, jclass clazz, jstring mime, jint width, jint height, jint bitrateKbps) {
(void) clazz;
#if !ANDROIDCAST_HAVE_LIBVPX
(void) env;
(void) mime;
(void) width;
(void) height;
(void) bitrateKbps;
return 0L;
#else
if (width <= 0 || height <= 0 || bitrateKbps <= 0) {
return 0L;
}
const char *mime_utf = (*env)->GetStringUTFChars(env, mime, NULL);
VpxEncoderCtx *ctx = (VpxEncoderCtx *) calloc(1, sizeof(VpxEncoderCtx));
if (ctx == NULL) {
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L;
}
ctx->magic = VPX_ENC_MAGIC;
ctx->width = width;
ctx->height = height;
vpx_codec_enc_cfg_t cfg;
vpx_codec_iface_t *iface = pick_encoder_iface(mime_utf);
if (vpx_codec_enc_config_default(iface, &cfg, 0) != VPX_CODEC_OK) {
free(ctx);
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L;
}
cfg.g_w = (unsigned int) width;
cfg.g_h = (unsigned int) height;
cfg.rc_target_bitrate = bitrateKbps;
cfg.g_timebase.num = 1;
cfg.g_timebase.den = 30;
cfg.g_error_resilient = VPX_ERROR_RESILIENT_DEFAULT;
cfg.g_lag_in_frames = 0;
if (vpx_codec_enc_init(&ctx->codec, iface, &cfg, 0) != VPX_CODEC_OK) {
free(ctx);
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L;
}
if (iface == vpx_codec_vp9_cx()) {
vpx_codec_control(&ctx->codec, VP9E_SET_CPUUSED, 8);
} else {
vpx_codec_control(&ctx->codec, VP8E_SET_CPUUSED, 8);
}
if (!vpx_img_alloc(&ctx->img, img_fmt(), width, height, 1)) {
vpx_codec_destroy(&ctx->codec);
free(ctx);
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return 0L;
}
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
return (jlong) (intptr_t) ctx;
#endif
}
JNIEXPORT jbyteArray JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncodeYuv(
JNIEnv *env, jclass clazz, jlong handle, jbyteArray yuv, jlong ptsUs, jboolean forceKeyframe) {
(void) clazz;
#if !ANDROIDCAST_HAVE_LIBVPX
(void) env;
(void) handle;
(void) yuv;
(void) ptsUs;
(void) forceKeyframe;
return NULL;
#else
VpxEncoderCtx *ctx = enc_from_handle(handle);
if (ctx == NULL || yuv == NULL) {
return NULL;
}
jsize yuv_len = (*env)->GetArrayLength(env, yuv);
int need = ctx->width * ctx->height * 3 / 2;
if (yuv_len < need) {
return NULL;
}
jbyte *yuv_bytes = (*env)->GetByteArrayElements(env, yuv, NULL);
if (yuv_bytes == NULL) {
return NULL;
}
copy_i420_to_image((const uint8_t *) yuv_bytes, &ctx->img, ctx->width, ctx->height);
(*env)->ReleaseByteArrayElements(env, yuv, yuv_bytes, JNI_ABORT);
vpx_enc_frame_flags_t flags = 0;
if (forceKeyframe || ctx->force_keyframe) {
flags |= VPX_EFLAG_FORCE_KF;
ctx->force_keyframe = 0;
}
if (vpx_codec_encode(&ctx->codec, &ctx->img, (vpx_codec_pts_t) ptsUs, 1, flags,
VPX_DL_REALTIME) != VPX_CODEC_OK) {
return NULL;
}
const vpx_codec_cx_pkt_t *pkt;
vpx_codec_iter_t iter = NULL;
while ((pkt = vpx_codec_get_cx_data(&ctx->codec, &iter)) != NULL) {
if (pkt->kind == VPX_CODEC_CX_FRAME_PKT) {
jbyteArray out = (*env)->NewByteArray(env, (jsize) pkt->data.frame.sz);
if (out != NULL) {
(*env)->SetByteArrayRegion(env, out, 0, (jsize) pkt->data.frame.sz,
(const jbyte *) pkt->data.frame.buf);
}
ctx->frame_count++;
return out;
}
}
return NULL;
#endif
}
JNIEXPORT void JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxEncoderRelease(
JNIEnv *env, jclass clazz, jlong handle) {
(void) env;
(void) clazz;
#if ANDROIDCAST_HAVE_LIBVPX
VpxEncoderCtx *ctx = enc_from_handle(handle);
if (ctx == NULL) {
return;
}
ctx->magic = 0;
vpx_img_free(&ctx->img);
vpx_codec_destroy(&ctx->codec);
free(ctx);
#endif
}
JNIEXPORT void JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxRequestKeyframe(
JNIEnv *env, jclass clazz, jlong handle) {
(void) env;
(void) clazz;
#if ANDROIDCAST_HAVE_LIBVPX
VpxEncoderCtx *ctx = enc_from_handle(handle);
if (ctx != NULL) {
ctx->force_keyframe = 1;
}
#endif
}

View File

@@ -1,32 +1,109 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Optional: build third-party codecs for Android and point Gradle/CMake at the outputs. # Build libvpx for Android ABIs and produce libvpx.a for ANDROIDCAST_LIBVPX_LIB.
# #
# Prerequisites: # Prerequisites:
# git submodule update --init --recursive third-party/libvpx third-party/opus third-party/speex # git submodule update --init third-party/libvpx
# Android NDK (same as used by the app, see local.properties or ndk.dir) # Android NDK (ndk.dir in local.properties)
# #
# Usage: # Usage:
# ./scripts/build-native-codecs.sh arm64-v8a # ./scripts/build-native-codecs.sh arm64-v8a
# ANDROIDCAST_LIBVPX_LIB=$PWD/build/native/arm64-v8a/libvpx.a ./gradlew assembleDebug # ANDROIDCAST_LIBVPX_LIB=$PWD/build/native/arm64-v8a/libvpx.a ./gradlew assembleDebug
# #
# Note: libvpx Makefiles do not support spaces in paths (SRC_PATH_BARE). If the repo
# path contains spaces, this script builds under /tmp and copies libvpx.a back.
#
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ABI="${1:-arm64-v8a}" ABI="${1:-arm64-v8a}"
OUT="$ROOT/build/native/$ABI" VPX_SRC_REAL="$ROOT/third-party/libvpx"
mkdir -p "$OUT" PROJECT_OUT="$ROOT/build/native/$ABI"
OUT="$PROJECT_OUT"
BUILD_DIR="$OUT/libvpx-build"
echo "Native codec build scaffold — ABI=$ABI" if [[ ! -d "$VPX_SRC_REAL" ]]; then
echo "Output directory: $OUT" echo "ERROR: $VPX_SRC_REAL missing. Run:"
echo "" echo " git submodule update --init third-party/libvpx"
echo "libvpx Android cross-compile is project-specific. After you produce libvpx.a, rebuild with:"
echo " export ANDROIDCAST_LIBVPX_LIB=$OUT/libvpx.a"
echo " ./gradlew assembleDebug"
echo ""
if [[ ! -d "$ROOT/third-party/libvpx" ]]; then
echo "WARN: third-party/libvpx missing. Run: git submodule update --init third-party/libvpx"
exit 1 exit 1
fi fi
echo "libvpx sources present at third-party/libvpx — add your configure/make steps here."
touch "$OUT/.placeholder" NDK=""
echo "Done (placeholder). See ndk/README.md for integration notes." if [[ -f "$ROOT/local.properties" ]]; then
NDK="$(sed -n 's/^ndk\.dir=//p' "$ROOT/local.properties" | tr -d '\r' | head -1)"
fi
if [[ -z "$NDK" && -n "${ANDROID_NDK_HOME:-}" ]]; then
NDK="$ANDROID_NDK_HOME"
fi
if [[ -z "$NDK" && -n "${ANDROID_NDK_ROOT:-}" ]]; then
NDK="$ANDROID_NDK_ROOT"
fi
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
echo "ERROR: Set ndk.dir in local.properties or ANDROID_NDK_HOME"
exit 1
fi
case "$ABI" in
arm64-v8a) VPX_TARGET=arm64-android-gcc ;;
armeabi-v7a) VPX_TARGET=armv7-android-gcc ;;
x86_64) VPX_TARGET=x86_64-android-gcc ;;
x86) VPX_TARGET=x86-android-gcc ;;
*)
echo "Unsupported ABI: $ABI"
exit 1
;;
esac
# libvpx: include $(SRC_PATH_BARE)/foo.mk breaks when SRC_PATH_BARE has spaces.
path_has_space() {
case "$1" in
*" "*) return 0 ;;
*) return 1 ;;
esac
}
VPX_SRC="$VPX_SRC_REAL"
STAGING=""
if path_has_space "$ROOT" || path_has_space "$VPX_SRC_REAL"; then
STAGING="/tmp/androidcast-libvpx-${USER:-build}"
VPX_SRC="$STAGING/src"
BUILD_DIR="$STAGING/build/$ABI"
OUT="$STAGING/out/$ABI"
mkdir -p "$STAGING/build" "$STAGING/out"
ln -sfn "$VPX_SRC_REAL" "$VPX_SRC"
echo "Repo path contains spaces; using space-free staging under $STAGING"
fi
mkdir -p "$BUILD_DIR" "$OUT" "$PROJECT_OUT"
export ANDROID_NDK_ROOT="$NDK"
export PATH="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
echo "Building libvpx for $ABI (target=$VPX_TARGET) ..."
echo " source: $VPX_SRC"
echo " build: $BUILD_DIR"
cd "$BUILD_DIR"
if [[ ! -f Makefile ]]; then
"$VPX_SRC/configure" \
--target="$VPX_TARGET" \
--disable-examples \
--disable-tools \
--disable-unit-tests \
--disable-webm-io \
--enable-vp8 \
--enable-vp9 \
--enable-static \
--disable-shared \
--extra-cflags="-fPIC -O2"
fi
make -j"$(nproc)" libvpx.a
cp -f libvpx.a "$OUT/libvpx.a"
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
echo ""
echo "Built: $PROJECT_OUT/libvpx.a"
if [[ -n "$STAGING" ]]; then
echo "(staged under $STAGING because of spaces in repo path)"
fi
echo "Rebuild APK with:"
echo " export ANDROIDCAST_LIBVPX_LIB=$PROJECT_OUT/libvpx.a"
echo " ./gradlew assembleDebug"

1
third-party/libvpx vendored Submodule

Submodule third-party/libvpx added at 6516e974f8