1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:20:00 +03:00

next force snapshot

This commit is contained in:
Anton Afanasyeu
2026-05-30 16:23:01 +02:00
parent eebee1864d
commit 3b0a694aa6
34 changed files with 1759 additions and 130 deletions

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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)};
}

View File

@@ -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));

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}

View File

@@ -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()));

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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"

View File

@@ -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>

View File

@@ -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);
}
}