diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d1c3f76 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java index f34d0af..49bf06f 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java @@ -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)"; diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java new file mode 100644 index 0000000..5127d11 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java index 536a5b5..aa5a527 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/PassthroughCodecPolicy.java @@ -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(); } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecBackend.java b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecBackend.java index e10baaf..5d6056c 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecBackend.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecBackend.java @@ -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("?"); diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java index dcaf5d9..96e3f4b 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/VideoCodecFactory.java @@ -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); } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java index 0eb9bae..2d048ee 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java @@ -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); } diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java index 6b16e75..15e54a4 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java index c330dc3..8bac0e4 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -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 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 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) { diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java index 77431fc..ac33314 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/FecProtectionEngine.java @@ -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 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> it = inboundGroups.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry 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; } diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/NackProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/NackProtectionEngine.java index d7f68b5..650f3eb 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/NackProtectionEngine.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/NackProtectionEngine.java @@ -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++; diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/NackRateLimiter.java b/app/src/main/java/com/foxx/androidcast/network/transport/NackRateLimiter.java new file mode 100644 index 0000000..828e416 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/NackRateLimiter.java @@ -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 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> 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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCompat.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCompat.java new file mode 100644 index 0000000..abfc46c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionCompat.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java index 3aecafd..70cdc61 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java @@ -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: diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java index 88d1a83..c720377 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionNegotiator.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java index 9b82aa9..c757b20 100644 --- a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java @@ -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; diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecEncodedGroup.java b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecEncodedGroup.java new file mode 100644 index 0000000..9f8a6ac --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecEncodedGroup.java @@ -0,0 +1,25 @@ +package com.foxx.androidcast.network.transport.fec; + +/** Reed–Solomon 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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardReassembly.java b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardReassembly.java new file mode 100644 index 0000000..0cbf7db --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardReassembly.java @@ -0,0 +1,86 @@ +package com.foxx.androidcast.network.transport.fec; + +import java.util.Arrays; + +/** Collects per-datagram FEC shards until Reed–Solomon 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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java new file mode 100644 index 0000000..960e63a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/fec/FecShardWire.java @@ -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 Reed–Solomon 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; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java index d67afac..64f31bc 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -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); diff --git a/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportBackwardCompatTest.java b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportBackwardCompatTest.java new file mode 100644 index 0000000..6848af4 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportBackwardCompatTest.java @@ -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); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java new file mode 100644 index 0000000..59e6cd7 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/UdpCastTransportFecTest.java @@ -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 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; + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java new file mode 100644 index 0000000..98054f1 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/transport/FecProtectionEngineShardTest.java @@ -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); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/NackRateLimiterTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/NackRateLimiterTest.java new file mode 100644 index 0000000..4af24a2 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/transport/NackRateLimiterTest.java @@ -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)); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionCompatTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionCompatTest.java new file mode 100644 index 0000000..b16995d --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/transport/StreamProtectionCompatTest.java @@ -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)); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java new file mode 100644 index 0000000..d08c5ab --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardReassemblyTest.java @@ -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()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java new file mode 100644 index 0000000..21caefc --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/transport/fec/FecShardWireTest.java @@ -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)); + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..841f1a8 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,31 @@ +# Android Cast — implementation roadmap + +## Stream protection (FEC / NACK) + +| Item | Status | +|------|--------| +| GF(256) Reed–Solomon + `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). diff --git a/ndk/CMakeLists.txt b/ndk/CMakeLists.txt index dbafdfe..39581b3 100644 --- a/ndk/CMakeLists.txt +++ b/ndk/CMakeLists.txt @@ -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}") target_compile_definitions(androidcast_codecs PRIVATE ANDROIDCAST_HAVE_LIBVPX=1) 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") message(STATUS "libvpx submodule present — set ANDROIDCAST_LIBVPX_LIB to link (see ndk/README.md)") endif() diff --git a/ndk/README.md b/ndk/README.md index 934289b..428c4ba 100644 --- a/ndk/README.md +++ b/ndk/README.md @@ -6,7 +6,7 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft | 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 | | libvpx VP8/VP9 | Stub | Probe only | HW passthrough until `ANDROIDCAST_LIBVPX_LIB` is linked | | 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) 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: ```bash @@ -28,4 +29,7 @@ CMake defines `ANDROIDCAST_HAVE_LIBVPX=1` and links the archive when the variabl - Default: **None** (no FEC/NACK) - 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) diff --git a/ndk/jni/libvpx_bridge.c b/ndk/jni/libvpx_bridge.c index 54b0952..9f0c028 100644 --- a/ndk/jni/libvpx_bridge.c +++ b/ndk/jni/libvpx_bridge.c @@ -1,15 +1,222 @@ #include +#include #include -/* VP8/VP9 via libvpx — linked when third-party/libvpx is built into this library. */ #ifndef ANDROIDCAST_HAVE_LIBVPX #define ANDROIDCAST_HAVE_LIBVPX 0 #endif +#if ANDROIDCAST_HAVE_LIBVPX +#include +#include +#include +#include +#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 Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsLibvpxAvailable( JNIEnv *env, jclass clazz) { - (void)env; - (void)clazz; + (void) env; + (void) clazz; 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 +} diff --git a/scripts/build-native-codecs.sh b/scripts/build-native-codecs.sh index f60d564..df94e6b 100755 --- a/scripts/build-native-codecs.sh +++ b/scripts/build-native-codecs.sh @@ -1,32 +1,109 @@ #!/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: -# git submodule update --init --recursive third-party/libvpx third-party/opus third-party/speex -# Android NDK (same as used by the app, see local.properties or ndk.dir) +# git submodule update --init third-party/libvpx +# Android NDK (ndk.dir in local.properties) # # Usage: # ./scripts/build-native-codecs.sh arm64-v8a # 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 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ABI="${1:-arm64-v8a}" -OUT="$ROOT/build/native/$ABI" -mkdir -p "$OUT" +VPX_SRC_REAL="$ROOT/third-party/libvpx" +PROJECT_OUT="$ROOT/build/native/$ABI" +OUT="$PROJECT_OUT" +BUILD_DIR="$OUT/libvpx-build" -echo "Native codec build scaffold — ABI=$ABI" -echo "Output directory: $OUT" -echo "" -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" +if [[ ! -d "$VPX_SRC_REAL" ]]; then + echo "ERROR: $VPX_SRC_REAL missing. Run:" + echo " git submodule update --init third-party/libvpx" exit 1 fi -echo "libvpx sources present at third-party/libvpx — add your configure/make steps here." -touch "$OUT/.placeholder" -echo "Done (placeholder). See ndk/README.md for integration notes." + +NDK="" +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" diff --git a/third-party/libvpx b/third-party/libvpx new file mode 160000 index 0000000..6516e97 --- /dev/null +++ b/third-party/libvpx @@ -0,0 +1 @@ +Subproject commit 6516e974f8c40d0e49b19a4b55b1c98e7432edbb