mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:20:00 +03:00
next force snapshot
This commit is contained in:
@@ -58,6 +58,8 @@ public final class AppPreferences {
|
||||
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
||||
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
|
||||
private static final String KEY_DEV_COMMERCIAL_PLAY_STORE = "dev_commercial_play_store";
|
||||
private static final String KEY_DEV_DIAG_PING_PONG = "dev_diag_ping_pong";
|
||||
private static final String KEY_DEV_DIAG_PING_RESPONSE_MODE = "dev_diag_ping_response_mode";
|
||||
|
||||
/** Minimum interval between automatic OTA checks on app launch. */
|
||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
||||
@@ -386,6 +388,36 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_PLAY_STORE, enabled).apply();
|
||||
}
|
||||
|
||||
/** Developer: ~1 Hz diagnostic cast ping-pong during sessions (default off). */
|
||||
public static boolean isDevDiagPingPongEnabled(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_DIAG_PING_PONG, false);
|
||||
}
|
||||
|
||||
public static void setDevDiagPingPongEnabled(Context context, boolean enabled) {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_DIAG_PING_PONG, enabled).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* How this device responds to peer diag probes:
|
||||
* 0 mirror-as-is, 1 mirror-smart (default), 2 reply-with.
|
||||
*/
|
||||
public static byte getDevDiagPingResponseMode(Context context) {
|
||||
int mode = prefs(context).getInt(KEY_DEV_DIAG_PING_RESPONSE_MODE,
|
||||
com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART);
|
||||
if (mode < 0 || mode > 2) {
|
||||
return com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART;
|
||||
}
|
||||
return (byte) mode;
|
||||
}
|
||||
|
||||
public static void setDevDiagPingResponseMode(Context context, byte mode) {
|
||||
int v = mode;
|
||||
if (v < 0 || v > 2) {
|
||||
v = com.foxx.androidcast.network.diag.CastDiagPing.DIR_MIRROR_SMART;
|
||||
}
|
||||
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply();
|
||||
}
|
||||
|
||||
public static CastSettings loadReceiverDefaults(Context context) {
|
||||
CastSettings s = new CastSettings();
|
||||
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||
import com.foxx.androidcast.sender.SenderResolutionUiMode;
|
||||
import com.foxx.androidcast.dev.DevDiagnosticsController;
|
||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
@@ -99,6 +100,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
bindAvPresets();
|
||||
bindDiagPingSection();
|
||||
bindSenderResolutionUiModeSection();
|
||||
bindWiredDisplaySection();
|
||||
bindCommercialSection();
|
||||
@@ -126,6 +128,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
findViewById(R.id.check_show_codec_priority_scores),
|
||||
findViewById(R.id.check_grab_session_stats),
|
||||
findViewById(R.id.check_receiver_adaptive_display));
|
||||
bindDiagPingSection();
|
||||
refreshOtaUi(
|
||||
findViewById(R.id.text_ota_installed_version),
|
||||
findViewById(R.id.edit_ota_manifest_url),
|
||||
@@ -136,6 +139,46 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
bindCommercialSection();
|
||||
}
|
||||
|
||||
private void bindDiagPingSection() {
|
||||
CheckBox enabled = findViewById(R.id.check_dev_diag_ping_pong);
|
||||
bindDeveloperCheckbox(enabled, AppPreferences.isDevDiagPingPongEnabled(this),
|
||||
AppPreferences::setDevDiagPingPongEnabled);
|
||||
Spinner spinner = findViewById(R.id.spinner_dev_diag_ping_response_mode);
|
||||
if (spinner == null) {
|
||||
return;
|
||||
}
|
||||
final byte[] modes = {
|
||||
CastDiagPing.DIR_MIRROR_AS_IS,
|
||||
CastDiagPing.DIR_MIRROR_SMART,
|
||||
CastDiagPing.DIR_REPLY_WITH
|
||||
};
|
||||
String[] labels = {
|
||||
getString(R.string.dev_diag_ping_response_mirror),
|
||||
getString(R.string.dev_diag_ping_response_smart),
|
||||
getString(R.string.dev_diag_ping_response_reply)
|
||||
};
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||
android.R.layout.simple_spinner_item, labels);
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
spinner.setAdapter(adapter);
|
||||
spinner.setOnItemSelectedListener(null);
|
||||
byte current = AppPreferences.getDevDiagPingResponseMode(this);
|
||||
int selection = 1;
|
||||
for (int i = 0; i < modes.length; i++) {
|
||||
if (modes[i] == current) {
|
||||
selection = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
spinner.setSelection(selection, false);
|
||||
spinner.setOnItemSelectedListener(simpleSelection(() -> {
|
||||
int pos = spinner.getSelectedItemPosition();
|
||||
if (pos >= 0 && pos < modes.length) {
|
||||
AppPreferences.setDevDiagPingResponseMode(this, modes[pos]);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void bindSenderResolutionUiModeSection() {
|
||||
Spinner spinner = findViewById(R.id.spinner_dev_sender_resolution_ui_mode);
|
||||
if (spinner == null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.receiver.StreamMetrics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -140,6 +141,12 @@ public final class CastDiagnosticsFormatter {
|
||||
html.append("BW in: ").append(colored(bwInKbps > 0 ? bwInKbps + " kbps" : "n/a",
|
||||
suggestedBwKbps > 0 && bwInKbps > 0 && bwInKbps < suggestedBwKbps * 0.7f));
|
||||
html.append(" RTT: ").append(colored(rttMs > 0 ? rttMs + " ms" : "n/a", rttMs > 350));
|
||||
DiagPingMetrics.Snapshot diagPing = metrics.getDiagPing();
|
||||
if (diagPing.enabled && diagPing.pongsRx > 0) {
|
||||
int pingRtt = diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs;
|
||||
html.append(" Diag ping: ")
|
||||
.append(colored(pingRtt + " ms avg (" + diagPing.pongsRx + ")", pingRtt > 350));
|
||||
}
|
||||
html.append("<br/>");
|
||||
|
||||
html.append("Suggested BW: ").append(colored(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.network.control.NetworkControlAdvice;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.CastDiagPing;
|
||||
|
||||
import android.media.MediaFormat;
|
||||
|
||||
@@ -46,6 +47,11 @@ public final class CastProtocol {
|
||||
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;
|
||||
/**
|
||||
* Diagnostic ping-pong (developer). Payload: {@link com.foxx.androidcast.network.diag.CastDiagPing}.
|
||||
* Peers on older builds ignore unknown types.
|
||||
*/
|
||||
public static final byte MSG_DIAG_PING = 18;
|
||||
/** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
|
||||
public static final byte MSG_WIRE_PACKET = 127;
|
||||
|
||||
@@ -419,6 +425,14 @@ public final class CastProtocol {
|
||||
return c;
|
||||
}
|
||||
|
||||
public static byte[] diagPingPayload(CastDiagPing.Packet packet) throws IOException {
|
||||
return CastDiagPing.encode(packet);
|
||||
}
|
||||
|
||||
public static CastDiagPing.Packet parseDiagPing(byte[] payload) throws IOException {
|
||||
return CastDiagPing.decode(payload);
|
||||
}
|
||||
|
||||
public static byte[] audioConsentPayload(boolean accept) {
|
||||
return new byte[]{(byte) (accept ? 1 : 0)};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.diag.DiagPingSession;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class NetworkFeedbackManager {
|
||||
private final NetworkControlPlane plane;
|
||||
private final CastSession session;
|
||||
private final boolean isSender;
|
||||
private DiagPingSession diagPing;
|
||||
|
||||
public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) {
|
||||
this.session = session;
|
||||
@@ -40,9 +42,27 @@ public class NetworkFeedbackManager {
|
||||
return plane;
|
||||
}
|
||||
|
||||
public void configureDiagPing(boolean enabled, byte responseMode, DiagPingSession.SampleListener listener) {
|
||||
if (!enabled) {
|
||||
if (diagPing != null) {
|
||||
diagPing.setEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
diagPing = new DiagPingSession(session, responseMode, listener);
|
||||
diagPing.setEnabled(true);
|
||||
}
|
||||
|
||||
public DiagPingSession getDiagPingSession() {
|
||||
return diagPing;
|
||||
}
|
||||
|
||||
/** Call on each stream loop iteration (~500ms). */
|
||||
public void tick() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (diagPing != null) {
|
||||
diagPing.tick(now);
|
||||
}
|
||||
NetworkControlAdvice advice = plane.tick(now);
|
||||
if (advice.sendStats) {
|
||||
sendStats(plane.buildLocalStats(now));
|
||||
@@ -53,6 +73,10 @@ public class NetworkFeedbackManager {
|
||||
}
|
||||
|
||||
public boolean handleMessage(CastProtocol.Message msg) {
|
||||
if (diagPing != null && diagPing.handleMessage(msg)) {
|
||||
applyDiagPingRtt();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case CastProtocol.MSG_NETWORK_STATS:
|
||||
@@ -73,6 +97,15 @@ public class NetworkFeedbackManager {
|
||||
}
|
||||
}
|
||||
|
||||
private void applyDiagPingRtt() {
|
||||
if (diagPing == null || !(plane instanceof PassThroughNetworkControlPlane)) {
|
||||
return;
|
||||
}
|
||||
PassThroughNetworkControlPlane pass = (PassThroughNetworkControlPlane) plane;
|
||||
int rtt = diagPing.metrics().preferredRttMs(pass.getDiagPingRttMs());
|
||||
pass.setDiagPingRttMs(rtt);
|
||||
}
|
||||
|
||||
private void sendStats(NetworkStatsSnapshot stats) {
|
||||
try {
|
||||
session.send(CastProtocol.MSG_NETWORK_STATS, CastProtocol.networkStatsPayload(stats));
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
private long sendWindowBytes;
|
||||
private long sendWindowStartMs;
|
||||
private StatsEnricher statsEnricher;
|
||||
private volatile int diagPingRttMs;
|
||||
|
||||
/** Optional hook to attach encode/decode metrics before stats are sent. */
|
||||
public interface StatsEnricher {
|
||||
@@ -39,6 +40,16 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
this.statsEnricher = enricher;
|
||||
}
|
||||
|
||||
public void setDiagPingRttMs(int rttMs) {
|
||||
if (rttMs > 0 && rttMs < 30_000) {
|
||||
diagPingRttMs = rttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public int getDiagPingRttMs() {
|
||||
return diagPingRttMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NetworkControlAdvice tick(long nowMs) {
|
||||
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
|
||||
@@ -90,7 +101,11 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
||||
NetworkStatsSnapshot s = new NetworkStatsSnapshot();
|
||||
s.sequence = ++sequence;
|
||||
s.timestampMs = nowMs;
|
||||
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
|
||||
if (diagPingRttMs > 0) {
|
||||
s.rttMs = diagPingRttMs;
|
||||
} else {
|
||||
s.rttMs = lastPeer != null ? lastPeer.rttMs : 0;
|
||||
}
|
||||
s.lossRatio = 0f;
|
||||
s.sendBitrateKbps = estimateSendKbps(nowMs);
|
||||
s.recvBitrateKbps = estimateRecvKbps(nowMs);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Diagnostic cast ping-pong wire format (optional; {@link com.foxx.androidcast.network.CastProtocol#MSG_DIAG_PING}).
|
||||
* <p>
|
||||
* v1 layout (big-endian):
|
||||
* magic(4) version(1) flags(1) direction(1) reserved(1)
|
||||
* timestamp_ms(8) seq(8) payload_size(4) [payload]
|
||||
* When {@link #FLAG_PONG} and direction {@link #DIR_REPLY_WITH}: reply_for_seq(8) reply_for_timestamp_ms(8)
|
||||
*/
|
||||
public final class CastDiagPing {
|
||||
public static final int MAGIC = 0x50494E47; // "PING"
|
||||
public static final byte VERSION = 1;
|
||||
|
||||
/** Set on pong packets. */
|
||||
public static final byte FLAG_PONG = 0x01;
|
||||
|
||||
/** Echo probe fields unchanged (dummy pong). */
|
||||
public static final byte DIR_MIRROR_AS_IS = 0;
|
||||
/** Pong uses local wall-clock timestamp; same seq as probe. */
|
||||
public static final byte DIR_MIRROR_SMART = 1;
|
||||
/** Pong includes reply-for fields plus local timestamp and seq. */
|
||||
public static final byte DIR_REPLY_WITH = 2;
|
||||
|
||||
private CastDiagPing() {}
|
||||
|
||||
public static final class Packet {
|
||||
public int magic;
|
||||
public byte version;
|
||||
public byte flags;
|
||||
public byte direction;
|
||||
public long timestampMs;
|
||||
public long seq;
|
||||
public byte[] payload = new byte[0];
|
||||
/** Valid when {@link #FLAG_PONG} and {@link #DIR_REPLY_WITH}. */
|
||||
public long replyForSeq;
|
||||
public long replyForTimestampMs;
|
||||
|
||||
public boolean isPong() {
|
||||
return (flags & FLAG_PONG) != 0;
|
||||
}
|
||||
|
||||
public boolean isValidMagic() {
|
||||
return magic == MAGIC && version == VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] encode(Packet p) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream(32);
|
||||
DataOutputStream out = new DataOutputStream(bos);
|
||||
out.writeInt(p.magic != 0 ? p.magic : MAGIC);
|
||||
out.writeByte(VERSION);
|
||||
out.writeByte(p.flags);
|
||||
out.writeByte(p.direction);
|
||||
out.writeByte(0);
|
||||
out.writeLong(p.timestampMs);
|
||||
out.writeLong(p.seq);
|
||||
byte[] body = p.payload != null ? p.payload : new byte[0];
|
||||
out.writeInt(body.length);
|
||||
if (body.length > 0) {
|
||||
out.write(body);
|
||||
}
|
||||
if (p.isPong() && p.direction == DIR_REPLY_WITH) {
|
||||
out.writeLong(p.replyForSeq);
|
||||
out.writeLong(p.replyForTimestampMs);
|
||||
}
|
||||
out.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
public static Packet decode(byte[] raw) throws IOException {
|
||||
if (raw == null || raw.length < 28) {
|
||||
throw new IOException("Diag ping too short");
|
||||
}
|
||||
DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw));
|
||||
Packet p = new Packet();
|
||||
p.magic = in.readInt();
|
||||
p.version = in.readByte();
|
||||
p.flags = in.readByte();
|
||||
p.direction = in.readByte();
|
||||
in.readByte(); // reserved
|
||||
p.timestampMs = in.readLong();
|
||||
p.seq = in.readLong();
|
||||
int payloadSize = in.readInt();
|
||||
if (payloadSize < 0 || payloadSize > 64 * 1024) {
|
||||
throw new IOException("Invalid diag ping payload size: " + payloadSize);
|
||||
}
|
||||
if (payloadSize > 0) {
|
||||
p.payload = new byte[payloadSize];
|
||||
in.readFully(p.payload);
|
||||
}
|
||||
if (p.isPong() && p.direction == DIR_REPLY_WITH && in.available() >= 16) {
|
||||
p.replyForSeq = in.readLong();
|
||||
p.replyForTimestampMs = in.readLong();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
/**
|
||||
* Rolling diagnostic ping RTT samples for overlays and bandwidth hints.
|
||||
*/
|
||||
public final class DiagPingMetrics {
|
||||
private static final int WINDOW = 16;
|
||||
|
||||
private final long[] rttMs = new long[WINDOW];
|
||||
private final long[] seq = new long[WINDOW];
|
||||
private int count;
|
||||
private int index;
|
||||
private long probesSent;
|
||||
private long pongsRx;
|
||||
private long lastRttMs;
|
||||
private long lastSeq;
|
||||
|
||||
public synchronized void recordProbeSent(long seqNum) {
|
||||
probesSent++;
|
||||
lastSeq = seqNum;
|
||||
}
|
||||
|
||||
public synchronized void recordPong(long rtt, long pongSeq) {
|
||||
if (rtt <= 0 || rtt > 30_000) {
|
||||
return;
|
||||
}
|
||||
pongsRx++;
|
||||
lastRttMs = rtt;
|
||||
lastSeq = pongSeq;
|
||||
rttMs[index] = rtt;
|
||||
seq[index] = pongSeq;
|
||||
index = (index + 1) % WINDOW;
|
||||
if (count < WINDOW) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
Snapshot s = new Snapshot();
|
||||
s.enabled = true;
|
||||
s.probesSent = probesSent;
|
||||
s.pongsRx = pongsRx;
|
||||
s.lastRttMs = (int) lastRttMs;
|
||||
s.lastSeq = lastSeq;
|
||||
if (count == 0) {
|
||||
return s;
|
||||
}
|
||||
long sum = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sum += rttMs[i];
|
||||
}
|
||||
s.avgRttMs = (int) (sum / count);
|
||||
return s;
|
||||
}
|
||||
|
||||
public synchronized int preferredRttMs(int fallbackRtt) {
|
||||
Snapshot s = snapshot();
|
||||
if (s.avgRttMs > 0) {
|
||||
return s.avgRttMs;
|
||||
}
|
||||
if (s.lastRttMs > 0) {
|
||||
return s.lastRttMs;
|
||||
}
|
||||
return fallbackRtt;
|
||||
}
|
||||
|
||||
public synchronized void reset() {
|
||||
count = 0;
|
||||
index = 0;
|
||||
probesSent = 0;
|
||||
pongsRx = 0;
|
||||
lastRttMs = 0;
|
||||
lastSeq = 0;
|
||||
}
|
||||
|
||||
public static final class Snapshot {
|
||||
public boolean enabled;
|
||||
public long probesSent;
|
||||
public long pongsRx;
|
||||
public int lastRttMs;
|
||||
public int avgRttMs;
|
||||
public long lastSeq;
|
||||
|
||||
public static final Snapshot EMPTY = new Snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Optional ~1 Hz diagnostic ping-pong during an active cast (developer setting).
|
||||
* Unknown {@link CastProtocol#MSG_DIAG_PING} on old peers is ignored by their handlers.
|
||||
*/
|
||||
public final class DiagPingSession {
|
||||
private static final String TAG = "DiagPing";
|
||||
private static final long PROBE_INTERVAL_MS = 1_000L;
|
||||
|
||||
public interface SampleListener {
|
||||
void onDiagPingSample(DiagPingMetrics.Snapshot snapshot);
|
||||
}
|
||||
|
||||
private final CastSession session;
|
||||
private final byte localResponseMode;
|
||||
private final DiagPingMetrics metrics = new DiagPingMetrics();
|
||||
private final ConcurrentHashMap<Long, Long> pendingProbeSentMs = new ConcurrentHashMap<>();
|
||||
private final SampleListener listener;
|
||||
|
||||
private volatile boolean enabled;
|
||||
private long nextSeq;
|
||||
private long lastProbeMs;
|
||||
|
||||
public DiagPingSession(CastSession session, byte localResponseMode, SampleListener listener) {
|
||||
this.session = session;
|
||||
this.localResponseMode = localResponseMode;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean on) {
|
||||
enabled = on;
|
||||
if (!on) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DiagPingMetrics metrics() {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
nextSeq = 0;
|
||||
lastProbeMs = 0;
|
||||
pendingProbeSentMs.clear();
|
||||
metrics.reset();
|
||||
}
|
||||
|
||||
public void tick(long nowMs) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (lastProbeMs == 0 || nowMs - lastProbeMs >= PROBE_INTERVAL_MS) {
|
||||
sendProbe(nowMs);
|
||||
lastProbeMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return true if consumed (including invalid magic). */
|
||||
public boolean handleMessage(CastProtocol.Message msg) {
|
||||
if (msg.type != CastProtocol.MSG_DIAG_PING) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
CastDiagPing.Packet p = CastProtocol.parseDiagPing(msg.payload);
|
||||
if (!p.isValidMagic()) {
|
||||
Log.w(TAG, "Ignoring diag ping with bad magic/version");
|
||||
return true;
|
||||
}
|
||||
if (p.isPong()) {
|
||||
onPong(p);
|
||||
} else {
|
||||
onProbe(p);
|
||||
}
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Diag ping parse failed", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void onProbe(CastDiagPing.Packet probe) throws IOException {
|
||||
CastDiagPing.Packet pong = new CastDiagPing.Packet();
|
||||
pong.flags = CastDiagPing.FLAG_PONG;
|
||||
pong.direction = probe.direction;
|
||||
pong.payload = probe.payload != null ? probe.payload : new byte[0];
|
||||
switch (probe.direction) {
|
||||
case CastDiagPing.DIR_MIRROR_AS_IS:
|
||||
pong.timestampMs = probe.timestampMs;
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
case CastDiagPing.DIR_MIRROR_SMART:
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
case CastDiagPing.DIR_REPLY_WITH:
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = nextSeq++;
|
||||
pong.replyForSeq = probe.seq;
|
||||
pong.replyForTimestampMs = probe.timestampMs;
|
||||
break;
|
||||
default:
|
||||
pong.direction = CastDiagPing.DIR_MIRROR_SMART;
|
||||
pong.timestampMs = System.currentTimeMillis();
|
||||
pong.seq = probe.seq;
|
||||
break;
|
||||
}
|
||||
session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(pong));
|
||||
}
|
||||
|
||||
private void onPong(CastDiagPing.Packet pong) {
|
||||
long now = System.currentTimeMillis();
|
||||
long rtt;
|
||||
if (pong.direction == CastDiagPing.DIR_REPLY_WITH) {
|
||||
Long sentAt = pendingProbeSentMs.remove(pong.replyForSeq);
|
||||
rtt = sentAt != null ? now - sentAt : now - pong.replyForTimestampMs;
|
||||
} else {
|
||||
Long sentAt = pendingProbeSentMs.remove(pong.seq);
|
||||
rtt = sentAt != null ? now - sentAt : now - pong.timestampMs;
|
||||
}
|
||||
metrics.recordPong(rtt, pong.seq);
|
||||
notifyListener();
|
||||
}
|
||||
|
||||
private void sendProbe(long nowMs) {
|
||||
try {
|
||||
long seq = nextSeq++;
|
||||
pendingProbeSentMs.put(seq, nowMs);
|
||||
metrics.recordProbeSent(seq);
|
||||
CastDiagPing.Packet probe = new CastDiagPing.Packet();
|
||||
probe.timestampMs = nowMs;
|
||||
probe.seq = seq;
|
||||
probe.direction = localResponseMode;
|
||||
probe.payload = new byte[0];
|
||||
session.send(CastProtocol.MSG_DIAG_PING, CastProtocol.diagPingPayload(probe));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Send diag probe failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListener() {
|
||||
if (listener != null) {
|
||||
listener.onDiagPingSample(metrics.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,11 @@ public class ReceiverCastService extends Service {
|
||||
public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
|
||||
mainHandler.post(() -> streamMetrics.onPeerStats(stats));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) {
|
||||
mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot));
|
||||
}
|
||||
}, this::enrichReceiverStats);
|
||||
session.start();
|
||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.util.Log;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.DeviceInfo;
|
||||
@@ -32,6 +33,7 @@ import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -71,6 +73,10 @@ public class ReceiverSession {
|
||||
void onNetworkAdviceApplied();
|
||||
|
||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||
|
||||
/** Diagnostic ping-pong sample (developer setting). */
|
||||
default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
@@ -174,8 +180,14 @@ public class ReceiverSession {
|
||||
listener.onCastSessionStarting();
|
||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||
.setStatsEnricher(statsEnricher);
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(statsEnricher);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(appContext),
|
||||
listener::onDiagPingSample);
|
||||
}
|
||||
}
|
||||
session.acceptClient();
|
||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||
|
||||
@@ -12,6 +12,7 @@ package com.foxx.androidcast.receiver;
|
||||
* Digest: SHA256 d8a6a1d9253073899f2be582d7334435d8b5e69563656098aef7749f038234ab
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
|
||||
/** Receiver-side counters for diagnostics overlay and bandwidth estimates. */
|
||||
public final class StreamMetrics {
|
||||
@@ -39,6 +40,7 @@ public final class StreamMetrics {
|
||||
private int peerRecvKbps;
|
||||
private int peerSendKbps;
|
||||
private NetworkStatsSnapshot lastPeer;
|
||||
private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
|
||||
public synchronized void reset() {
|
||||
videoPackets = 0;
|
||||
@@ -65,6 +67,20 @@ public final class StreamMetrics {
|
||||
peerRecvKbps = 0;
|
||||
peerSendKbps = 0;
|
||||
lastPeer = null;
|
||||
diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
}
|
||||
|
||||
public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) {
|
||||
diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY;
|
||||
if (diagPing.avgRttMs > 0) {
|
||||
peerRttMs = diagPing.avgRttMs;
|
||||
} else if (diagPing.lastRttMs > 0) {
|
||||
peerRttMs = diagPing.lastRttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized DiagPingMetrics.Snapshot getDiagPing() {
|
||||
return diagPing;
|
||||
}
|
||||
|
||||
public synchronized void onVideoPacket(boolean keyFrame, int bytes) {
|
||||
|
||||
@@ -278,6 +278,11 @@ public class ScreenCastService extends Service implements
|
||||
PassThroughNetworkControlPlane plane =
|
||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||
plane.setStatsEnricher(this::enrichSenderStats);
|
||||
if (AppPreferences.isDevDiagPingPongEnabled(this)) {
|
||||
networkFeedback.configureDiagPing(true,
|
||||
AppPreferences.getDevDiagPingResponseMode(this),
|
||||
snapshot -> senderMetrics.onDiagPingSample(snapshot));
|
||||
}
|
||||
if (peers.size() > 1) {
|
||||
fanoutPump = new CastFanoutPump();
|
||||
fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane);
|
||||
|
||||
@@ -11,6 +11,8 @@ package com.foxx.androidcast.sender;
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 65d468449f4cd236559457f2519730c3c07688bfe3082a961c081d6bd629d7f0
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
|
||||
/** Sender-side encode/send counters for notification diagnostics. */
|
||||
public final class SenderStreamMetrics {
|
||||
private long encodedFrames;
|
||||
@@ -20,6 +22,16 @@ public final class SenderStreamMetrics {
|
||||
private float lastSecondFps;
|
||||
private int lastSendKbps;
|
||||
private int peerRttMs;
|
||||
private DiagPingMetrics.Snapshot diagPing = DiagPingMetrics.Snapshot.EMPTY;
|
||||
|
||||
public synchronized void onDiagPingSample(DiagPingMetrics.Snapshot sample) {
|
||||
diagPing = sample != null ? sample : DiagPingMetrics.Snapshot.EMPTY;
|
||||
if (diagPing.avgRttMs > 0) {
|
||||
peerRttMs = diagPing.avgRttMs;
|
||||
} else if (diagPing.lastRttMs > 0) {
|
||||
peerRttMs = diagPing.lastRttMs;
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void onEncodedFrame(boolean keyFrame) {
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -66,6 +78,9 @@ public final class SenderStreamMetrics {
|
||||
if (peerRttMs > 0) {
|
||||
sb.append(" · rtt ").append(peerRttMs).append("ms");
|
||||
}
|
||||
if (diagPing.enabled && diagPing.pongsRx > 0) {
|
||||
sb.append(" · ping ").append(diagPing.avgRttMs > 0 ? diagPing.avgRttMs : diagPing.lastRttMs).append("ms");
|
||||
}
|
||||
if (pump != null) {
|
||||
int dropped = pump.getDroppedPFrames();
|
||||
int q = pump.getQueueDepth();
|
||||
|
||||
@@ -30,6 +30,33 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_grab_session_stats" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_dev_diag_ping_pong"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_diag_ping_pong" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/dev_diag_ping_response_mode"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_dev_diag_ping_response_mode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/dev_diag_ping_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_receiver_adaptive_display"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -250,6 +250,12 @@
|
||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
||||
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||
<string name="dev_diag_ping_pong">Diagnostic cast ping-pong (~1/s)</string>
|
||||
<string name="dev_diag_ping_response_mode">Ping response mode (this device)</string>
|
||||
<string name="dev_diag_ping_response_mirror">Mirror as-is (dummy pong)</string>
|
||||
<string name="dev_diag_ping_response_smart">Mirror smart (local timestamp)</string>
|
||||
<string name="dev_diag_ping_response_reply">Reply-with (seq + timestamps)</string>
|
||||
<string name="dev_diag_ping_hint">Requires both peers on a build that supports MSG_DIAG_PING. Old peers ignore unknown packets.</string>
|
||||
<string name="session_stats_title">Session statistics</string>
|
||||
<string name="session_stats_subtitle">Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).</string>
|
||||
<string name="session_stats_open">Open statistics analyzer</string>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.foxx.androidcast.network.diag;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class CastDiagPingTest {
|
||||
|
||||
@Test
|
||||
public void roundTrip_probe() throws Exception {
|
||||
CastDiagPing.Packet in = new CastDiagPing.Packet();
|
||||
in.timestampMs = 1_700_000_000_123L;
|
||||
in.seq = 42;
|
||||
in.direction = CastDiagPing.DIR_MIRROR_SMART;
|
||||
in.payload = new byte[0];
|
||||
byte[] wire = CastDiagPing.encode(in);
|
||||
CastDiagPing.Packet out = CastDiagPing.decode(wire);
|
||||
assertTrue(out.isValidMagic());
|
||||
assertEquals(in.timestampMs, out.timestampMs);
|
||||
assertEquals(in.seq, out.seq);
|
||||
assertEquals(in.direction, out.direction);
|
||||
assertEquals(0, out.payload.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTrip_replyWithPong() throws Exception {
|
||||
CastDiagPing.Packet in = new CastDiagPing.Packet();
|
||||
in.flags = CastDiagPing.FLAG_PONG;
|
||||
in.direction = CastDiagPing.DIR_REPLY_WITH;
|
||||
in.timestampMs = 1_700_000_000_500L;
|
||||
in.seq = 7;
|
||||
in.replyForSeq = 6;
|
||||
in.replyForTimestampMs = 1_700_000_000_400L;
|
||||
byte[] wire = CastDiagPing.encode(in);
|
||||
CastDiagPing.Packet out = CastDiagPing.decode(wire);
|
||||
assertTrue(out.isPong());
|
||||
assertEquals(CastDiagPing.DIR_REPLY_WITH, out.direction);
|
||||
assertEquals(in.replyForSeq, out.replyForSeq);
|
||||
assertEquals(in.replyForTimestampMs, out.replyForTimestampMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user