mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
short libvpx try
This commit is contained in:
10
.gitmodules
vendored
Normal file
10
.gitmodules
vendored
Normal 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
|
||||
@@ -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)";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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("?");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,11 +2,17 @@ package com.foxx.androidcast.network;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.FecProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.NackProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.RetransmitCache;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionCompat;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionMode;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionScopes;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||
import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -34,6 +40,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||
private final TransportLossStats lossStats = new TransportLossStats();
|
||||
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
|
||||
private FecProtectionEngine fecEngine;
|
||||
private NackProtectionEngine nackEngine;
|
||||
private final RetransmitCache retransmitCache = new RetransmitCache();
|
||||
private CastFramingContext framingContext;
|
||||
@@ -97,6 +104,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
/** Select stream protection engine (FEC / NACK). */
|
||||
public void setProtectionEngine(StreamProtectionEngine engine) {
|
||||
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
|
||||
fecEngine = FecProtectionEngine.extract(protectionEngine);
|
||||
nackEngine = extractNackEngine(protectionEngine);
|
||||
if (nackEngine != null) {
|
||||
nackEngine.setNackSender(messageId -> sendRaw(CastProtocol.MSG_NACK,
|
||||
@@ -170,6 +178,10 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
} else {
|
||||
body = payload == null ? new byte[0] : payload;
|
||||
}
|
||||
if (fecEngine != null && StreamProtectionScopes.protectsUdpPayload(type)) {
|
||||
sendFecShardGroup(wireType, body);
|
||||
return;
|
||||
}
|
||||
body = protectionEngine.onOutboundPayload(type, body);
|
||||
if (body.length <= MAX_RELIABLE_BYTES) {
|
||||
sendOnce(wireType, body, true);
|
||||
@@ -179,8 +191,23 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendFecShardGroup(byte wireType, byte[] payload) throws IOException {
|
||||
FecEncodedGroup group = fecEngine.encodeGroup(payload);
|
||||
int baseId = nextMessageId;
|
||||
nextMessageId += group.totalShards();
|
||||
boolean cache = nackEngine != null;
|
||||
for (int i = 0; i < group.totalShards(); i++) {
|
||||
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
|
||||
sendOnceWithId(wireType, shardWire, cache, baseId + i);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendOnce(byte type, byte[] body, boolean cacheForNack) throws IOException {
|
||||
int messageId = nextMessageId++;
|
||||
sendOnceWithId(type, body, cacheForNack, nextMessageId++);
|
||||
}
|
||||
|
||||
private void sendOnceWithId(byte type, byte[] body, boolean cacheForNack, int messageId)
|
||||
throws IOException {
|
||||
if (cacheForNack) {
|
||||
retransmitCache.put(messageId, type, body);
|
||||
}
|
||||
@@ -206,6 +233,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
expireStaleReassembly();
|
||||
expireStaleFecGroups();
|
||||
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
||||
socket.setSoTimeout(remaining);
|
||||
try {
|
||||
@@ -228,6 +256,16 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void expireStaleFecGroups() {
|
||||
if (fecEngine == null || nackEngine == null) {
|
||||
if (fecEngine != null) {
|
||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack);
|
||||
}
|
||||
|
||||
private void expireStaleReassembly() {
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
||||
@@ -268,6 +306,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
int fragIndex = readShort(data, 9);
|
||||
int fragCount = readShort(data, 11);
|
||||
if (type == CastProtocol.MSG_NACK && fragCount <= 1) {
|
||||
if (nackEngine == null) {
|
||||
return null;
|
||||
}
|
||||
int payloadLenNack = length - HEADER;
|
||||
byte[] nackBody = payloadLenNack > 0 ? new byte[payloadLenNack] : new byte[0];
|
||||
if (payloadLenNack > 0) {
|
||||
@@ -293,11 +334,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
} else {
|
||||
body = chunk;
|
||||
}
|
||||
body = protectionEngine.onInboundPayload(type, body);
|
||||
body = decodeInboundBody(type, body, messageId);
|
||||
if (body == null) {
|
||||
if (nackEngine != null) {
|
||||
nackEngine.requestNack(messageId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
raw = new CastProtocol.Message(type, body);
|
||||
@@ -331,16 +369,48 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
lossStats.reassemblyCompleted++;
|
||||
}
|
||||
byte[] assembled = r.assemble();
|
||||
assembled = protectionEngine.onInboundPayload(type, assembled);
|
||||
assembled = decodeInboundBody(type, assembled, messageId);
|
||||
if (assembled == null) {
|
||||
if (nackEngine != null) {
|
||||
nackEngine.requestNack(messageId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return unwrapWire(new CastProtocol.Message(type, assembled));
|
||||
}
|
||||
|
||||
private byte[] decodeInboundBody(byte type, byte[] body, int messageId) {
|
||||
StreamProtectionMode active = protectionEngine.mode();
|
||||
if (StreamProtectionCompat.shouldIgnoreInbound(body, active)) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.unsupportedProtectionDropped++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (fecEngine != null && FecShardWire.isShardPacket(body)) {
|
||||
FecProtectionEngine.ShardDecodeResult shard = fecEngine.onInboundShard(type, body);
|
||||
if (shard.status == FecProtectionEngine.ShardDecodeResult.Status.PENDING) {
|
||||
return null;
|
||||
}
|
||||
if (shard.status == FecProtectionEngine.ShardDecodeResult.Status.FAILED) {
|
||||
if (nackEngine != null) {
|
||||
nackEngine.requestNack(messageId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return shard.payload;
|
||||
}
|
||||
byte[] decoded = protectionEngine.onInboundPayload(type, body);
|
||||
if (decoded != null
|
||||
&& StreamProtectionCompat.shouldIgnoreInbound(decoded, active)) {
|
||||
synchronized (lossStats) {
|
||||
lossStats.unsupportedProtectionDropped++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (decoded == null && nackEngine != null && active != StreamProtectionMode.NONE) {
|
||||
nackEngine.requestNack(messageId);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
|
||||
if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
|
||||
if (inboundSessionGate != null) {
|
||||
|
||||
@@ -2,19 +2,30 @@ package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtoHeader;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||
import com.foxx.androidcast.network.transport.fec.FecShardReassembly;
|
||||
import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
||||
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
||||
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Reed-Solomon FEC wrapper for UDP logical payloads. */
|
||||
/** Reed-Solomon FEC: v2 sends one shard per UDP message; v1 monolithic decode still accepted inbound. */
|
||||
public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1);
|
||||
|
||||
private final byte mode;
|
||||
private final int dataShards;
|
||||
private final int parityShards;
|
||||
private final TransportLossStats stats;
|
||||
private final StreamProtectionMode protectionMode;
|
||||
private final Map<Integer, FecShardReassembly> inboundGroups = new ConcurrentHashMap<>();
|
||||
|
||||
public FecProtectionEngine(byte envelopeMode, int dataShards, int parityShards,
|
||||
TransportLossStats stats) {
|
||||
@@ -27,32 +38,142 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
: StreamProtectionMode.FEC_3_4;
|
||||
}
|
||||
|
||||
public static FecProtectionEngine extract(StreamProtectionEngine engine) {
|
||||
if (engine instanceof FecProtectionEngine) {
|
||||
return (FecProtectionEngine) engine;
|
||||
}
|
||||
if (engine instanceof CombinedProtectionEngine) {
|
||||
return ((CombinedProtectionEngine) engine).fec();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamProtectionMode mode() {
|
||||
return protectionMode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
|
||||
return payload;
|
||||
}
|
||||
public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
|
||||
if (payload == null || payload.length == 0) {
|
||||
return payload;
|
||||
throw new IOException("empty payload");
|
||||
}
|
||||
try {
|
||||
byte[] out = ProtectionEnvelope.encode(mode, dataShards, parityShards, payload);
|
||||
ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
|
||||
byte[][] shards = fec.encode(payload);
|
||||
int groupId = NEXT_GROUP_ID.getAndIncrement();
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsEncoded += shards.length;
|
||||
}
|
||||
}
|
||||
return new FecEncodedGroup(mode, groupId, payload.length, dataShards, parityShards, shards);
|
||||
}
|
||||
|
||||
public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
|
||||
throws IOException {
|
||||
return FecShardWire.encodeShard(
|
||||
group.mode,
|
||||
group.groupId,
|
||||
baseMessageId,
|
||||
shardIndex,
|
||||
group.totalShards(),
|
||||
group.originalLen,
|
||||
group.dataShards,
|
||||
group.parityShards,
|
||||
group.shards[shardIndex]);
|
||||
}
|
||||
|
||||
public static final class ShardDecodeResult {
|
||||
public enum Status { PENDING, OK, FAILED }
|
||||
|
||||
public final Status status;
|
||||
public final byte[] payload;
|
||||
|
||||
ShardDecodeResult(Status status, byte[] payload) {
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
public ShardDecodeResult onInboundShard(byte type, byte[] payload) {
|
||||
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.OK, payload);
|
||||
}
|
||||
if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) {
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.OK, payload);
|
||||
}
|
||||
FecShardWire.Parsed shard = FecShardWire.tryParse(payload);
|
||||
if (shard == null) {
|
||||
byte[] legacy = onInboundPayload(type, payload);
|
||||
if (legacy == null) {
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
|
||||
}
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.OK, legacy);
|
||||
}
|
||||
FecShardReassembly assembly = inboundGroups.get(shard.groupId);
|
||||
if (assembly == null) {
|
||||
FecShardReassembly created = new FecShardReassembly(shard);
|
||||
FecShardReassembly prev = inboundGroups.putIfAbsent(shard.groupId, created);
|
||||
assembly = prev != null ? prev : created;
|
||||
}
|
||||
if (!assembly.addShard(shard)) {
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
|
||||
}
|
||||
byte[] decoded = assembly.tryDecode();
|
||||
if (decoded == null) {
|
||||
if (assembly.receivedCount() >= assembly.getTotalShards()) {
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecDecodeFailures++;
|
||||
}
|
||||
}
|
||||
inboundGroups.remove(shard.groupId);
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
|
||||
}
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
|
||||
}
|
||||
inboundGroups.remove(shard.groupId);
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsDecoded++;
|
||||
}
|
||||
}
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.OK, decoded);
|
||||
}
|
||||
|
||||
public void expireStaleGroups(long maxAgeMs, ShardNackRequester nack) {
|
||||
long now = System.currentTimeMillis();
|
||||
Iterator<Map.Entry<Integer, FecShardReassembly>> it = inboundGroups.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<Integer, FecShardReassembly> e = it.next();
|
||||
FecShardReassembly asm = e.getValue();
|
||||
if (now - asm.getStartedMs() <= maxAgeMs) {
|
||||
continue;
|
||||
}
|
||||
it.remove();
|
||||
if (nack != null) {
|
||||
for (int i = 0; i < asm.getTotalShards(); i++) {
|
||||
if (!asm.hasShard(i)) {
|
||||
nack.requestNack(asm.nackMessageIdForIndex(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsEncoded++;
|
||||
stats.fecDecodeFailures++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (IOException e) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ShardNackRequester {
|
||||
void requestNack(int messageId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||
if (!StreamProtectionScopes.protectsUdpPayload(type)) {
|
||||
@@ -61,6 +182,13 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
if (type == CastProtocol.MSG_WIRE_PACKET && looksLikeWireHeader(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (FecShardWire.isShardPacket(payload)) {
|
||||
ShardDecodeResult shard = onInboundShard(type, payload);
|
||||
if (shard.status == ShardDecodeResult.Status.OK) {
|
||||
return shard.payload;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!ProtectionEnvelope.isProtected(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
31
docs/ROADMAP.md
Normal file
@@ -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).
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,222 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 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 <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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
third-party/libvpx
vendored
Submodule
1
third-party/libvpx
vendored
Submodule
Submodule third-party/libvpx added at 6516e974f8
Reference in New Issue
Block a user