diff --git a/app/build.gradle b/app/build.gradle
index 4d4aeb5..9b59889 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -93,6 +93,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
+ implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
testImplementation 'junit:junit:4.13.2'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e31e0a9..8ef1fb7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -64,6 +64,22 @@
android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" />
+
+
+
+
+
+
+ startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
+
reloadCodecs.setOnClickListener(v -> {
String summary = CodecPriorityCatalog.reload(this);
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
index af790f0..124344d 100644
--- a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
+++ b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
@@ -42,8 +42,15 @@ public final class RgbToYuv420 {
int height = image.getHeight();
int need = bufferSize(width, height);
byte[] out = scratch != null && scratch.length >= need ? scratch : new byte[need];
- Image.Plane plane = image.getPlanes()[0];
+ Image.Plane[] planes = image.getPlanes();
+ if (planes == null || planes.length == 0) {
+ return null;
+ }
+ Image.Plane plane = planes[0];
java.nio.ByteBuffer buffer = plane.getBuffer();
+ if (buffer == null) {
+ return null;
+ }
int rowStride = plane.getRowStride();
int pixelStride = plane.getPixelStride();
int[] argb = new int[width * height];
diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java
index 8030046..1a4d87b 100644
--- a/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java
+++ b/app/src/main/java/com/foxx/androidcast/media/codec/LibvpxVideoEncoderSink.java
@@ -18,6 +18,8 @@ import com.foxx.androidcast.media.codec.jni.VpxEncodedFrame;
import com.foxx.androidcast.sender.VideoEncoder;
import java.io.IOException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
/**
* Software VP8/VP9 via libvpx when {@link NativeCodecBridge#isLibvpxAvailable()}.
@@ -30,17 +32,15 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
private final VideoEncoder hwFallback = new VideoEncoder();
private Callback callback;
- private long nativeHandle;
- private boolean nativeActive;
+ private final AtomicLong nativeHandle = new AtomicLong();
private int width;
private int height;
private String mime;
private byte[] yuvScratch;
private boolean forceKeyframe;
- private HandlerThread captureThread;
- private Handler captureHandler;
- private ImageReader imageReader;
+ /** Active capture pipeline; {@link #releaseCapture()} swaps it out via {@code getAndSet(null)}. */
+ private final AtomicReference capturePipeline = new AtomicReference<>();
public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) {
this.preference = preference;
@@ -48,16 +48,17 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
@Override
public Surface getInputSurface() {
- if (nativeActive && imageReader != null) {
- return imageReader.getSurface();
+ CapturePipeline pipe = capturePipeline.get();
+ if (nativeHandle.get() != 0L && pipe != null) {
+ return pipe.reader.getSurface();
}
return hwFallback.getInputSurface();
}
@Override
public void requestKeyframe() {
- if (nativeActive) {
- NativeCodecBridge.vpxRequestKeyframe(nativeHandle);
+ if (nativeHandle.get() != 0L) {
+ NativeCodecBridge.vpxRequestKeyframe(nativeHandle.get());
forceKeyframe = true;
} else {
hwFallback.requestKeyframe();
@@ -68,6 +69,7 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
releaseCapture();
+ releaseNativeEncoder();
this.callback = callback;
this.width = width;
this.height = height;
@@ -75,38 +77,36 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
- nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
- nativeActive = nativeHandle != 0L;
- if (!nativeActive) {
+ long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
+ if (handle == 0L) {
Log.w(TAG, "libvpx encoder init failed; using MediaCodec for surface capture");
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
return hwFallback.prepare(width, height, mime, params, callback);
}
+ nativeHandle.set(handle);
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
- captureThread = new HandlerThread("libvpx-capture");
- captureThread.start();
- captureHandler = new Handler(captureThread.getLooper());
- imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
- imageReader.setOnImageAvailableListener(this::onImageAvailable, captureHandler);
+ CapturePipeline pipe = CapturePipeline.create(width, height, this::onImageAvailable);
+ capturePipeline.set(pipe);
callback.onConfig(width, height, null, null);
Log.i(TAG, "libvpx surface encoder " + width + "x" + height + " mime=" + mime);
- return imageReader.getSurface();
+ return pipe.reader.getSurface();
}
@Override
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
Callback callback) throws IOException {
releaseCapture();
+ releaseNativeEncoder();
this.width = width;
this.height = height;
this.mime = mime;
this.callback = callback;
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
- nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
- nativeActive = nativeHandle != 0L;
- if (nativeActive) {
+ long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
+ if (handle != 0L) {
+ nativeHandle.set(handle);
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
callback.onConfig(width, height, null, null);
return;
@@ -116,26 +116,40 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
}
private void onImageAvailable(ImageReader reader) {
- if (!nativeActive || callback == null) {
+ CapturePipeline pipe = capturePipeline.get();
+ if (pipe == null || pipe.reader != reader || callback == null) {
+ return;
+ }
+ long handle = nativeHandle.get();
+ if (handle == 0L) {
return;
}
try (Image image = reader.acquireLatestImage()) {
if (image == null) {
return;
}
+ if (image.getWidth() != width || image.getHeight() != height) {
+ return;
+ }
byte[] yuv = RgbToYuv420.fromRgbaImage(image, yuvScratch);
if (yuv == null) {
return;
}
+ if (capturePipeline.get() != pipe) {
+ return;
+ }
long ptsUs = image.getTimestamp() / 1000L;
- emitEncodedFrame(yuv, ptsUs);
+ emitEncodedFrame(handle, yuv, ptsUs);
} catch (Exception e) {
Log.w(TAG, "Capture frame skipped: " + e.getMessage());
}
}
- private void emitEncodedFrame(byte[] yuv, long ptsUs) {
- VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(nativeHandle, yuv, ptsUs, forceKeyframe);
+ private void emitEncodedFrame(long handle, byte[] yuv, long ptsUs) {
+ if (nativeHandle.get() != handle) {
+ return;
+ }
+ VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(handle, yuv, ptsUs, forceKeyframe);
forceKeyframe = false;
if (frame != null && frame.data != null && callback != null) {
callback.onEncodedFrame(ptsUs, frame.keyFrame, frame.data);
@@ -144,43 +158,73 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
@Override
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
- if (!nativeActive) {
+ long handle = nativeHandle.get();
+ if (handle == 0L) {
hwFallback.queueBitmapFrame(bitmap, ptsUs);
return;
}
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420);
- emitEncodedFrame(yuv, ptsUs);
+ emitEncodedFrame(handle, yuv, ptsUs);
}
@Override
public void queueYuvFrame(byte[] yuv, long ptsUs) {
- if (!nativeActive) {
+ long handle = nativeHandle.get();
+ if (handle == 0L) {
hwFallback.queueYuvFrame(yuv, ptsUs);
return;
}
- emitEncodedFrame(yuv, ptsUs);
+ emitEncodedFrame(handle, yuv, ptsUs);
}
@Override
public void stop() {
- if (nativeActive) {
- NativeCodecBridge.vpxEncoderRelease(nativeHandle);
- nativeHandle = 0L;
- nativeActive = false;
- }
releaseCapture();
+ releaseNativeEncoder();
hwFallback.stop();
}
private void releaseCapture() {
- if (imageReader != null) {
- imageReader.close();
- imageReader = null;
+ CapturePipeline old = capturePipeline.getAndSet(null);
+ if (old != null) {
+ old.release();
}
- if (captureThread != null) {
- captureThread.quitSafely();
- captureThread = null;
- captureHandler = null;
+ }
+
+ private void releaseNativeEncoder() {
+ long handle = nativeHandle.getAndSet(0L);
+ if (handle != 0L) {
+ NativeCodecBridge.vpxEncoderRelease(handle);
+ }
+ }
+
+ private static final class CapturePipeline {
+ final ImageReader reader;
+ final HandlerThread thread;
+
+ private CapturePipeline(ImageReader reader, HandlerThread thread) {
+ this.reader = reader;
+ this.thread = thread;
+ }
+
+ static CapturePipeline create(int width, int height, ImageReader.OnImageAvailableListener listener) {
+ HandlerThread thread = new HandlerThread("libvpx-capture");
+ thread.start();
+ Handler handler = new Handler(thread.getLooper());
+ ImageReader reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
+ reader.setOnImageAvailableListener(listener, handler);
+ return new CapturePipeline(reader, thread);
+ }
+
+ void release() {
+ reader.setOnImageAvailableListener(null, null);
+ reader.close();
+ thread.quitSafely();
+ try {
+ thread.join(2000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java
index 80b4a10..877be8c 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java
@@ -5,6 +5,7 @@ import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.sender.CodecCatalog;
+import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.IOException;
import java.util.List;
@@ -55,20 +56,28 @@ public class CastSession {
}
public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
- transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
- transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
- CastProtocol.Message reply = transport.receive(10_000);
+ byte[] hello = CastProtocol.helloPayload(deviceName);
+ transport.send(CastProtocol.MSG_HELLO, hello);
+ recordOutbound(CastProtocol.MSG_HELLO, hello);
+ byte[] auth = CastProtocol.authPayload(pin);
+ transport.send(CastProtocol.MSG_AUTH, auth);
+ recordOutbound(CastProtocol.MSG_AUTH, auth);
+ CastProtocol.Message reply = receive(10_000);
if (reply == null || reply.type != CastProtocol.MSG_AUTH_OK) {
throw new IOException("Authentication failed");
}
- transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
- transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
+ byte[] castSettings = CastProtocol.castSettingsPayload(settings);
+ transport.send(CastProtocol.MSG_CAST_SETTINGS, castSettings);
+ recordOutbound(CastProtocol.MSG_CAST_SETTINGS, castSettings);
+ byte[] codecCaps = CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes());
+ transport.send(CastProtocol.MSG_CODEC_CAPS, codecCaps);
+ recordOutbound(CastProtocol.MSG_CODEC_CAPS, codecCaps);
String negotiated = null;
CastSettings receiverAdvertisement = null;
long deadline = System.currentTimeMillis() + 15_000;
while (System.currentTimeMillis() < deadline) {
- CastProtocol.Message msg = transport.receive(2_000);
+ CastProtocol.Message msg = receive(2_000);
if (msg == null) {
continue;
}
@@ -94,7 +103,7 @@ public class CastSession {
List senderCaps = null;
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) {
- CastProtocol.Message msg = transport.receive(2_000);
+ CastProtocol.Message msg = receive(2_000);
if (msg == null) {
continue;
}
@@ -106,8 +115,10 @@ public class CastSession {
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
authed = true;
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
+ recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
} else {
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
throw new IOException("Bad PIN");
}
break;
@@ -130,13 +141,18 @@ public class CastSession {
receiverAdvert.setStreamProtection(
com.foxx.androidcast.network.transport.StreamProtectionCapability
.handshakeAdvertisement());
- transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS,
- CastProtocol.castSettingsPayload(receiverAdvert));
+ byte[] recvSettings = CastProtocol.castSettingsPayload(receiverAdvert);
+ transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
+ recordOutbound(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
List receiverCaps = CodecCatalog.localDecoderMimes();
- transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
+ byte[] recvCaps = CastProtocol.codecCapsPayload(receiverCaps);
+ transport.send(CastProtocol.MSG_CODEC_CAPS, recvCaps);
+ recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps);
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
- transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
+ byte[] selected = CastProtocol.codecSelectedPayload(mime);
+ transport.send(CastProtocol.MSG_CODEC_SELECTED, selected);
+ recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected);
remote.setNegotiatedVideoMime(mime);
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
}
@@ -148,8 +164,30 @@ public class CastSession {
transport.send(type, payload);
}
+ private static void recordOutbound(byte type, byte[] payload) {
+ int size = payload != null ? payload.length : 0;
+ SessionStatsContext.recordOutboundMessage(type, false, size);
+ }
+
+ private static void recordInbound(CastProtocol.Message msg) {
+ if (msg == null) {
+ return;
+ }
+ boolean keyFrame = false;
+ if (msg.type == CastProtocol.MSG_VIDEO_FRAME) {
+ try {
+ keyFrame = CastProtocol.parseVideoFrame(msg.payload).keyFrame;
+ } catch (IOException ignored) {
+ }
+ }
+ int size = msg.payload != null ? msg.payload.length : 0;
+ SessionStatsContext.recordInboundMessage(msg.type, keyFrame, size);
+ }
+
public CastProtocol.Message receive(int timeoutMs) throws IOException {
- return transport.receive(timeoutMs);
+ CastProtocol.Message msg = transport.receive(timeoutMs);
+ recordInbound(msg);
+ return msg;
}
public void releaseConnection() {
diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
index d21cc13..2a2f075 100644
--- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
+++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java
@@ -15,6 +15,7 @@ import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportStatsProvider;
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
import com.foxx.androidcast.network.transport.fec.FecShardWire;
+import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -242,6 +243,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
socket.send(dp);
+ SessionStatsContext.onDatagramSent(packet.length);
synchronized (lossStats) {
lossStats.datagramsSent++;
}
@@ -266,6 +268,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
DatagramPacket dp = new DatagramPacket(buf, buf.length);
socket.receive(dp);
+ SessionStatsContext.onDatagramReceived(dp.getLength());
synchronized (lossStats) {
lossStats.datagramsReceived++;
}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index d7f5a61..9d5e71a 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -36,6 +36,7 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
+import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
@@ -255,6 +256,17 @@ public class ReceiverCastService extends Service {
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
}
+ @Override
+ public void onCastSessionStarting() {
+ if (AppPreferences.isGrabSessionStats(ReceiverCastService.this)) {
+ if (sessionStatsRecorder == null) {
+ sessionStatsRecorder = new SessionStatsRecorder("recv",
+ settings.getTransport());
+ SessionStatsContext.bind(sessionStatsRecorder);
+ }
+ }
+ }
+
@Override
public void onStatus(String s) {
updateStatus(s);
@@ -393,7 +405,8 @@ public class ReceiverCastService extends Service {
castEnded = false;
if (AppPreferences.isGrabSessionStats(this)) {
if (sessionStatsRecorder == null) {
- sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
+ sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
+ SessionStatsContext.bind(sessionStatsRecorder);
sessionStatsRecorder.setClientCount(1);
}
sessionStatsRecorder.mergeSettings(remoteSettings);
@@ -460,7 +473,8 @@ public class ReceiverCastService extends Service {
}
private void checkStreamIdle() {
- if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
+ if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0
+ || awaitingKeyframe || reconfiguring) {
return;
}
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
@@ -487,14 +501,14 @@ public class ReceiverCastService extends Service {
/** Stops decode, shows robot overlay; keeps TCP session if still connected. */
private void enterAwaitingMode(int messageResId, boolean fullReset) {
streamIdle = true;
- hasPendingConfig = false;
enterAwaitingKeyframe();
- resetDecoderState();
- if (videoDecoder != null) {
- videoDecoder.release();
- videoDecoder = VideoDecoderFactory.createDecoder();
- }
if (fullReset) {
+ hasPendingConfig = false;
+ resetDecoderState();
+ if (videoDecoder != null) {
+ videoDecoder.release();
+ videoDecoder = VideoDecoderFactory.createDecoder();
+ }
streamMetrics.reset();
}
broadcastStreamIdle();
@@ -576,6 +590,7 @@ public class ReceiverCastService extends Service {
}
reconfiguring = true;
enterAwaitingKeyframe();
+ lastVideoFrameMs = System.currentTimeMillis();
openPlaybackAwaiting(R.string.playback_awaiting_stream);
pendingWidth = size.width;
pendingHeight = size.height;
@@ -684,7 +699,6 @@ public class ReceiverCastService extends Service {
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
- reconfiguring = false;
return;
}
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
@@ -865,6 +879,10 @@ public class ReceiverCastService extends Service {
videoDecoder.release();
videoDecoder = VideoDecoderFactory.createDecoder();
}
+ if (hasPendingConfig && phase == Phase.STREAMING) {
+ enterAwaitingKeyframe();
+ scheduleDecoderConfigureRetries();
+ }
}
private void updateStatus(String s) {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
index 3efa09f..fb4f0d6 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -73,7 +73,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
private boolean bound;
private boolean autoStartListening;
private boolean listeningCancelled;
- private boolean autoPipAttempted;
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
@Override
@@ -249,7 +248,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
mainHandler.post(diagnosticsRefresh);
updateDiagnosticsVisibility();
updatePipUi();
- maybeAutoEnterPip();
}
@Override
@@ -272,7 +270,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
updatePipUi();
- maybeAutoEnterPip();
}
}
@@ -489,6 +486,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
texture.setDefaultBufferSize(videoWidth, videoHeight);
}
applyTextureFitCenter();
+ // Rotation often recreates the texture; re-bind decoder to the new Surface.
+ surfaceSentToService = false;
+ attachSurfaceIfReady();
}
@Override
@@ -562,32 +562,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
}
}
+ /** Auto PiP when leaving the app during an active cast, not while waiting for a sender. */
private boolean shouldAutoEnterPipOnLeave() {
if (!AppPreferences.isReceiverPipEnabled(this)
|| !PermissionHelper.hasReceiverPlaybackReady(this)) {
return false;
}
- if (castSessionActive || streamRendering) {
- return true;
- }
- return bound && awaitingOverlay != null
- && awaitingOverlay.getVisibility() == View.VISIBLE;
- }
-
- private void maybeAutoEnterPip() {
- if (!autoStartListening || autoPipAttempted || inPictureInPicture) {
- return;
- }
- if (!AppPreferences.isReceiverPipEnabled(this)
- || !PictureInPictureHelper.isSupported()) {
- return;
- }
- if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
- PermissionHelper.remindForReceiverPlayback(this);
- return;
- }
- autoPipAttempted = true;
- mainHandler.post(() -> tryEnterPictureInPicture(false));
+ return castSessionActive || streamRendering;
}
private void tryEnterPictureInPicture(boolean userInitiated) {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
index eb634ea..9cb254f 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -54,6 +54,9 @@ public class ReceiverSession {
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
void onListening();
+ /** About to accept a sender and run handshake (bind session stats before I/O). */
+ void onCastSessionStarting();
+
void onNetworkAdviceApplied();
void onPeerNetworkStats(NetworkStatsSnapshot stats);
@@ -157,6 +160,7 @@ public class ReceiverSession {
session = new CastSession(listenTransport);
}
inboundSessionGate.reset();
+ listener.onCastSessionStarting();
networkFeedback = new NetworkFeedbackManager(session, false);
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java
index c7743c1..6d932c7 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastFanoutPump.java
@@ -5,6 +5,7 @@ import android.util.Log;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
+import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.IOException;
import java.util.ArrayList;
@@ -53,6 +54,11 @@ public final class CastFanoutPump {
return videoQueue.size() + audioQueue.size();
}
+ /** Drops stale video frames (e.g. after capture resize / rotation). */
+ public void flushVideoQueue() {
+ videoQueue.clear();
+ }
+
public void start(List sessionList, Runnable onSendFailed,
PassThroughNetworkControlPlane statsPlane) {
setSessions(sessionList);
@@ -144,7 +150,7 @@ public final class CastFanoutPump {
continue;
}
byte[] payload = item.buildPayload();
- fanOut(item.type, payload);
+ fanOut(item.type, payload, item.keyFrame);
} catch (InterruptedException e) {
if (!running.get()) {
break;
@@ -158,7 +164,9 @@ public final class CastFanoutPump {
}
}
- private void fanOut(byte type, byte[] payload) {
+ private void fanOut(byte type, byte[] payload, boolean keyFrame) {
+ int payloadSize = payload != null ? payload.length : 0;
+ SessionStatsContext.recordOutboundMessage(type, keyFrame, payloadSize);
int peer = 0;
for (CastSession session : sessions) {
peer++;
diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
index a7da65c..4231c10 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java
@@ -5,6 +5,7 @@ import android.util.Log;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
+import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.IOException;
import java.util.concurrent.LinkedBlockingQueue;
@@ -39,6 +40,11 @@ public final class CastSendPump {
return videoQueue.size() + audioQueue.size();
}
+ /** Drops stale video frames (e.g. after capture resize / rotation). */
+ public void flushVideoQueue() {
+ videoQueue.clear();
+ }
+
public void start(CastSession session, Runnable onSendFailed) {
start(session, onSendFailed, null);
}
@@ -133,6 +139,8 @@ public final class CastSendPump {
}
byte[] payload = item.buildPayload();
session.send(item.type, payload);
+ int payloadSize = payload != null ? payload.length : 0;
+ SessionStatsContext.recordOutboundMessage(item.type, item.keyFrame, payloadSize);
if (statsPlane != null) {
statsPlane.recordSendBytes(payload.length + 5);
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index a54a462..60f62ed 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -47,6 +47,7 @@ import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecFactory;
import com.foxx.androidcast.media.codec.VideoEncoderSink;
import com.foxx.androidcast.media.codec.AudioEncoderSink;
+import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
@@ -240,6 +241,10 @@ public class ScreenCastService extends Service implements
}
}));
updateStatus(getString(R.string.connecting_to, host));
+ if (AppPreferences.isGrabSessionStats(this)) {
+ sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
+ SessionStatsContext.bind(sessionStatsRecorder);
+ }
multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
List peers = multiCoordinator.getPeers();
if (peers.isEmpty()) {
@@ -267,7 +272,10 @@ public class ScreenCastService extends Service implements
CastTrayNotifier.refresh(this);
activeSettings = settings;
if (AppPreferences.isGrabSessionStats(this)) {
- sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
+ if (sessionStatsRecorder == null) {
+ sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
+ SessionStatsContext.bind(sessionStatsRecorder);
+ }
sessionStatsRecorder.mergeSettings(settings);
sessionStatsRecorder.setClientCount(peers.size());
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
@@ -461,6 +469,11 @@ public class ScreenCastService extends Service implements
videoEncoder.stop();
videoEncoder = null;
}
+ if (fanoutPump != null) {
+ fanoutPump.flushVideoQueue();
+ } else if (sendPump != null) {
+ sendPump.flushVideoQueue();
+ }
captureWidth = size.width;
captureHeight = size.height;
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionEventJournal.java b/app/src/main/java/com/foxx/androidcast/stats/SessionEventJournal.java
new file mode 100644
index 0000000..6dd3e33
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionEventJournal.java
@@ -0,0 +1,79 @@
+package com.foxx.androidcast.stats;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/**
+ * Per-session event log ({@code send} or {@code recv} array only) with ~1 Hz {@code general} rollups.
+ */
+final class SessionEventJournal {
+ private static final long GENERAL_INTERVAL_MS = 1000L;
+ private static final int MAX_ENTRIES = 100_000;
+
+ private final long sessionStartMs;
+ private final JSONArray events = new JSONArray();
+ private long generalAccumulator;
+ private long lastGeneralFlushMs;
+
+ SessionEventJournal(long sessionStartMs) {
+ this.sessionStartMs = sessionStartMs;
+ lastGeneralFlushMs = sessionStartMs;
+ }
+
+ synchronized void record(String type, int size) {
+ if (type == null || size <= 0) {
+ return;
+ }
+ if (events.length() >= MAX_ENTRIES) {
+ return;
+ }
+ try {
+ JSONObject e = new JSONObject();
+ e.put("ts", System.currentTimeMillis() - sessionStartMs);
+ e.put("type", type);
+ e.put("size", size);
+ events.put(e);
+ } catch (Exception ignored) {
+ }
+ }
+
+ synchronized void addGeneralBytes(int wireBytes) {
+ if (wireBytes > 0) {
+ generalAccumulator += wireBytes;
+ }
+ }
+
+ synchronized void flushGeneralIfDue() {
+ long now = System.currentTimeMillis();
+ if (now - lastGeneralFlushMs < GENERAL_INTERVAL_MS) {
+ return;
+ }
+ flushGeneral(now);
+ }
+
+ synchronized void flushGeneralFinal() {
+ flushGeneral(System.currentTimeMillis());
+ }
+
+ private void flushGeneral(long now) {
+ if (events.length() >= MAX_ENTRIES) {
+ generalAccumulator = 0;
+ lastGeneralFlushMs = now;
+ return;
+ }
+ try {
+ JSONObject e = new JSONObject();
+ e.put("ts", now - sessionStartMs);
+ e.put("type", SessionStatsEventMapper.GENERAL);
+ e.put("size", generalAccumulator);
+ events.put(e);
+ } catch (Exception ignored) {
+ }
+ generalAccumulator = 0;
+ lastGeneralFlushMs = now;
+ }
+
+ synchronized JSONArray copyEvents() {
+ return events;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java
new file mode 100644
index 0000000..be66f10
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsContext.java
@@ -0,0 +1,57 @@
+package com.foxx.androidcast.stats;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Active session stats recorder for transport/pump hooks without threading references everywhere. */
+public final class SessionStatsContext {
+ private static final AtomicReference ACTIVE = new AtomicReference<>();
+
+ private SessionStatsContext() {}
+
+ public static void bind(SessionStatsRecorder recorder) {
+ ACTIVE.set(recorder);
+ }
+
+ public static void unbind(SessionStatsRecorder recorder) {
+ ACTIVE.compareAndSet(recorder, null);
+ }
+
+ public static SessionStatsRecorder get() {
+ return ACTIVE.get();
+ }
+
+ public static void recordOutboundMessage(byte type, boolean keyFrame, int payloadBytes) {
+ SessionStatsRecorder r = ACTIVE.get();
+ if (r != null) {
+ r.recordOutboundMessage(type, keyFrame, payloadBytes);
+ }
+ }
+
+ public static void recordInboundMessage(byte type, boolean keyFrame, int payloadBytes) {
+ SessionStatsRecorder r = ACTIVE.get();
+ if (r != null) {
+ r.recordInboundMessage(type, keyFrame, payloadBytes);
+ }
+ }
+
+ public static void onDatagramSent(int wireBytes) {
+ SessionStatsRecorder r = ACTIVE.get();
+ if (r != null) {
+ r.onDatagramSent(wireBytes);
+ }
+ }
+
+ public static void onDatagramReceived(int wireBytes) {
+ SessionStatsRecorder r = ACTIVE.get();
+ if (r != null) {
+ r.onDatagramReceived(wireBytes);
+ }
+ }
+
+ public static void flushGeneralIfDue() {
+ SessionStatsRecorder r = ACTIVE.get();
+ if (r != null) {
+ r.flushGeneralIfDue();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsEventMapper.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsEventMapper.java
new file mode 100644
index 0000000..3f7760c
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsEventMapper.java
@@ -0,0 +1,44 @@
+package com.foxx.androidcast.stats;
+
+import com.foxx.androidcast.network.CastProtocol;
+
+/** Maps cast protocol message types to session journal event types. */
+public final class SessionStatsEventMapper {
+ public static final String KF = "kf";
+ public static final String VIDEO = "video";
+ public static final String AUDIO = "audio";
+ public static final String META = "meta";
+ public static final String SERVICE = "service";
+ public static final String UNKNOWN = "unknown";
+ public static final String GENERAL = "general";
+
+ private SessionStatsEventMapper() {}
+
+ /** Returns journal type for an app-layer message, or null if it should not be logged. */
+ public static String eventTypeForMessage(byte type, boolean keyFrame) {
+ switch (type) {
+ case CastProtocol.MSG_VIDEO_FRAME:
+ return keyFrame ? KF : VIDEO;
+ case CastProtocol.MSG_AUDIO_FRAME:
+ return AUDIO;
+ case CastProtocol.MSG_STREAM_CONFIG:
+ case CastProtocol.MSG_AUDIO_CONFIG:
+ case CastProtocol.MSG_NETWORK_STATS:
+ case CastProtocol.MSG_NETWORK_CONTROL:
+ return META;
+ case CastProtocol.MSG_HELLO:
+ case CastProtocol.MSG_AUTH:
+ case CastProtocol.MSG_AUTH_OK:
+ case CastProtocol.MSG_AUTH_FAIL:
+ case CastProtocol.MSG_CAST_SETTINGS:
+ case CastProtocol.MSG_RECEIVER_CAST_SETTINGS:
+ case CastProtocol.MSG_CODEC_CAPS:
+ case CastProtocol.MSG_CODEC_SELECTED:
+ case CastProtocol.MSG_GOODBYE:
+ case CastProtocol.MSG_AUDIO_CONSENT:
+ return SERVICE;
+ default:
+ return UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java
index c3e317f..596cede 100644
--- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java
@@ -16,8 +16,11 @@ import org.json.JSONObject;
*/
public final class SessionStatsRecorder {
private final long startedMs = System.currentTimeMillis();
+ /** {@code send} or {@code recv}. */
+ private final String direction;
private final String role;
private final String transport;
+ private final SessionEventJournal journal;
private String videoCodec = "";
private String audioCodec = "AAC";
private String captureMode = "";
@@ -40,9 +43,56 @@ public final class SessionStatsRecorder {
private final JSONArray samples = new JSONArray();
private long lastSampleMs;
- public SessionStatsRecorder(String role, String transport) {
- this.role = role;
+ /** @param direction {@code send} or {@code recv}. */
+ public SessionStatsRecorder(String direction, String transport) {
+ this.direction = "recv".equals(direction) ? "recv" : "send";
+ this.role = "recv".equals(this.direction) ? "receiver" : "sender";
this.transport = transport != null ? transport : "unknown";
+ this.journal = new SessionEventJournal(startedMs);
+ }
+
+ public String getDirection() {
+ return direction;
+ }
+
+ public boolean isSendDirection() {
+ return "send".equals(direction);
+ }
+
+ public void recordOutboundMessage(byte type, boolean keyFrame, int payloadBytes) {
+ if (!isSendDirection() || payloadBytes <= 0) {
+ return;
+ }
+ String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
+ if (eventType != null) {
+ journal.record(eventType, payloadBytes);
+ }
+ }
+
+ public void recordInboundMessage(byte type, boolean keyFrame, int payloadBytes) {
+ if (isSendDirection() || payloadBytes <= 0) {
+ return;
+ }
+ String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
+ if (eventType != null) {
+ journal.record(eventType, payloadBytes);
+ }
+ }
+
+ public void onDatagramSent(int wireBytes) {
+ if (isSendDirection()) {
+ journal.addGeneralBytes(wireBytes);
+ }
+ }
+
+ public void onDatagramReceived(int wireBytes) {
+ if (!isSendDirection()) {
+ journal.addGeneralBytes(wireBytes);
+ }
+ }
+
+ public void flushGeneralIfDue() {
+ journal.flushGeneralIfDue();
}
public void setClientCount(int count) {
@@ -80,6 +130,7 @@ public final class SessionStatsRecorder {
}
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
+ flushGeneralIfDue();
networkSamples++;
NetworkStatsSnapshot s = local != null ? local : peer;
if (s == null) {
@@ -130,7 +181,15 @@ public final class SessionStatsRecorder {
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
+ lossTotals.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
lossTotals.protectionMode = loss.protectionMode;
+ lossTotals.fecPacketsEncoded = loss.fecPacketsEncoded;
+ lossTotals.fecPacketsDecoded = loss.fecPacketsDecoded;
+ lossTotals.fecDecodeFailures = loss.fecDecodeFailures;
+ lossTotals.nackRequestsSent = loss.nackRequestsSent;
+ lossTotals.nackRequestsReceived = loss.nackRequestsReceived;
+ lossTotals.nackRetransmits = loss.nackRetransmits;
+ lossTotals.nackCacheMisses = loss.nackCacheMisses;
}
}
@@ -178,13 +237,16 @@ public final class SessionStatsRecorder {
}
public void finish(Context context) {
+ SessionStatsContext.unbind(this);
if (!AppPreferences.isGrabSessionStats(context)) {
return;
}
+ journal.flushGeneralFinal();
long endedMs = System.currentTimeMillis();
try {
JSONObject root = new JSONObject();
- root.put("schema_version", 1);
+ root.put("schema_version", 2);
+ root.put("direction", direction);
root.put("started_at_epoch_ms", startedMs);
root.put("ended_at_epoch_ms", endedMs);
root.put("duration_ms", endedMs - startedMs);
@@ -199,6 +261,11 @@ public final class SessionStatsRecorder {
root.put("resolution", resolution);
root.put("network_samples", networkSamples);
+ JSONArray journalEvents = journal.copyEvents();
+ if (journalEvents.length() > 0) {
+ root.put(direction, journalEvents);
+ }
+
if (networkSamples > 0) {
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
@@ -244,13 +311,20 @@ public final class SessionStatsRecorder {
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
+ udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
+ udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
+ udp.put("fec_decode_failures", lossTotals.fecDecodeFailures);
+ udp.put("nack_requests_sent", lossTotals.nackRequestsSent);
+ udp.put("nack_requests_received", lossTotals.nackRequestsReceived);
+ udp.put("nack_retransmits", lossTotals.nackRetransmits);
+ udp.put("nack_cache_misses", lossTotals.nackCacheMisses);
}
root.put("udp", udp);
if (samples.length() > 0) {
root.put("samples", samples);
}
- SessionStatsStore.saveSession(context, startedMs, root);
+ SessionStatsStore.saveSession(context, startedMs, direction, root);
} catch (Exception ignored) {
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java
index c0a4f24..e3a94d7 100644
--- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java
+++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java
@@ -20,7 +20,10 @@ import java.util.Locale;
/** Rotating flat JSON session logs in app-private storage. */
public final class SessionStatsStore {
private static final String DIR_NAME = "session_stats";
- private static final String PREFIX = "session_";
+ /** Legacy captures before schema v2. */
+ private static final String PREFIX_LEGACY = "session_";
+ public static final String PREFIX_SEND = "session_send_";
+ public static final String PREFIX_RECV = "session_recv_";
private static final String SUFFIX = ".json";
private SessionStatsStore() {}
@@ -29,6 +32,31 @@ public final class SessionStatsStore {
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
}
+ private static boolean isSessionJson(String name) {
+ if (!name.endsWith(SUFFIX)) {
+ return false;
+ }
+ return name.startsWith(PREFIX_LEGACY)
+ || name.startsWith(PREFIX_SEND)
+ || name.startsWith(PREFIX_RECV);
+ }
+
+ /** Session JSON files, newest first (by filename / start time). */
+ public static List listSessionFiles(Context context) {
+ File dir = statsDir(context);
+ if (!dir.exists()) {
+ return Collections.emptyList();
+ }
+ File[] files = dir.listFiles((d, name) -> isSessionJson(name));
+ if (files == null || files.length == 0) {
+ return Collections.emptyList();
+ }
+ List list = new ArrayList<>();
+ Collections.addAll(list, files);
+ Collections.sort(list, (a, b) -> b.getName().compareTo(a.getName()));
+ return list;
+ }
+
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
public static void pruneOnAppStart(Context context) {
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
@@ -39,7 +67,7 @@ public final class SessionStatsStore {
if (dir == null || !dir.exists()) {
return;
}
- File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
+ File[] files = dir.listFiles((d, name) -> isSessionJson(name));
if (files == null || files.length == 0) {
return;
}
@@ -54,12 +82,14 @@ public final class SessionStatsStore {
}
}
- public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
+ public static void saveSession(Context context, long startedMs, String direction, JSONObject json)
+ throws Exception {
File dir = statsDir(context);
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("Cannot create session_stats dir");
}
- String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
+ String prefix = prefixForDirection(direction);
+ String name = prefix + formatTimestamp(startedMs) + SUFFIX;
File out = new File(dir, name);
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
try (FileOutputStream fos = new FileOutputStream(out)) {
@@ -67,6 +97,13 @@ public final class SessionStatsStore {
}
}
+ static String prefixForDirection(String direction) {
+ if ("recv".equals(direction)) {
+ return PREFIX_RECV;
+ }
+ return PREFIX_SEND;
+ }
+
private static String formatTimestamp(long epochMs) {
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/ChartTickHelper.java b/app/src/main/java/com/foxx/androidcast/stats/ui/ChartTickHelper.java
new file mode 100644
index 0000000..61d55fd
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/ChartTickHelper.java
@@ -0,0 +1,79 @@
+package com.foxx.androidcast.stats.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** Nice axis ticks for session charts. */
+final class ChartTickHelper {
+ private ChartTickHelper() {}
+
+ static final class Tick {
+ final double value;
+ final String label;
+
+ Tick(double value, String label) {
+ this.value = value;
+ this.label = label;
+ }
+ }
+
+ static List linear(double min, double max, int maxTicks) {
+ List out = new ArrayList<>();
+ if (!Double.isFinite(min) || !Double.isFinite(max) || max <= min) {
+ return out;
+ }
+ double step = niceStep(max - min, maxTicks);
+ double start = Math.ceil(min / step) * step;
+ for (double v = start; v <= max + step * 0.001; v += step) {
+ out.add(new Tick(v, formatValue(v, step)));
+ }
+ return out;
+ }
+
+ static List timeMs(long minMs, long maxMs, int maxTicks) {
+ List out = new ArrayList<>();
+ if (maxMs <= minMs) {
+ return out;
+ }
+ double range = maxMs - minMs;
+ double step = niceStep(range, maxTicks);
+ double start = Math.ceil(minMs / step) * step;
+ for (double v = start; v <= maxMs + step * 0.001; v += step) {
+ long ms = Math.round(v);
+ out.add(new Tick(ms, formatTimeMs(ms)));
+ }
+ return out;
+ }
+
+ private static double niceStep(double range, int maxTicks) {
+ double raw = range / Math.max(2, maxTicks);
+ if (raw <= 0) {
+ return 1;
+ }
+ double mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ double norm = raw / mag;
+ double nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
+ return nice * mag;
+ }
+
+ private static String formatValue(double v, double step) {
+ if (step >= 1) {
+ return String.format(Locale.US, "%.0f", v);
+ }
+ if (step >= 0.1) {
+ return String.format(Locale.US, "%.1f", v);
+ }
+ return String.format(Locale.US, "%.2f", v);
+ }
+
+ private static String formatTimeMs(long ms) {
+ if (ms >= 60_000) {
+ return String.format(Locale.US, "%.1fm", ms / 60_000.0);
+ }
+ if (ms >= 1000) {
+ return String.format(Locale.US, "%.1fs", ms / 1000.0);
+ }
+ return ms + "ms";
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionBarChartView.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionBarChartView.java
new file mode 100644
index 0000000..776cae9
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionBarChartView.java
@@ -0,0 +1,110 @@
+package com.foxx.androidcast.stats.ui;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** Horizontal bar chart for UDP / protection counter summaries. */
+public class SessionBarChartView extends View {
+ private static final class Bar {
+ final String label;
+ final long value;
+ final int color;
+
+ Bar(String label, long value, int color) {
+ this.label = label;
+ this.value = value;
+ this.color = color;
+ }
+ }
+
+ private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final List bars = new ArrayList<>();
+
+ public SessionBarChartView(Context context) {
+ super(context);
+ init();
+ }
+
+ public SessionBarChartView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ textPaint.setColor(0xFFEEEEEE);
+ textPaint.setTextSize(24f);
+ labelPaint.setColor(0xFFAAAAAA);
+ labelPaint.setTextSize(22f);
+ }
+
+ public void setSession(@Nullable SessionRecord session) {
+ bars.clear();
+ if (session != null) {
+ SessionRecord.UdpStats u = session.udp;
+ addBar("UDP tx", u.datagramsTx, 0xFF64B5F6);
+ addBar("UDP rx", u.datagramsRx, 0xFF42A5F5);
+ addBar("Reasm OK", u.reassemblyOk, 0xFF81C784);
+ addBar("Reasm lost", u.reassemblyLost, 0xFFFF8A65);
+ addBar("Wire drop", u.wireDropped, 0xFFE57373);
+ addBar("Vid dec err", u.recvVideoDecodeErrors, 0xFFFF5252);
+ SessionRecord.ProtectionStats p = session.protection;
+ addBar("FEC enc", p.fecEncoded, 0xFFBA68C8);
+ addBar("FEC dec", p.fecDecoded, 0xFFAB47BC);
+ addBar("NACK rtx", p.nackRetransmits, 0xFFFFD54F);
+ }
+ requestLayout();
+ invalidate();
+ }
+
+ private void addBar(String label, long value, int color) {
+ if (value > 0) {
+ bars.add(new Bar(label, value, color));
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ int h = bars.isEmpty() ? 80 : Math.max(120, bars.size() * 36 + 24);
+ setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), h);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ if (bars.isEmpty()) {
+ labelPaint.setTextSize(26f);
+ canvas.drawText("No counter data", 24f, 48f, labelPaint);
+ return;
+ }
+ long max = 1L;
+ for (Bar b : bars) {
+ max = Math.max(max, b.value);
+ }
+ float rowH = 32f;
+ float y = 12f;
+ float labelW = 140f;
+ float barLeft = labelW + 16f;
+ float barMaxW = getWidth() - barLeft - 80f;
+ for (Bar b : bars) {
+ labelPaint.setColor(0xFFAAAAAA);
+ canvas.drawText(b.label, 8f, y + 22f, labelPaint);
+ float bw = barMaxW * (b.value / (float) max);
+ barPaint.setColor(b.color);
+ canvas.drawRect(barLeft, y + 4f, barLeft + bw, y + 28f, barPaint);
+ textPaint.setColor(0xFFEEEEEE);
+ canvas.drawText(String.format(Locale.US, "%d", b.value), barLeft + bw + 8f, y + 22f, textPaint);
+ y += rowH;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionLineChartView.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionLineChartView.java
new file mode 100644
index 0000000..d19dcdb
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionLineChartView.java
@@ -0,0 +1,481 @@
+package com.foxx.androidcast.stats.ui;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Multi-session line chart with zoom, grid, legend, and hover marker + tooltip.
+ */
+public class SessionLineChartView extends View {
+ public enum Metric {
+ BITRATE("Video bitrate (kbps)"),
+ LOSS("Packet loss (%)"),
+ ENCODE_FPS("Encode FPS");
+
+ final String axisLabel;
+
+ Metric(String axisLabel) {
+ this.axisLabel = axisLabel;
+ }
+ }
+
+ private static final int[] SESSION_COLORS = {
+ 0xFF64B5F6, 0xFFFF8A65, 0xFF81C784, 0xFFFFD54F, 0xFFBA68C8, 0xFF4DD0E1
+ };
+
+ private static final float PAD_LEFT = 72f;
+ private static final float PAD_RIGHT = 16f;
+ private static final float PAD_TOP = 16f;
+ private static final float PAD_BOTTOM = 48f;
+
+ private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint axisPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint legendBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint legendBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint swatchPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint markerFill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint markerStroke = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tooltipBg = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tooltipFg = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final List sessions = new ArrayList<>();
+ private Metric metric = Metric.BITRATE;
+ private final ScaleGestureDetector scaleDetector;
+
+ private double viewMinT;
+ private double viewMaxT = 1;
+ private double viewMinY;
+ private double viewMaxY = 1;
+
+ private float hoverX = -1;
+ private float hoverY = -1;
+ private int hoverSessionIndex = -1;
+ private SessionRecord.SamplePoint hoverPoint;
+ private float hoverPlotX;
+ private float hoverPlotY;
+
+ public SessionLineChartView(Context context) {
+ super(context);
+ scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
+ initPaints();
+ }
+
+ public SessionLineChartView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ scaleDetector = new ScaleGestureDetector(context, new ScaleListener());
+ initPaints();
+ }
+
+ private void initPaints() {
+ gridPaint.setColor(0xFF3A3A3A);
+ gridPaint.setStrokeWidth(1f);
+ axisPaint.setColor(0xFFAAAAAA);
+ axisPaint.setStrokeWidth(2f);
+ linePaint.setStyle(Paint.Style.STROKE);
+ linePaint.setStrokeWidth(3f);
+ textPaint.setColor(0xFFBBBBBB);
+ textPaint.setTextSize(22f);
+ legendBgPaint.setColor(0xB8000000);
+ legendBorderPaint.setColor(0xFF888888);
+ legendBorderPaint.setStyle(Paint.Style.STROKE);
+ legendBorderPaint.setStrokeWidth(1f);
+ swatchPaint.setStyle(Paint.Style.FILL);
+ markerFill.setStyle(Paint.Style.FILL);
+ markerStroke.setStyle(Paint.Style.STROKE);
+ markerStroke.setColor(0xFFFFFFFF);
+ markerStroke.setStrokeWidth(2f);
+ tooltipBg.setColor(0xE0000000);
+ tooltipFg.setColor(0xFFFFFFFF);
+ tooltipFg.setTextSize(24f);
+ }
+
+ public void setSessions(@Nullable List records, Metric metric) {
+ sessions.clear();
+ if (records != null) {
+ sessions.addAll(records);
+ }
+ this.metric = metric != null ? metric : Metric.BITRATE;
+ resetView();
+ clearHover();
+ invalidate();
+ }
+
+ private void resetView() {
+ viewMinT = 0;
+ viewMaxT = Math.max(1, dataMaxTimeMs());
+ double[] y = dataYRange();
+ viewMinY = y[0];
+ viewMaxY = y[1];
+ }
+
+ private long dataMaxTimeMs() {
+ long max = 1;
+ for (SessionRecord doc : sessions) {
+ for (SessionRecord.SamplePoint p : doc.samples) {
+ max = Math.max(max, p.tMs);
+ }
+ max = Math.max(max, doc.durationMs);
+ }
+ return max;
+ }
+
+ private double[] dataYRange() {
+ double min = Double.POSITIVE_INFINITY;
+ double max = Double.NEGATIVE_INFINITY;
+ for (SessionRecord doc : sessions) {
+ for (SessionRecord.SamplePoint p : doc.samples) {
+ double v = metricValue(p);
+ if (Double.isFinite(v)) {
+ min = Math.min(min, v);
+ max = Math.max(max, v);
+ }
+ }
+ }
+ if (!Double.isFinite(min)) {
+ return new double[] {0, 1};
+ }
+ if (max <= min) {
+ max = min + 1;
+ }
+ double pad = (max - min) * 0.06;
+ return new double[] {min - pad, max + pad};
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ scaleDetector.onTouchEvent(event);
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE) {
+ hoverX = event.getX();
+ hoverY = event.getY();
+ updateHoverHit();
+ invalidate();
+ return true;
+ }
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ clearHover();
+ return true;
+ }
+ return super.onTouchEvent(event);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent event) {
+ if ((event.getSource() & android.view.InputDevice.SOURCE_CLASS_POINTER) != 0
+ && event.getAction() == MotionEvent.ACTION_SCROLL) {
+ float factor = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0 ? 0.85f : 1.18f;
+ zoomAt(event.getX(), event.getY(), factor);
+ invalidate();
+ return true;
+ }
+ return super.onGenericMotionEvent(event);
+ }
+
+ private void zoomAt(float mx, float my, float factor) {
+ float plotW = plotWidth();
+ float plotH = plotHeight();
+ if (plotW <= 0 || plotH <= 0) {
+ return;
+ }
+ if (mx >= PAD_LEFT && mx <= PAD_LEFT + plotW && my >= PAD_TOP && my <= PAD_TOP + plotH) {
+ double relX = (mx - PAD_LEFT) / plotW;
+ double relY = 1.0 - (my - PAD_TOP) / plotH;
+ zoomTime(viewMinT + relX * (viewMaxT - viewMinT), factor);
+ zoomY(viewMinY + relY * (viewMaxY - viewMinY), factor);
+ } else {
+ zoomTime((viewMinT + viewMaxT) / 2, factor);
+ zoomY((viewMinY + viewMaxY) / 2, factor);
+ }
+ }
+
+ private void zoomTime(double anchor, float factor) {
+ double span = viewMaxT - viewMinT;
+ if (span <= 0) {
+ return;
+ }
+ double newSpan = Math.max(50, span * factor);
+ viewMinT = anchor - (anchor - viewMinT) * (newSpan / span);
+ viewMaxT = viewMinT + newSpan;
+ }
+
+ private void zoomY(double anchor, float factor) {
+ double span = viewMaxY - viewMinY;
+ if (span <= 0) {
+ return;
+ }
+ double newSpan = Math.max(1, span * factor);
+ viewMinY = anchor - (anchor - viewMinY) * (newSpan / span);
+ viewMaxY = viewMinY + newSpan;
+ }
+
+ private void clearHover() {
+ hoverX = -1;
+ hoverY = -1;
+ hoverSessionIndex = -1;
+ hoverPoint = null;
+ invalidate();
+ }
+
+ private void updateHoverHit() {
+ hoverSessionIndex = -1;
+ hoverPoint = null;
+ float plotW = plotWidth();
+ float plotH = plotHeight();
+ if (plotW <= 0 || plotH <= 0 || sessions.isEmpty()) {
+ return;
+ }
+ if (hoverX < PAD_LEFT || hoverX > PAD_LEFT + plotW
+ || hoverY < PAD_TOP || hoverY > PAD_TOP + plotH) {
+ return;
+ }
+ double best = 48;
+ for (int si = 0; si < sessions.size(); si++) {
+ for (SessionRecord.SamplePoint p : sessions.get(si).samples) {
+ if (p.tMs < viewMinT || p.tMs > viewMaxT) {
+ continue;
+ }
+ double v = metricValue(p);
+ if (v < viewMinY || v > viewMaxY) {
+ continue;
+ }
+ float x = toScreenX(p.tMs);
+ float y = toScreenY(v);
+ double d = Math.hypot(hoverX - x, hoverY - y);
+ if (d < best) {
+ best = d;
+ hoverSessionIndex = si;
+ hoverPoint = p;
+ hoverPlotX = x;
+ hoverPlotY = y;
+ }
+ }
+ }
+ }
+
+ private float plotWidth() {
+ return Math.max(0, getWidth() - PAD_LEFT - PAD_RIGHT);
+ }
+
+ private float plotHeight() {
+ return Math.max(0, getHeight() - PAD_TOP - PAD_BOTTOM);
+ }
+
+ private float toScreenX(double tMs) {
+ float plotW = plotWidth();
+ double span = viewMaxT - viewMinT;
+ if (span <= 0) {
+ return PAD_LEFT;
+ }
+ return PAD_LEFT + (float) (((tMs - viewMinT) / span) * plotW);
+ }
+
+ private float toScreenY(double v) {
+ float plotH = plotHeight();
+ double span = viewMaxY - viewMinY;
+ if (span <= 0) {
+ return PAD_TOP + plotH;
+ }
+ return PAD_TOP + plotH - (float) (((v - viewMinY) / span) * plotH);
+ }
+
+ private double metricValue(SessionRecord.SamplePoint p) {
+ switch (metric) {
+ case LOSS:
+ return p.lossRatio * 100.0;
+ case ENCODE_FPS:
+ return p.encodeFps;
+ case BITRATE:
+ default:
+ return p.videoBitrateKbps > 0 ? p.videoBitrateKbps : p.targetBitrateKbps;
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ canvas.drawColor(0xFF121212);
+ if (sessions.isEmpty() || !hasPlottableData()) {
+ textPaint.setTextSize(28f);
+ canvas.drawText(sessions.isEmpty() ? "Select session(s)" : "No time-series samples",
+ PAD_LEFT, PAD_TOP + 32f, textPaint);
+ return;
+ }
+
+ float plotW = plotWidth();
+ float plotH = plotHeight();
+ if (plotW <= 1 || plotH <= 1) {
+ return;
+ }
+
+ List xTicks = ChartTickHelper.timeMs(
+ (long) viewMinT, (long) viewMaxT, 7);
+ List yTicks = ChartTickHelper.linear(viewMinY, viewMaxY, 5);
+
+ for (ChartTickHelper.Tick t : xTicks) {
+ float x = toScreenX(t.value);
+ canvas.drawLine(x, PAD_TOP, x, PAD_TOP + plotH, gridPaint);
+ }
+ for (ChartTickHelper.Tick t : yTicks) {
+ float y = toScreenY(t.value);
+ canvas.drawLine(PAD_LEFT, y, PAD_LEFT + plotW, y, gridPaint);
+ }
+
+ canvas.drawLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH, axisPaint);
+ canvas.drawLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH, axisPaint);
+
+ textPaint.setTextSize(20f);
+ textPaint.setColor(0xFFAAAAAA);
+ for (ChartTickHelper.Tick t : xTicks) {
+ float x = toScreenX(t.value);
+ canvas.drawText(t.label, x - 20f, PAD_TOP + plotH + 32f, textPaint);
+ }
+ for (ChartTickHelper.Tick t : yTicks) {
+ float y = toScreenY(t.value);
+ canvas.drawText(t.label, 4f, y + 6f, textPaint);
+ }
+
+ for (int si = 0; si < sessions.size(); si++) {
+ SessionRecord doc = sessions.get(si);
+ if (doc.samples.size() < 2) {
+ continue;
+ }
+ linePaint.setColor(SESSION_COLORS[si % SESSION_COLORS.length]);
+ drawSessionLine(canvas, doc);
+ }
+
+ drawLegend(canvas, plotW);
+
+ if (hoverPoint != null && hoverSessionIndex >= 0) {
+ int color = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
+ markerFill.setColor(color);
+ canvas.drawCircle(hoverPlotX, hoverPlotY, 8f, markerFill);
+ canvas.drawCircle(hoverPlotX, hoverPlotY, 8f, markerStroke);
+ drawHoverPopup(canvas, color);
+ }
+ }
+
+ private boolean hasPlottableData() {
+ for (SessionRecord doc : sessions) {
+ if (doc.samples.size() >= 2) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void drawSessionLine(Canvas canvas, SessionRecord doc) {
+ Path path = new Path();
+ boolean first = true;
+ for (SessionRecord.SamplePoint p : doc.samples) {
+ if (p.tMs < viewMinT || p.tMs > viewMaxT) {
+ continue;
+ }
+ double v = metricValue(p);
+ if (v < viewMinY || v > viewMaxY) {
+ continue;
+ }
+ float x = toScreenX(p.tMs);
+ float y = toScreenY(v);
+ if (first) {
+ path.moveTo(x, y);
+ first = false;
+ } else {
+ path.lineTo(x, y);
+ }
+ }
+ canvas.drawPath(path, linePaint);
+ }
+
+ private void drawLegend(Canvas canvas, float plotW) {
+ int rows = 0;
+ for (SessionRecord doc : sessions) {
+ if (doc.samples.size() >= 2) {
+ rows++;
+ }
+ }
+ if (rows == 0) {
+ return;
+ }
+ float legendW = 280f;
+ float rowH = 28f;
+ float legendH = rows * rowH + 16f;
+ float x0 = PAD_LEFT + plotW - legendW - 8f;
+ float y0 = PAD_TOP + 8f;
+ canvas.drawRoundRect(x0, y0, x0 + legendW, y0 + legendH, 8f, 8f, legendBgPaint);
+ canvas.drawRoundRect(x0, y0, x0 + legendW, y0 + legendH, 8f, 8f, legendBorderPaint);
+
+ int row = 0;
+ for (int si = 0; si < sessions.size(); si++) {
+ SessionRecord doc = sessions.get(si);
+ if (doc.samples.size() < 2) {
+ continue;
+ }
+ int color = SESSION_COLORS[si % SESSION_COLORS.length];
+ float ry = y0 + 8f + row * rowH;
+ swatchPaint.setColor(color);
+ canvas.drawRoundRect(x0 + 10f, ry, x0 + 28f, ry + 16f, 3f, 3f, swatchPaint);
+ legendBorderPaint.setStyle(Paint.Style.STROKE);
+ canvas.drawRoundRect(x0 + 10f, ry, x0 + 28f, ry + 16f, 3f, 3f, legendBorderPaint);
+ textPaint.setColor(0xFFFFFFFF);
+ textPaint.setTextSize(20f);
+ textPaint.setTypeface(si == hoverSessionIndex ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
+ String name = doc.fileName;
+ if (name.length() > 32) {
+ name = "…" + name.substring(name.length() - 31);
+ }
+ canvas.drawText(name, x0 + 36f, ry + 14f, textPaint);
+ row++;
+ }
+ textPaint.setTypeface(Typeface.DEFAULT);
+ }
+
+ private void drawHoverPopup(Canvas canvas, int accentColor) {
+ String text = String.format(Locale.US,
+ "t=%d ms bitrate=%.0f kbps loss=%.2f%% fps=%.1f",
+ hoverPoint.tMs,
+ hoverPoint.videoBitrateKbps,
+ hoverPoint.lossRatio * 100.0,
+ hoverPoint.encodeFps);
+ float tw = tooltipFg.measureText(text) + 24f;
+ float th = 36f;
+ float px = hoverPlotX + 14f;
+ float py = hoverPlotY - th - 16f;
+ if (px + tw > PAD_LEFT + plotWidth()) {
+ px = hoverPlotX - tw - 14f;
+ }
+ if (py < PAD_TOP) {
+ py = hoverPlotY + 14f;
+ }
+ tooltipBg.setColor(0xE0000000);
+ canvas.drawRoundRect(px, py, px + tw, py + th, 8f, 8f, tooltipBg);
+ markerStroke.setColor(accentColor);
+ canvas.drawRoundRect(px, py, px + tw, py + th, 8f, 8f, markerStroke);
+ markerStroke.setColor(0xFFFFFFFF);
+ canvas.drawText(text, px + 12f, py + 24f, tooltipFg);
+ }
+
+ private final class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ float factor = 1f / detector.getScaleFactor();
+ zoomAt(detector.getFocusX(), detector.getFocusY(), factor);
+ invalidate();
+ return true;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionRecord.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionRecord.java
new file mode 100644
index 0000000..8eabefb
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionRecord.java
@@ -0,0 +1,228 @@
+package com.foxx.androidcast.stats.ui;
+
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Parsed session JSON for the in-app statistics analyzer. */
+public final class SessionRecord {
+ public final String fileName;
+ public final int schemaVersion;
+ public final long startedAtEpochMs;
+ public final long endedAtEpochMs;
+ public final long durationMs;
+ /** {@code send} or {@code recv}; inferred from filename when absent. */
+ public final String direction;
+ public final String role;
+ public final String transport;
+ public final int clientCount;
+ public final int disruptCount;
+ public final int idleTimeoutCount;
+ public final String videoCodec;
+ public final String audioCodec;
+ public final String captureMode;
+ public final String resolution;
+ public final int networkSamples;
+ public final long avgRttMs;
+ public final double avgLossRatio;
+ public final long avgVideoBitrateKbps;
+ public final double avgEncodeFps;
+ public final ProtectionStats protection;
+ public final UdpStats udp;
+ public final List samples;
+
+ public SessionRecord(String fileName, JSONObject root) {
+ this.fileName = fileName;
+ schemaVersion = root.optInt("schema_version", 0);
+ startedAtEpochMs = root.optLong("started_at_epoch_ms", 0L);
+ endedAtEpochMs = root.optLong("ended_at_epoch_ms", 0L);
+ durationMs = root.optLong("duration_ms", 0L);
+ direction = inferDirection(fileName, root);
+ role = root.optString("role", direction.equals("recv") ? "receiver" : "sender");
+ transport = root.optString("transport", "?");
+ clientCount = root.optInt("client_count", 1);
+ disruptCount = root.optInt("disrupt_count", 0);
+ idleTimeoutCount = root.optInt("idle_timeout_count", 0);
+ videoCodec = root.optString("video_codec", "");
+ audioCodec = root.optString("audio_codec", "");
+ captureMode = root.optString("capture_mode", "");
+ resolution = root.optString("resolution", "");
+ networkSamples = root.optInt("network_samples", 0);
+ avgRttMs = root.optLong("avg_rtt_ms", 0L);
+ avgLossRatio = root.optDouble("avg_loss_ratio", 0.0);
+ avgVideoBitrateKbps = root.optLong("avg_video_bitrate_kbps", 0L);
+ avgEncodeFps = root.optDouble("avg_encode_fps", 0.0);
+ protection = ProtectionStats.fromJson(root.optJSONObject("stream_protection"));
+ udp = UdpStats.fromJson(root.optJSONObject("udp"));
+ samples = SamplePoint.parseArray(root.optJSONArray("samples"));
+ }
+
+ public boolean hasTimeSeries() {
+ return samples.size() >= 2;
+ }
+
+ public String listSubtitle() {
+ long min = durationMs / 60_000L;
+ long sec = (durationMs / 1000L) % 60L;
+ return direction + " · " + transport + " · " + min + "m " + sec + "s";
+ }
+
+ private static String inferDirection(String fileName, JSONObject root) {
+ String d = root.optString("direction", "");
+ if ("send".equals(d) || "recv".equals(d)) {
+ return d;
+ }
+ if (fileName != null) {
+ if (fileName.startsWith("session_send_")) {
+ return "send";
+ }
+ if (fileName.startsWith("session_recv_")) {
+ return "recv";
+ }
+ }
+ String role = root.optString("role", "");
+ return "receiver".equals(role) ? "recv" : "send";
+ }
+
+ public static final class SamplePoint {
+ public final long tMs;
+ public final double videoBitrateKbps;
+ public final int rttMs;
+ public final double lossRatio;
+ public final double encodeFps;
+ public final int targetBitrateKbps;
+ public final int congestionLevel;
+
+ public SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio,
+ double encodeFps, int targetBitrateKbps, int congestionLevel) {
+ this.tMs = tMs;
+ this.videoBitrateKbps = videoBitrateKbps;
+ this.rttMs = rttMs;
+ this.lossRatio = lossRatio;
+ this.encodeFps = encodeFps;
+ this.targetBitrateKbps = targetBitrateKbps;
+ this.congestionLevel = congestionLevel;
+ }
+
+ static SamplePoint fromJson(JSONObject o) {
+ return new SamplePoint(
+ o.optLong("t_ms", 0L),
+ o.optDouble("video_bitrate_kbps", 0.0),
+ o.optInt("rtt_ms", 0),
+ o.optDouble("loss_ratio", 0.0),
+ o.optDouble("encode_fps", 0.0),
+ o.optInt("target_bitrate_kbps", 0),
+ o.optInt("congestion_level", 0));
+ }
+
+ static List parseArray(org.json.JSONArray arr) {
+ if (arr == null || arr.length() == 0) {
+ return Collections.emptyList();
+ }
+ List out = new ArrayList<>(arr.length());
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject o = arr.optJSONObject(i);
+ if (o != null) {
+ out.add(fromJson(o));
+ }
+ }
+ return out;
+ }
+ }
+
+ public static final class ProtectionStats {
+ public final String mode;
+ public final boolean fecEnabled;
+ public final boolean nackEnabled;
+ public final long fecEncoded;
+ public final long fecDecoded;
+ public final long fecDecodeFailures;
+ public final long nackSent;
+ public final long nackReceived;
+ public final long nackRetransmits;
+
+ ProtectionStats(String mode, boolean fecEnabled, boolean nackEnabled, long fecEncoded,
+ long fecDecoded, long fecDecodeFailures, long nackSent, long nackReceived,
+ long nackRetransmits) {
+ this.mode = mode != null ? mode : "NONE";
+ this.fecEnabled = fecEnabled;
+ this.nackEnabled = nackEnabled;
+ this.fecEncoded = fecEncoded;
+ this.fecDecoded = fecDecoded;
+ this.fecDecodeFailures = fecDecodeFailures;
+ this.nackSent = nackSent;
+ this.nackReceived = nackReceived;
+ this.nackRetransmits = nackRetransmits;
+ }
+
+ static ProtectionStats fromJson(JSONObject o) {
+ if (o == null) {
+ return new ProtectionStats("NONE", false, false, 0, 0, 0, 0, 0, 0);
+ }
+ return new ProtectionStats(
+ o.optString("mode", "NONE"),
+ o.optBoolean("fec_enabled", false),
+ o.optBoolean("nack_enabled", false),
+ o.optLong("fec_packets_encoded", 0L),
+ o.optLong("fec_packets_decoded", 0L),
+ o.optLong("fec_decode_failures", 0L),
+ o.optLong("nack_requests_sent", 0L),
+ o.optLong("nack_requests_received", 0L),
+ o.optLong("nack_retransmits", 0L));
+ }
+ }
+
+ public static final class UdpStats {
+ public final long datagramsTx;
+ public final long datagramsRx;
+ public final long datagramsInvalid;
+ public final long fragmentsRx;
+ public final long fragmentsDuplicate;
+ public final long reassemblyOk;
+ public final long reassemblyLost;
+ public final long wireDropped;
+ public final long malformed;
+ public final long sendDroppedVideoP;
+ public final long recvVideoDecodeErrors;
+ public final long recvDamagedVideoFrames;
+
+ UdpStats(long datagramsTx, long datagramsRx, long datagramsInvalid, long fragmentsRx,
+ long fragmentsDuplicate, long reassemblyOk, long reassemblyLost, long wireDropped,
+ long malformed, long sendDroppedVideoP, long recvVideoDecodeErrors,
+ long recvDamagedVideoFrames) {
+ this.datagramsTx = datagramsTx;
+ this.datagramsRx = datagramsRx;
+ this.datagramsInvalid = datagramsInvalid;
+ this.fragmentsRx = fragmentsRx;
+ this.fragmentsDuplicate = fragmentsDuplicate;
+ this.reassemblyOk = reassemblyOk;
+ this.reassemblyLost = reassemblyLost;
+ this.wireDropped = wireDropped;
+ this.malformed = malformed;
+ this.sendDroppedVideoP = sendDroppedVideoP;
+ this.recvVideoDecodeErrors = recvVideoDecodeErrors;
+ this.recvDamagedVideoFrames = recvDamagedVideoFrames;
+ }
+
+ static UdpStats fromJson(JSONObject o) {
+ if (o == null) {
+ return new UdpStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
+ }
+ return new UdpStats(
+ o.optLong("datagrams_tx", 0L),
+ o.optLong("datagrams_rx", 0L),
+ o.optLong("datagrams_invalid", 0L),
+ o.optLong("fragments_rx", 0L),
+ o.optLong("fragments_duplicate", 0L),
+ o.optLong("reassembly_ok", 0L),
+ o.optLong("reassembly_lost", 0L),
+ o.optLong("wire_dropped", 0L),
+ o.optLong("malformed", 0L),
+ o.optLong("send_dropped_video_p", 0L),
+ o.optLong("recv_video_decode_errors", 0L),
+ o.optLong("recv_damaged_video_frames", 0L));
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsAnalyzerActivity.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsAnalyzerActivity.java
new file mode 100644
index 0000000..d322019
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsAnalyzerActivity.java
@@ -0,0 +1,182 @@
+package com.foxx.androidcast.stats.ui;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.CastLocaleHelper;
+import com.foxx.androidcast.CastThemeHelper;
+import com.foxx.androidcast.R;
+import com.foxx.androidcast.stats.SessionStatsStore;
+import com.foxx.androidcast.util.CastBackgroundExecutor;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** In-app viewer for saved session_send_* / session_recv_* cast statistics. */
+public class SessionStatsAnalyzerActivity extends AppCompatActivity
+ implements SessionStatsListAdapter.Listener {
+ private SessionLineChartView lineChart;
+ private SessionBarChartView barChart;
+ private TextView summaryText;
+ private TextView emptyText;
+ private View detailPanel;
+ private SessionRecord primarySelection;
+ private SessionLineChartView.Metric chartMetric = SessionLineChartView.Metric.BITRATE;
+ private List chartSessions = new ArrayList<>();
+
+ @Override
+ protected void attachBaseContext(android.content.Context newBase) {
+ super.attachBaseContext(CastLocaleHelper.attach(newBase));
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ CastThemeHelper.applyTheme(this);
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_session_stats_analyzer);
+ setTitle(R.string.session_stats_title);
+
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ emptyText = findViewById(R.id.text_session_stats_empty);
+ detailPanel = findViewById(R.id.panel_session_detail);
+ summaryText = findViewById(R.id.text_session_summary);
+ lineChart = findViewById(R.id.chart_session_lines);
+ barChart = findViewById(R.id.chart_session_bars);
+ RecyclerView list = findViewById(R.id.list_sessions);
+
+ SessionStatsListAdapter adapter = new SessionStatsListAdapter(this);
+ list.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
+ list.setAdapter(adapter);
+
+ setupMetricRadios();
+ findViewById(R.id.btn_share_session).setOnClickListener(v -> shareSelected());
+ findViewById(R.id.btn_refresh_sessions).setOnClickListener(v -> reloadSessions(adapter));
+
+ TextView hint = findViewById(R.id.text_chart_interaction_hint);
+ if (hint != null) {
+ hint.setText(R.string.session_stats_chart_hint);
+ }
+
+ if (!AppPreferences.isGrabSessionStats(this)) {
+ Toast.makeText(this, R.string.session_stats_disabled_hint, Toast.LENGTH_LONG).show();
+ }
+ reloadSessions(adapter);
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ finish();
+ return true;
+ }
+
+ private void setupMetricRadios() {
+ RadioGroup group = findViewById(R.id.radio_group_metric);
+ if (group == null) {
+ return;
+ }
+ group.setOnCheckedChangeListener((g, id) -> {
+ if (id == R.id.radio_metric_loss) {
+ chartMetric = SessionLineChartView.Metric.LOSS;
+ } else if (id == R.id.radio_metric_fps) {
+ chartMetric = SessionLineChartView.Metric.ENCODE_FPS;
+ } else {
+ chartMetric = SessionLineChartView.Metric.BITRATE;
+ }
+ refreshChart();
+ });
+ }
+
+ private void reloadSessions(SessionStatsListAdapter adapter) {
+ emptyText.setVisibility(View.VISIBLE);
+ detailPanel.setVisibility(View.GONE);
+ CastBackgroundExecutor.execute(() -> {
+ List loaded = new ArrayList<>();
+ List files = SessionStatsStore.listSessionFiles(this);
+ for (File f : files) {
+ try {
+ loaded.add(SessionStatsParser.load(f));
+ } catch (Exception ignored) {
+ }
+ }
+ runOnUiThread(() -> {
+ adapter.setItems(loaded);
+ boolean has = !loaded.isEmpty();
+ emptyText.setVisibility(has ? View.GONE : View.VISIBLE);
+ emptyText.setText(has ? "" : getString(R.string.session_stats_empty));
+ detailPanel.setVisibility(has ? View.VISIBLE : View.GONE);
+ });
+ });
+ }
+
+ @Override
+ public void onSelectionChanged(List selected) {
+ chartSessions = selected != null ? selected : new ArrayList<>();
+ primarySelection = chartSessions.isEmpty() ? null : chartSessions.get(0);
+ refreshChart();
+ if (primarySelection != null) {
+ summaryText.setText(buildSummary(primarySelection));
+ barChart.setSession(primarySelection);
+ }
+ }
+
+ private void refreshChart() {
+ lineChart.setSessions(chartSessions, chartMetric);
+ }
+
+ private String buildSummary(SessionRecord r) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(r.role).append(" · ").append(r.transport).append('\n');
+ sb.append(String.format(Locale.US, "Duration: %d ms\n", r.durationMs));
+ if (!r.videoCodec.isEmpty()) {
+ sb.append("Video: ").append(r.videoCodec).append('\n');
+ }
+ if (!r.resolution.isEmpty()) {
+ sb.append("Resolution: ").append(r.resolution).append('\n');
+ }
+ sb.append(String.format(Locale.US, "Disrupts: %d idle timeouts: %d\n",
+ r.disruptCount, r.idleTimeoutCount));
+ if (r.networkSamples > 0) {
+ sb.append(String.format(Locale.US, "Avg RTT: %d ms loss: %.3f bitrate: %d kbps fps: %.1f\n",
+ r.avgRttMs, r.avgLossRatio, r.avgVideoBitrateKbps, r.avgEncodeFps));
+ }
+ SessionRecord.ProtectionStats p = r.protection;
+ sb.append(String.format(Locale.US, "Protection: %s (FEC=%s NACK=%s)\n",
+ p.mode, p.fecEnabled, p.nackEnabled));
+ sb.append(String.format(Locale.US, "Samples in chart: %d", r.samples.size()));
+ return sb.toString().trim();
+ }
+
+ private void shareSelected() {
+ if (primarySelection == null) {
+ return;
+ }
+ File dir = SessionStatsStore.statsDir(this);
+ File file = new File(dir, primarySelection.fileName);
+ if (!file.isFile()) {
+ Toast.makeText(this, R.string.session_stats_share_failed, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", file);
+ Intent send = new Intent(Intent.ACTION_SEND);
+ send.setType("application/json");
+ send.putExtra(Intent.EXTRA_STREAM, uri);
+ send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivity(Intent.createChooser(send, getString(R.string.session_stats_share)));
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsListAdapter.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsListAdapter.java
new file mode 100644
index 0000000..89b9eef
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsListAdapter.java
@@ -0,0 +1,105 @@
+package com.foxx.androidcast.stats.ui;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.foxx.androidcast.R;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+final class SessionStatsListAdapter extends RecyclerView.Adapter {
+ interface Listener {
+ void onSelectionChanged(List selected);
+ }
+
+ private final List items = new ArrayList<>();
+ private final Set selectedPositions = new HashSet<>();
+ private final Listener listener;
+
+ SessionStatsListAdapter(Listener listener) {
+ this.listener = listener;
+ }
+
+ void setItems(List records) {
+ items.clear();
+ selectedPositions.clear();
+ if (records != null) {
+ items.addAll(records);
+ for (int i = 0; i < items.size(); i++) {
+ selectedPositions.add(i);
+ }
+ }
+ notifyDataSetChanged();
+ notifySelection();
+ }
+
+ List getSelected() {
+ List out = new ArrayList<>();
+ for (int i : selectedPositions) {
+ if (i >= 0 && i < items.size()) {
+ out.add(items.get(i));
+ }
+ }
+ return out;
+ }
+
+ @NonNull
+ @Override
+ public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_session_stats, parent, false);
+ return new Holder(v);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull Holder holder, int position) {
+ SessionRecord r = items.get(position);
+ holder.title.setText(r.fileName);
+ holder.subtitle.setText(r.listSubtitle());
+ boolean selected = selectedPositions.contains(position);
+ holder.itemView.setAlpha(selected ? 1f : 0.45f);
+ holder.itemView.setOnClickListener(v -> {
+ int pos = holder.getBindingAdapterPosition();
+ if (pos == RecyclerView.NO_POSITION) {
+ return;
+ }
+ if (selectedPositions.contains(pos)) {
+ selectedPositions.remove(pos);
+ } else {
+ selectedPositions.add(pos);
+ }
+ notifyItemChanged(pos);
+ notifySelection();
+ });
+ }
+
+ private void notifySelection() {
+ if (listener != null) {
+ listener.onSelectionChanged(getSelected());
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return items.size();
+ }
+
+ static final class Holder extends RecyclerView.ViewHolder {
+ final TextView title;
+ final TextView subtitle;
+
+ Holder(@NonNull View itemView) {
+ super(itemView);
+ title = itemView.findViewById(R.id.text_session_title);
+ subtitle = itemView.findViewById(R.id.text_session_subtitle);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsParser.java b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsParser.java
new file mode 100644
index 0000000..6d1b9fe
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/stats/ui/SessionStatsParser.java
@@ -0,0 +1,22 @@
+package com.foxx.androidcast.stats.ui;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+/** Loads {@link SessionRecord} from on-device session JSON files. */
+public final class SessionStatsParser {
+ private SessionStatsParser() {}
+
+ public static SessionRecord load(File file) throws Exception {
+ if (file == null || !file.isFile()) {
+ throw new IllegalArgumentException("Not a file");
+ }
+ byte[] bytes = Files.readAllBytes(file.toPath());
+ String text = new String(bytes, StandardCharsets.UTF_8);
+ JSONObject root = new JSONObject(text);
+ return new SessionRecord(file.getName(), root);
+ }
+}
diff --git a/app/src/main/res/layout/activity_developer_settings.xml b/app/src/main/res/layout/activity_developer_settings.xml
index fe4da1e..140a26e 100644
--- a/app/src/main/res/layout/activity_developer_settings.xml
+++ b/app/src/main/res/layout/activity_developer_settings.xml
@@ -39,10 +39,17 @@
android:text="@string/dev_grab_session_stats" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_session_stats.xml b/app/src/main/res/layout/item_session_stats.xml
new file mode 100644
index 0000000..4c11788
--- /dev/null
+++ b/app/src/main/res/layout/item_session_stats.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 06f0482..94786bc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -201,6 +201,19 @@
Show debug overlay on receiver
Show codec priority scores in settings
Grab anonymous session stats
+ Session statistics
+ Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).
+ Open statistics analyzer
+ Refresh
+ Share JSON
+ No session files yet. Cast with “Grab session stats” enabled.
+ Session grab is off — enable it here to record new sessions.
+ UDP & protection counters
+ Could not share session file
+ Bitrate
+ Loss %
+ Encode FPS
+ Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.
Reload codecs.json
Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.
· %1$d
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..d31665b
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/test/java/com/foxx/androidcast/stats/SessionStatsEventMapperTest.java b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsEventMapperTest.java
new file mode 100644
index 0000000..1039772
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsEventMapperTest.java
@@ -0,0 +1,38 @@
+package com.foxx.androidcast.stats;
+
+import static org.junit.Assert.assertEquals;
+
+import com.foxx.androidcast.network.CastProtocol;
+
+import org.junit.Test;
+
+public class SessionStatsEventMapperTest {
+
+ @Test
+ public void videoKeyframe() {
+ assertEquals(SessionStatsEventMapper.KF,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_VIDEO_FRAME, true));
+ assertEquals(SessionStatsEventMapper.VIDEO,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_VIDEO_FRAME, false));
+ }
+
+ @Test
+ public void audioAndMeta() {
+ assertEquals(SessionStatsEventMapper.AUDIO,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_AUDIO_FRAME, false));
+ assertEquals(SessionStatsEventMapper.META,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_STREAM_CONFIG, false));
+ assertEquals(SessionStatsEventMapper.META,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_NETWORK_STATS, false));
+ }
+
+ @Test
+ public void serviceAndUnknown() {
+ assertEquals(SessionStatsEventMapper.SERVICE,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_HELLO, false));
+ assertEquals(SessionStatsEventMapper.SERVICE,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_CODEC_SELECTED, false));
+ assertEquals(SessionStatsEventMapper.UNKNOWN,
+ SessionStatsEventMapper.eventTypeForMessage(CastProtocol.MSG_NACK, false));
+ }
+}
diff --git a/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java
index fd79f1d..d5e3a40 100644
--- a/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java
+++ b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java
@@ -39,4 +39,32 @@ public class SessionStatsStoreTest {
}
assertTrue(dir.delete());
}
+
+ @Test
+ public void prefixForDirection() {
+ assertEquals(SessionStatsStore.PREFIX_SEND, SessionStatsStore.prefixForDirection("send"));
+ assertEquals(SessionStatsStore.PREFIX_RECV, SessionStatsStore.prefixForDirection("recv"));
+ assertEquals(SessionStatsStore.PREFIX_SEND, SessionStatsStore.prefixForDirection(null));
+ }
+
+ @Test
+ public void pruneDirectory_includesSendAndRecvPrefixes() throws IOException {
+ File dir = Files.createTempDirectory("session_stats_prefix").toFile();
+ new File(dir, "session_send_20260101_000001.json").createNewFile();
+ new File(dir, "session_recv_20260101_000002.json").createNewFile();
+ new File(dir, "session_20260101_000003.json").createNewFile();
+ new File(dir, "other.txt").createNewFile();
+
+ SessionStatsStore.pruneDirectory(dir, 2);
+
+ String[] remaining = dir.list((d, n) -> n.endsWith(".json"));
+ Arrays.sort(remaining);
+ assertEquals(1, remaining.length);
+ assertEquals("session_send_20260101_000001.json", remaining[0]);
+
+ for (File f : dir.listFiles()) {
+ assertTrue(f.delete());
+ }
+ assertTrue(dir.delete());
+ }
}
diff --git a/desktop/README.md b/desktop/README.md
index a12099d..572a71a 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -8,7 +8,7 @@ JavaFX desktop app for **session log analysis** (Linux `.jar` or native image la
./gradlew :session-studio:run
```
-Open `session_*.json` from the device (`files/session_stats/`). New captures include a `samples[]` time series (~2 s) for charts.
+Open `session_send_*` / `session_recv_*` JSON from the device (`files/session_stats/`). Legacy `session_*.json` still loads. Charts use `samples[]` (~2 s): bitrate, loss %, encode FPS.
### Roadmap (full mobile parity + analytics)
diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/ChartTicks.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/ChartTicks.java
new file mode 100644
index 0000000..a8d3c76
--- /dev/null
+++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/ChartTicks.java
@@ -0,0 +1,79 @@
+package com.foxx.androidcast.studio;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** Nice axis tick steps for charts. */
+final class ChartTicks {
+ private ChartTicks() {}
+
+ static final class Tick {
+ final double value;
+ final String label;
+
+ Tick(double value, String label) {
+ this.value = value;
+ this.label = label;
+ }
+ }
+
+ static List linear(double min, double max, int maxTicks) {
+ List out = new ArrayList<>();
+ if (!Double.isFinite(min) || !Double.isFinite(max) || max <= min) {
+ return out;
+ }
+ double step = niceStep(max - min, maxTicks);
+ double start = Math.ceil(min / step) * step;
+ for (double v = start; v <= max + step * 0.001; v += step) {
+ out.add(new Tick(v, formatValue(v, step)));
+ }
+ return out;
+ }
+
+ static List timeMs(long minMs, long maxMs, int maxTicks) {
+ List out = new ArrayList<>();
+ if (maxMs <= minMs) {
+ return out;
+ }
+ double range = maxMs - minMs;
+ double step = niceStep(range, maxTicks);
+ double start = Math.ceil(minMs / step) * step;
+ for (double v = start; v <= maxMs + step * 0.001; v += step) {
+ long ms = Math.round(v);
+ out.add(new Tick(ms, formatTimeMs(ms)));
+ }
+ return out;
+ }
+
+ private static double niceStep(double range, int maxTicks) {
+ double raw = range / Math.max(2, maxTicks);
+ if (raw <= 0) {
+ return 1;
+ }
+ double mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ double norm = raw / mag;
+ double nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
+ return nice * mag;
+ }
+
+ private static String formatValue(double v, double step) {
+ if (step >= 1) {
+ return String.format(Locale.US, "%.0f", v);
+ }
+ if (step >= 0.1) {
+ return String.format(Locale.US, "%.1f", v);
+ }
+ return String.format(Locale.US, "%.2f", v);
+ }
+
+ private static String formatTimeMs(long ms) {
+ if (ms >= 60_000) {
+ return String.format(Locale.US, "%.1fm", ms / 60_000.0);
+ }
+ if (ms >= 1000) {
+ return String.format(Locale.US, "%.1fs", ms / 1000.0);
+ }
+ return ms + "ms";
+ }
+}
diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java
index 025ac60..a428be0 100644
--- a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java
+++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java
@@ -3,172 +3,354 @@ package com.foxx.androidcast.studio;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.MouseEvent;
+import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
+import javafx.scene.text.FontWeight;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
-/** Multi-series line chart with hover tooltip for session samples. */
+/** Multi-session line chart: zoom, grid, legend, hover marker + tooltip. */
public final class SessionChartPane extends StackPane {
- private static final Color[] SERIES_COLORS = {
- Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
+ public enum Metric {
+ BITRATE("Video bitrate (kbps)"),
+ LOSS("Packet loss (%)"),
+ ENCODE_FPS("Encode FPS");
+
+ final String axisLabel;
+
+ Metric(String axisLabel) {
+ this.axisLabel = axisLabel;
+ }
+ }
+
+ private static final Color[] SESSION_COLORS = {
+ Color.web("#64B5F6"), Color.web("#FF8A65"), Color.web("#81C784"),
+ Color.web("#FFD54F"), Color.web("#BA68C8"), Color.web("#4DD0E1")
};
+ private static final double PAD_LEFT = 64;
+ private static final double PAD_RIGHT = 16;
+ private static final double PAD_TOP = 16;
+ private static final double PAD_BOTTOM = 40;
+ private static final Color GRID_COLOR = Color.web("#3a3a3a");
+ private static final Color AXIS_COLOR = Color.web("#aaaaaa");
+
private final Canvas canvas = new Canvas(800, 480);
private final List sessions = new ArrayList<>();
- private boolean absoluteTime = true;
- private boolean showBitrate = true;
- private boolean showRtt;
- private boolean showLoss;
- private String hoverText = "";
+ private Metric metric = Metric.BITRATE;
+
+ private double viewMinT;
+ private double viewMaxT = 1;
+ private double viewMinY;
+ private double viewMaxY = 1;
+ private boolean viewInitialized;
+
+ private double hoverMouseX = -1;
+ private double hoverMouseY = -1;
+ private int hoverSessionIndex = -1;
+ private SessionDocument.SamplePoint hoverPoint;
+ private double hoverPlotX;
+ private double hoverPlotY;
public SessionChartPane() {
getChildren().add(canvas);
canvas.widthProperty().bind(widthProperty());
- canvas.heightProperty().bind(heightProperty().subtract(0));
+ canvas.heightProperty().bind(heightProperty());
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
- canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
- hoverText = "";
- draw();
- });
+ canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
+ canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
}
- public void setAbsoluteTime(boolean absoluteTime) {
- this.absoluteTime = absoluteTime;
- }
-
- public void render(List docs, boolean bitrate, boolean rtt, boolean loss) {
+ public void render(List docs, Metric metric) {
sessions.clear();
if (docs != null) {
sessions.addAll(docs);
}
- showBitrate = bitrate;
- showRtt = rtt;
- showLoss = loss;
+ this.metric = metric != null ? metric : Metric.BITRATE;
+ resetView();
draw();
}
- private void onMouseMoved(MouseEvent e) {
- if (sessions.isEmpty()) {
- return;
- }
- double pad = 48;
- double w = canvas.getWidth() - pad * 2;
- double h = canvas.getHeight() - pad * 2;
- if (w <= 0 || h <= 0) {
- return;
- }
- long tMax = maxTimeMs();
- if (tMax <= 0) {
- return;
- }
- double relX = (e.getX() - pad) / w;
- if (relX < 0 || relX > 1) {
- hoverText = "";
- draw();
- return;
- }
- long tQuery = (long) (relX * tMax);
- SessionDocument doc = sessions.get(0);
- SessionDocument.SamplePoint nearest = null;
- long best = Long.MAX_VALUE;
- for (SessionDocument.SamplePoint p : doc.samples) {
- long d = Math.abs(p.tMs - tQuery);
- if (d < best) {
- best = d;
- nearest = p;
- }
- }
- if (nearest != null) {
- hoverText = String.format("t=%d ms bitrate=%.0f kbps rtt=%d ms loss=%.3f",
- nearest.tMs, nearest.videoBitrateKbps, nearest.rttMs, nearest.lossRatio);
- }
- draw();
+ private void resetView() {
+ viewMinT = 0;
+ viewMaxT = Math.max(1, dataMaxTimeMs());
+ double[] y = dataYRange();
+ viewMinY = y[0];
+ viewMaxY = y[1];
+ viewInitialized = sessions.stream().anyMatch(d -> d.samples.size() >= 2);
}
- private long maxTimeMs() {
+ private long dataMaxTimeMs() {
long max = 1;
for (SessionDocument doc : sessions) {
for (SessionDocument.SamplePoint p : doc.samples) {
max = Math.max(max, p.tMs);
}
- if (!absoluteTime && doc.durationMs > max) {
- max = doc.durationMs;
- }
+ max = Math.max(max, doc.durationMs);
}
return max;
}
+ private double[] dataYRange() {
+ double min = Double.POSITIVE_INFINITY;
+ double max = Double.NEGATIVE_INFINITY;
+ for (SessionDocument doc : sessions) {
+ for (SessionDocument.SamplePoint p : doc.samples) {
+ double v = metricValue(p);
+ if (Double.isFinite(v)) {
+ min = Math.min(min, v);
+ max = Math.max(max, v);
+ }
+ }
+ }
+ if (!Double.isFinite(min)) {
+ min = 0;
+ max = 1;
+ }
+ if (max <= min) {
+ max = min + 1;
+ }
+ double pad = (max - min) * 0.06;
+ return new double[] {min - pad, max + pad};
+ }
+
+ private void onScroll(ScrollEvent e) {
+ if (!viewInitialized) {
+ return;
+ }
+ double plotW = plotWidth();
+ double plotH = plotHeight();
+ if (plotW <= 0 || plotH <= 0) {
+ return;
+ }
+ double factor = e.getDeltaY() > 0 ? 0.85 : 1.0 / 0.85;
+ double mx = e.getX();
+ double my = e.getY();
+ if (mx >= PAD_LEFT && mx <= PAD_LEFT + plotW && my >= PAD_TOP && my <= PAD_TOP + plotH) {
+ double relX = (mx - PAD_LEFT) / plotW;
+ double relY = 1.0 - (my - PAD_TOP) / plotH;
+ double tAnchor = viewMinT + relX * (viewMaxT - viewMinT);
+ double yAnchor = viewMinY + relY * (viewMaxY - viewMinY);
+ zoomTime(tAnchor, factor);
+ zoomY(yAnchor, factor);
+ } else {
+ zoomTime((viewMinT + viewMaxT) / 2, factor);
+ zoomY((viewMinY + viewMaxY) / 2, factor);
+ }
+ e.consume();
+ draw();
+ }
+
+ private void zoomTime(double anchor, double factor) {
+ double span = viewMaxT - viewMinT;
+ if (span <= 0) {
+ return;
+ }
+ double newSpan = Math.max(50, span * factor);
+ viewMinT = anchor - (anchor - viewMinT) * (newSpan / span);
+ viewMaxT = viewMinT + newSpan;
+ }
+
+ private void zoomY(double anchor, double factor) {
+ double span = viewMaxY - viewMinY;
+ if (span <= 0) {
+ return;
+ }
+ double newSpan = Math.max(1, span * factor);
+ viewMinY = anchor - (anchor - viewMinY) * (newSpan / span);
+ viewMaxY = viewMinY + newSpan;
+ }
+
+ private void onMouseMoved(MouseEvent e) {
+ hoverMouseX = e.getX();
+ hoverMouseY = e.getY();
+ updateHoverHit();
+ draw();
+ }
+
+ private void clearHover() {
+ hoverMouseX = -1;
+ hoverMouseY = -1;
+ hoverSessionIndex = -1;
+ hoverPoint = null;
+ draw();
+ }
+
+ private void updateHoverHit() {
+ hoverSessionIndex = -1;
+ hoverPoint = null;
+ double plotW = plotWidth();
+ double plotH = plotHeight();
+ if (plotW <= 0 || plotH <= 0 || sessions.isEmpty()) {
+ return;
+ }
+ if (hoverMouseX < PAD_LEFT || hoverMouseX > PAD_LEFT + plotW
+ || hoverMouseY < PAD_TOP || hoverMouseY > PAD_TOP + plotH) {
+ return;
+ }
+ double bestDist = 24;
+ for (int si = 0; si < sessions.size(); si++) {
+ SessionDocument doc = sessions.get(si);
+ for (SessionDocument.SamplePoint p : doc.samples) {
+ if (p.tMs < viewMinT || p.tMs > viewMaxT) {
+ continue;
+ }
+ double v = metricValue(p);
+ if (v < viewMinY || v > viewMaxY) {
+ continue;
+ }
+ double x = toScreenX(p.tMs);
+ double y = toScreenY(v);
+ double d = Math.hypot(hoverMouseX - x, hoverMouseY - y);
+ if (d < bestDist) {
+ bestDist = d;
+ hoverSessionIndex = si;
+ hoverPoint = p;
+ hoverPlotX = x;
+ hoverPlotY = y;
+ }
+ }
+ }
+ }
+
+ private double plotWidth() {
+ return Math.max(0, canvas.getWidth() - PAD_LEFT - PAD_RIGHT);
+ }
+
+ private double plotHeight() {
+ return Math.max(0, canvas.getHeight() - PAD_TOP - PAD_BOTTOM);
+ }
+
+ private double toScreenX(double tMs) {
+ double plotW = plotWidth();
+ double span = viewMaxT - viewMinT;
+ if (span <= 0) {
+ return PAD_LEFT;
+ }
+ return PAD_LEFT + ((tMs - viewMinT) / span) * plotW;
+ }
+
+ private double toScreenY(double v) {
+ double plotH = plotHeight();
+ double span = viewMaxY - viewMinY;
+ if (span <= 0) {
+ return PAD_TOP + plotH;
+ }
+ return PAD_TOP + plotH - ((v - viewMinY) / span) * plotH;
+ }
+
+ private double metricValue(SessionDocument.SamplePoint p) {
+ switch (metric) {
+ case LOSS:
+ return p.lossRatio * 100.0;
+ case ENCODE_FPS:
+ return p.encodeFps;
+ case BITRATE:
+ default:
+ return p.videoBitrateKbps;
+ }
+ }
+
private void draw() {
GraphicsContext g = canvas.getGraphicsContext2D();
double w = canvas.getWidth();
double h = canvas.getHeight();
- g.setFill(Color.web("#1a1a1a"));
+ g.setFill(Color.web("#121212"));
g.fillRect(0, 0, w, h);
+
if (sessions.isEmpty()) {
g.setFill(Color.LIGHTGRAY);
g.setFont(Font.font(14));
- g.fillText("Open session_*.json (enable Grab session stats on device)", 24, 40);
+ g.fillText("Open session_send_* / session_recv_* JSON (Grab session stats on device)", 24, 40);
return;
}
- double pad = 48;
- double plotW = w - pad * 2;
- double plotH = h - pad * 2;
- long tMax = maxTimeMs();
- g.setStroke(Color.GRAY);
- g.strokeRect(pad, pad, plotW, plotH);
- int series = 0;
+ double plotW = plotWidth();
+ double plotH = plotHeight();
+ if (plotW <= 1 || plotH <= 1) {
+ return;
+ }
+
+ List xTicks = ChartTicks.timeMs(
+ (long) viewMinT, (long) viewMaxT, 8);
+ List yTicks = ChartTicks.linear(viewMinY, viewMaxY, 6);
+
+ g.setStroke(GRID_COLOR);
+ g.setLineWidth(1);
+ for (ChartTicks.Tick t : xTicks) {
+ double x = toScreenX(t.value);
+ g.strokeLine(x, PAD_TOP, x, PAD_TOP + plotH);
+ }
+ for (ChartTicks.Tick t : yTicks) {
+ double y = toScreenY(t.value);
+ g.strokeLine(PAD_LEFT, y, PAD_LEFT + plotW, y);
+ }
+
+ g.setStroke(AXIS_COLOR);
+ g.setLineWidth(1.5);
+ g.strokeLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH);
+ g.strokeLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH);
+
+ g.setFill(AXIS_COLOR);
+ g.setFont(Font.font(10));
+ for (ChartTicks.Tick t : xTicks) {
+ double x = toScreenX(t.value);
+ g.fillText(t.label, x - 16, PAD_TOP + plotH + 14);
+ }
+ for (ChartTicks.Tick t : yTicks) {
+ double y = toScreenY(t.value);
+ g.fillText(t.label, 4, y + 4);
+ }
+
+ g.setFill(Color.web("#cccccc"));
+ g.setFont(Font.font(11));
+ g.fillText("Time", PAD_LEFT + plotW / 2 - 16, h - 4);
+ g.save();
+ g.translate(12, PAD_TOP + plotH / 2);
+ g.rotate(-90);
+ g.fillText(metric.axisLabel, 0, 0);
+ g.restore();
+
for (int si = 0; si < sessions.size(); si++) {
SessionDocument doc = sessions.get(si);
- if (doc.samples.isEmpty()) {
+ if (doc.samples.size() < 2) {
continue;
}
- Color color = SERIES_COLORS[si % SERIES_COLORS.length];
- if (showBitrate) {
- drawSeries(g, doc, pad, plotW, plotH, tMax, color,
- p -> p.videoBitrateKbps, 0, 20_000);
- series++;
- }
- if (showRtt) {
- drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(0, 1, 1, 0.6),
- p -> p.rttMs, 0, 500);
- }
- if (showLoss) {
- drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(30, 1, 1, 0.6),
- p -> p.lossRatio * 1000, 0, 1000);
- }
+ drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_COLORS.length]);
}
- if (!hoverText.isEmpty()) {
- g.setFill(Color.rgb(0, 0, 0, 0.75));
- g.fillRoundRect(56, 12, Math.min(plotW, 420), 28, 8, 8);
- g.setFill(Color.WHITE);
- g.setFont(Font.font(12));
- g.fillText(hoverText, 64, 30);
+
+ drawLegend(g, plotW);
+
+ if (hoverPoint != null && hoverSessionIndex >= 0) {
+ Color c = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
+ g.setFill(c);
+ g.fillOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
+ g.setStroke(Color.WHITE);
+ g.setLineWidth(1.5);
+ g.strokeOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
+ drawHoverPopup(g, c);
}
- g.setFill(Color.LIGHTGRAY);
- g.setFont(Font.font(11));
- g.fillText(absoluteTime ? "X: ms from session start" : "X: relative (multi-session)", pad, h - 12);
}
- private interface ValueFn {
- double value(SessionDocument.SamplePoint p);
- }
-
- private void drawSeries(GraphicsContext g, SessionDocument doc, double pad, double plotW, double plotH,
- long tMax, Color color, ValueFn fn, double minY, double maxY) {
- if (doc.samples.size() < 2) {
- return;
- }
+ private void drawSessionLine(GraphicsContext g, SessionDocument doc, Color color) {
g.setStroke(color);
+ g.setLineWidth(2);
g.beginPath();
boolean first = true;
for (SessionDocument.SamplePoint p : doc.samples) {
- double x = pad + (p.tMs / (double) tMax) * plotW;
- double v = fn.value(p);
- double y = pad + plotH - ((v - minY) / (maxY - minY)) * plotH;
+ if (p.tMs < viewMinT || p.tMs > viewMaxT) {
+ continue;
+ }
+ double v = metricValue(p);
+ if (v < viewMinY || v > viewMaxY) {
+ continue;
+ }
+ double x = toScreenX(p.tMs);
+ double y = toScreenY(v);
if (first) {
g.moveTo(x, y);
first = false;
@@ -178,4 +360,78 @@ public final class SessionChartPane extends StackPane {
}
g.stroke();
}
+
+ private void drawLegend(GraphicsContext g, double plotW) {
+ double x0 = PAD_LEFT + plotW - 8;
+ double y = PAD_TOP + 8;
+ double rowH = 18;
+ double boxW = 14;
+ int rows = 0;
+ for (SessionDocument doc : sessions) {
+ if (!doc.samples.isEmpty()) {
+ rows++;
+ }
+ }
+ if (rows == 0) {
+ return;
+ }
+ double legendW = 220;
+ double legendH = rows * rowH + 12;
+ x0 -= legendW;
+ g.setFill(Color.rgb(0, 0, 0, 0.72));
+ g.fillRoundRect(x0, y, legendW, legendH, 6, 6);
+ g.setStroke(Color.web("#888888"));
+ g.strokeRoundRect(x0, y, legendW, legendH, 6, 6);
+
+ int row = 0;
+ for (int si = 0; si < sessions.size(); si++) {
+ SessionDocument doc = sessions.get(si);
+ if (doc.samples.isEmpty()) {
+ continue;
+ }
+ Color c = SESSION_COLORS[si % SESSION_COLORS.length];
+ double ry = y + 8 + row * rowH;
+ g.setFill(c);
+ g.fillRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
+ g.setStroke(Color.web("#bbbbbb"));
+ g.strokeRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
+ boolean bold = si == hoverSessionIndex;
+ g.setFill(Color.WHITE);
+ g.setFont(Font.font("System", bold ? FontWeight.BOLD : FontWeight.NORMAL, 11));
+ String name = doc.path;
+ if (name.length() > 26) {
+ name = "…" + name.substring(name.length() - 25);
+ }
+ g.fillText(name, x0 + 8 + boxW + 6, ry + 11);
+ row++;
+ }
+ }
+
+ private void drawHoverPopup(GraphicsContext g, Color accent) {
+ SessionDocument doc = sessions.get(hoverSessionIndex);
+ String text = String.format(Locale.US,
+ "t=%d ms bitrate=%.0f kbps loss=%.2f%% fps=%.1f",
+ hoverPoint.tMs,
+ hoverPoint.videoBitrateKbps,
+ hoverPoint.lossRatio * 100.0,
+ hoverPoint.encodeFps);
+ double tw = Math.min(plotWidth() - 20, g.getFont().getSize() * 0.55 * text.length() + 20);
+ double th = 28;
+ double px = hoverPlotX + 12;
+ double py = hoverPlotY - th - 12;
+ if (px + tw > PAD_LEFT + plotWidth()) {
+ px = hoverPlotX - tw - 12;
+ }
+ if (py < PAD_TOP) {
+ py = hoverPlotY + 12;
+ }
+ g.setFill(Color.rgb(0, 0, 0, 0.88));
+ g.fillRoundRect(px, py, tw, th, 6, 6);
+ g.setStroke(accent);
+ g.setLineWidth(1);
+ g.strokeRoundRect(px, py, tw, th, 6, 6);
+ g.setFill(Color.WHITE);
+ g.setFont(Font.font(11));
+ g.fillText(text, px + 8, py + 18);
+ }
}
diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java
index ab8f358..5acbdd2 100644
--- a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java
+++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java
@@ -9,9 +9,10 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
-/** Parsed session_*.json (aggregate fields + optional time series). */
+/** Parsed session JSON (aggregate fields + optional time series). */
public final class SessionDocument {
public final String path;
+ public final String direction;
public final String role;
public final String transport;
public final long startedMs;
@@ -20,7 +21,8 @@ public final class SessionDocument {
private SessionDocument(String path, JSONObject root) {
this.path = path;
- role = root.optString("role", "?");
+ direction = inferDirection(path, root);
+ role = root.optString("role", "recv".equals(direction) ? "receiver" : "sender");
transport = root.optString("transport", "?");
startedMs = root.optLong("started_at_epoch_ms", 0L);
durationMs = root.optLong("duration_ms", 0L);
@@ -42,7 +44,23 @@ public final class SessionDocument {
}
public String title() {
- return path + " (" + role + ", " + transport + ")";
+ return path + " (" + direction + ", " + transport + ")";
+ }
+
+ private static String inferDirection(String fileName, JSONObject root) {
+ String d = root.optString("direction", "");
+ if ("send".equals(d) || "recv".equals(d)) {
+ return d;
+ }
+ if (fileName != null) {
+ if (fileName.startsWith("session_send_")) {
+ return "send";
+ }
+ if (fileName.startsWith("session_recv_")) {
+ return "recv";
+ }
+ }
+ return "receiver".equals(root.optString("role", "")) ? "recv" : "send";
}
public static final class SamplePoint {
@@ -50,12 +68,17 @@ public final class SessionDocument {
public final double videoBitrateKbps;
public final int rttMs;
public final double lossRatio;
+ public final double encodeFps;
+ public final int congestionLevel;
- SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) {
+ SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio,
+ double encodeFps, int congestionLevel) {
this.tMs = tMs;
this.videoBitrateKbps = videoBitrateKbps;
this.rttMs = rttMs;
this.lossRatio = lossRatio;
+ this.encodeFps = encodeFps;
+ this.congestionLevel = congestionLevel;
}
static SamplePoint fromJson(JSONObject o) {
@@ -63,7 +86,9 @@ public final class SessionDocument {
o.optLong("t_ms", 0L),
o.optDouble("video_bitrate_kbps", 0.0),
o.optInt("rtt_ms", 0),
- o.optDouble("loss_ratio", 0.0));
+ o.optDouble("loss_ratio", 0.0),
+ o.optDouble("encode_fps", 0.0),
+ o.optInt("congestion_level", 0));
}
}
}
diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java
index a1183ed..248f322 100644
--- a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java
+++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java
@@ -1,12 +1,13 @@
package com.foxx.androidcast.studio;
import javafx.application.Application;
+import javafx.collections.ListChangeListener;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
-import javafx.scene.control.ToggleGroup;
+import javafx.scene.control.SelectionMode;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
@@ -19,45 +20,71 @@ import java.util.ArrayList;
import java.util.List;
/**
- * Linux desktop session log viewer (foundation for full Android Cast parity on desktop).
- * Open one or more session_*.json files; charts use {@code samples[]} when present (schema v1+).
+ * Desktop session log viewer — open session_send_* / session_recv_* JSON, overlay charts.
*/
public class SessionStudioApp extends Application {
private final List sessions = new ArrayList<>();
private final SessionChartPane chartPane = new SessionChartPane();
- private final Label statusLabel = new Label("Open session_*.json files from the device.");
+ private final Label statusLabel = new Label("Open session_send_* / session_recv_* JSON from the device.");
@Override
public void start(Stage stage) {
Button openBtn = new Button("Open sessions…");
- CheckBox absTime = new CheckBox("Absolute time (single session)");
- absTime.setSelected(true);
- absTime.setOnAction(e -> chartPane.setAbsoluteTime(absTime.isSelected()));
-
- ToggleGroup metrics = new ToggleGroup();
- CheckBox metricBitrate = metricToggle(metrics, "Video bitrate", true);
- CheckBox metricRtt = metricToggle(metrics, "RTT", false);
- CheckBox metricLoss = metricToggle(metrics, "Loss ratio", false);
- Runnable refresh = () -> chartPane.render(sessions,
- metricBitrate.isSelected(), metricRtt.isSelected(), metricLoss.isSelected());
- metricBitrate.setOnAction(e -> refresh.run());
- metricRtt.setOnAction(e -> refresh.run());
- metricLoss.setOnAction(e -> refresh.run());
- absTime.setOnAction(e -> {
- chartPane.setAbsoluteTime(absTime.isSelected());
- refresh.run();
- });
+ CheckBox metricBitrate = new CheckBox("Video bitrate");
+ metricBitrate.setSelected(true);
+ CheckBox metricLoss = new CheckBox("Packet loss %");
+ CheckBox metricFps = new CheckBox("Encode FPS");
ListView sessionList = new ListView<>();
- sessionList.setPrefHeight(120);
- sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
+ sessionList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
+ sessionList.setPrefHeight(100);
- HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss);
+ Runnable refresh = () -> {
+ List selected = selectedSessions(sessionList);
+ SessionChartPane.Metric metric = SessionChartPane.Metric.BITRATE;
+ if (metricLoss.isSelected()) {
+ metric = SessionChartPane.Metric.LOSS;
+ } else if (metricFps.isSelected()) {
+ metric = SessionChartPane.Metric.ENCODE_FPS;
+ } else if (metricBitrate.isSelected()) {
+ metric = SessionChartPane.Metric.BITRATE;
+ }
+ chartPane.render(selected, metric);
+ };
+
+ metricBitrate.setOnAction(e -> {
+ if (metricBitrate.isSelected()) {
+ metricLoss.setSelected(false);
+ metricFps.setSelected(false);
+ }
+ refresh.run();
+ });
+ metricLoss.setOnAction(e -> {
+ if (metricLoss.isSelected()) {
+ metricBitrate.setSelected(false);
+ metricFps.setSelected(false);
+ }
+ refresh.run();
+ });
+ metricFps.setOnAction(e -> {
+ if (metricFps.isSelected()) {
+ metricBitrate.setSelected(false);
+ metricLoss.setSelected(false);
+ }
+ refresh.run();
+ });
+ sessionList.getSelectionModel().getSelectedIndices()
+ .addListener((ListChangeListener) c -> refresh.run());
+
+ HBox toolbar = new HBox(12, openBtn, metricBitrate, metricLoss, metricFps);
toolbar.setStyle("-fx-padding: 8;");
- VBox top = new VBox(8, toolbar, sessionList, statusLabel);
+ Label hint = new Label("Scroll wheel over chart to zoom X+Y. Hover a line for values. RTT removed — use loss % or bitrate.");
+ hint.setStyle("-fx-text-fill: #888; -fx-font-size: 11;");
+
+ VBox top = new VBox(8, toolbar, hint, sessionList, statusLabel);
top.setStyle("-fx-padding: 8;");
BorderPane root = new BorderPane();
@@ -67,7 +94,10 @@ public class SessionStudioApp extends Application {
openFiles(stage);
sessionList.getItems().clear();
for (SessionDocument doc : sessions) {
- sessionList.getItems().add(doc.title());
+ sessionList.getItems().add(doc.path);
+ }
+ if (!sessions.isEmpty()) {
+ sessionList.getSelectionModel().selectAll();
}
refresh.run();
});
@@ -77,10 +107,17 @@ public class SessionStudioApp extends Application {
stage.show();
}
- private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
- CheckBox box = new CheckBox(text);
- box.setSelected(selected);
- return box;
+ private List selectedSessions(ListView list) {
+ List out = new ArrayList<>();
+ for (int idx : list.getSelectionModel().getSelectedIndices()) {
+ if (idx >= 0 && idx < sessions.size()) {
+ out.add(sessions.get(idx));
+ }
+ }
+ if (out.isEmpty() && !sessions.isEmpty()) {
+ out.addAll(sessions);
+ }
+ return out;
}
private void openFiles(Stage stage) {
@@ -101,7 +138,7 @@ public class SessionStudioApp extends Application {
return;
}
}
- statusLabel.setText("Loaded " + sessions.size() + " session file(s).");
+ statusLabel.setText("Loaded " + sessions.size() + " session file(s). Select rows to overlay.");
}
public static void main(String[] args) {
diff --git a/pull_stats.sh b/pull_stats.sh
index 1ee3264..5fe894f 100755
--- a/pull_stats.sh
+++ b/pull_stats.sh
@@ -9,6 +9,25 @@ filter_out() {
return 0
}
+LOG_ROOT="log_capture/session_stats/"
+CLEAN_LOGS="no"
+
+while true; do
+ read -p "clean logs on device after fetch? [Y/n]" yn
+ case $yn in
+ [Y|Yes|y|yes]* )
+ CLEAN_LOGS="yes"
+ break
+ ;;
+ [N|No|n|no]* )
+ break
+ ;;
+ *)
+ echo "answer Y(es) or N(no) (short y/n or yes/no)"
+ ;;
+ esac
+done
+
echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
SERIAL="$(echo "${DEV}" | awk '{print $1}')"
filter_out SERIAL
@@ -16,6 +35,14 @@ echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
# echo "Device: ${DEV}"
# echo "Serial: ${SERIAL}"
adb -s "${SERIAL}" shell run-as com.foxx.androidcast ls files/session_stats/
- adb -s "${SERIAL}" shell run-as com.foxx.androidcast cat files/session_stats/session_*.json
+ LOG_PATH="${LOG_ROOT}/${SERIAL}/"
+ echo "------------ dumping in ${LOG_PATH}"
+ mkdir -p "${LOG_PATH}"
+ for f in $(adb -s ${SERIAL} shell run-as com.foxx.androidcast ls files/session_stats/); do
+ adb -s ${SERIAL} exec-out run-as com.foxx.androidcast cat "files/session_stats/$f" > "${LOG_PATH}/$f"
+ [[ "no" != "${CLEAN_LOGS}" ]] && adb -s ${SERIAL} shell run-as com.foxx.androidcast rm -f "files/session_stats/$f"
+ done
done
+echo "run './gradlew :session-studio:run' to analyze logs; find the latest logs in ${LOG_ROOT}"
+
diff --git a/rebuild.sh b/rebuild.sh
index b6db45d..c142dce 100755
--- a/rebuild.sh
+++ b/rebuild.sh
@@ -5,16 +5,37 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
-# Space-separated host:port list (wireless debugging). Edit as needed.
-CONNECTIONS_LIST=""
+# Hints for wireless debugging. Edit as needed.
+## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
+declare -A CONNECTIONS_LIST_HINTS
## android tv projector / android 13
-CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.153:5555"
+CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
## tab g6 max / android 16
-CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.39:5555"
+CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
## bl6000pro main / android 11
-CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.106:5555"
+CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
## bl6000pro aux / android 11
-CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.173:5555"
+CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
+
+get_ip_for_hint_key() {
+ local ip=${1}
+ ip="$(echo "${ip}" | sed -e s'@_@\.@g' -e s'@:.*@@' | tr -d '\n' )"
+ printf "%s" "${ip}"
+}
+
+get_hint_key_for_ip() {
+ local ip=${1}
+ ip="$(echo "${ip}" | sed -e s'@\.@_@g' -e s'@:.*@@' | tr -d '\n' )"
+ printf "%s" "${ip}"
+}
+
+get_hint() {
+ local ip_mask="${1}"
+ local -n hint
+ ip_mask="$(get_hint_key_for_ip "${ip_mask}")"
+ hint=CONNECTIONS_LIST_HINTS[${ip_mask}]
+ printf "%s" "${hint}"
+}
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
PACKAGE="com.foxx.androidcast"
@@ -33,7 +54,7 @@ for arg in "$@"; do
;;
-h|--help)
echo "Usage: $0 [--skip-tests] [--java-home=PATH]"
- echo " Builds debug APK, runs unit tests (unless skipped), reconnects devices in CONNECTIONS_LIST,"
+ echo " Builds debug APK, runs unit tests (unless skipped), reconnects devices in CONNECTIONS_LIST_HINTS,"
echo " installs on all adb devices in 'device' state, and launches the app."
exit 0
;;
@@ -79,6 +100,10 @@ reconnect_endpoint() {
local endpoint="$1"
[[ -z "$endpoint" ]] && return 1
local attempt out
+ local hint=""
+ hint="$(get_hint "${endpoint}")"
+ [[ -n "${hint}" ]] && hint="(having hint: ${hint})"
+ echo " trying to connect to $endpoint ${hint}"
adb disconnect "$endpoint" >/dev/null 2>&1 || true
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
out="$(adb connect "$endpoint" 2>&1)" || true
@@ -101,8 +126,8 @@ reconnect_known_devices() {
adb start-server >/dev/null 2>&1 || true
adb reconnect offline >/dev/null 2>&1 || true
local endpoint
- for endpoint in $CONNECTIONS_LIST; do
- reconnect_endpoint "$endpoint" || true
+ for endpoint in "${!CONNECTIONS_LIST_HINTS[@]}"; do
+ reconnect_endpoint "$(get_ip_for_hint_key "$endpoint")" || true
done
adb devices -l
}
@@ -133,7 +158,8 @@ done
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
./gradlew --stop
-./gradlew clean assembleDebug
+./gradlew clean
+./gradlew --build-cache assembleDebug
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
echo "==> Running unit tests (./gradlew testDebugUnitTest)"