mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:38:52 +03:00
snap
This commit is contained in:
@@ -49,11 +49,10 @@ android {
|
||||
buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
def cmakeArgs = ["-DANDROID_STL=c++_shared"]
|
||||
arguments "-DANDROID_STL=c++_shared"
|
||||
if (resolveCcachePath) {
|
||||
cmakeArgs += "-DANDROID_CCACHE=${resolveCcachePath}"
|
||||
arguments "-DANDROID_CCACHE=${resolveCcachePath}"
|
||||
}
|
||||
arguments cmakeArgs
|
||||
cppFlags ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="mediaProjection|microphone" />
|
||||
|
||||
<service
|
||||
android:name=".sender.SenderPreviewService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name=".receiver.ReceiverCastService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -158,9 +158,14 @@ public final class CastSettingsBinder {
|
||||
};
|
||||
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
||||
settings.setTransport(v);
|
||||
updateStreamProtectionEnabled(activity, settings);
|
||||
refreshStreamProtectionUi(activity, settings);
|
||||
});
|
||||
updateStreamProtectionEnabled(activity, settings);
|
||||
refreshStreamProtectionUi(activity, settings);
|
||||
}
|
||||
|
||||
private static void refreshStreamProtectionUi(AppCompatActivity activity, CastSettings settings) {
|
||||
updateStreamProtectionSpinnerEnabled(activity, settings, R.id.spinner_sender_stream_protection,
|
||||
R.id.text_stream_protection_hint, R.id.spinner_sender_transport);
|
||||
}
|
||||
|
||||
private static void readTransport(AppCompatActivity activity, CastSettings settings, int spinnerId) {
|
||||
@@ -404,7 +409,16 @@ public final class CastSettingsBinder {
|
||||
}
|
||||
|
||||
private static void bindStreamProtection(AppCompatActivity activity, CastSettings settings) {
|
||||
Spinner spinner = activity.findViewById(R.id.spinner_sender_stream_protection);
|
||||
bindStreamProtectionSpinner(activity, settings, R.id.spinner_sender_stream_protection,
|
||||
R.id.text_stream_protection_hint, R.id.spinner_sender_transport);
|
||||
}
|
||||
|
||||
private static void bindStreamProtectionSpinner(AppCompatActivity activity, CastSettings settings,
|
||||
int spinnerId, int hintId, int transportSpinnerId) {
|
||||
Spinner spinner = activity.findViewById(spinnerId);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values();
|
||||
String[] labels = {
|
||||
activity.getString(R.string.option_auto),
|
||||
@@ -422,7 +436,23 @@ public final class CastSettingsBinder {
|
||||
}
|
||||
SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current,
|
||||
settings::setStreamProtection);
|
||||
updateStreamProtectionEnabled(activity, settings);
|
||||
Spinner transportSpinner = activity.findViewById(transportSpinnerId);
|
||||
if (transportSpinner != null) {
|
||||
transportSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(android.widget.AdapterView<?> parent, android.view.View view,
|
||||
int position, long id) {
|
||||
readTransport(activity, settings, transportSpinnerId);
|
||||
updateStreamProtectionSpinnerEnabled(activity, settings, spinnerId, hintId,
|
||||
transportSpinnerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(android.widget.AdapterView<?> parent) {
|
||||
}
|
||||
});
|
||||
}
|
||||
updateStreamProtectionSpinnerEnabled(activity, settings, spinnerId, hintId, transportSpinnerId);
|
||||
}
|
||||
|
||||
private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) {
|
||||
@@ -430,14 +460,16 @@ public final class CastSettingsBinder {
|
||||
settings::setStreamProtection);
|
||||
}
|
||||
|
||||
private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) {
|
||||
Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection);
|
||||
private static void updateStreamProtectionSpinnerEnabled(AppCompatActivity activity,
|
||||
CastSettings settings, int spinnerId, int hintId, int transportSpinnerId) {
|
||||
Spinner protection = activity.findViewById(spinnerId);
|
||||
if (protection == null) {
|
||||
return;
|
||||
}
|
||||
readTransport(activity, settings, transportSpinnerId);
|
||||
boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport());
|
||||
protection.setEnabled(udp);
|
||||
TextView hint = activity.findViewById(R.id.text_stream_protection_hint);
|
||||
TextView hint = activity.findViewById(hintId);
|
||||
if (hint != null) {
|
||||
hint.setVisibility(udp ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,13 @@ public final class PictureInPictureHelper {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PictureInPictureParams buildParams(int videoWidth, int videoHeight) {
|
||||
public static PictureInPictureParams buildParams(Context context, int videoWidth, int videoHeight) {
|
||||
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(videoWidth, videoHeight));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(true);
|
||||
builder.setSeamlessResizeEnabled(true);
|
||||
boolean allowed = isAllowedForApp(context);
|
||||
builder.setAutoEnterEnabled(allowed);
|
||||
builder.setSeamlessResizeEnabled(allowed);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
@@ -59,7 +60,7 @@ public final class PictureInPictureHelper {
|
||||
if (!isSupported()) {
|
||||
return;
|
||||
}
|
||||
activity.setPictureInPictureParams(buildParams(videoWidth, videoHeight));
|
||||
activity.setPictureInPictureParams(buildParams(activity, videoWidth, videoHeight));
|
||||
}
|
||||
|
||||
public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) {
|
||||
@@ -67,7 +68,7 @@ public final class PictureInPictureHelper {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return activity.enterPictureInPictureMode(buildParams(videoWidth, videoHeight));
|
||||
return activity.enterPictureInPictureMode(buildParams(activity, videoWidth, videoHeight));
|
||||
} catch (IllegalStateException e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public final class CastDiagnosticsFormatter {
|
||||
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
|
||||
int streamW, int streamH, int renderW, int renderH, boolean idle,
|
||||
CastSettings localSettings, CastSettings remoteSettings,
|
||||
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) {
|
||||
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
|
||||
CastMtuSampler.Info mtuInfo) {
|
||||
float inFps = metrics.getInFps();
|
||||
float renderFps = metrics.getRenderFps();
|
||||
int bwInKbps = metrics.getBwInKbps();
|
||||
@@ -103,6 +104,7 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
|
||||
.append(escape(transport != null ? transport.toUpperCase() : "?"))
|
||||
.append("</font><br/>");
|
||||
appendMtuSection(html, mtuInfo);
|
||||
|
||||
if (videoCodec != null && !videoCodec.isEmpty()) {
|
||||
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
|
||||
@@ -251,6 +253,12 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("<br/>");
|
||||
}
|
||||
|
||||
CpuLoadSampler.Sample cpu = CpuLoadSampler.sample();
|
||||
html.append("CPU: ").append(vuBarHtml(cpu.loadPercent)).append(' ')
|
||||
.append(colored(cpu.loadPercent + "%", cpu.loadPercent > 85))
|
||||
.append(' ').append(CpuLoadSampler.formatCoreSuffix(cpu));
|
||||
html.append("<br/>");
|
||||
|
||||
for (String hint : hints) {
|
||||
html.append("<font color='").append(COLOR_WARN).append("'>")
|
||||
.append(escape(hint)).append("</font><br/>");
|
||||
@@ -270,6 +278,29 @@ public final class CastDiagnosticsFormatter {
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private static void appendMtuSection(StringBuilder html, CastMtuSampler.Info mtu) {
|
||||
if (mtu == null) {
|
||||
return;
|
||||
}
|
||||
html.append("<font color='").append(COLOR_DIM).append("'>MTU:</font> ");
|
||||
if (mtu.interfaceName.length() > 0) {
|
||||
html.append(escape(mtu.interfaceName)).append(' ');
|
||||
}
|
||||
if (mtu.linkMtu > 0) {
|
||||
html.append("link ").append(mtu.linkMtu);
|
||||
} else {
|
||||
html.append("link ?");
|
||||
}
|
||||
html.append(" · dg ").append(mtu.castDatagramBytes);
|
||||
html.append(" frag ").append(mtu.castFragPayloadBytes);
|
||||
html.append(" · IPv4 ").append(mtu.ipv4WireBytes);
|
||||
if (mtu.headroomBytes >= 0) {
|
||||
html.append(" · room ").append(colored(String.valueOf(mtu.headroomBytes),
|
||||
mtu.headroomBytes < 100));
|
||||
}
|
||||
html.append("<br/>");
|
||||
}
|
||||
|
||||
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||
NetworkStatsSnapshot local) {
|
||||
long rx = pick(peer, local, true, false);
|
||||
@@ -314,7 +345,7 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append(' ').append(colored("aud-dec " + aDecErr, true));
|
||||
}
|
||||
if (aDrop > 0) {
|
||||
html.append(' ').append(colored("aud-q " + aDrop, true));
|
||||
html.append(' ').append(colored("aud-drop " + aDrop, true));
|
||||
}
|
||||
if (dropAudio > 0) {
|
||||
html.append(' ').append(colored("snd-aud " + dropAudio, true));
|
||||
@@ -364,9 +395,15 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append(" · ").append(escape(summary));
|
||||
}
|
||||
long fecPkts = val(peer, local, p -> p.fecStubPackets, l -> l.fecStubPackets);
|
||||
long fecOk = val(peer, local, p -> p.fecPacketsDecoded, l -> l.fecPacketsDecoded);
|
||||
long fecFail = val(peer, local, p -> p.fecDecodeFailures, l -> l.fecDecodeFailures);
|
||||
long nackReq = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
|
||||
if (fecPkts > 0) {
|
||||
html.append("<br/>FEC packets=").append(fecPkts);
|
||||
if (fecPkts > 0 || fecOk > 0 || fecFail > 0) {
|
||||
html.append("<br/>FEC enc=").append(fecPkts).append(" dec=").append(fecOk);
|
||||
if (fecFail > 0) {
|
||||
html.append(" <font color='").append(COLOR_WARN).append("'>fail=").append(fecFail)
|
||||
.append("</font>");
|
||||
}
|
||||
}
|
||||
if (nackReq > 0) {
|
||||
html.append("<br/>NACK events=").append(nackReq);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.foxx.androidcast.diagnostics;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.LinkProperties;
|
||||
import android.net.Network;
|
||||
import android.os.Build;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
/**
|
||||
* Link MTU from the OS plus derived cast UDP sizing (see {@link com.foxx.androidcast.network.UdpCastTransport}).
|
||||
*/
|
||||
public final class CastMtuSampler {
|
||||
/** ACUD header: magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4). */
|
||||
public static final int ACUD_HEADER_BYTES = 17;
|
||||
private static final int IPV4_UDP_OVERHEAD = 20 + 8;
|
||||
|
||||
public static final class Info {
|
||||
public final int linkMtu;
|
||||
public final String interfaceName;
|
||||
public final int castDatagramBytes;
|
||||
public final int castFragPayloadBytes;
|
||||
public final int ipv4WireBytes;
|
||||
/** {@code linkMtu - ipv4WireBytes}, or {@code -1} when link MTU is unknown. */
|
||||
public final int headroomBytes;
|
||||
|
||||
Info(int linkMtu, String interfaceName, int castDatagramBytes, int castFragPayloadBytes,
|
||||
int ipv4WireBytes, int headroomBytes) {
|
||||
this.linkMtu = linkMtu;
|
||||
this.interfaceName = interfaceName != null ? interfaceName : "";
|
||||
this.castDatagramBytes = castDatagramBytes;
|
||||
this.castFragPayloadBytes = castFragPayloadBytes;
|
||||
this.ipv4WireBytes = ipv4WireBytes;
|
||||
this.headroomBytes = headroomBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private CastMtuSampler() {}
|
||||
|
||||
public static Info sample(Context context) {
|
||||
int linkMtu = 0;
|
||||
String iface = "";
|
||||
if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
|
||||
if (cm != null) {
|
||||
Network network = cm.getActiveNetwork();
|
||||
LinkProperties lp = network != null ? cm.getLinkProperties(network) : null;
|
||||
if (lp != null) {
|
||||
linkMtu = lp.getMtu();
|
||||
if (lp.getInterfaceName() != null) {
|
||||
iface = lp.getInterfaceName();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return calculate(linkMtu, iface);
|
||||
}
|
||||
|
||||
public static Info calculate(int linkMtu, String interfaceName) {
|
||||
int datagram = CastConfig.UDP_CHUNK_SIZE;
|
||||
int fragPayload = Math.max(0, datagram - ACUD_HEADER_BYTES);
|
||||
int wire = datagram + IPV4_UDP_OVERHEAD;
|
||||
int headroom = linkMtu > 0 ? linkMtu - wire : -1;
|
||||
return new Info(linkMtu, interfaceName, datagram, fragPayload, wire, headroom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.foxx.androidcast.diagnostics;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Samples device or process CPU load (0–100% smoothed). */
|
||||
public final class CpuLoadSampler {
|
||||
private static final long MIN_INTERVAL_MS = 200;
|
||||
private static final int CORE_BUSY_THRESHOLD_PERCENT = 12;
|
||||
private static final int USER_HZ = 100;
|
||||
|
||||
private static final Object LOCK = new Object();
|
||||
private static long[] prevTotals;
|
||||
private static long[][] prevPerCpu;
|
||||
private static long prevSelfJiffies;
|
||||
private static long lastSampleMs;
|
||||
private static boolean procStatReadable = true;
|
||||
private static Sample cached = new Sample(0, Runtime.getRuntime().availableProcessors(), -1);
|
||||
|
||||
public static final class Sample {
|
||||
public final int loadPercent;
|
||||
public final int totalCores;
|
||||
/** Cores above {@link #CORE_BUSY_THRESHOLD_PERCENT} usage; {@code -1} if unavailable. */
|
||||
public final int activeCores;
|
||||
|
||||
Sample(int loadPercent, int totalCores, int activeCores) {
|
||||
this.loadPercent = Math.max(0, Math.min(100, loadPercent));
|
||||
this.totalCores = Math.max(1, totalCores);
|
||||
this.activeCores = activeCores;
|
||||
}
|
||||
}
|
||||
|
||||
private CpuLoadSampler() {}
|
||||
|
||||
/** Prime baseline so the first displayed sample is not stuck at 0%. */
|
||||
public static void warmUp() {
|
||||
synchronized (LOCK) {
|
||||
lastSampleMs = 0;
|
||||
prevTotals = null;
|
||||
prevPerCpu = null;
|
||||
prevSelfJiffies = 0;
|
||||
}
|
||||
sample();
|
||||
try {
|
||||
Thread.sleep(MIN_INTERVAL_MS + 50);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
sample();
|
||||
}
|
||||
|
||||
public static Sample getCached() {
|
||||
synchronized (LOCK) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
public static Sample sample() {
|
||||
synchronized (LOCK) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastSampleMs < MIN_INTERVAL_MS && prevTotals != null) {
|
||||
return cached;
|
||||
}
|
||||
int totalCores = Math.max(1, cached.totalCores);
|
||||
int deviceLoad = -1;
|
||||
int active = -1;
|
||||
|
||||
if (procStatReadable) {
|
||||
ProcStat current = readProcStat();
|
||||
if (current != null) {
|
||||
totalCores = Math.max(1, current.perCpu.size());
|
||||
if (prevTotals != null) {
|
||||
deviceLoad = percentDelta(prevTotals, current.totals);
|
||||
active = countActiveCores(prevPerCpu, current.perCpu, totalCores);
|
||||
}
|
||||
prevTotals = current.totals;
|
||||
prevPerCpu = copyPerCpu(current.perCpu);
|
||||
} else {
|
||||
procStatReadable = false;
|
||||
}
|
||||
}
|
||||
|
||||
int processLoad = sampleProcessLoad(now);
|
||||
int load = deviceLoad >= 0 ? deviceLoad : processLoad;
|
||||
if (deviceLoad >= 0 && processLoad > load) {
|
||||
load = processLoad;
|
||||
}
|
||||
|
||||
if (prevTotals != null || prevSelfJiffies > 0) {
|
||||
cached = new Sample(load, totalCores, active);
|
||||
} else if (processLoad > 0) {
|
||||
cached = new Sample(processLoad, totalCores, -1);
|
||||
}
|
||||
lastSampleMs = now;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static int sampleProcessLoad(long now) {
|
||||
long jiffies = readSelfCpuJiffies();
|
||||
if (jiffies < 0) {
|
||||
return 0;
|
||||
}
|
||||
int load = 0;
|
||||
if (prevSelfJiffies > 0 && lastSampleMs > 0) {
|
||||
long wallMs = now - lastSampleMs;
|
||||
if (wallMs > 0) {
|
||||
long delta = jiffies - prevSelfJiffies;
|
||||
float pct = delta * 1000f * 100f / (USER_HZ * wallMs * cached.totalCores);
|
||||
load = Math.round(Math.max(0f, Math.min(100f, pct)));
|
||||
}
|
||||
}
|
||||
prevSelfJiffies = jiffies;
|
||||
return load;
|
||||
}
|
||||
|
||||
private static long readSelfCpuJiffies() {
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader("/proc/self/stat"))) {
|
||||
String line = reader.readLine();
|
||||
if (line == null) {
|
||||
return -1;
|
||||
}
|
||||
int close = line.lastIndexOf(')');
|
||||
if (close < 0 || close + 2 >= line.length()) {
|
||||
return -1;
|
||||
}
|
||||
String[] parts = line.substring(close + 2).split("\\s+");
|
||||
if (parts.length < 2) {
|
||||
return -1;
|
||||
}
|
||||
return Long.parseLong(parts[0]) + Long.parseLong(parts[1]);
|
||||
} catch (IOException | NumberFormatException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static int countActiveCores(long[][] prev, List<long[]> curr, int totalCores) {
|
||||
if (prev == null || curr.isEmpty() || prev.length != curr.size()) {
|
||||
return -1;
|
||||
}
|
||||
int active = 0;
|
||||
for (int i = 0; i < curr.size(); i++) {
|
||||
if (percentDelta(prev[i], curr.get(i)) >= CORE_BUSY_THRESHOLD_PERCENT) {
|
||||
active++;
|
||||
}
|
||||
}
|
||||
return Math.min(active, totalCores);
|
||||
}
|
||||
|
||||
private static int percentDelta(long[] prev, long[] curr) {
|
||||
long prevIdle = prev[3] + prev[4];
|
||||
long currIdle = curr[3] + curr[4];
|
||||
long prevTotal = sumTimes(prev);
|
||||
long currTotal = sumTimes(curr);
|
||||
long totalDelta = currTotal - prevTotal;
|
||||
if (totalDelta <= 0) {
|
||||
return 0;
|
||||
}
|
||||
long idleDelta = currIdle - prevIdle;
|
||||
float busy = (totalDelta - idleDelta) * 100f / totalDelta;
|
||||
return Math.round(Math.max(0f, Math.min(100f, busy)));
|
||||
}
|
||||
|
||||
private static long sumTimes(long[] times) {
|
||||
long sum = 0;
|
||||
for (long t : times) {
|
||||
sum += t;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static long[][] copyPerCpu(List<long[]> list) {
|
||||
long[][] out = new long[list.size()][];
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
out[i] = list.get(i).clone();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static ProcStat readProcStat() {
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader("/proc/stat"))) {
|
||||
ProcStat stat = new ProcStat();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.startsWith("cpu ")) {
|
||||
stat.totals = parseCpuLine(line);
|
||||
} else if (line.startsWith("cpu") && line.length() > 3 && Character.isDigit(line.charAt(3))) {
|
||||
long[] cpu = parseCpuLine(line);
|
||||
if (cpu != null) {
|
||||
stat.perCpu.add(cpu);
|
||||
}
|
||||
} else if (!line.startsWith("cpu")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return stat.totals != null ? stat : null;
|
||||
} catch (IOException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static long[] parseCpuLine(String line) {
|
||||
String[] parts = line.trim().split("\\s+");
|
||||
if (parts.length < 5) {
|
||||
return null;
|
||||
}
|
||||
long[] times = new long[Math.min(10, parts.length - 1)];
|
||||
for (int i = 0; i < times.length; i++) {
|
||||
try {
|
||||
times[i] = Long.parseLong(parts[i + 1]);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
private static final class ProcStat {
|
||||
long[] totals;
|
||||
final List<long[]> perCpu = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** HTML suffix: {@code (2/8)} or {@code (8 cores)}. */
|
||||
public static String formatCoreSuffix(Sample sample) {
|
||||
if (sample.activeCores >= 0 && sample.activeCores <= sample.totalCores) {
|
||||
return String.format(Locale.US, "(%d/%d)", sample.activeCores, sample.totalCores);
|
||||
}
|
||||
return String.format(Locale.US, "(%d cores)", sample.totalCores);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public final class CastProtocol {
|
||||
public static final byte MSG_CODEC_SELECTED = 15;
|
||||
/** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
|
||||
public static final byte MSG_NACK = 16;
|
||||
/** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */
|
||||
public static final byte MSG_RECEIVER_CAST_SETTINGS = 17;
|
||||
/** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
|
||||
public static final byte MSG_WIRE_PACKET = 127;
|
||||
|
||||
@@ -268,7 +270,7 @@ public final class CastProtocol {
|
||||
return local.getTransport().equals(remote.getTransport());
|
||||
}
|
||||
|
||||
private static final byte NETWORK_STATS_VERSION = 4;
|
||||
private static final byte NETWORK_STATS_VERSION = 5;
|
||||
|
||||
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
@@ -318,6 +320,10 @@ public final class CastProtocol {
|
||||
dos.writeLong(s.fecStubPackets);
|
||||
dos.writeLong(s.nackStubRequests);
|
||||
}
|
||||
if (NETWORK_STATS_VERSION >= 5) {
|
||||
dos.writeLong(s.fecPacketsDecoded);
|
||||
dos.writeLong(s.fecDecodeFailures);
|
||||
}
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
@@ -372,6 +378,10 @@ public final class CastProtocol {
|
||||
s.fecStubPackets = dis.readLong();
|
||||
s.nackStubRequests = dis.readLong();
|
||||
}
|
||||
if (ver >= 5 && dis.available() > 0) {
|
||||
s.fecPacketsDecoded = dis.readLong();
|
||||
s.fecDecodeFailures = dis.readLong();
|
||||
}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
|
||||
@@ -13,12 +13,17 @@ import java.util.List;
|
||||
public class CastSession {
|
||||
public static final class HandshakeResult {
|
||||
public final String senderName;
|
||||
/** Sender cast settings from handshake. */
|
||||
public final CastSettings settings;
|
||||
/** Receiver advertisement ({@code AUTO} = auto-detected FEC capability). */
|
||||
public final CastSettings receiverAdvertisement;
|
||||
public final String negotiatedVideoMime;
|
||||
|
||||
public HandshakeResult(String senderName, CastSettings settings, String negotiatedVideoMime) {
|
||||
public HandshakeResult(String senderName, CastSettings settings,
|
||||
CastSettings receiverAdvertisement, String negotiatedVideoMime) {
|
||||
this.senderName = senderName;
|
||||
this.settings = settings;
|
||||
this.receiverAdvertisement = receiverAdvertisement;
|
||||
this.negotiatedVideoMime = negotiatedVideoMime;
|
||||
}
|
||||
}
|
||||
@@ -60,13 +65,16 @@ public class CastSession {
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
||||
|
||||
String negotiated = null;
|
||||
CastSettings receiverAdvertisement = null;
|
||||
long deadline = System.currentTimeMillis() + 15_000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
CastProtocol.Message msg = transport.receive(2_000);
|
||||
if (msg == null) {
|
||||
continue;
|
||||
}
|
||||
if (msg.type == CastProtocol.MSG_CODEC_SELECTED) {
|
||||
if (msg.type == CastProtocol.MSG_RECEIVER_CAST_SETTINGS) {
|
||||
receiverAdvertisement = CastProtocol.parseCastSettings(msg.payload);
|
||||
} else if (msg.type == CastProtocol.MSG_CODEC_SELECTED) {
|
||||
negotiated = CastProtocol.parseCodecSelected(msg.payload);
|
||||
break;
|
||||
}
|
||||
@@ -76,7 +84,7 @@ public class CastSession {
|
||||
}
|
||||
settings.setNegotiatedVideoMime(negotiated);
|
||||
CodecPriorityCatalog.logReceivedCodec("sender", negotiated, settings);
|
||||
return new HandshakeResult(deviceName, settings, negotiated);
|
||||
return new HandshakeResult(deviceName, settings, receiverAdvertisement, negotiated);
|
||||
}
|
||||
|
||||
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
|
||||
@@ -117,13 +125,20 @@ public class CastSession {
|
||||
break;
|
||||
}
|
||||
if (authed && remote != null && senderCaps != null) {
|
||||
CastSettings receiverAdvert = new CastSettings();
|
||||
receiverAdvert.copyFrom(localSettings);
|
||||
receiverAdvert.setStreamProtection(
|
||||
com.foxx.androidcast.network.transport.StreamProtectionCapability
|
||||
.handshakeAdvertisement());
|
||||
transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS,
|
||||
CastProtocol.castSettingsPayload(receiverAdvert));
|
||||
List<String> receiverCaps = CodecCatalog.localDecoderMimes();
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
|
||||
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
||||
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
||||
remote.setNegotiatedVideoMime(mime);
|
||||
return new HandshakeResult(senderName, remote, mime);
|
||||
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
|
||||
}
|
||||
}
|
||||
throw new IOException("Handshake timeout");
|
||||
|
||||
@@ -57,17 +57,27 @@ public final class CastTransportFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply protection after handshake using negotiated local/remote preferences. */
|
||||
public static void applyNegotiatedUdpProtection(UdpCastTransport udp, CastSettings local,
|
||||
CastSettings remote) {
|
||||
/**
|
||||
* Apply protection after handshake using negotiated local/remote preferences.
|
||||
*
|
||||
* @param localIsReceiver true when {@code local} is the receiver's capability from settings.
|
||||
* @return negotiation details (prompts, agreed mode) for UI/logging.
|
||||
*/
|
||||
public static com.foxx.androidcast.network.transport.StreamProtectionNegotiation applyNegotiatedUdpProtection(
|
||||
UdpCastTransport udp, CastSettings local, CastSettings remote, boolean localIsReceiver) {
|
||||
if (udp == null || local == null) {
|
||||
return;
|
||||
return com.foxx.androidcast.network.transport.StreamProtectionNegotiation.none();
|
||||
}
|
||||
CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator
|
||||
.resolve(local.getStreamProtection(), remote != null ? remote.getStreamProtection() : null);
|
||||
com.foxx.androidcast.network.transport.StreamProtectionNegotiation negotiated =
|
||||
com.foxx.androidcast.network.transport.StreamProtectionNegotiator.negotiate(
|
||||
local.getStreamProtection(),
|
||||
remote != null ? remote.getStreamProtection() : null,
|
||||
localIsReceiver);
|
||||
CastSettings effective = new CastSettings();
|
||||
effective.copyFrom(local);
|
||||
effective.setStreamProtection(mode);
|
||||
applyUdpProtection(udp, effective);
|
||||
effective.setStreamProtection(negotiated.agreed);
|
||||
udp.setProtectionEngine(com.foxx.androidcast.network.transport.StreamProtectionFactory.create(
|
||||
negotiated.agreed, udp.getLossStats(), negotiated.nackEnabled));
|
||||
return negotiated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.foxx.androidcast.network;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
|
||||
import com.foxx.androidcast.network.transport.FecProtectionEngine;
|
||||
@@ -27,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Best-effort message transport over UDP with fragmentation. */
|
||||
public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
private static final String TAG = "UdpCastTransport";
|
||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
||||
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
||||
private static final int HEADER = 17;
|
||||
@@ -45,7 +48,12 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
private final RetransmitCache retransmitCache = new RetransmitCache();
|
||||
private CastFramingContext framingContext;
|
||||
private CastSessionGate inboundSessionGate;
|
||||
private static final long REASSEMBLY_TIMEOUT_MS = 3_000;
|
||||
private static final long REASSEMBLY_TIMEOUT_MIN_MS = 3_000;
|
||||
private static final long REASSEMBLY_TIMEOUT_MAX_MS = 20_000;
|
||||
/** Large VP9 keyframes need many UDP fragments per FEC shard; allow extra time to collect shards. */
|
||||
private static final long FEC_GROUP_TIMEOUT_MS = 12_000;
|
||||
private static final int REASSEMBLY_LOG_EVERY = 16;
|
||||
private int reassemblyExpireLogCount;
|
||||
|
||||
public boolean isListening() {
|
||||
return listening && socket != null && !socket.isClosed();
|
||||
@@ -81,8 +89,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
}
|
||||
socket = new DatagramSocket(null);
|
||||
socket.setReuseAddress(true);
|
||||
socket.setReceiveBufferSize(512 * 1024);
|
||||
socket.setSendBufferSize(512 * 1024);
|
||||
socket.setReceiveBufferSize(2 * 1024 * 1024);
|
||||
socket.setSendBufferSize(2 * 1024 * 1024);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -142,9 +150,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
sendOnce(type, body, false);
|
||||
}
|
||||
|
||||
void retransmitCached(byte wireType, byte[] body) {
|
||||
void retransmitCached(byte wireType, byte[] body, int messageId) {
|
||||
try {
|
||||
sendOnce(wireType, body, false);
|
||||
sendOnceWithId(wireType, body, false, messageId);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
@@ -156,12 +164,15 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
synchronized (lossStats) {
|
||||
lossStats.nackCacheMisses++;
|
||||
}
|
||||
if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 1) {
|
||||
Log.w(TAG, "NACK cache miss id=" + messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
synchronized (lossStats) {
|
||||
lossStats.nackRetransmits++;
|
||||
}
|
||||
retransmitCached(entry.wireType, entry.body);
|
||||
retransmitCached(entry.wireType, entry.body, messageId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -199,6 +210,17 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
for (int i = 0; i < group.totalShards(); i++) {
|
||||
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
|
||||
sendOnceWithId(wireType, shardWire, cache, baseId + i);
|
||||
if (i + 1 < group.totalShards() && shardWire.length > 8_000) {
|
||||
paceBetweenLargeShards();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void paceBetweenLargeShards() {
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +235,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
}
|
||||
int chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
|
||||
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
||||
boolean paceFragments = fragments > 24;
|
||||
for (int i = 0; i < fragments; i++) {
|
||||
int offset = i * chunkSize;
|
||||
int len = Math.min(chunkSize, body.length - offset);
|
||||
@@ -222,6 +245,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsSent++;
|
||||
}
|
||||
if (paceFragments && i + 1 < fragments && (i & 7) == 7) {
|
||||
paceBetweenLargeShards();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,30 +285,55 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
private void expireStaleFecGroups() {
|
||||
if (fecEngine == null || nackEngine == null) {
|
||||
if (fecEngine != null) {
|
||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null);
|
||||
fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack);
|
||||
fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, nackEngine::requestNack);
|
||||
}
|
||||
|
||||
private void expireStaleReassembly() {
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
||||
Reassembly r = e.getValue();
|
||||
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) {
|
||||
if (r != null && now > r.deadlineMs) {
|
||||
pending.remove(e.getKey());
|
||||
synchronized (lossStats) {
|
||||
lossStats.reassemblyIncomplete++;
|
||||
}
|
||||
maybeLogReassemblyExpired(e.getKey(), r);
|
||||
int messageId = e.getKey();
|
||||
if (nackEngine != null) {
|
||||
nackEngine.requestNack(messageId);
|
||||
} else if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 2) {
|
||||
Log.w(TAG, "Reassembly expired without NACK id=" + messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeLogReassemblyExpired(int messageId, Reassembly r) {
|
||||
if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) != 1) {
|
||||
return;
|
||||
}
|
||||
Log.w(TAG, "UDP reassembly timeout id=" + messageId + " frags=" + r.received + "/"
|
||||
+ r.fragCount + " bytes=" + r.totalLen);
|
||||
}
|
||||
|
||||
private static long reassemblyDeadlineMs(int fragCount, int totalLen) {
|
||||
if (fragCount <= 1) {
|
||||
return System.currentTimeMillis() + REASSEMBLY_TIMEOUT_MIN_MS;
|
||||
}
|
||||
int chunkSize = Math.max(1, CastConfig.UDP_CHUNK_SIZE - HEADER);
|
||||
int expectedFrags = Math.max(fragCount, (totalLen + chunkSize - 1) / chunkSize);
|
||||
long budget = REASSEMBLY_TIMEOUT_MIN_MS + expectedFrags * 200L + (totalLen / 2048);
|
||||
if (fragCount > 1) {
|
||||
budget = Math.max(8_000L, budget);
|
||||
}
|
||||
budget = Math.min(REASSEMBLY_TIMEOUT_MAX_MS, budget);
|
||||
return System.currentTimeMillis() + budget;
|
||||
}
|
||||
|
||||
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
||||
if (length < HEADER) {
|
||||
synchronized (lossStats) {
|
||||
@@ -495,6 +546,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
final int fragCount;
|
||||
final int totalLen;
|
||||
final long startedMs;
|
||||
final long deadlineMs;
|
||||
final byte[][] parts;
|
||||
final int[] sizes;
|
||||
int received;
|
||||
@@ -503,6 +555,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
this.fragCount = fragCount;
|
||||
this.totalLen = totalLen;
|
||||
this.startedMs = System.currentTimeMillis();
|
||||
this.deadlineMs = reassemblyDeadlineMs(fragCount, totalLen);
|
||||
this.parts = new byte[fragCount][];
|
||||
this.sizes = new int[fragCount];
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ public class NetworkStatsSnapshot {
|
||||
public long recvDamagedVideoFrames;
|
||||
public String protectionMode = "NONE";
|
||||
public long fecStubPackets;
|
||||
public long fecPacketsDecoded;
|
||||
public long fecDecodeFailures;
|
||||
public long nackStubRequests;
|
||||
|
||||
public NetworkStatsSnapshot copy() {
|
||||
@@ -90,6 +92,8 @@ public class NetworkStatsSnapshot {
|
||||
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||
c.protectionMode = protectionMode;
|
||||
c.fecStubPackets = fecStubPackets;
|
||||
c.fecPacketsDecoded = fecPacketsDecoded;
|
||||
c.fecDecodeFailures = fecDecodeFailures;
|
||||
c.nackStubRequests = nackStubRequests;
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ public final class CombinedProtectionEngine implements StreamProtectionEngine {
|
||||
|
||||
@Override
|
||||
public StreamProtectionMode mode() {
|
||||
return StreamProtectionMode.FEC_NACK;
|
||||
return fec.mode() == StreamProtectionMode.FEC_REED_SOLOMON
|
||||
? StreamProtectionMode.FEC_REED_SOLOMON
|
||||
: StreamProtectionMode.FEC_3_4;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
||||
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
||||
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
@@ -16,9 +18,13 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Reed-Solomon FEC: v2 sends one shard per UDP message; v1 monolithic decode still accepted inbound. */
|
||||
/** Reed-Solomon FEC: one shard per UDP message; v1 monolithic decode still accepted inbound. */
|
||||
public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
private static final String TAG = "FecProtection";
|
||||
private static final AtomicInteger NEXT_GROUP_ID = new AtomicInteger(1);
|
||||
private static final int LOG_EVERY_N = 32;
|
||||
private int inboundShardRejectLogCount;
|
||||
private int inboundDecodeFailLogCount;
|
||||
|
||||
private final byte mode;
|
||||
private final int dataShards;
|
||||
@@ -53,11 +59,25 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
return protectionMode;
|
||||
}
|
||||
|
||||
/** Pick shard counts so each ACUD logical message stays small on lossy Wi‑Fi. */
|
||||
static int[] shardPlanForPayload(int payloadLength, int defaultData, int defaultParity) {
|
||||
if (payloadLength > 64_000) {
|
||||
return new int[] {16, 8};
|
||||
}
|
||||
if (payloadLength > 16_000) {
|
||||
return new int[] {8, 4};
|
||||
}
|
||||
return new int[] {defaultData, defaultParity};
|
||||
}
|
||||
|
||||
public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
|
||||
if (payload == null || payload.length == 0) {
|
||||
throw new IOException("empty payload");
|
||||
}
|
||||
ReedSolomonFec fec = new ReedSolomonFec(dataShards, parityShards);
|
||||
int[] plan = shardPlanForPayload(payload.length, dataShards, parityShards);
|
||||
int k = plan[0];
|
||||
int m = plan[1];
|
||||
ReedSolomonFec fec = new ReedSolomonFec(k, m);
|
||||
byte[][] shards = fec.encode(payload);
|
||||
int groupId = NEXT_GROUP_ID.getAndIncrement();
|
||||
if (stats != null) {
|
||||
@@ -65,7 +85,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
stats.fecPacketsEncoded += shards.length;
|
||||
}
|
||||
}
|
||||
return new FecEncodedGroup(mode, groupId, payload.length, dataShards, parityShards, shards);
|
||||
return new FecEncodedGroup(mode, groupId, payload.length, k, m, shards);
|
||||
}
|
||||
|
||||
public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
|
||||
@@ -116,6 +136,17 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
assembly = prev != null ? prev : created;
|
||||
}
|
||||
if (!assembly.addShard(shard)) {
|
||||
maybeLogShardReject(shard, assembly);
|
||||
byte[] retry = assembly.tryDecode();
|
||||
if (retry != null) {
|
||||
inboundGroups.remove(shard.groupId);
|
||||
if (stats != null) {
|
||||
synchronized (stats) {
|
||||
stats.fecPacketsDecoded++;
|
||||
}
|
||||
}
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.OK, retry);
|
||||
}
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
|
||||
}
|
||||
byte[] decoded = assembly.tryDecode();
|
||||
@@ -126,6 +157,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
stats.fecDecodeFailures++;
|
||||
}
|
||||
}
|
||||
maybeLogDecodeFail(shard.groupId, assembly.receivedCount(), assembly.getTotalShards());
|
||||
inboundGroups.remove(shard.groupId);
|
||||
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
|
||||
}
|
||||
@@ -212,6 +244,22 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
||||
return decoded.payload;
|
||||
}
|
||||
|
||||
private void maybeLogShardReject(FecShardWire.Parsed shard, FecShardReassembly assembly) {
|
||||
if ((++inboundShardRejectLogCount % LOG_EVERY_N) != 1) {
|
||||
return;
|
||||
}
|
||||
Log.w(TAG, "FEC shard reject gid=" + shard.groupId + " idx=" + shard.shardIndex
|
||||
+ " shardLen=" + shard.shardData.length + " recv=" + assembly.receivedCount()
|
||||
+ "/" + assembly.getTotalShards());
|
||||
}
|
||||
|
||||
private void maybeLogDecodeFail(int groupId, int received, int total) {
|
||||
if ((++inboundDecodeFailLogCount % LOG_EVERY_N) != 1) {
|
||||
return;
|
||||
}
|
||||
Log.w(TAG, "FEC RS decode failed gid=" + groupId + " shards=" + received + "/" + total);
|
||||
}
|
||||
|
||||
private static boolean looksLikeWireHeader(byte[] payload) {
|
||||
if (payload == null || payload.length < 4) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.R;
|
||||
|
||||
/** Formats {@link StreamProtectionNegotiation.Prompt} for logs and on-screen status. */
|
||||
public final class ProtectionNegotiationPrompts {
|
||||
private ProtectionNegotiationPrompts() {}
|
||||
|
||||
public static String format(Context context, StreamProtectionNegotiation.Prompt prompt,
|
||||
boolean localIsReceiver) {
|
||||
if (context == null || prompt == null) {
|
||||
return "";
|
||||
}
|
||||
String local = label(context, prompt.localPreference);
|
||||
String remote = label(context, prompt.remotePreference);
|
||||
String role = context.getString(prompt.target == StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
? R.string.protection_negotiation_role_sender
|
||||
: R.string.protection_negotiation_role_receiver);
|
||||
return context.getString(prompt.messageResId, local, remote, role);
|
||||
}
|
||||
|
||||
public static String summarize(Context context, StreamProtectionNegotiation negotiation) {
|
||||
if (context == null || negotiation == null) {
|
||||
return "";
|
||||
}
|
||||
String mode = label(context, negotiation.agreed);
|
||||
String nack = negotiation.nackEnabled
|
||||
? context.getString(R.string.protection_negotiation_nack_on)
|
||||
: context.getString(R.string.protection_negotiation_nack_off);
|
||||
return context.getString(R.string.protection_negotiation_agreed, mode, nack);
|
||||
}
|
||||
|
||||
public static String label(Context context, CastSettings.StreamProtection mode) {
|
||||
if (context == null || mode == null) {
|
||||
return "?";
|
||||
}
|
||||
switch (mode) {
|
||||
case FEC_3_4:
|
||||
return context.getString(R.string.protection_fec_34);
|
||||
case FEC_REED_SOLOMON:
|
||||
return context.getString(R.string.protection_fec_rs);
|
||||
case NACK:
|
||||
return context.getString(R.string.protection_nack);
|
||||
case FEC_NACK:
|
||||
return context.getString(R.string.protection_fec_nack);
|
||||
case AUTO:
|
||||
return context.getString(R.string.option_auto);
|
||||
case NONE:
|
||||
default:
|
||||
return context.getString(R.string.protection_none);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import java.util.Map;
|
||||
|
||||
/** Ring buffer of recent UDP logical messages for NACK retransmission. */
|
||||
public final class RetransmitCache {
|
||||
private static final int DEFAULT_CAPACITY = 64;
|
||||
/** VP9+FEC uses 7 message ids per frame; must cover several seconds of in-flight shards. */
|
||||
private static final int DEFAULT_CAPACITY = 4096;
|
||||
|
||||
private final int capacity;
|
||||
private final LinkedHashMap<Integer, CachedMessage> map;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
import java.util.EnumSet;
|
||||
|
||||
/** Runtime FEC/NACK implementations on this device (not a user setting). */
|
||||
public final class StreamProtectionCapability {
|
||||
private StreamProtectionCapability() {}
|
||||
|
||||
/** Wire value: receiver supports negotiation from {@link #receiverImplementations()}. */
|
||||
public static CastSettings.StreamProtection handshakeAdvertisement() {
|
||||
return CastSettings.StreamProtection.AUTO;
|
||||
}
|
||||
|
||||
public static boolean isAutoAdvertisement(CastSettings.StreamProtection mode) {
|
||||
return mode == null || mode == CastSettings.StreamProtection.AUTO;
|
||||
}
|
||||
|
||||
/** Modes this build can encode/decode over UDP. */
|
||||
public static EnumSet<CastSettings.StreamProtection> receiverImplementations() {
|
||||
return EnumSet.of(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.FEC_REED_SOLOMON,
|
||||
CastSettings.StreamProtection.NACK,
|
||||
CastSettings.StreamProtection.FEC_NACK,
|
||||
CastSettings.StreamProtection.NONE);
|
||||
}
|
||||
|
||||
public static boolean supports(CastSettings.StreamProtection mode) {
|
||||
CastSettings.StreamProtection n = StreamProtectionNegotiator.normalize(mode);
|
||||
return receiverImplementations().contains(n);
|
||||
}
|
||||
|
||||
public static EnumSet<CastSettings.StreamProtection> resolveRemoteCapability(
|
||||
CastSettings.StreamProtection remote) {
|
||||
if (isAutoAdvertisement(remote)) {
|
||||
return receiverImplementations();
|
||||
}
|
||||
CastSettings.StreamProtection n = StreamProtectionNegotiator.normalize(remote);
|
||||
if (supports(n)) {
|
||||
return EnumSet.of(n);
|
||||
}
|
||||
return EnumSet.of(CastSettings.StreamProtection.NONE);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.UdpCastTransport;
|
||||
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
||||
|
||||
@@ -16,17 +15,22 @@ public final class StreamProtectionFactory {
|
||||
|
||||
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
|
||||
TransportLossStats stats) {
|
||||
return create(setting, stats, wantsBundledNack(setting));
|
||||
}
|
||||
|
||||
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
|
||||
TransportLossStats stats, boolean nackEnabled) {
|
||||
CastSettings.StreamProtection mode = StreamProtectionNegotiator.normalize(setting);
|
||||
RetransmitCache cache = new RetransmitCache();
|
||||
switch (mode) {
|
||||
case NONE:
|
||||
return StreamProtectionEngine.noop();
|
||||
case FEC_3_4:
|
||||
return new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
|
||||
FEC34_DATA, FEC34_PARITY, stats);
|
||||
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
|
||||
FEC34_DATA, FEC34_PARITY, stats), stats, cache, nackEnabled);
|
||||
case FEC_REED_SOLOMON:
|
||||
return new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
|
||||
RS_DATA, RS_PARITY, stats);
|
||||
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
|
||||
RS_DATA, RS_PARITY, stats), stats, cache, nackEnabled);
|
||||
case NACK:
|
||||
return new NackProtectionEngine(stats, cache);
|
||||
case FEC_NACK:
|
||||
@@ -38,6 +42,20 @@ public final class StreamProtectionFactory {
|
||||
}
|
||||
}
|
||||
|
||||
private static StreamProtectionEngine wrapFec(FecProtectionEngine fec,
|
||||
TransportLossStats stats, RetransmitCache cache, boolean nackEnabled) {
|
||||
if (!nackEnabled) {
|
||||
return fec;
|
||||
}
|
||||
return new CombinedProtectionEngine(fec, new NackProtectionEngine(stats, cache));
|
||||
}
|
||||
|
||||
private static boolean wantsBundledNack(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.FEC_3_4
|
||||
|| mode == CastSettings.StreamProtection.FEC_NACK
|
||||
|| mode == CastSettings.StreamProtection.FEC_REED_SOLOMON;
|
||||
}
|
||||
|
||||
public static void wireUdp(UdpCastTransport udp, StreamProtectionEngine engine) {
|
||||
if (udp == null || engine == null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
/** Outcome of matching local/remote {@link CastSettings.StreamProtection} preferences. */
|
||||
public final class StreamProtectionNegotiation {
|
||||
public final CastSettings.StreamProtection agreed;
|
||||
public final boolean nackEnabled;
|
||||
public final Prompt prompt;
|
||||
|
||||
public StreamProtectionNegotiation(CastSettings.StreamProtection agreed, boolean nackEnabled,
|
||||
Prompt prompt) {
|
||||
this.agreed = agreed != null ? agreed : CastSettings.StreamProtection.NONE;
|
||||
this.nackEnabled = nackEnabled;
|
||||
this.prompt = prompt;
|
||||
}
|
||||
|
||||
public static StreamProtectionNegotiation none() {
|
||||
return new StreamProtectionNegotiation(CastSettings.StreamProtection.NONE, false, null);
|
||||
}
|
||||
|
||||
public static final class Prompt {
|
||||
public enum Target {
|
||||
/** Ask the casting phone to change stream protection in settings. */
|
||||
SENDER,
|
||||
/** Ask the receiver device to change stream protection in settings. */
|
||||
RECEIVER
|
||||
}
|
||||
|
||||
public final Target target;
|
||||
/** String resource id for a short user-visible hint. */
|
||||
public final int messageResId;
|
||||
public final CastSettings.StreamProtection localPreference;
|
||||
public final CastSettings.StreamProtection remotePreference;
|
||||
|
||||
public Prompt(Target target, int messageResId,
|
||||
CastSettings.StreamProtection localPreference,
|
||||
CastSettings.StreamProtection remotePreference) {
|
||||
this.target = target;
|
||||
this.messageResId = messageResId;
|
||||
this.localPreference = localPreference;
|
||||
this.remotePreference = remotePreference;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,242 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.R;
|
||||
|
||||
/** Picks an agreed stream protection mode for both peers. */
|
||||
import java.util.EnumSet;
|
||||
|
||||
/** Picks agreed stream protection from receiver capability vs sender preference. */
|
||||
public final class StreamProtectionNegotiator {
|
||||
private StreamProtectionNegotiator() {}
|
||||
|
||||
/**
|
||||
* Agreed mode for this session. Any mismatch, missing remote preference, or {@code NONE}
|
||||
* on either side yields {@link CastSettings.StreamProtection#NONE} (plain UDP).
|
||||
*/
|
||||
/** @deprecated Use {@link #negotiate(CastSettings.StreamProtection, CastSettings.StreamProtection, boolean)}. */
|
||||
@Deprecated
|
||||
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
|
||||
CastSettings.StreamProtection remote) {
|
||||
CastSettings.StreamProtection a = normalize(local);
|
||||
CastSettings.StreamProtection b = normalize(remote);
|
||||
if (a == CastSettings.StreamProtection.NONE || b == CastSettings.StreamProtection.NONE) {
|
||||
return CastSettings.StreamProtection.NONE;
|
||||
return negotiate(local, remote, false).agreed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param local sender preference when {@code localIsReceiver} is false; ignored on receiver
|
||||
* (capability is auto-detected)
|
||||
* @param remote sender preference when {@code localIsReceiver}; receiver wire adv. when sender
|
||||
*/
|
||||
public static StreamProtectionNegotiation negotiate(CastSettings.StreamProtection local,
|
||||
CastSettings.StreamProtection remote, boolean localIsReceiver) {
|
||||
EnumSet<CastSettings.StreamProtection> receiverCaps;
|
||||
CastSettings.StreamProtection senderPref;
|
||||
if (localIsReceiver) {
|
||||
receiverCaps = StreamProtectionCapability.receiverImplementations();
|
||||
senderPref = remote;
|
||||
} else {
|
||||
senderPref = local;
|
||||
receiverCaps = StreamProtectionCapability.resolveRemoteCapability(remote);
|
||||
}
|
||||
if (a == b) {
|
||||
return a;
|
||||
return negotiateWithReceiverCaps(receiverCaps, senderPref, local, remote, localIsReceiver);
|
||||
}
|
||||
|
||||
private static StreamProtectionNegotiation negotiateWithReceiverCaps(
|
||||
EnumSet<CastSettings.StreamProtection> receiverCaps,
|
||||
CastSettings.StreamProtection senderPref,
|
||||
CastSettings.StreamProtection localRaw,
|
||||
CastSettings.StreamProtection remoteRaw,
|
||||
boolean localIsReceiver) {
|
||||
CastSettings.StreamProtection sender = normalize(senderPref);
|
||||
CastSettings.StreamProtection receiverLabel = localIsReceiver
|
||||
? StreamProtectionCapability.handshakeAdvertisement()
|
||||
: pickRepresentativeCap(receiverCaps);
|
||||
|
||||
if (sender == CastSettings.StreamProtection.NONE) {
|
||||
if (receiverCaps.stream().anyMatch(StreamProtectionNegotiator::isFec)) {
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
return new StreamProtectionNegotiation(
|
||||
CastSettings.StreamProtection.NONE,
|
||||
false,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_use_none,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
return StreamProtectionNegotiation.none();
|
||||
}
|
||||
|
||||
if (receiverCaps.contains(sender)) {
|
||||
return agreeExact(sender, receiverLabel, senderPref);
|
||||
}
|
||||
|
||||
if (isFec34(sender) && normalize(remoteRaw) == CastSettings.StreamProtection.FEC_NACK) {
|
||||
return fec34WithoutNack(receiverLabel, senderPref, localIsReceiver);
|
||||
}
|
||||
|
||||
if (isNackOnly(sender) && receiverCaps.stream().anyMatch(StreamProtectionNegotiator::isFec)) {
|
||||
CastSettings.StreamProtection fec = receiverCaps.contains(CastSettings.StreamProtection.FEC_NACK)
|
||||
? CastSettings.StreamProtection.FEC_NACK
|
||||
: receiverCaps.contains(CastSettings.StreamProtection.FEC_3_4)
|
||||
? CastSettings.StreamProtection.FEC_3_4
|
||||
: CastSettings.StreamProtection.FEC_REED_SOLOMON;
|
||||
return agreeFecWithNack(fec, receiverLabel, senderPref, localIsReceiver);
|
||||
}
|
||||
|
||||
if (isFec(sender)) {
|
||||
if (isFec34(sender) && receiverCaps.contains(CastSettings.StreamProtection.FEC_REED_SOLOMON)) {
|
||||
return negotiateFecFamilyMismatch(
|
||||
CastSettings.StreamProtection.FEC_REED_SOLOMON,
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
receiverLabel,
|
||||
senderPref,
|
||||
localIsReceiver);
|
||||
}
|
||||
if (sender == CastSettings.StreamProtection.FEC_REED_SOLOMON
|
||||
&& receiverCaps.contains(CastSettings.StreamProtection.FEC_3_4)) {
|
||||
return negotiateFecFamilyMismatch(
|
||||
CastSettings.StreamProtection.FEC_REED_SOLOMON,
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
receiverLabel,
|
||||
senderPref,
|
||||
localIsReceiver);
|
||||
}
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
return new StreamProtectionNegotiation(
|
||||
CastSettings.StreamProtection.NONE,
|
||||
false,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_use_none,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
return new StreamProtectionNegotiation(
|
||||
CastSettings.StreamProtection.NONE,
|
||||
false,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_mismatch,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
|
||||
private static CastSettings.StreamProtection pickRepresentativeCap(
|
||||
EnumSet<CastSettings.StreamProtection> caps) {
|
||||
if (caps.contains(CastSettings.StreamProtection.FEC_REED_SOLOMON)) {
|
||||
return CastSettings.StreamProtection.FEC_REED_SOLOMON;
|
||||
}
|
||||
if (caps.contains(CastSettings.StreamProtection.FEC_3_4)) {
|
||||
return CastSettings.StreamProtection.FEC_3_4;
|
||||
}
|
||||
if (caps.contains(CastSettings.StreamProtection.FEC_NACK)) {
|
||||
return CastSettings.StreamProtection.FEC_NACK;
|
||||
}
|
||||
if (caps.contains(CastSettings.StreamProtection.NACK)) {
|
||||
return CastSettings.StreamProtection.NACK;
|
||||
}
|
||||
return CastSettings.StreamProtection.NONE;
|
||||
}
|
||||
|
||||
private static StreamProtectionNegotiation negotiateFecFamilyMismatch(
|
||||
CastSettings.StreamProtection agreed,
|
||||
CastSettings.StreamProtection other,
|
||||
CastSettings.StreamProtection receiverLabel,
|
||||
CastSettings.StreamProtection senderPref,
|
||||
boolean localIsReceiver) {
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
boolean nack = wantsNack(other);
|
||||
if (isFecNack(other)) {
|
||||
nack = false;
|
||||
}
|
||||
return new StreamProtectionNegotiation(
|
||||
agreed,
|
||||
nack,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_fec_family_rs,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
|
||||
private static StreamProtectionNegotiation fec34WithoutNack(
|
||||
CastSettings.StreamProtection receiverLabel,
|
||||
CastSettings.StreamProtection senderPref,
|
||||
boolean localIsReceiver) {
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
return new StreamProtectionNegotiation(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
false,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_fec34_no_nack,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
|
||||
private static StreamProtectionNegotiation agreeFecWithNack(
|
||||
CastSettings.StreamProtection fecMode,
|
||||
CastSettings.StreamProtection receiverLabel,
|
||||
CastSettings.StreamProtection senderPref,
|
||||
boolean localIsReceiver) {
|
||||
StreamProtectionNegotiation.Prompt.Target target = localIsReceiver
|
||||
? StreamProtectionNegotiation.Prompt.Target.SENDER
|
||||
: StreamProtectionNegotiation.Prompt.Target.RECEIVER;
|
||||
return new StreamProtectionNegotiation(
|
||||
fecMode,
|
||||
true,
|
||||
new StreamProtectionNegotiation.Prompt(
|
||||
target,
|
||||
R.string.protection_negotiation_nack_to_fec,
|
||||
receiverLabel,
|
||||
senderPref));
|
||||
}
|
||||
|
||||
private static StreamProtectionNegotiation agreeExact(
|
||||
CastSettings.StreamProtection mode,
|
||||
CastSettings.StreamProtection receiverLabel,
|
||||
CastSettings.StreamProtection senderPref) {
|
||||
boolean nack = wantsNack(mode);
|
||||
if (mode == CastSettings.StreamProtection.FEC_3_4) {
|
||||
nack = true;
|
||||
}
|
||||
return new StreamProtectionNegotiation(mode, nack, null);
|
||||
}
|
||||
|
||||
public static CastSettings.StreamProtection normalize(CastSettings.StreamProtection mode) {
|
||||
if (mode == null || mode == CastSettings.StreamProtection.AUTO) {
|
||||
return CastSettings.StreamProtection.NONE;
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
private static boolean wantsNack(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.NACK
|
||||
|| mode == CastSettings.StreamProtection.FEC_NACK;
|
||||
}
|
||||
|
||||
private static boolean isNackOnly(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.NACK;
|
||||
}
|
||||
|
||||
private static boolean isFec(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.FEC_3_4
|
||||
|| mode == CastSettings.StreamProtection.FEC_REED_SOLOMON
|
||||
|| mode == CastSettings.StreamProtection.FEC_NACK;
|
||||
}
|
||||
|
||||
private static boolean isFec34(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.FEC_3_4;
|
||||
}
|
||||
|
||||
private static boolean isFecNack(CastSettings.StreamProtection mode) {
|
||||
return mode == CastSettings.StreamProtection.FEC_NACK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ public final class TransportLossStatsBridge {
|
||||
s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||
s.protectionMode = loss.protectionMode.name();
|
||||
s.fecStubPackets = loss.fecPacketsEncoded;
|
||||
s.fecPacketsDecoded = loss.fecPacketsDecoded;
|
||||
s.fecDecodeFailures = loss.fecDecodeFailures;
|
||||
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived;
|
||||
long lossEvents = loss.estimatedUdpLossEvents();
|
||||
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
||||
|
||||
@@ -7,14 +7,19 @@ import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Per-datagram FEC shard (v2). Each shard is its own ACUD logical message so loss of one
|
||||
* Per-datagram FEC shard. Each shard is its own ACUD logical message so loss of one
|
||||
* fragment does not invalidate the whole Reed–Solomon block.
|
||||
*
|
||||
* <p>v2 used a single-byte shard length (max 255), which breaks VP9 keyframes. v3 uses uint16.
|
||||
*/
|
||||
public final class FecShardWire {
|
||||
private static final byte[] MAGIC = {'A', 'C', 'P', 'F'};
|
||||
public static final byte VERSION_SHARD = 2;
|
||||
/** Bytes after {@link #MAGIC} before shard payload. */
|
||||
private static final int HEADER_AFTER_MAGIC = 19;
|
||||
/** Legacy on-wire format; shard length field is 1 byte (max 255). */
|
||||
public static final byte VERSION_SHARD_V2 = 2;
|
||||
/** Current format; shard length field is uint16. */
|
||||
public static final byte VERSION_SHARD = 3;
|
||||
private static final int HEADER_AFTER_MAGIC_V2 = 19;
|
||||
private static final int HEADER_AFTER_MAGIC_V3 = 20;
|
||||
|
||||
private FecShardWire() {}
|
||||
|
||||
@@ -31,6 +36,9 @@ public final class FecShardWire {
|
||||
if (shardData == null) {
|
||||
throw new IOException("shardData required");
|
||||
}
|
||||
if (shardData.length > 0xFFFF) {
|
||||
throw new IOException("shard too large: " + shardData.length);
|
||||
}
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.write(MAGIC);
|
||||
@@ -43,18 +51,17 @@ public final class FecShardWire {
|
||||
dos.writeInt(originalLen);
|
||||
dos.writeByte(dataShards);
|
||||
dos.writeByte(parityShards);
|
||||
dos.writeByte(shardData.length);
|
||||
dos.writeShort(shardData.length);
|
||||
dos.write(shardData);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static boolean isShardPacket(byte[] wire) {
|
||||
Parsed p = tryParse(wire);
|
||||
return p != null;
|
||||
return tryParse(wire) != null;
|
||||
}
|
||||
|
||||
public static Parsed tryParse(byte[] wire) {
|
||||
if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC) {
|
||||
if (wire == null || wire.length < MAGIC.length + HEADER_AFTER_MAGIC_V2) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < MAGIC.length; i++) {
|
||||
@@ -66,7 +73,7 @@ public final class FecShardWire {
|
||||
DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length,
|
||||
wire.length - MAGIC.length));
|
||||
byte ver = dis.readByte();
|
||||
if (ver != VERSION_SHARD) {
|
||||
if (ver != VERSION_SHARD_V2 && ver != VERSION_SHARD) {
|
||||
return null;
|
||||
}
|
||||
byte mode = dis.readByte();
|
||||
@@ -77,14 +84,15 @@ public final class FecShardWire {
|
||||
int originalLen = dis.readInt();
|
||||
int dataShards = dis.readUnsignedByte();
|
||||
int parityShards = dis.readUnsignedByte();
|
||||
int shardSize = dis.readUnsignedByte();
|
||||
int shardSize = ver == VERSION_SHARD ? dis.readUnsignedShort() : dis.readUnsignedByte();
|
||||
if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) {
|
||||
return null;
|
||||
}
|
||||
if (shardIndex >= totalShards || originalLen < 1) {
|
||||
if (shardIndex >= totalShards || originalLen < 1 || shardSize < 1) {
|
||||
return null;
|
||||
}
|
||||
if (wire.length < MAGIC.length + HEADER_AFTER_MAGIC + shardSize) {
|
||||
int headerAfterMagic = ver == VERSION_SHARD ? HEADER_AFTER_MAGIC_V3 : HEADER_AFTER_MAGIC_V2;
|
||||
if (wire.length < MAGIC.length + headerAfterMagic + shardSize) {
|
||||
return null;
|
||||
}
|
||||
byte[] shardData = new byte[shardSize];
|
||||
|
||||
@@ -166,21 +166,31 @@ public class AudioDecoder {
|
||||
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
|
||||
}
|
||||
|
||||
/** Drops queued AAC input without tearing down the decoder (e.g. after video keyframe wait). */
|
||||
public void clearInputQueue() {
|
||||
inputQueue.clear();
|
||||
retryFrame = null;
|
||||
}
|
||||
|
||||
public void queueFrame(long ptsUs, byte[] data) {
|
||||
if (data == null || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
stats.framesQueued++;
|
||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||
PendingFrame dropped = inputQueue.poll();
|
||||
if (dropped != null) {
|
||||
stats.queueDrops++;
|
||||
logDropBurst();
|
||||
}
|
||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||
stats.queueDrops++;
|
||||
logDropBurst();
|
||||
}
|
||||
PendingFrame frame = new PendingFrame(ptsUs, data.clone());
|
||||
if (inputQueue.offer(frame)) {
|
||||
return;
|
||||
}
|
||||
int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
|
||||
inputQueue.clear();
|
||||
retryFrame = null;
|
||||
if (dropped > 0) {
|
||||
stats.queueDrops += dropped;
|
||||
logDropBurst();
|
||||
}
|
||||
if (!inputQueue.offer(frame)) {
|
||||
stats.queueDrops++;
|
||||
logDropBurst();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import java.io.IOException;
|
||||
/** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */
|
||||
public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
||||
private static final String TAG = "LibvpxVideoDecoder";
|
||||
private static final int DECODE_FAIL_LOG_EVERY = 16;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private int decodeFailLogCount;
|
||||
private long nativeHandle;
|
||||
private Surface outputSurface;
|
||||
private Listener listener;
|
||||
@@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
||||
}
|
||||
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
|
||||
notifyRenderedLocked();
|
||||
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
|
||||
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import com.foxx.androidcast.IntentExtras;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
||||
import com.foxx.androidcast.diagnostics.CastMtuSampler;
|
||||
import com.foxx.androidcast.diagnostics.CpuLoadSampler;
|
||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.media.codec.VideoCodecBackend;
|
||||
@@ -169,6 +171,7 @@ public class ReceiverCastService extends Service {
|
||||
videoDecodeThread = new HandlerThread("VideoDecode");
|
||||
videoDecodeThread.start();
|
||||
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
|
||||
CpuLoadSampler.warmUp();
|
||||
mainHandler.post(idleWatchdog);
|
||||
}
|
||||
|
||||
@@ -337,7 +340,7 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
@Override
|
||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||
if (castEnded) {
|
||||
if (castEnded || awaitingKeyframe || streamIdle) {
|
||||
return;
|
||||
}
|
||||
final int frameBytes = data != null ? data.length : 0;
|
||||
@@ -476,7 +479,7 @@ public class ReceiverCastService extends Service {
|
||||
return;
|
||||
}
|
||||
streamIdle = false;
|
||||
awaitingKeyframe = true;
|
||||
enterAwaitingKeyframe();
|
||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||
scheduleDecoderConfigureRetries();
|
||||
}
|
||||
@@ -485,7 +488,7 @@ public class ReceiverCastService extends Service {
|
||||
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
||||
streamIdle = true;
|
||||
hasPendingConfig = false;
|
||||
awaitingKeyframe = true;
|
||||
enterAwaitingKeyframe();
|
||||
resetDecoderState();
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
@@ -500,7 +503,7 @@ public class ReceiverCastService extends Service {
|
||||
|
||||
private void resetStreamState() {
|
||||
hasPendingConfig = false;
|
||||
awaitingKeyframe = true;
|
||||
enterAwaitingKeyframe();
|
||||
pendingWidth = 0;
|
||||
pendingHeight = 0;
|
||||
displayWidth = 0;
|
||||
@@ -516,6 +519,19 @@ public class ReceiverCastService extends Service {
|
||||
decoderHeight = 0;
|
||||
}
|
||||
|
||||
private void enterAwaitingKeyframe() {
|
||||
awaitingKeyframe = true;
|
||||
flushAudioInput();
|
||||
}
|
||||
|
||||
private void flushAudioInput() {
|
||||
synchronized (audioDecoderLock) {
|
||||
if (audioDecoder != null) {
|
||||
audioDecoder.clearInputQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
|
||||
pendingAudioRate = sampleRate;
|
||||
pendingAudioChannels = channels;
|
||||
@@ -559,7 +575,7 @@ public class ReceiverCastService extends Service {
|
||||
streamMetrics.reset();
|
||||
}
|
||||
reconfiguring = true;
|
||||
awaitingKeyframe = true;
|
||||
enterAwaitingKeyframe();
|
||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||
pendingWidth = size.width;
|
||||
pendingHeight = size.height;
|
||||
@@ -796,7 +812,8 @@ public class ReceiverCastService extends Service {
|
||||
remoteCastSettings,
|
||||
peer,
|
||||
lastLocalStats,
|
||||
PlaybackViewState.getZoomPercent());
|
||||
PlaybackViewState.getZoomPercent(),
|
||||
CastMtuSampler.sample(this));
|
||||
}
|
||||
|
||||
private TransportLossStats buildReceiverLossSnapshot() {
|
||||
|
||||
@@ -73,6 +73,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
private boolean bound;
|
||||
private boolean autoStartListening;
|
||||
private boolean listeningCancelled;
|
||||
private boolean autoPipAttempted;
|
||||
|
||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||
@Override
|
||||
@@ -182,11 +183,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||
pipButton = findViewById(R.id.button_pip);
|
||||
if (pipButton != null) {
|
||||
pipButton.setOnClickListener(v -> tryEnterPictureInPicture());
|
||||
pipButton.setOnClickListener(v -> tryEnterPictureInPicture(true));
|
||||
}
|
||||
textureView.setSurfaceTextureListener(surfaceListener);
|
||||
applyAspectRatio();
|
||||
updatePipUi();
|
||||
if (AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported()) {
|
||||
PictureInPictureHelper.applyParams(this, 16, 9);
|
||||
}
|
||||
|
||||
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
|
||||
if (autoStartListening) {
|
||||
@@ -245,14 +249,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
mainHandler.post(diagnosticsRefresh);
|
||||
updateDiagnosticsVisibility();
|
||||
updatePipUi();
|
||||
maybeAutoEnterPip();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUserLeaveHint() {
|
||||
super.onUserLeaveHint();
|
||||
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
tryEnterPictureInPicture();
|
||||
if (shouldAutoEnterPipOnLeave()) {
|
||||
tryEnterPictureInPicture(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +272,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
||||
updatePipUi();
|
||||
maybeAutoEnterPip();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +551,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
if (pipButton == null) {
|
||||
return;
|
||||
}
|
||||
boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||
boolean sessionVisible = castSessionActive || streamRendering
|
||||
|| (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE);
|
||||
boolean show = sessionVisible && AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PictureInPictureHelper.isSupported()
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
@@ -555,23 +562,64 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void tryEnterPictureInPicture() {
|
||||
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
|
||||
private boolean shouldAutoEnterPipOnLeave() {
|
||||
if (!AppPreferences.isReceiverPipEnabled(this)
|
||||
|| !PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
return false;
|
||||
}
|
||||
if (castSessionActive || streamRendering) {
|
||||
return true;
|
||||
}
|
||||
return bound && awaitingOverlay != null
|
||||
&& awaitingOverlay.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
private void maybeAutoEnterPip() {
|
||||
if (!autoStartListening || autoPipAttempted || inPictureInPicture) {
|
||||
return;
|
||||
}
|
||||
if (!AppPreferences.isReceiverPipEnabled(this)
|
||||
|| !PictureInPictureHelper.isSupported()) {
|
||||
return;
|
||||
}
|
||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
return;
|
||||
}
|
||||
autoPipAttempted = true;
|
||||
mainHandler.post(() -> tryEnterPictureInPicture(false));
|
||||
}
|
||||
|
||||
private void tryEnterPictureInPicture(boolean userInitiated) {
|
||||
if (!AppPreferences.isReceiverPipEnabled(this)) {
|
||||
return;
|
||||
}
|
||||
if (!castSessionActive && !streamRendering
|
||||
&& (awaitingOverlay == null || awaitingOverlay.getVisibility() != View.VISIBLE)) {
|
||||
if (userInitiated) {
|
||||
Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
if (userInitiated) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.isSupported()) {
|
||||
Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
|
||||
if (userInitiated) {
|
||||
Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.isAllowedForApp(this)) {
|
||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||
if (userInitiated) {
|
||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) {
|
||||
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight) && userInitiated) {
|
||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||
}
|
||||
}
|
||||
@@ -582,7 +630,12 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
if (pipButton != null) {
|
||||
pipButton.setVisibility(inPictureInPicture || !castSessionActive ? View.GONE : View.VISIBLE);
|
||||
boolean showPip = (castSessionActive || streamRendering
|
||||
|| (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE))
|
||||
&& AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PictureInPictureHelper.isSupported()
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||
pipButton.setVisibility(inPictureInPicture || !showPip ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
if (inPictureInPicture) {
|
||||
if (diagnosticsText != null) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastSessionGate;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
|
||||
import com.foxx.androidcast.network.UdpCastTransport;
|
||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
@@ -74,6 +76,7 @@ public class ReceiverSession {
|
||||
private CastSession session;
|
||||
private CastTransport listenTransport;
|
||||
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
||||
private boolean loggedFirstVideoFrame;
|
||||
private NetworkFeedbackManager networkFeedback;
|
||||
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||
|
||||
@@ -195,6 +198,7 @@ public class ReceiverSession {
|
||||
listenTransport = null;
|
||||
}
|
||||
inboundSessionGate.reset();
|
||||
loggedFirstVideoFrame = false;
|
||||
networkFeedback = null;
|
||||
listener.onDisconnected();
|
||||
}
|
||||
@@ -237,6 +241,12 @@ public class ReceiverSession {
|
||||
case CastProtocol.MSG_VIDEO_FRAME:
|
||||
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
||||
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
|
||||
if (!loggedFirstVideoFrame) {
|
||||
loggedFirstVideoFrame = true;
|
||||
Log.i(TAG, "First video over network "
|
||||
+ (vf.keyFrame ? "KEY" : "P") + " len="
|
||||
+ (vf.data != null ? vf.data.length : 0));
|
||||
}
|
||||
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
|
||||
break;
|
||||
case CastProtocol.MSG_AUDIO_CONFIG:
|
||||
@@ -286,7 +296,16 @@ public class ReceiverSession {
|
||||
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
|
||||
}
|
||||
if (udp != null) {
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(udp, localSettings, remoteSettings);
|
||||
StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
|
||||
udp, localSettings, remoteSettings, true);
|
||||
String summary = ProtectionNegotiationPrompts.summarize(appContext, negotiated);
|
||||
Log.i(TAG, summary);
|
||||
postStatus(summary);
|
||||
if (negotiated.prompt != null) {
|
||||
String hint = ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, true);
|
||||
Log.i(TAG, hint);
|
||||
postStatus(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,26 +88,27 @@ public class AudioEncoder implements AudioEncoderSink {
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
MediaCodec c;
|
||||
synchronized (inputLock) {
|
||||
c = codec;
|
||||
codec = null;
|
||||
}
|
||||
if (drainThread != null) {
|
||||
Thread drain = drainThread;
|
||||
drainThread = null;
|
||||
if (drain != null) {
|
||||
try {
|
||||
drainThread.join(1000);
|
||||
drain.join(1000);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
if (c != null) {
|
||||
try {
|
||||
c.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
c.release();
|
||||
} catch (Exception ignored) {
|
||||
synchronized (inputLock) {
|
||||
MediaCodec c = codec;
|
||||
codec = null;
|
||||
if (c != null) {
|
||||
try {
|
||||
c.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try {
|
||||
c.release();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,13 +116,28 @@ public class AudioEncoder implements AudioEncoderSink {
|
||||
private void drainLoop() {
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean configSent = false;
|
||||
while (running) {
|
||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
||||
while (true) {
|
||||
MediaCodec c;
|
||||
synchronized (inputLock) {
|
||||
if (!running) {
|
||||
break;
|
||||
}
|
||||
c = codec;
|
||||
if (c == null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
int index;
|
||||
try {
|
||||
index = c.dequeueOutputBuffer(info, 10_000);
|
||||
} catch (IllegalStateException e) {
|
||||
break;
|
||||
}
|
||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
continue;
|
||||
}
|
||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
MediaFormat fmt = codec.getOutputFormat();
|
||||
MediaFormat fmt = c.getOutputFormat();
|
||||
if (!configSent && callback != null) {
|
||||
ByteBuffer csd = fmt.getByteBuffer("csd-0");
|
||||
callback.onConfig(
|
||||
@@ -135,7 +151,7 @@ public class AudioEncoder implements AudioEncoderSink {
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
ByteBuffer buffer = codec.getOutputBuffer(index);
|
||||
ByteBuffer buffer = c.getOutputBuffer(index);
|
||||
if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
|
||||
byte[] data = new byte[info.size];
|
||||
buffer.position(info.offset);
|
||||
@@ -145,7 +161,11 @@ public class AudioEncoder implements AudioEncoderSink {
|
||||
callback.onEncodedFrame(info.presentationTimeUs, data);
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false);
|
||||
try {
|
||||
c.releaseOutputBuffer(index, false);
|
||||
} catch (IllegalStateException ignored) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
import com.foxx.androidcast.network.CastTransportFactory;
|
||||
import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
|
||||
import com.foxx.androidcast.network.UdpCastTransport;
|
||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
|
||||
@@ -99,7 +101,7 @@ public final class MultiCastCoordinator {
|
||||
session = createSession(peerSettings);
|
||||
connectWithRetry(session, t.host, t.port);
|
||||
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
|
||||
applyNegotiatedProtection(session, peerSettings, hs.settings);
|
||||
applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement);
|
||||
peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
|
||||
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime
|
||||
+ " score="
|
||||
@@ -128,7 +130,7 @@ public final class MultiCastCoordinator {
|
||||
CastSession session = createSession(peerSettings);
|
||||
connectWithRetry(session, target.host, target.port);
|
||||
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
|
||||
applyNegotiatedProtection(session, peerSettings, hs.settings);
|
||||
applyNegotiatedProtection(session, peerSettings, hs.receiverAdvertisement);
|
||||
peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
|
||||
if (keyframeRequester != null) {
|
||||
keyframeRequester.requestKeyframe();
|
||||
@@ -202,8 +204,8 @@ public final class MultiCastCoordinator {
|
||||
return session;
|
||||
}
|
||||
|
||||
private static void applyNegotiatedProtection(CastSession session, CastSettings local,
|
||||
CastSettings remote) {
|
||||
private void applyNegotiatedProtection(CastSession session, CastSettings local,
|
||||
CastSettings receiverAdvertisement) {
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
@@ -215,7 +217,13 @@ public final class MultiCastCoordinator {
|
||||
udp = ((QuicCronetCastTransport) transport).getUdpTransport();
|
||||
}
|
||||
if (udp != null) {
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(udp, local, remote);
|
||||
CastSettings remote = receiverAdvertisement;
|
||||
StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
|
||||
udp, local, remote, false);
|
||||
Log.i(TAG, ProtectionNegotiationPrompts.summarize(appContext, negotiated));
|
||||
if (negotiated.prompt != null) {
|
||||
Log.i(TAG, ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
CastNotifications.createChannels(this);
|
||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||
com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp();
|
||||
projectionThread = new HandlerThread("CastProjection");
|
||||
projectionThread.start();
|
||||
projectionHandler = new Handler(projectionThread.getLooper());
|
||||
@@ -411,6 +412,7 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
|
||||
private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||
SenderPreviewService.stop(this);
|
||||
MediaProjectionManager mgr =
|
||||
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||
projection = mgr.getMediaProjection(resultCode, data);
|
||||
|
||||
@@ -464,10 +464,14 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
if (resultCode == Activity.RESULT_OK && data != null) {
|
||||
screenPreview.setProjectionResult(resultCode, data);
|
||||
previewConsentDeclined = false;
|
||||
findViewById(R.id.panel_preview).post(() -> {
|
||||
refreshPreview();
|
||||
findViewById(R.id.panel_preview).postDelayed(this::refreshPreview, 200);
|
||||
});
|
||||
} else {
|
||||
previewConsentDeclined = true;
|
||||
refreshPreview();
|
||||
}
|
||||
refreshPreview();
|
||||
return;
|
||||
}
|
||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.foxx.androidcast.CastActiveState;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
|
||||
@@ -62,6 +63,10 @@ public final class SenderCapturePreview {
|
||||
public void updateForMode(CastSettings.CaptureMode mode) {
|
||||
activeMode = mode;
|
||||
stopCamera();
|
||||
if (CastActiveState.isSenderCasting()) {
|
||||
showCastingPlaceholder();
|
||||
return;
|
||||
}
|
||||
if (mode == CastSettings.CaptureMode.CAMERA) {
|
||||
useFront = false;
|
||||
startCameraPreview();
|
||||
@@ -71,7 +76,7 @@ public final class SenderCapturePreview {
|
||||
} else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||
showCalibrationPreview();
|
||||
} else if (screenPreview != null && screenPreview.needsProjection(mode)) {
|
||||
if (screenPreview.hasProjection()) {
|
||||
if (screenPreview.hasConsent()) {
|
||||
labelView.setVisibility(android.view.View.GONE);
|
||||
textureView.setVisibility(android.view.View.VISIBLE);
|
||||
screenPreview.attach(textureView, mode);
|
||||
@@ -92,6 +97,16 @@ public final class SenderCapturePreview {
|
||||
labelView.setText(labelView.getContext().getString(textRes));
|
||||
}
|
||||
|
||||
public void showCastingPlaceholder() {
|
||||
if (screenPreview != null) {
|
||||
screenPreview.pausePreview();
|
||||
}
|
||||
stopCamera();
|
||||
textureView.setVisibility(android.view.View.GONE);
|
||||
labelView.setVisibility(android.view.View.VISIBLE);
|
||||
labelView.setText(labelView.getContext().getString(R.string.preview_casting_active));
|
||||
}
|
||||
|
||||
private void showCalibrationPreview() {
|
||||
if (screenPreview != null) {
|
||||
screenPreview.stop();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.foxx.androidcast.sender;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.foxx.androidcast.CastNotifications;
|
||||
|
||||
/**
|
||||
* Foreground service so {@link SenderScreenPreview} can use MediaProjection on Android 10+.
|
||||
* Stopped when screen cast {@link ScreenCastService} starts (that service holds projection FGS).
|
||||
*/
|
||||
public final class SenderPreviewService extends Service {
|
||||
private static volatile boolean running;
|
||||
|
||||
public static boolean isRunning() {
|
||||
return running;
|
||||
}
|
||||
|
||||
public static void start(Context context) {
|
||||
Intent intent = new Intent(context, SenderPreviewService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent);
|
||||
} else {
|
||||
context.startService(intent);
|
||||
}
|
||||
}
|
||||
|
||||
public static void stop(Context context) {
|
||||
context.stopService(new Intent(context, SenderPreviewService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
CastNotifications.createChannels(this);
|
||||
running = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
startForeground(CastNotifications.ID_SENDER + 100,
|
||||
CastNotifications.sender(this, getString(com.foxx.androidcast.R.string.preview_screen_active),
|
||||
SenderPreviewService.class));
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import android.view.Surface;
|
||||
import android.view.TextureView;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.foxx.androidcast.CastActiveState;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
/**
|
||||
@@ -105,6 +106,9 @@ public final class SenderScreenPreview {
|
||||
if (projection != null || resultData == null) {
|
||||
return;
|
||||
}
|
||||
if (!CastActiveState.isSenderCasting() && !SenderPreviewService.isRunning()) {
|
||||
SenderPreviewService.start(appContext);
|
||||
}
|
||||
MediaProjectionManager mgr =
|
||||
appContext.getSystemService(MediaProjectionManager.class);
|
||||
projection = mgr.getMediaProjection(resultCode, resultData);
|
||||
@@ -133,8 +137,14 @@ public final class SenderScreenPreview {
|
||||
DisplayMetrics metrics = new DisplayMetrics();
|
||||
WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
|
||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||
int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 2);
|
||||
int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 2);
|
||||
int viewW = textureView.getWidth();
|
||||
int viewH = textureView.getHeight();
|
||||
if (viewW <= 0 || viewH <= 0) {
|
||||
textureView.post(() -> ensureVirtualDisplay(textureView, mode));
|
||||
return;
|
||||
}
|
||||
int w = Math.max(320, viewW);
|
||||
int h = Math.max(240, viewH);
|
||||
SurfaceTexture st = textureView.getSurfaceTexture();
|
||||
if (st == null) {
|
||||
return;
|
||||
@@ -181,6 +191,9 @@ public final class SenderScreenPreview {
|
||||
}
|
||||
projection = null;
|
||||
}
|
||||
if (!CastActiveState.isSenderCasting()) {
|
||||
SenderPreviewService.stop(appContext);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause preview surface only; keep MediaProjection consent for cast START. */
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
<string name="capture_camera_front">Камера (фронт)</string>
|
||||
<string name="label_mute">Тихо</string>
|
||||
<string name="preview_placeholder">Предпросмотр\n(трансляция начнется после нажатия кнопки)</string>
|
||||
<string name="preview_screen_active">Предпросмотр экрана</string>
|
||||
<string name="preview_casting_active">Трансляция — предпросмотр приостановлен</string>
|
||||
<string name="preview_camera">Предпросмотр с камеры</string>
|
||||
<string name="preview_calibration">Калибрационная таблица</string>
|
||||
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string>
|
||||
|
||||
@@ -133,7 +133,17 @@
|
||||
<string name="receiver_display_auto_fit">AUTO (fits screen)</string>
|
||||
<string name="receiver_display_adaptive">Adaptive (experimental)</string>
|
||||
<string name="label_stream_protection">Stream protection (UDP)</string>
|
||||
<string name="stream_protection_hint">Hint only — negotiated with receiver. UDP transports only.</string>
|
||||
<string name="stream_protection_hint">Sender preference — receiver FEC/NACK is auto-detected at connect. UDP only.</string>
|
||||
<string name="protection_negotiation_role_sender">sender</string>
|
||||
<string name="protection_negotiation_role_receiver">receiver</string>
|
||||
<string name="protection_negotiation_nack_on">NACK on</string>
|
||||
<string name="protection_negotiation_nack_off">NACK off</string>
|
||||
<string name="protection_negotiation_agreed">Stream protection: %1$s (%2$s)</string>
|
||||
<string name="protection_negotiation_use_none">No protection this session (receiver %1$s, sender %2$s). Set %3$s stream protection to None or match the peer.</string>
|
||||
<string name="protection_negotiation_mismatch">Protection mismatch (receiver %1$s, sender %2$s). Casting without protection; align %3$s settings.</string>
|
||||
<string name="protection_negotiation_fec_family_rs">Using Reed–Solomon FEC (receiver %1$s, sender %2$s). For a direct match, set %3$s to FEC (Reed–Solomon).</string>
|
||||
<string name="protection_negotiation_fec34_no_nack">Using FEC 3/4 without NACK (receiver %1$s, sender %2$s). NACK disabled for this session.</string>
|
||||
<string name="protection_negotiation_nack_to_fec">Using FEC with NACK (receiver %1$s, sender %2$s).</string>
|
||||
<string name="protection_fec_34">FEC 3/4</string>
|
||||
<string name="protection_fec_rs">FEC (Reed-Solomon)</string>
|
||||
<string name="protection_nack">NACK</string>
|
||||
@@ -149,6 +159,8 @@
|
||||
<string name="capture_camera_front">Camera (front)</string>
|
||||
<string name="label_mute">Mute</string>
|
||||
<string name="preview_placeholder">Source preview\n(screen capture starts after START)</string>
|
||||
<string name="preview_screen_active">Screen preview</string>
|
||||
<string name="preview_casting_active">Casting — preview paused</string>
|
||||
<string name="preview_camera">Camera preview</string>
|
||||
<string name="preview_calibration">Calibration test pattern</string>
|
||||
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.foxx.androidcast.diagnostics;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class CastMtuSamplerTest {
|
||||
@Test
|
||||
public void calculate_wifiMtu1500() {
|
||||
CastMtuSampler.Info info = CastMtuSampler.calculate(1500, "wlan0");
|
||||
assertEquals(1500, info.linkMtu);
|
||||
assertEquals(CastConfig.UDP_CHUNK_SIZE, info.castDatagramBytes);
|
||||
assertEquals(CastConfig.UDP_CHUNK_SIZE - CastMtuSampler.ACUD_HEADER_BYTES,
|
||||
info.castFragPayloadBytes);
|
||||
assertEquals(CastConfig.UDP_CHUNK_SIZE + 28, info.ipv4WireBytes);
|
||||
assertEquals(1500 - info.ipv4WireBytes, info.headroomBytes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void calculate_unknownLink_noHeadroom() {
|
||||
CastMtuSampler.Info info = CastMtuSampler.calculate(0, "");
|
||||
assertEquals(-1, info.headroomBytes);
|
||||
assertEquals(1228, info.ipv4WireBytes);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionFactory;
|
||||
|
||||
import com.foxx.androidcast.network.CastFramingContext;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
@@ -24,8 +26,8 @@ public class UdpCastTransportFecTest {
|
||||
receiver.prepareListen(portB);
|
||||
sender.connect("127.0.0.1", portB);
|
||||
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
|
||||
|
||||
byte[] payload = new byte[900];
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
@@ -58,6 +60,54 @@ public class UdpCastTransportFecTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fecShardGroup_wireFramedLargeKeyframe_delivers() throws Exception {
|
||||
int portB = 38000 + (int) (System.nanoTime() % 2000);
|
||||
UdpCastTransport sender = new UdpCastTransport();
|
||||
UdpCastTransport receiver = new UdpCastTransport();
|
||||
CastFramingContext framing = new CastFramingContext(42, 1, 0, 0);
|
||||
try {
|
||||
receiver.prepareListen(portB);
|
||||
sender.connect("127.0.0.1", portB);
|
||||
sender.setFramingContext(framing);
|
||||
receiver.setFramingContext(framing);
|
||||
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
|
||||
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
|
||||
|
||||
byte[] video = new byte[171_077];
|
||||
for (int i = 0; i < video.length; i++) {
|
||||
video[i] = (byte) (i & 0xFF);
|
||||
}
|
||||
byte[] videoMsg = CastProtocol.videoFramePayload(0, true, video);
|
||||
|
||||
Thread t = new Thread(() -> {
|
||||
try {
|
||||
sender.send(CastProtocol.MSG_VIDEO_FRAME, videoMsg);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
t.start();
|
||||
|
||||
AtomicReference<byte[]> received = new AtomicReference<>();
|
||||
long deadline = System.currentTimeMillis() + 15_000;
|
||||
while (System.currentTimeMillis() < deadline && received.get() == null) {
|
||||
CastProtocol.Message msg = receiver.receive(500);
|
||||
if (msg != null && msg.type == CastProtocol.MSG_VIDEO_FRAME) {
|
||||
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
||||
received.set(vf.data);
|
||||
}
|
||||
}
|
||||
t.join(3000);
|
||||
assertNotNull(received.get());
|
||||
assertArrayEquals(video, received.get());
|
||||
} finally {
|
||||
sender.close();
|
||||
receiver.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static CastSettings settingsWith(CastSettings.StreamProtection protection) {
|
||||
CastSettings s = new CastSettings();
|
||||
s.setTransport(CastConfig.TRANSPORT_UDP);
|
||||
|
||||
@@ -4,6 +4,7 @@ import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||
@@ -12,6 +13,20 @@ import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
||||
import org.junit.Test;
|
||||
|
||||
public class FecProtectionEngineShardTest {
|
||||
@Test
|
||||
public void largePayload_usesMoreSmallerShards() throws Exception {
|
||||
TransportLossStats stats = new TransportLossStats();
|
||||
FecProtectionEngine fec = new FecProtectionEngine(
|
||||
ProtectionEnvelope.MODE_FEC_34, 4, 3, stats);
|
||||
byte[] payload = new byte[180_000];
|
||||
FecEncodedGroup group = fec.encodeGroup(payload);
|
||||
assertEquals(16, group.dataShards);
|
||||
assertEquals(8, group.parityShards);
|
||||
assertEquals(24, group.totalShards());
|
||||
int shardBytes = group.shards[0].length;
|
||||
assertTrue(shardBytes < 16_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodeAndDecodeShards_viaEngine() throws Exception {
|
||||
TransportLossStats stats = new TransportLossStats();
|
||||
|
||||
@@ -27,6 +27,23 @@ public class StreamProtectionFactoryTest {
|
||||
StreamProtectionEngine engine = StreamProtectionFactory.create(
|
||||
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats());
|
||||
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode());
|
||||
assertTrue(engine instanceof CombinedProtectionEngine);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fec34_withoutNack_isFecOnly() {
|
||||
StreamProtectionEngine engine = StreamProtectionFactory.create(
|
||||
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats(), false);
|
||||
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode());
|
||||
assertTrue(engine instanceof FecProtectionEngine);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fecRs_withNack_isCombined() {
|
||||
StreamProtectionEngine engine = StreamProtectionFactory.create(
|
||||
CastSettings.StreamProtection.FEC_REED_SOLOMON, new TransportLossStats(), true);
|
||||
assertEquals(StreamProtectionMode.FEC_REED_SOLOMON, engine.mode());
|
||||
assertTrue(engine instanceof CombinedProtectionEngine);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.foxx.androidcast.network.transport;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
|
||||
@@ -14,18 +18,93 @@ public class StreamProtectionNegotiatorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mismatchFallsBackToNone() {
|
||||
assertEquals(CastSettings.StreamProtection.NONE,
|
||||
StreamProtectionNegotiator.resolve(
|
||||
CastSettings.StreamProtection.FEC_NACK,
|
||||
CastSettings.StreamProtection.NACK));
|
||||
public void bothFec34_agreesWithNack() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
assertNull(n.prompt);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingModesPreserved() {
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4,
|
||||
StreamProtectionNegotiator.resolve(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.FEC_3_4));
|
||||
public void senderFec34_receiverAuto_usesFec34() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void receiverSide_senderFec34_usesFec34() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
true);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void senderNone_receiverAuto_noProtectionWithPrompt() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.NONE,
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.NONE, n.agreed);
|
||||
assertNotNull(n.prompt);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void senderFec34_receiverRsAdvert_usesRs() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.FEC_REED_SOLOMON,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_REED_SOLOMON, n.agreed);
|
||||
assertNotNull(n.prompt);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void senderFec34_receiverAuto_usesFec34WithNack() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void senderFec34_receiverAdvertFecNack_usesFec34WithoutNack() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_3_4,
|
||||
CastSettings.StreamProtection.FEC_NACK,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||
assertFalse(n.nackEnabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void senderFecNack_receiverAuto_keepsFecNack() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_NACK,
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_NACK, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fecNackWithNackOnly_usesFecNack() {
|
||||
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||
CastSettings.StreamProtection.FEC_NACK,
|
||||
CastSettings.StreamProtection.AUTO,
|
||||
false);
|
||||
assertEquals(CastSettings.StreamProtection.FEC_NACK, n.agreed);
|
||||
assertTrue(n.nackEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,31 @@ public class FecShardReassemblyTest {
|
||||
assertArrayEquals(payload, recovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vp9SizedPayload_recoversAllShards() throws Exception {
|
||||
byte[] payload = new byte[54_000];
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
payload[i] = (byte) (i & 0xFF);
|
||||
}
|
||||
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
|
||||
byte[][] shards = fec.encode(payload);
|
||||
FecShardReassembly asm = null;
|
||||
for (int i = 0; i < shards.length; i++) {
|
||||
byte[] wire = FecShardWire.encodeShard(
|
||||
ProtectionEnvelope.MODE_FEC_34, 42, 900, i, shards.length,
|
||||
payload.length, 4, 3, shards[i]);
|
||||
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
|
||||
assertNotNull(p);
|
||||
if (asm == null) {
|
||||
asm = new FecShardReassembly(p);
|
||||
}
|
||||
assertTrue(asm.addShard(p));
|
||||
}
|
||||
byte[] recovered = asm.tryDecode();
|
||||
assertNotNull(recovered);
|
||||
assertArrayEquals(payload, recovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void insufficientShards_returnsNull() throws Exception {
|
||||
byte[] payload = new byte[] {1, 2, 3, 4, 5};
|
||||
|
||||
@@ -5,9 +5,52 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class FecShardWireTest {
|
||||
@Test
|
||||
public void largeShard_roundTrip() throws Exception {
|
||||
byte[] payload = new byte[54_000];
|
||||
for (int i = 0; i < payload.length; i++) {
|
||||
payload[i] = (byte) (i & 0xFF);
|
||||
}
|
||||
ReedSolomonFec fec = new ReedSolomonFec(4, 3);
|
||||
byte[][] shards = fec.encode(payload);
|
||||
byte[] shard = shards[0];
|
||||
assertTrue(shard.length > 255);
|
||||
byte[] wire = FecShardWire.encodeShard(
|
||||
ProtectionEnvelope.MODE_FEC_34, 99, 1000, 0, shards.length,
|
||||
payload.length, 4, 3, shard);
|
||||
FecShardWire.Parsed p = FecShardWire.tryParse(wire);
|
||||
assertNotNull(p);
|
||||
assertArrayEquals(shard, p.shardData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void v2SmallShard_stillParses() throws Exception {
|
||||
byte[] shard = new byte[200];
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
DataOutputStream dos = new DataOutputStream(bos);
|
||||
dos.write(new byte[] {'A', 'C', 'P', 'F'});
|
||||
dos.writeByte(FecShardWire.VERSION_SHARD_V2);
|
||||
dos.writeByte(ProtectionEnvelope.MODE_FEC_34);
|
||||
dos.writeInt(1);
|
||||
dos.writeInt(10);
|
||||
dos.writeByte(0);
|
||||
dos.writeByte(7);
|
||||
dos.writeInt(800);
|
||||
dos.writeByte(4);
|
||||
dos.writeByte(3);
|
||||
dos.writeByte(shard.length);
|
||||
dos.write(shard);
|
||||
FecShardWire.Parsed p = FecShardWire.tryParse(bos.toByteArray());
|
||||
assertNotNull(p);
|
||||
assertArrayEquals(shard, p.shardData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodeParse_roundTrip() throws Exception {
|
||||
byte[] shard = new byte[64];
|
||||
|
||||
@@ -8,7 +8,7 @@ The app loads `libandroidcast_codecs.so` for availability probes and future soft
|
||||
|-----------|------|--------|-------|
|
||||
| FEC 3/4 + RS | Yes | — | v2 **per-shard** on wire (`FecShardWire`); v1 monolithic decode still accepted |
|
||||
| NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled |
|
||||
| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx or MediaCodec fallback |
|
||||
| libvpx VP8/VP9 | Native | Encode + decode | Screen cast via ImageReader; receiver libvpx (decoder uses `VPX_CODEC_USE_ERROR_CONCEALMENT` when supported) or MediaCodec fallback |
|
||||
| Opus / Speex | Stub | Probe only | Submodules under `third-party/` |
|
||||
|
||||
## Enable libvpx (developer)
|
||||
|
||||
@@ -342,9 +342,15 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCrea
|
||||
return 0L;
|
||||
}
|
||||
ctx->magic = VPX_DEC_MAGIC;
|
||||
vpx_codec_iface_t *iface = pick_decoder_iface(mime_utf);
|
||||
vpx_codec_dec_cfg_t cfg = {0};
|
||||
cfg.threads = 1;
|
||||
if (vpx_codec_dec_init(&ctx->codec, pick_decoder_iface(mime_utf), &cfg, 0) != VPX_CODEC_OK) {
|
||||
/* vpxdec.c: dec_flags |= VPX_CODEC_USE_ERROR_CONCEALMENT when --error-concealment */
|
||||
int dec_flags = 0;
|
||||
if (vpx_codec_get_caps(iface) & VPX_CODEC_CAP_ERROR_CONCEALMENT) {
|
||||
dec_flags |= VPX_CODEC_USE_ERROR_CONCEALMENT;
|
||||
}
|
||||
if (vpx_codec_dec_init(&ctx->codec, iface, &cfg, dec_flags) != VPX_CODEC_OK) {
|
||||
free(ctx);
|
||||
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
|
||||
return 0L;
|
||||
@@ -381,9 +387,8 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecodeFrame
|
||||
vpx_codec_err_t err = vpx_codec_decode(&ctx->codec, (const uint8_t *) bytes, (unsigned int) len,
|
||||
NULL, 0);
|
||||
(*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT);
|
||||
if (err != VPX_CODEC_OK) {
|
||||
return JNI_FALSE;
|
||||
}
|
||||
/* With error concealment, vpx_codec_decode may warn yet still output a frame (see vpxdec.c). */
|
||||
(void) err;
|
||||
|
||||
ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
|
||||
if (window == NULL) {
|
||||
|
||||
@@ -225,7 +225,7 @@ cp -f "$LIBVPX_A" "$OUT/libvpx.a"
|
||||
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
|
||||
|
||||
echo ""
|
||||
echo "Built: $PROJECT_OUT/libvpx.a"
|
||||
echo "Built: $PROJECT_OUT/libvpx.a (size $(ls -lah "${PROJECT_OUT}/libvpx.a" | awk '{print $5}'))"
|
||||
if [[ -n "$STAGING" ]]; then
|
||||
echo "(staged under $STAGING because of spaces in repo path)"
|
||||
fi
|
||||
|
||||
41
scripts/capture-vp9-fec-logs.sh
Executable file
41
scripts/capture-vp9-fec-logs.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture VP9+FEC cast logs from sender (.106) and receiver (.39).
|
||||
# Usage: ./scripts/capture-vp9-fec-logs.sh [label]
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CAP="$ROOT/.log_capture"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
LABEL="${1:-fec}"
|
||||
mkdir -p "$CAP"
|
||||
|
||||
SENDER="192.168.33.106:5555"
|
||||
RECEIVER="192.168.33.39:5555"
|
||||
|
||||
adb connect "$SENDER" >/dev/null 2>&1 || true
|
||||
adb connect "$RECEIVER" >/dev/null 2>&1 || true
|
||||
|
||||
FILTER='FEC|Fec|fec|UdpCast|StreamProtection|Protection|reasm|ReedSolomon|shard|ScreenCast|Libvpx|ReceiverCast|ReceiverSession|VideoDecoder|NativeCodec|CodecPriority|CastFanout|CastSendPump|NetworkFeedback|transportLoss|decode|keyframe|awaiting|AudioDecoder|drop'
|
||||
|
||||
echo "==> Clearing logcat buffers"
|
||||
adb -s "$SENDER" logcat -c 2>/dev/null || true
|
||||
adb -s "$RECEIVER" logcat -c 2>/dev/null || true
|
||||
|
||||
SPID=$(adb -s "$SENDER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
|
||||
RPID=$(adb -s "$RECEIVER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
|
||||
echo "==> PIDs sender=$SPID receiver=$RPID"
|
||||
|
||||
echo "==> Recording 90s (start cast now)..."
|
||||
timeout 90 adb -s "$SENDER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_sender_106_${TS}.log" &
|
||||
PID_S=$!
|
||||
timeout 90 adb -s "$RECEIVER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_receiver_39_${TS}.log" &
|
||||
PID_R=$!
|
||||
wait "$PID_S" "$PID_R" 2>/dev/null || true
|
||||
|
||||
# Snapshot dumps after live capture
|
||||
adb -s "$SENDER" logcat -d --pid="$SPID" -t 8000 > "$CAP/${LABEL}_sender_pid_${TS}.log" 2>&1 || true
|
||||
adb -s "$RECEIVER" logcat -d --pid="$RPID" -t 8000 > "$CAP/${LABEL}_receiver_pid_${TS}.log" 2>&1 || true
|
||||
adb -s "$SENDER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_sender_crash_${TS}.log" || true
|
||||
adb -s "$RECEIVER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_receiver_crash_${TS}.log" || true
|
||||
|
||||
echo "==> Saved under $CAP/${LABEL}_*_${TS}.log"
|
||||
wc -c "$CAP"/${LABEL}_*_${TS}.log 2>/dev/null | tail -6
|
||||
Reference in New Issue
Block a user