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('\"', '\\\\\"')}\""
|
buildConfigField 'String', 'BUILD_TIME_DISPLAY', "\"${buildTimeDisplay.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\""
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
def cmakeArgs = ["-DANDROID_STL=c++_shared"]
|
arguments "-DANDROID_STL=c++_shared"
|
||||||
if (resolveCcachePath) {
|
if (resolveCcachePath) {
|
||||||
cmakeArgs += "-DANDROID_CCACHE=${resolveCcachePath}"
|
arguments "-DANDROID_CCACHE=${resolveCcachePath}"
|
||||||
}
|
}
|
||||||
arguments cmakeArgs
|
|
||||||
cppFlags ""
|
cppFlags ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,11 @@
|
|||||||
android:stopWithTask="false"
|
android:stopWithTask="false"
|
||||||
android:foregroundServiceType="mediaProjection|microphone" />
|
android:foregroundServiceType="mediaProjection|microphone" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".sender.SenderPreviewService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".receiver.ReceiverCastService"
|
android:name=".receiver.ReceiverCastService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -158,9 +158,14 @@ public final class CastSettingsBinder {
|
|||||||
};
|
};
|
||||||
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
||||||
settings.setTransport(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) {
|
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) {
|
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();
|
CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values();
|
||||||
String[] labels = {
|
String[] labels = {
|
||||||
activity.getString(R.string.option_auto),
|
activity.getString(R.string.option_auto),
|
||||||
@@ -422,7 +436,23 @@ public final class CastSettingsBinder {
|
|||||||
}
|
}
|
||||||
SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current,
|
SettingsEnumSpinner.bind(spinner, modes, labels, selectable, current,
|
||||||
settings::setStreamProtection);
|
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) {
|
private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) {
|
||||||
@@ -430,14 +460,16 @@ public final class CastSettingsBinder {
|
|||||||
settings::setStreamProtection);
|
settings::setStreamProtection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) {
|
private static void updateStreamProtectionSpinnerEnabled(AppCompatActivity activity,
|
||||||
Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection);
|
CastSettings settings, int spinnerId, int hintId, int transportSpinnerId) {
|
||||||
|
Spinner protection = activity.findViewById(spinnerId);
|
||||||
if (protection == null) {
|
if (protection == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
readTransport(activity, settings, transportSpinnerId);
|
||||||
boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport());
|
boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport());
|
||||||
protection.setEnabled(udp);
|
protection.setEnabled(udp);
|
||||||
TextView hint = activity.findViewById(R.id.text_stream_protection_hint);
|
TextView hint = activity.findViewById(hintId);
|
||||||
if (hint != null) {
|
if (hint != null) {
|
||||||
hint.setVisibility(udp ? View.VISIBLE : View.GONE);
|
hint.setVisibility(udp ? View.VISIBLE : View.GONE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ public final class PictureInPictureHelper {
|
|||||||
return true;
|
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()
|
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(aspectRatio(videoWidth, videoHeight));
|
.setAspectRatio(aspectRatio(videoWidth, videoHeight));
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(true);
|
boolean allowed = isAllowedForApp(context);
|
||||||
builder.setSeamlessResizeEnabled(true);
|
builder.setAutoEnterEnabled(allowed);
|
||||||
|
builder.setSeamlessResizeEnabled(allowed);
|
||||||
}
|
}
|
||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
@@ -59,7 +60,7 @@ public final class PictureInPictureHelper {
|
|||||||
if (!isSupported()) {
|
if (!isSupported()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activity.setPictureInPictureParams(buildParams(videoWidth, videoHeight));
|
activity.setPictureInPictureParams(buildParams(activity, videoWidth, videoHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) {
|
public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) {
|
||||||
@@ -67,7 +68,7 @@ public final class PictureInPictureHelper {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return activity.enterPictureInPictureMode(buildParams(videoWidth, videoHeight));
|
return activity.enterPictureInPictureMode(buildParams(activity, videoWidth, videoHeight));
|
||||||
} catch (IllegalStateException e) {
|
} catch (IllegalStateException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ public final class CastDiagnosticsFormatter {
|
|||||||
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
|
public static String formatHtml(StreamMetrics metrics, String transport, String videoCodec,
|
||||||
int streamW, int streamH, int renderW, int renderH, boolean idle,
|
int streamW, int streamH, int renderW, int renderH, boolean idle,
|
||||||
CastSettings localSettings, CastSettings remoteSettings,
|
CastSettings localSettings, CastSettings remoteSettings,
|
||||||
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent) {
|
NetworkStatsSnapshot peer, NetworkStatsSnapshot lastLocal, int viewZoomPercent,
|
||||||
|
CastMtuSampler.Info mtuInfo) {
|
||||||
float inFps = metrics.getInFps();
|
float inFps = metrics.getInFps();
|
||||||
float renderFps = metrics.getRenderFps();
|
float renderFps = metrics.getRenderFps();
|
||||||
int bwInKbps = metrics.getBwInKbps();
|
int bwInKbps = metrics.getBwInKbps();
|
||||||
@@ -103,6 +104,7 @@ public final class CastDiagnosticsFormatter {
|
|||||||
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
|
html.append("<font color='").append(COLOR_DIM).append("'>Transport: ")
|
||||||
.append(escape(transport != null ? transport.toUpperCase() : "?"))
|
.append(escape(transport != null ? transport.toUpperCase() : "?"))
|
||||||
.append("</font><br/>");
|
.append("</font><br/>");
|
||||||
|
appendMtuSection(html, mtuInfo);
|
||||||
|
|
||||||
if (videoCodec != null && !videoCodec.isEmpty()) {
|
if (videoCodec != null && !videoCodec.isEmpty()) {
|
||||||
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
|
html.append("Codec: <font color='").append(COLOR_OK).append("'>")
|
||||||
@@ -251,6 +253,12 @@ public final class CastDiagnosticsFormatter {
|
|||||||
html.append("<br/>");
|
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) {
|
for (String hint : hints) {
|
||||||
html.append("<font color='").append(COLOR_WARN).append("'>")
|
html.append("<font color='").append(COLOR_WARN).append("'>")
|
||||||
.append(escape(hint)).append("</font><br/>");
|
.append(escape(hint)).append("</font><br/>");
|
||||||
@@ -270,6 +278,29 @@ public final class CastDiagnosticsFormatter {
|
|||||||
return html.toString();
|
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,
|
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||||
NetworkStatsSnapshot local) {
|
NetworkStatsSnapshot local) {
|
||||||
long rx = pick(peer, local, true, false);
|
long rx = pick(peer, local, true, false);
|
||||||
@@ -314,7 +345,7 @@ public final class CastDiagnosticsFormatter {
|
|||||||
html.append(' ').append(colored("aud-dec " + aDecErr, true));
|
html.append(' ').append(colored("aud-dec " + aDecErr, true));
|
||||||
}
|
}
|
||||||
if (aDrop > 0) {
|
if (aDrop > 0) {
|
||||||
html.append(' ').append(colored("aud-q " + aDrop, true));
|
html.append(' ').append(colored("aud-drop " + aDrop, true));
|
||||||
}
|
}
|
||||||
if (dropAudio > 0) {
|
if (dropAudio > 0) {
|
||||||
html.append(' ').append(colored("snd-aud " + dropAudio, true));
|
html.append(' ').append(colored("snd-aud " + dropAudio, true));
|
||||||
@@ -364,9 +395,15 @@ public final class CastDiagnosticsFormatter {
|
|||||||
html.append(" · ").append(escape(summary));
|
html.append(" · ").append(escape(summary));
|
||||||
}
|
}
|
||||||
long fecPkts = val(peer, local, p -> p.fecStubPackets, l -> l.fecStubPackets);
|
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);
|
long nackReq = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
|
||||||
if (fecPkts > 0) {
|
if (fecPkts > 0 || fecOk > 0 || fecFail > 0) {
|
||||||
html.append("<br/>FEC packets=").append(fecPkts);
|
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) {
|
if (nackReq > 0) {
|
||||||
html.append("<br/>NACK events=").append(nackReq);
|
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;
|
public static final byte MSG_CODEC_SELECTED = 15;
|
||||||
/** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
|
/** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
|
||||||
public static final byte MSG_NACK = 16;
|
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. */
|
/** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
|
||||||
public static final byte MSG_WIRE_PACKET = 127;
|
public static final byte MSG_WIRE_PACKET = 127;
|
||||||
|
|
||||||
@@ -268,7 +270,7 @@ public final class CastProtocol {
|
|||||||
return local.getTransport().equals(remote.getTransport());
|
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 {
|
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
@@ -318,6 +320,10 @@ public final class CastProtocol {
|
|||||||
dos.writeLong(s.fecStubPackets);
|
dos.writeLong(s.fecStubPackets);
|
||||||
dos.writeLong(s.nackStubRequests);
|
dos.writeLong(s.nackStubRequests);
|
||||||
}
|
}
|
||||||
|
if (NETWORK_STATS_VERSION >= 5) {
|
||||||
|
dos.writeLong(s.fecPacketsDecoded);
|
||||||
|
dos.writeLong(s.fecDecodeFailures);
|
||||||
|
}
|
||||||
dos.flush();
|
dos.flush();
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
@@ -372,6 +378,10 @@ public final class CastProtocol {
|
|||||||
s.fecStubPackets = dis.readLong();
|
s.fecStubPackets = dis.readLong();
|
||||||
s.nackStubRequests = dis.readLong();
|
s.nackStubRequests = dis.readLong();
|
||||||
}
|
}
|
||||||
|
if (ver >= 5 && dis.available() > 0) {
|
||||||
|
s.fecPacketsDecoded = dis.readLong();
|
||||||
|
s.fecDecodeFailures = dis.readLong();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ import java.util.List;
|
|||||||
public class CastSession {
|
public class CastSession {
|
||||||
public static final class HandshakeResult {
|
public static final class HandshakeResult {
|
||||||
public final String senderName;
|
public final String senderName;
|
||||||
|
/** Sender cast settings from handshake. */
|
||||||
public final CastSettings settings;
|
public final CastSettings settings;
|
||||||
|
/** Receiver advertisement ({@code AUTO} = auto-detected FEC capability). */
|
||||||
|
public final CastSettings receiverAdvertisement;
|
||||||
public final String negotiatedVideoMime;
|
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.senderName = senderName;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.receiverAdvertisement = receiverAdvertisement;
|
||||||
this.negotiatedVideoMime = negotiatedVideoMime;
|
this.negotiatedVideoMime = negotiatedVideoMime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,13 +65,16 @@ public class CastSession {
|
|||||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
||||||
|
|
||||||
String negotiated = null;
|
String negotiated = null;
|
||||||
|
CastSettings receiverAdvertisement = null;
|
||||||
long deadline = System.currentTimeMillis() + 15_000;
|
long deadline = System.currentTimeMillis() + 15_000;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
CastProtocol.Message msg = transport.receive(2_000);
|
CastProtocol.Message msg = transport.receive(2_000);
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
continue;
|
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);
|
negotiated = CastProtocol.parseCodecSelected(msg.payload);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -76,7 +84,7 @@ public class CastSession {
|
|||||||
}
|
}
|
||||||
settings.setNegotiatedVideoMime(negotiated);
|
settings.setNegotiatedVideoMime(negotiated);
|
||||||
CodecPriorityCatalog.logReceivedCodec("sender", negotiated, settings);
|
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 {
|
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
|
||||||
@@ -117,13 +125,20 @@ public class CastSession {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (authed && remote != null && senderCaps != null) {
|
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();
|
List<String> receiverCaps = CodecCatalog.localDecoderMimes();
|
||||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
|
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
|
||||||
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
||||||
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
||||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
||||||
remote.setNegotiatedVideoMime(mime);
|
remote.setNegotiatedVideoMime(mime);
|
||||||
return new HandshakeResult(senderName, remote, mime);
|
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new IOException("Handshake timeout");
|
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,
|
* Apply protection after handshake using negotiated local/remote preferences.
|
||||||
CastSettings remote) {
|
*
|
||||||
|
* @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) {
|
if (udp == null || local == null) {
|
||||||
return;
|
return com.foxx.androidcast.network.transport.StreamProtectionNegotiation.none();
|
||||||
}
|
}
|
||||||
CastSettings.StreamProtection mode = com.foxx.androidcast.network.transport.StreamProtectionNegotiator
|
com.foxx.androidcast.network.transport.StreamProtectionNegotiation negotiated =
|
||||||
.resolve(local.getStreamProtection(), remote != null ? remote.getStreamProtection() : null);
|
com.foxx.androidcast.network.transport.StreamProtectionNegotiator.negotiate(
|
||||||
|
local.getStreamProtection(),
|
||||||
|
remote != null ? remote.getStreamProtection() : null,
|
||||||
|
localIsReceiver);
|
||||||
CastSettings effective = new CastSettings();
|
CastSettings effective = new CastSettings();
|
||||||
effective.copyFrom(local);
|
effective.copyFrom(local);
|
||||||
effective.setStreamProtection(mode);
|
effective.setStreamProtection(negotiated.agreed);
|
||||||
applyUdpProtection(udp, effective);
|
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;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
|
import com.foxx.androidcast.network.transport.CombinedProtectionEngine;
|
||||||
import com.foxx.androidcast.network.transport.FecProtectionEngine;
|
import com.foxx.androidcast.network.transport.FecProtectionEngine;
|
||||||
@@ -27,6 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
/** Best-effort message transport over UDP with fragmentation. */
|
/** Best-effort message transport over UDP with fragmentation. */
|
||||||
public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||||
|
private static final String TAG = "UdpCastTransport";
|
||||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
||||||
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
||||||
private static final int HEADER = 17;
|
private static final int HEADER = 17;
|
||||||
@@ -45,7 +48,12 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
private final RetransmitCache retransmitCache = new RetransmitCache();
|
private final RetransmitCache retransmitCache = new RetransmitCache();
|
||||||
private CastFramingContext framingContext;
|
private CastFramingContext framingContext;
|
||||||
private CastSessionGate inboundSessionGate;
|
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() {
|
public boolean isListening() {
|
||||||
return listening && socket != null && !socket.isClosed();
|
return listening && socket != null && !socket.isClosed();
|
||||||
@@ -81,8 +89,8 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
}
|
}
|
||||||
socket = new DatagramSocket(null);
|
socket = new DatagramSocket(null);
|
||||||
socket.setReuseAddress(true);
|
socket.setReuseAddress(true);
|
||||||
socket.setReceiveBufferSize(512 * 1024);
|
socket.setReceiveBufferSize(2 * 1024 * 1024);
|
||||||
socket.setSendBufferSize(512 * 1024);
|
socket.setSendBufferSize(2 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -142,9 +150,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
sendOnce(type, body, false);
|
sendOnce(type, body, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void retransmitCached(byte wireType, byte[] body) {
|
void retransmitCached(byte wireType, byte[] body, int messageId) {
|
||||||
try {
|
try {
|
||||||
sendOnce(wireType, body, false);
|
sendOnceWithId(wireType, body, false, messageId);
|
||||||
} catch (IOException ignored) {
|
} catch (IOException ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,12 +164,15 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.nackCacheMisses++;
|
lossStats.nackCacheMisses++;
|
||||||
}
|
}
|
||||||
|
if ((++reassemblyExpireLogCount % REASSEMBLY_LOG_EVERY) == 1) {
|
||||||
|
Log.w(TAG, "NACK cache miss id=" + messageId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.nackRetransmits++;
|
lossStats.nackRetransmits++;
|
||||||
}
|
}
|
||||||
retransmitCached(entry.wireType, entry.body);
|
retransmitCached(entry.wireType, entry.body, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -199,6 +210,17 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
for (int i = 0; i < group.totalShards(); i++) {
|
for (int i = 0; i < group.totalShards(); i++) {
|
||||||
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
|
byte[] shardWire = fecEngine.encodeShardWire(group, baseId, i);
|
||||||
sendOnceWithId(wireType, shardWire, cache, 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 chunkSize = CastConfig.UDP_CHUNK_SIZE - HEADER;
|
||||||
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
int fragments = Math.max(1, (body.length + chunkSize - 1) / chunkSize);
|
||||||
|
boolean paceFragments = fragments > 24;
|
||||||
for (int i = 0; i < fragments; i++) {
|
for (int i = 0; i < fragments; i++) {
|
||||||
int offset = i * chunkSize;
|
int offset = i * chunkSize;
|
||||||
int len = Math.min(chunkSize, body.length - offset);
|
int len = Math.min(chunkSize, body.length - offset);
|
||||||
@@ -222,6 +245,9 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.datagramsSent++;
|
lossStats.datagramsSent++;
|
||||||
}
|
}
|
||||||
|
if (paceFragments && i + 1 < fragments && (i & 7) == 7) {
|
||||||
|
paceBetweenLargeShards();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,30 +285,55 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
private void expireStaleFecGroups() {
|
private void expireStaleFecGroups() {
|
||||||
if (fecEngine == null || nackEngine == null) {
|
if (fecEngine == null || nackEngine == null) {
|
||||||
if (fecEngine != null) {
|
if (fecEngine != null) {
|
||||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, null);
|
fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fecEngine.expireStaleGroups(REASSEMBLY_TIMEOUT_MS, nackEngine::requestNack);
|
fecEngine.expireStaleGroups(FEC_GROUP_TIMEOUT_MS, nackEngine::requestNack);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void expireStaleReassembly() {
|
private void expireStaleReassembly() {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
||||||
Reassembly r = e.getValue();
|
Reassembly r = e.getValue();
|
||||||
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) {
|
if (r != null && now > r.deadlineMs) {
|
||||||
pending.remove(e.getKey());
|
pending.remove(e.getKey());
|
||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.reassemblyIncomplete++;
|
lossStats.reassemblyIncomplete++;
|
||||||
}
|
}
|
||||||
|
maybeLogReassemblyExpired(e.getKey(), r);
|
||||||
int messageId = e.getKey();
|
int messageId = e.getKey();
|
||||||
if (nackEngine != null) {
|
if (nackEngine != null) {
|
||||||
nackEngine.requestNack(messageId);
|
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 {
|
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
||||||
if (length < HEADER) {
|
if (length < HEADER) {
|
||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
@@ -495,6 +546,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
final int fragCount;
|
final int fragCount;
|
||||||
final int totalLen;
|
final int totalLen;
|
||||||
final long startedMs;
|
final long startedMs;
|
||||||
|
final long deadlineMs;
|
||||||
final byte[][] parts;
|
final byte[][] parts;
|
||||||
final int[] sizes;
|
final int[] sizes;
|
||||||
int received;
|
int received;
|
||||||
@@ -503,6 +555,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
this.fragCount = fragCount;
|
this.fragCount = fragCount;
|
||||||
this.totalLen = totalLen;
|
this.totalLen = totalLen;
|
||||||
this.startedMs = System.currentTimeMillis();
|
this.startedMs = System.currentTimeMillis();
|
||||||
|
this.deadlineMs = reassemblyDeadlineMs(fragCount, totalLen);
|
||||||
this.parts = new byte[fragCount][];
|
this.parts = new byte[fragCount][];
|
||||||
this.sizes = new int[fragCount];
|
this.sizes = new int[fragCount];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public class NetworkStatsSnapshot {
|
|||||||
public long recvDamagedVideoFrames;
|
public long recvDamagedVideoFrames;
|
||||||
public String protectionMode = "NONE";
|
public String protectionMode = "NONE";
|
||||||
public long fecStubPackets;
|
public long fecStubPackets;
|
||||||
|
public long fecPacketsDecoded;
|
||||||
|
public long fecDecodeFailures;
|
||||||
public long nackStubRequests;
|
public long nackStubRequests;
|
||||||
|
|
||||||
public NetworkStatsSnapshot copy() {
|
public NetworkStatsSnapshot copy() {
|
||||||
@@ -90,6 +92,8 @@ public class NetworkStatsSnapshot {
|
|||||||
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||||
c.protectionMode = protectionMode;
|
c.protectionMode = protectionMode;
|
||||||
c.fecStubPackets = fecStubPackets;
|
c.fecStubPackets = fecStubPackets;
|
||||||
|
c.fecPacketsDecoded = fecPacketsDecoded;
|
||||||
|
c.fecDecodeFailures = fecDecodeFailures;
|
||||||
c.nackStubRequests = nackStubRequests;
|
c.nackStubRequests = nackStubRequests;
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ public final class CombinedProtectionEngine implements StreamProtectionEngine {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StreamProtectionMode mode() {
|
public StreamProtectionMode mode() {
|
||||||
return StreamProtectionMode.FEC_NACK;
|
return fec.mode() == StreamProtectionMode.FEC_REED_SOLOMON
|
||||||
|
? StreamProtectionMode.FEC_REED_SOLOMON
|
||||||
|
: StreamProtectionMode.FEC_3_4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.ProtectionEnvelope;
|
||||||
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
|
import com.foxx.androidcast.network.transport.fec.ReedSolomonFec;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
@@ -16,9 +18,13 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
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 {
|
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 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 byte mode;
|
||||||
private final int dataShards;
|
private final int dataShards;
|
||||||
@@ -53,11 +59,25 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
|||||||
return protectionMode;
|
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 {
|
public FecEncodedGroup encodeGroup(byte[] payload) throws IOException {
|
||||||
if (payload == null || payload.length == 0) {
|
if (payload == null || payload.length == 0) {
|
||||||
throw new IOException("empty payload");
|
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);
|
byte[][] shards = fec.encode(payload);
|
||||||
int groupId = NEXT_GROUP_ID.getAndIncrement();
|
int groupId = NEXT_GROUP_ID.getAndIncrement();
|
||||||
if (stats != null) {
|
if (stats != null) {
|
||||||
@@ -65,7 +85,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
|||||||
stats.fecPacketsEncoded += shards.length;
|
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)
|
public byte[] encodeShardWire(FecEncodedGroup group, int baseMessageId, int shardIndex)
|
||||||
@@ -116,6 +136,17 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
|||||||
assembly = prev != null ? prev : created;
|
assembly = prev != null ? prev : created;
|
||||||
}
|
}
|
||||||
if (!assembly.addShard(shard)) {
|
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);
|
return new ShardDecodeResult(ShardDecodeResult.Status.PENDING, null);
|
||||||
}
|
}
|
||||||
byte[] decoded = assembly.tryDecode();
|
byte[] decoded = assembly.tryDecode();
|
||||||
@@ -126,6 +157,7 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
|||||||
stats.fecDecodeFailures++;
|
stats.fecDecodeFailures++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maybeLogDecodeFail(shard.groupId, assembly.receivedCount(), assembly.getTotalShards());
|
||||||
inboundGroups.remove(shard.groupId);
|
inboundGroups.remove(shard.groupId);
|
||||||
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
|
return new ShardDecodeResult(ShardDecodeResult.Status.FAILED, null);
|
||||||
}
|
}
|
||||||
@@ -212,6 +244,22 @@ public final class FecProtectionEngine implements StreamProtectionEngine {
|
|||||||
return decoded.payload;
|
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) {
|
private static boolean looksLikeWireHeader(byte[] payload) {
|
||||||
if (payload == null || payload.length < 4) {
|
if (payload == null || payload.length < 4) {
|
||||||
return false;
|
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. */
|
/** Ring buffer of recent UDP logical messages for NACK retransmission. */
|
||||||
public final class RetransmitCache {
|
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 int capacity;
|
||||||
private final LinkedHashMap<Integer, CachedMessage> map;
|
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;
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.network.CastProtocol;
|
|
||||||
import com.foxx.androidcast.network.UdpCastTransport;
|
import com.foxx.androidcast.network.UdpCastTransport;
|
||||||
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
||||||
|
|
||||||
@@ -16,17 +15,22 @@ public final class StreamProtectionFactory {
|
|||||||
|
|
||||||
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
|
public static StreamProtectionEngine create(CastSettings.StreamProtection setting,
|
||||||
TransportLossStats stats) {
|
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);
|
CastSettings.StreamProtection mode = StreamProtectionNegotiator.normalize(setting);
|
||||||
RetransmitCache cache = new RetransmitCache();
|
RetransmitCache cache = new RetransmitCache();
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case NONE:
|
case NONE:
|
||||||
return StreamProtectionEngine.noop();
|
return StreamProtectionEngine.noop();
|
||||||
case FEC_3_4:
|
case FEC_3_4:
|
||||||
return new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
|
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_FEC_34,
|
||||||
FEC34_DATA, FEC34_PARITY, stats);
|
FEC34_DATA, FEC34_PARITY, stats), stats, cache, nackEnabled);
|
||||||
case FEC_REED_SOLOMON:
|
case FEC_REED_SOLOMON:
|
||||||
return new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
|
return wrapFec(new FecProtectionEngine(ProtectionEnvelope.MODE_REED_SOLOMON,
|
||||||
RS_DATA, RS_PARITY, stats);
|
RS_DATA, RS_PARITY, stats), stats, cache, nackEnabled);
|
||||||
case NACK:
|
case NACK:
|
||||||
return new NackProtectionEngine(stats, cache);
|
return new NackProtectionEngine(stats, cache);
|
||||||
case FEC_NACK:
|
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) {
|
public static void wireUdp(UdpCastTransport udp, StreamProtectionEngine engine) {
|
||||||
if (udp == null || engine == null) {
|
if (udp == null || engine == null) {
|
||||||
return;
|
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;
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastSettings;
|
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 {
|
public final class StreamProtectionNegotiator {
|
||||||
private StreamProtectionNegotiator() {}
|
private StreamProtectionNegotiator() {}
|
||||||
|
|
||||||
/**
|
/** @deprecated Use {@link #negotiate(CastSettings.StreamProtection, CastSettings.StreamProtection, boolean)}. */
|
||||||
* Agreed mode for this session. Any mismatch, missing remote preference, or {@code NONE}
|
@Deprecated
|
||||||
* on either side yields {@link CastSettings.StreamProtection#NONE} (plain UDP).
|
|
||||||
*/
|
|
||||||
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
|
public static CastSettings.StreamProtection resolve(CastSettings.StreamProtection local,
|
||||||
CastSettings.StreamProtection remote) {
|
CastSettings.StreamProtection remote) {
|
||||||
CastSettings.StreamProtection a = normalize(local);
|
return negotiate(local, remote, false).agreed;
|
||||||
CastSettings.StreamProtection b = normalize(remote);
|
}
|
||||||
if (a == CastSettings.StreamProtection.NONE || b == CastSettings.StreamProtection.NONE) {
|
|
||||||
return CastSettings.StreamProtection.NONE;
|
/**
|
||||||
|
* @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 negotiateWithReceiverCaps(receiverCaps, senderPref, local, remote, localIsReceiver);
|
||||||
return a;
|
}
|
||||||
|
|
||||||
|
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;
|
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) {
|
public static CastSettings.StreamProtection normalize(CastSettings.StreamProtection mode) {
|
||||||
if (mode == null || mode == CastSettings.StreamProtection.AUTO) {
|
if (mode == null || mode == CastSettings.StreamProtection.AUTO) {
|
||||||
return CastSettings.StreamProtection.NONE;
|
return CastSettings.StreamProtection.NONE;
|
||||||
}
|
}
|
||||||
return mode;
|
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.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||||
s.protectionMode = loss.protectionMode.name();
|
s.protectionMode = loss.protectionMode.name();
|
||||||
s.fecStubPackets = loss.fecPacketsEncoded;
|
s.fecStubPackets = loss.fecPacketsEncoded;
|
||||||
|
s.fecPacketsDecoded = loss.fecPacketsDecoded;
|
||||||
|
s.fecDecodeFailures = loss.fecDecodeFailures;
|
||||||
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived;
|
s.nackStubRequests = loss.nackRequestsSent + loss.nackRequestsReceived;
|
||||||
long lossEvents = loss.estimatedUdpLossEvents();
|
long lossEvents = loss.estimatedUdpLossEvents();
|
||||||
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ import java.io.DataOutputStream;
|
|||||||
import java.io.IOException;
|
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.
|
* 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 {
|
public final class FecShardWire {
|
||||||
private static final byte[] MAGIC = {'A', 'C', 'P', 'F'};
|
private static final byte[] MAGIC = {'A', 'C', 'P', 'F'};
|
||||||
public static final byte VERSION_SHARD = 2;
|
/** Legacy on-wire format; shard length field is 1 byte (max 255). */
|
||||||
/** Bytes after {@link #MAGIC} before shard payload. */
|
public static final byte VERSION_SHARD_V2 = 2;
|
||||||
private static final int HEADER_AFTER_MAGIC = 19;
|
/** 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() {}
|
private FecShardWire() {}
|
||||||
|
|
||||||
@@ -31,6 +36,9 @@ public final class FecShardWire {
|
|||||||
if (shardData == null) {
|
if (shardData == null) {
|
||||||
throw new IOException("shardData required");
|
throw new IOException("shardData required");
|
||||||
}
|
}
|
||||||
|
if (shardData.length > 0xFFFF) {
|
||||||
|
throw new IOException("shard too large: " + shardData.length);
|
||||||
|
}
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
|
ByteArrayOutputStream bos = new ByteArrayOutputStream(32 + shardData.length);
|
||||||
DataOutputStream dos = new DataOutputStream(bos);
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
dos.write(MAGIC);
|
dos.write(MAGIC);
|
||||||
@@ -43,18 +51,17 @@ public final class FecShardWire {
|
|||||||
dos.writeInt(originalLen);
|
dos.writeInt(originalLen);
|
||||||
dos.writeByte(dataShards);
|
dos.writeByte(dataShards);
|
||||||
dos.writeByte(parityShards);
|
dos.writeByte(parityShards);
|
||||||
dos.writeByte(shardData.length);
|
dos.writeShort(shardData.length);
|
||||||
dos.write(shardData);
|
dos.write(shardData);
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isShardPacket(byte[] wire) {
|
public static boolean isShardPacket(byte[] wire) {
|
||||||
Parsed p = tryParse(wire);
|
return tryParse(wire) != null;
|
||||||
return p != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Parsed tryParse(byte[] wire) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < MAGIC.length; i++) {
|
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,
|
DataInputStream dis = new DataInputStream(new ByteArrayInputStream(wire, MAGIC.length,
|
||||||
wire.length - MAGIC.length));
|
wire.length - MAGIC.length));
|
||||||
byte ver = dis.readByte();
|
byte ver = dis.readByte();
|
||||||
if (ver != VERSION_SHARD) {
|
if (ver != VERSION_SHARD_V2 && ver != VERSION_SHARD) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
byte mode = dis.readByte();
|
byte mode = dis.readByte();
|
||||||
@@ -77,14 +84,15 @@ public final class FecShardWire {
|
|||||||
int originalLen = dis.readInt();
|
int originalLen = dis.readInt();
|
||||||
int dataShards = dis.readUnsignedByte();
|
int dataShards = dis.readUnsignedByte();
|
||||||
int parityShards = 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) {
|
if (dataShards < 1 || parityShards < 1 || totalShards != dataShards + parityShards) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (shardIndex >= totalShards || originalLen < 1) {
|
if (shardIndex >= totalShards || originalLen < 1 || shardSize < 1) {
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
byte[] shardData = new byte[shardSize];
|
byte[] shardData = new byte[shardSize];
|
||||||
|
|||||||
@@ -166,21 +166,31 @@ public class AudioDecoder {
|
|||||||
Log.i(TAG, "AudioTrack buf=" + bufSize + " playState=" + track.getPlayState());
|
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) {
|
public void queueFrame(long ptsUs, byte[] data) {
|
||||||
if (data == null || data.length == 0) {
|
if (data == null || data.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stats.framesQueued++;
|
stats.framesQueued++;
|
||||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
PendingFrame frame = new PendingFrame(ptsUs, data.clone());
|
||||||
PendingFrame dropped = inputQueue.poll();
|
if (inputQueue.offer(frame)) {
|
||||||
if (dropped != null) {
|
return;
|
||||||
stats.queueDrops++;
|
}
|
||||||
logDropBurst();
|
int dropped = inputQueue.size() + (retryFrame != null ? 1 : 0);
|
||||||
}
|
inputQueue.clear();
|
||||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
retryFrame = null;
|
||||||
stats.queueDrops++;
|
if (dropped > 0) {
|
||||||
logDropBurst();
|
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}. */
|
/** Software VP8/VP9 decode via libvpx, rendering to a {@link Surface}. */
|
||||||
public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
||||||
private static final String TAG = "LibvpxVideoDecoder";
|
private static final String TAG = "LibvpxVideoDecoder";
|
||||||
|
private static final int DECODE_FAIL_LOG_EVERY = 16;
|
||||||
|
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
|
private int decodeFailLogCount;
|
||||||
private long nativeHandle;
|
private long nativeHandle;
|
||||||
private Surface outputSurface;
|
private Surface outputSurface;
|
||||||
private Listener listener;
|
private Listener listener;
|
||||||
@@ -47,6 +49,8 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
|
|||||||
}
|
}
|
||||||
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
|
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
|
||||||
notifyRenderedLocked();
|
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.R;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
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.CodecSessionRegistry;
|
||||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||||
import com.foxx.androidcast.media.codec.VideoCodecBackend;
|
import com.foxx.androidcast.media.codec.VideoCodecBackend;
|
||||||
@@ -169,6 +171,7 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecodeThread = new HandlerThread("VideoDecode");
|
videoDecodeThread = new HandlerThread("VideoDecode");
|
||||||
videoDecodeThread.start();
|
videoDecodeThread.start();
|
||||||
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
|
videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
|
||||||
|
CpuLoadSampler.warmUp();
|
||||||
mainHandler.post(idleWatchdog);
|
mainHandler.post(idleWatchdog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +340,7 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||||
if (castEnded) {
|
if (castEnded || awaitingKeyframe || streamIdle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final int frameBytes = data != null ? data.length : 0;
|
final int frameBytes = data != null ? data.length : 0;
|
||||||
@@ -476,7 +479,7 @@ public class ReceiverCastService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
awaitingKeyframe = true;
|
enterAwaitingKeyframe();
|
||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
scheduleDecoderConfigureRetries();
|
scheduleDecoderConfigureRetries();
|
||||||
}
|
}
|
||||||
@@ -485,7 +488,7 @@ public class ReceiverCastService extends Service {
|
|||||||
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
||||||
streamIdle = true;
|
streamIdle = true;
|
||||||
hasPendingConfig = false;
|
hasPendingConfig = false;
|
||||||
awaitingKeyframe = true;
|
enterAwaitingKeyframe();
|
||||||
resetDecoderState();
|
resetDecoderState();
|
||||||
if (videoDecoder != null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
@@ -500,7 +503,7 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private void resetStreamState() {
|
private void resetStreamState() {
|
||||||
hasPendingConfig = false;
|
hasPendingConfig = false;
|
||||||
awaitingKeyframe = true;
|
enterAwaitingKeyframe();
|
||||||
pendingWidth = 0;
|
pendingWidth = 0;
|
||||||
pendingHeight = 0;
|
pendingHeight = 0;
|
||||||
displayWidth = 0;
|
displayWidth = 0;
|
||||||
@@ -516,6 +519,19 @@ public class ReceiverCastService extends Service {
|
|||||||
decoderHeight = 0;
|
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) {
|
private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
|
||||||
pendingAudioRate = sampleRate;
|
pendingAudioRate = sampleRate;
|
||||||
pendingAudioChannels = channels;
|
pendingAudioChannels = channels;
|
||||||
@@ -559,7 +575,7 @@ public class ReceiverCastService extends Service {
|
|||||||
streamMetrics.reset();
|
streamMetrics.reset();
|
||||||
}
|
}
|
||||||
reconfiguring = true;
|
reconfiguring = true;
|
||||||
awaitingKeyframe = true;
|
enterAwaitingKeyframe();
|
||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
pendingWidth = size.width;
|
pendingWidth = size.width;
|
||||||
pendingHeight = size.height;
|
pendingHeight = size.height;
|
||||||
@@ -796,7 +812,8 @@ public class ReceiverCastService extends Service {
|
|||||||
remoteCastSettings,
|
remoteCastSettings,
|
||||||
peer,
|
peer,
|
||||||
lastLocalStats,
|
lastLocalStats,
|
||||||
PlaybackViewState.getZoomPercent());
|
PlaybackViewState.getZoomPercent(),
|
||||||
|
CastMtuSampler.sample(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
private TransportLossStats buildReceiverLossSnapshot() {
|
private TransportLossStats buildReceiverLossSnapshot() {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
private boolean bound;
|
private boolean bound;
|
||||||
private boolean autoStartListening;
|
private boolean autoStartListening;
|
||||||
private boolean listeningCancelled;
|
private boolean listeningCancelled;
|
||||||
|
private boolean autoPipAttempted;
|
||||||
|
|
||||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -182,11 +183,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||||
pipButton = findViewById(R.id.button_pip);
|
pipButton = findViewById(R.id.button_pip);
|
||||||
if (pipButton != null) {
|
if (pipButton != null) {
|
||||||
pipButton.setOnClickListener(v -> tryEnterPictureInPicture());
|
pipButton.setOnClickListener(v -> tryEnterPictureInPicture(true));
|
||||||
}
|
}
|
||||||
textureView.setSurfaceTextureListener(surfaceListener);
|
textureView.setSurfaceTextureListener(surfaceListener);
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
|
if (AppPreferences.isReceiverPipEnabled(this) && PictureInPictureHelper.isSupported()) {
|
||||||
|
PictureInPictureHelper.applyParams(this, 16, 9);
|
||||||
|
}
|
||||||
|
|
||||||
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
|
autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false);
|
||||||
if (autoStartListening) {
|
if (autoStartListening) {
|
||||||
@@ -245,14 +249,14 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
mainHandler.post(diagnosticsRefresh);
|
mainHandler.post(diagnosticsRefresh);
|
||||||
updateDiagnosticsVisibility();
|
updateDiagnosticsVisibility();
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
|
maybeAutoEnterPip();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onUserLeaveHint() {
|
public void onUserLeaveHint() {
|
||||||
super.onUserLeaveHint();
|
super.onUserLeaveHint();
|
||||||
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
if (shouldAutoEnterPipOnLeave()) {
|
||||||
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
tryEnterPictureInPicture(false);
|
||||||
tryEnterPictureInPicture();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +272,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
|
maybeAutoEnterPip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +551,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
if (pipButton == null) {
|
if (pipButton == null) {
|
||||||
return;
|
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()
|
&& PictureInPictureHelper.isSupported()
|
||||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||||
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
@@ -555,23 +562,64 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tryEnterPictureInPicture() {
|
private boolean shouldAutoEnterPipOnLeave() {
|
||||||
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||||
PermissionHelper.remindForReceiverPlayback(this);
|
PermissionHelper.remindForReceiverPlayback(this);
|
||||||
return;
|
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()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!PictureInPictureHelper.isAllowedForApp(this)) {
|
if (!PictureInPictureHelper.isAllowedForApp(this)) {
|
||||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
if (userInitiated) {
|
||||||
|
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) {
|
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight) && userInitiated) {
|
||||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +630,12 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
|
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
|
||||||
}
|
}
|
||||||
if (pipButton != null) {
|
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 (inPictureInPicture) {
|
||||||
if (diagnosticsText != null) {
|
if (diagnosticsText != null) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.foxx.androidcast.network.CastSession;
|
|||||||
import com.foxx.androidcast.network.CastSessionGate;
|
import com.foxx.androidcast.network.CastSessionGate;
|
||||||
import com.foxx.androidcast.network.CastTransport;
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
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.UdpCastTransport;
|
||||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
@@ -74,6 +76,7 @@ public class ReceiverSession {
|
|||||||
private CastSession session;
|
private CastSession session;
|
||||||
private CastTransport listenTransport;
|
private CastTransport listenTransport;
|
||||||
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
||||||
|
private boolean loggedFirstVideoFrame;
|
||||||
private NetworkFeedbackManager networkFeedback;
|
private NetworkFeedbackManager networkFeedback;
|
||||||
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||||
|
|
||||||
@@ -195,6 +198,7 @@ public class ReceiverSession {
|
|||||||
listenTransport = null;
|
listenTransport = null;
|
||||||
}
|
}
|
||||||
inboundSessionGate.reset();
|
inboundSessionGate.reset();
|
||||||
|
loggedFirstVideoFrame = false;
|
||||||
networkFeedback = null;
|
networkFeedback = null;
|
||||||
listener.onDisconnected();
|
listener.onDisconnected();
|
||||||
}
|
}
|
||||||
@@ -237,6 +241,12 @@ public class ReceiverSession {
|
|||||||
case CastProtocol.MSG_VIDEO_FRAME:
|
case CastProtocol.MSG_VIDEO_FRAME:
|
||||||
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
|
||||||
recordNetworkBytes(vf.data != null ? vf.data.length : 0);
|
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);
|
listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
|
||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_AUDIO_CONFIG:
|
case CastProtocol.MSG_AUDIO_CONFIG:
|
||||||
@@ -286,7 +296,16 @@ public class ReceiverSession {
|
|||||||
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
|
udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
|
||||||
}
|
}
|
||||||
if (udp != null) {
|
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() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
MediaCodec c;
|
Thread drain = drainThread;
|
||||||
synchronized (inputLock) {
|
drainThread = null;
|
||||||
c = codec;
|
if (drain != null) {
|
||||||
codec = null;
|
|
||||||
}
|
|
||||||
if (drainThread != null) {
|
|
||||||
try {
|
try {
|
||||||
drainThread.join(1000);
|
drain.join(1000);
|
||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException ignored) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c != null) {
|
synchronized (inputLock) {
|
||||||
try {
|
MediaCodec c = codec;
|
||||||
c.stop();
|
codec = null;
|
||||||
} catch (Exception ignored) {
|
if (c != null) {
|
||||||
}
|
try {
|
||||||
try {
|
c.stop();
|
||||||
c.release();
|
} catch (Exception ignored) {
|
||||||
} catch (Exception ignored) {
|
}
|
||||||
|
try {
|
||||||
|
c.release();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,13 +116,28 @@ public class AudioEncoder implements AudioEncoderSink {
|
|||||||
private void drainLoop() {
|
private void drainLoop() {
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
boolean configSent = false;
|
boolean configSent = false;
|
||||||
while (running) {
|
while (true) {
|
||||||
int index = codec.dequeueOutputBuffer(info, 10_000);
|
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) {
|
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||||
MediaFormat fmt = codec.getOutputFormat();
|
MediaFormat fmt = c.getOutputFormat();
|
||||||
if (!configSent && callback != null) {
|
if (!configSent && callback != null) {
|
||||||
ByteBuffer csd = fmt.getByteBuffer("csd-0");
|
ByteBuffer csd = fmt.getByteBuffer("csd-0");
|
||||||
callback.onConfig(
|
callback.onConfig(
|
||||||
@@ -135,7 +151,7 @@ public class AudioEncoder implements AudioEncoderSink {
|
|||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
continue;
|
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) {
|
if (buffer != null && info.size > 0 && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
|
||||||
byte[] data = new byte[info.size];
|
byte[] data = new byte[info.size];
|
||||||
buffer.position(info.offset);
|
buffer.position(info.offset);
|
||||||
@@ -145,7 +161,11 @@ public class AudioEncoder implements AudioEncoderSink {
|
|||||||
callback.onEncodedFrame(info.presentationTimeUs, data);
|
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.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransport;
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
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.UdpCastTransport;
|
||||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ public final class MultiCastCoordinator {
|
|||||||
session = createSession(peerSettings);
|
session = createSession(peerSettings);
|
||||||
connectWithRetry(session, t.host, t.port);
|
connectWithRetry(session, t.host, t.port);
|
||||||
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
|
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));
|
peers.add(new ActivePeer(t, session, hs.settings, hs.negotiatedVideoMime));
|
||||||
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime
|
Log.i(TAG, "Peer ready " + t.name + " " + hs.negotiatedVideoMime
|
||||||
+ " score="
|
+ " score="
|
||||||
@@ -128,7 +130,7 @@ public final class MultiCastCoordinator {
|
|||||||
CastSession session = createSession(peerSettings);
|
CastSession session = createSession(peerSettings);
|
||||||
connectWithRetry(session, target.host, target.port);
|
connectWithRetry(session, target.host, target.port);
|
||||||
CastSession.HandshakeResult hs = session.clientHandshake(deviceName, pin, peerSettings);
|
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));
|
peers.add(new ActivePeer(target, session, hs.settings, hs.negotiatedVideoMime));
|
||||||
if (keyframeRequester != null) {
|
if (keyframeRequester != null) {
|
||||||
keyframeRequester.requestKeyframe();
|
keyframeRequester.requestKeyframe();
|
||||||
@@ -202,8 +204,8 @@ public final class MultiCastCoordinator {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void applyNegotiatedProtection(CastSession session, CastSettings local,
|
private void applyNegotiatedProtection(CastSession session, CastSettings local,
|
||||||
CastSettings remote) {
|
CastSettings receiverAdvertisement) {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -215,7 +217,13 @@ public final class MultiCastCoordinator {
|
|||||||
udp = ((QuicCronetCastTransport) transport).getUdpTransport();
|
udp = ((QuicCronetCastTransport) transport).getUdpTransport();
|
||||||
}
|
}
|
||||||
if (udp != null) {
|
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);
|
CastNotifications.createChannels(this);
|
||||||
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||||
|
com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp();
|
||||||
projectionThread = new HandlerThread("CastProjection");
|
projectionThread = new HandlerThread("CastProjection");
|
||||||
projectionThread.start();
|
projectionThread.start();
|
||||||
projectionHandler = new Handler(projectionThread.getLooper());
|
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 {
|
private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||||
|
SenderPreviewService.stop(this);
|
||||||
MediaProjectionManager mgr =
|
MediaProjectionManager mgr =
|
||||||
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
(MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||||
projection = mgr.getMediaProjection(resultCode, data);
|
projection = mgr.getMediaProjection(resultCode, data);
|
||||||
|
|||||||
@@ -464,10 +464,14 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
if (resultCode == Activity.RESULT_OK && data != null) {
|
if (resultCode == Activity.RESULT_OK && data != null) {
|
||||||
screenPreview.setProjectionResult(resultCode, data);
|
screenPreview.setProjectionResult(resultCode, data);
|
||||||
previewConsentDeclined = false;
|
previewConsentDeclined = false;
|
||||||
|
findViewById(R.id.panel_preview).post(() -> {
|
||||||
|
refreshPreview();
|
||||||
|
findViewById(R.id.panel_preview).postDelayed(this::refreshPreview, 200);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
previewConsentDeclined = true;
|
previewConsentDeclined = true;
|
||||||
|
refreshPreview();
|
||||||
}
|
}
|
||||||
refreshPreview();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import android.widget.TextView;
|
|||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastActiveState;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
|
import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
|
||||||
@@ -62,6 +63,10 @@ public final class SenderCapturePreview {
|
|||||||
public void updateForMode(CastSettings.CaptureMode mode) {
|
public void updateForMode(CastSettings.CaptureMode mode) {
|
||||||
activeMode = mode;
|
activeMode = mode;
|
||||||
stopCamera();
|
stopCamera();
|
||||||
|
if (CastActiveState.isSenderCasting()) {
|
||||||
|
showCastingPlaceholder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode == CastSettings.CaptureMode.CAMERA) {
|
if (mode == CastSettings.CaptureMode.CAMERA) {
|
||||||
useFront = false;
|
useFront = false;
|
||||||
startCameraPreview();
|
startCameraPreview();
|
||||||
@@ -71,7 +76,7 @@ public final class SenderCapturePreview {
|
|||||||
} else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
} else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||||
showCalibrationPreview();
|
showCalibrationPreview();
|
||||||
} else if (screenPreview != null && screenPreview.needsProjection(mode)) {
|
} else if (screenPreview != null && screenPreview.needsProjection(mode)) {
|
||||||
if (screenPreview.hasProjection()) {
|
if (screenPreview.hasConsent()) {
|
||||||
labelView.setVisibility(android.view.View.GONE);
|
labelView.setVisibility(android.view.View.GONE);
|
||||||
textureView.setVisibility(android.view.View.VISIBLE);
|
textureView.setVisibility(android.view.View.VISIBLE);
|
||||||
screenPreview.attach(textureView, mode);
|
screenPreview.attach(textureView, mode);
|
||||||
@@ -92,6 +97,16 @@ public final class SenderCapturePreview {
|
|||||||
labelView.setText(labelView.getContext().getString(textRes));
|
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() {
|
private void showCalibrationPreview() {
|
||||||
if (screenPreview != null) {
|
if (screenPreview != null) {
|
||||||
screenPreview.stop();
|
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.TextureView;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastActiveState;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +106,9 @@ public final class SenderScreenPreview {
|
|||||||
if (projection != null || resultData == null) {
|
if (projection != null || resultData == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!CastActiveState.isSenderCasting() && !SenderPreviewService.isRunning()) {
|
||||||
|
SenderPreviewService.start(appContext);
|
||||||
|
}
|
||||||
MediaProjectionManager mgr =
|
MediaProjectionManager mgr =
|
||||||
appContext.getSystemService(MediaProjectionManager.class);
|
appContext.getSystemService(MediaProjectionManager.class);
|
||||||
projection = mgr.getMediaProjection(resultCode, resultData);
|
projection = mgr.getMediaProjection(resultCode, resultData);
|
||||||
@@ -133,8 +137,14 @@ public final class SenderScreenPreview {
|
|||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
|
||||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 2);
|
int viewW = textureView.getWidth();
|
||||||
int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 2);
|
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();
|
SurfaceTexture st = textureView.getSurfaceTexture();
|
||||||
if (st == null) {
|
if (st == null) {
|
||||||
return;
|
return;
|
||||||
@@ -181,6 +191,9 @@ public final class SenderScreenPreview {
|
|||||||
}
|
}
|
||||||
projection = null;
|
projection = null;
|
||||||
}
|
}
|
||||||
|
if (!CastActiveState.isSenderCasting()) {
|
||||||
|
SenderPreviewService.stop(appContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pause preview surface only; keep MediaProjection consent for cast START. */
|
/** Pause preview surface only; keep MediaProjection consent for cast START. */
|
||||||
|
|||||||
@@ -149,6 +149,8 @@
|
|||||||
<string name="capture_camera_front">Камера (фронт)</string>
|
<string name="capture_camera_front">Камера (фронт)</string>
|
||||||
<string name="label_mute">Тихо</string>
|
<string name="label_mute">Тихо</string>
|
||||||
<string name="preview_placeholder">Предпросмотр\n(трансляция начнется после нажатия кнопки)</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_camera">Предпросмотр с камеры</string>
|
||||||
<string name="preview_calibration">Калибрационная таблица</string>
|
<string name="preview_calibration">Калибрационная таблица</string>
|
||||||
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</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_auto_fit">AUTO (fits screen)</string>
|
||||||
<string name="receiver_display_adaptive">Adaptive (experimental)</string>
|
<string name="receiver_display_adaptive">Adaptive (experimental)</string>
|
||||||
<string name="label_stream_protection">Stream protection (UDP)</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_34">FEC 3/4</string>
|
||||||
<string name="protection_fec_rs">FEC (Reed-Solomon)</string>
|
<string name="protection_fec_rs">FEC (Reed-Solomon)</string>
|
||||||
<string name="protection_nack">NACK</string>
|
<string name="protection_nack">NACK</string>
|
||||||
@@ -149,6 +159,8 @@
|
|||||||
<string name="capture_camera_front">Camera (front)</string>
|
<string name="capture_camera_front">Camera (front)</string>
|
||||||
<string name="label_mute">Mute</string>
|
<string name="label_mute">Mute</string>
|
||||||
<string name="preview_placeholder">Source preview\n(screen capture starts after START)</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_camera">Camera preview</string>
|
||||||
<string name="preview_calibration">Calibration test pattern</string>
|
<string name="preview_calibration">Calibration test pattern</string>
|
||||||
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</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.CastSettings;
|
||||||
import com.foxx.androidcast.network.transport.StreamProtectionFactory;
|
import com.foxx.androidcast.network.transport.StreamProtectionFactory;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.CastFramingContext;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.net.DatagramPacket;
|
import java.net.DatagramPacket;
|
||||||
@@ -24,8 +26,8 @@ public class UdpCastTransportFecTest {
|
|||||||
receiver.prepareListen(portB);
|
receiver.prepareListen(portB);
|
||||||
sender.connect("127.0.0.1", portB);
|
sender.connect("127.0.0.1", portB);
|
||||||
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
|
CastSettings fec = settingsWith(CastSettings.StreamProtection.FEC_3_4);
|
||||||
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec);
|
CastTransportFactory.applyNegotiatedUdpProtection(receiver, fec, fec, true);
|
||||||
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec);
|
CastTransportFactory.applyNegotiatedUdpProtection(sender, fec, fec, false);
|
||||||
|
|
||||||
byte[] payload = new byte[900];
|
byte[] payload = new byte[900];
|
||||||
for (int i = 0; i < payload.length; i++) {
|
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) {
|
private static CastSettings settingsWith(CastSettings.StreamProtection protection) {
|
||||||
CastSettings s = new CastSettings();
|
CastSettings s = new CastSettings();
|
||||||
s.setTransport(CastConfig.TRANSPORT_UDP);
|
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.assertArrayEquals;
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||||
@@ -12,6 +13,20 @@ import com.foxx.androidcast.network.transport.fec.ProtectionEnvelope;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
public class FecProtectionEngineShardTest {
|
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
|
@Test
|
||||||
public void encodeAndDecodeShards_viaEngine() throws Exception {
|
public void encodeAndDecodeShards_viaEngine() throws Exception {
|
||||||
TransportLossStats stats = new TransportLossStats();
|
TransportLossStats stats = new TransportLossStats();
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ public class StreamProtectionFactoryTest {
|
|||||||
StreamProtectionEngine engine = StreamProtectionFactory.create(
|
StreamProtectionEngine engine = StreamProtectionFactory.create(
|
||||||
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats());
|
CastSettings.StreamProtection.FEC_3_4, new TransportLossStats());
|
||||||
assertEquals(StreamProtectionMode.FEC_3_4, engine.mode());
|
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
|
@Test
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.foxx.androidcast.network.transport;
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
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;
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
|
||||||
@@ -14,18 +18,93 @@ public class StreamProtectionNegotiatorTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void mismatchFallsBackToNone() {
|
public void bothFec34_agreesWithNack() {
|
||||||
assertEquals(CastSettings.StreamProtection.NONE,
|
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||||
StreamProtectionNegotiator.resolve(
|
CastSettings.StreamProtection.FEC_3_4,
|
||||||
CastSettings.StreamProtection.FEC_NACK,
|
CastSettings.StreamProtection.FEC_3_4,
|
||||||
CastSettings.StreamProtection.NACK));
|
false);
|
||||||
|
assertEquals(CastSettings.StreamProtection.FEC_3_4, n.agreed);
|
||||||
|
assertTrue(n.nackEnabled);
|
||||||
|
assertNull(n.prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void matchingModesPreserved() {
|
public void senderFec34_receiverAuto_usesFec34() {
|
||||||
assertEquals(CastSettings.StreamProtection.FEC_3_4,
|
StreamProtectionNegotiation n = StreamProtectionNegotiator.negotiate(
|
||||||
StreamProtectionNegotiator.resolve(
|
CastSettings.StreamProtection.FEC_3_4,
|
||||||
CastSettings.StreamProtection.FEC_3_4,
|
CastSettings.StreamProtection.AUTO,
|
||||||
CastSettings.StreamProtection.FEC_3_4));
|
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);
|
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
|
@Test
|
||||||
public void insufficientShards_returnsNull() throws Exception {
|
public void insufficientShards_returnsNull() throws Exception {
|
||||||
byte[] payload = new byte[] {1, 2, 3, 4, 5};
|
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.assertNotNull;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
public class FecShardWireTest {
|
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
|
@Test
|
||||||
public void encodeParse_roundTrip() throws Exception {
|
public void encodeParse_roundTrip() throws Exception {
|
||||||
byte[] shard = new byte[64];
|
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 |
|
| FEC 3/4 + RS | Yes | — | v2 **per-shard** on wire (`FecShardWire`); v1 monolithic decode still accepted |
|
||||||
| NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled |
|
| NACK + retransmit cache | Yes | — | UDP `MSG_NACK`, combined with FEC when enabled |
|
||||||
| libvpx VP8/VP9 | 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/` |
|
| Opus / Speex | Stub | Probe only | Submodules under `third-party/` |
|
||||||
|
|
||||||
## Enable libvpx (developer)
|
## Enable libvpx (developer)
|
||||||
|
|||||||
@@ -342,9 +342,15 @@ Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeVpxDecoderCrea
|
|||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
ctx->magic = VPX_DEC_MAGIC;
|
ctx->magic = VPX_DEC_MAGIC;
|
||||||
|
vpx_codec_iface_t *iface = pick_decoder_iface(mime_utf);
|
||||||
vpx_codec_dec_cfg_t cfg = {0};
|
vpx_codec_dec_cfg_t cfg = {0};
|
||||||
cfg.threads = 1;
|
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);
|
free(ctx);
|
||||||
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
|
(*env)->ReleaseStringUTFChars(env, mime, mime_utf);
|
||||||
return 0L;
|
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,
|
vpx_codec_err_t err = vpx_codec_decode(&ctx->codec, (const uint8_t *) bytes, (unsigned int) len,
|
||||||
NULL, 0);
|
NULL, 0);
|
||||||
(*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT);
|
(*env)->ReleaseByteArrayElements(env, data, bytes, JNI_ABORT);
|
||||||
if (err != VPX_CODEC_OK) {
|
/* With error concealment, vpx_codec_decode may warn yet still output a frame (see vpxdec.c). */
|
||||||
return JNI_FALSE;
|
(void) err;
|
||||||
}
|
|
||||||
|
|
||||||
ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
|
ANativeWindow *window = ANativeWindow_fromSurface(env, surface);
|
||||||
if (window == NULL) {
|
if (window == NULL) {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ cp -f "$LIBVPX_A" "$OUT/libvpx.a"
|
|||||||
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
|
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
|
||||||
|
|
||||||
echo ""
|
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
|
if [[ -n "$STAGING" ]]; then
|
||||||
echo "(staged under $STAGING because of spaces in repo path)"
|
echo "(staged under $STAGING because of spaces in repo path)"
|
||||||
fi
|
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