1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 02:59:00 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-19 19:43:06 +02:00
parent 35ab5622d7
commit a4107797fa
40 changed files with 2602 additions and 254 deletions

View File

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

View File

@@ -64,6 +64,22 @@
android:label="@string/developer_settings_title"
android:parentActivityName=".MainActivity" />
<activity
android:name=".stats.ui.SessionStatsAnalyzerActivity"
android:exported="false"
android:label="@string/session_stats_title"
android:parentActivityName=".DeveloperSettingsActivity" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".receiver.ReceiverPlaybackActivity"
android:exported="false"

View File

@@ -106,7 +106,7 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, show).apply();
}
/** Developer: write anonymous session_*.json files (default on). */
/** Developer: write anonymous session_send_* / session_recv_* JSON (default on). */
public static boolean isGrabSessionStats(Context context) {
return prefs(context).getBoolean(KEY_DEV_GRAB_SESSION_STATS, true);
}

View File

@@ -9,6 +9,7 @@ import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
/** Hidden developer settings (opened via version label easter egg). */
public class DeveloperSettingsActivity extends AppCompatActivity {
@@ -31,6 +32,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
refreshFromPreferences(debugOverlay, codecScores, sessionStats);
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
reloadCodecs.setOnClickListener(v -> {
String summary = CodecPriorityCatalog.reload(this);
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CastSession> 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++;

View File

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

View File

@@ -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<MultiCastCoordinator.ActivePeer> 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Tick> linear(double min, double max, int maxTicks) {
List<Tick> 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<Tick> timeMs(long minMs, long maxMs, int maxTicks) {
List<Tick> 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";
}
}

View File

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

View File

@@ -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<SessionRecord> 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<SessionRecord> 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<ChartTickHelper.Tick> xTicks = ChartTickHelper.timeMs(
(long) viewMinT, (long) viewMaxT, 7);
List<ChartTickHelper.Tick> 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;
}
}
}

View File

@@ -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<SamplePoint> 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<SamplePoint> parseArray(org.json.JSONArray arr) {
if (arr == null || arr.length() == 0) {
return Collections.emptyList();
}
List<SamplePoint> 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));
}
}
}

View File

@@ -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<SessionRecord> 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<SessionRecord> loaded = new ArrayList<>();
List<File> 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<SessionRecord> 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)));
}
}

View File

@@ -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<SessionStatsListAdapter.Holder> {
interface Listener {
void onSelectionChanged(List<SessionRecord> selected);
}
private final List<SessionRecord> items = new ArrayList<>();
private final Set<Integer> selectedPositions = new HashSet<>();
private final Listener listener;
SessionStatsListAdapter(Listener listener) {
this.listener = listener;
}
void setItems(List<SessionRecord> 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<SessionRecord> getSelected() {
List<SessionRecord> 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);
}
}
}

View File

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

View File

@@ -39,10 +39,17 @@
android:text="@string/dev_grab_session_stats" />
<Button
android:id="@+id/btn_reload_codecs_json"
android:id="@+id/btn_open_session_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/session_stats_open" />
<Button
android:id="@+id/btn_reload_codecs_json"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_reload_codecs_json" />
<TextView

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/session_stats_subtitle"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/btn_refresh_sessions"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/session_stats_refresh" />
<Button
android:id="@+id/btn_share_session"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/session_stats_share" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_sessions"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginTop="4dp"
android:clipToPadding="false"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/text_session_stats_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/session_stats_empty"
android:visibility="gone" />
<ScrollView
android:id="@+id/panel_session_detail"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/text_session_summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textSize="12sp" />
<RadioGroup
android:id="@+id/radio_group_metric"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<RadioButton
android:id="@+id/radio_metric_bitrate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/session_stats_metric_bitrate" />
<RadioButton
android:id="@+id/radio_metric_loss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/session_stats_metric_loss" />
<RadioButton
android:id="@+id/radio_metric_fps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/session_stats_metric_fps" />
</RadioGroup>
<TextView
android:id="@+id/text_chart_interaction_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="11sp" />
<com.foxx.androidcast.stats.ui.SessionLineChartView
android:id="@+id/chart_session_lines"
android:layout_width="match_parent"
android:layout_height="320dp"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/session_stats_counters_title"
android:textStyle="bold" />
<com.foxx.androidcast.stats.ui.SessionBarChartView
android:id="@+id/chart_session_bars"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="220dp"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
android:checkable="true"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="8dp"
app:strokeWidth="2dp"
app:strokeColor="?attr/colorPrimary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/text_session_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_session_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="11sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -201,6 +201,19 @@
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
<string name="session_stats_title">Session statistics</string>
<string name="session_stats_subtitle">Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).</string>
<string name="session_stats_open">Open statistics analyzer</string>
<string name="session_stats_refresh">Refresh</string>
<string name="session_stats_share">Share JSON</string>
<string name="session_stats_empty">No session files yet. Cast with “Grab session stats” enabled.</string>
<string name="session_stats_disabled_hint">Session grab is off — enable it here to record new sessions.</string>
<string name="session_stats_counters_title">UDP &amp; protection counters</string>
<string name="session_stats_share_failed">Could not share session file</string>
<string name="session_stats_metric_bitrate">Bitrate</string>
<string name="session_stats_metric_loss">Loss %</string>
<string name="session_stats_metric_fps">Encode FPS</string>
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
<string name="dev_reload_codecs_json">Reload codecs.json</string>
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
<string name="codec_priority_score_suffix"> · %1$d</string>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="session_stats" path="session_stats/" />
</paths>

View File

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

View File

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

View File

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

View File

@@ -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<Tick> linear(double min, double max, int maxTicks) {
List<Tick> 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<Tick> timeMs(long minMs, long maxMs, int maxTicks) {
List<Tick> 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";
}
}

View File

@@ -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<SessionDocument> 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<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
public void render(List<SessionDocument> 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<ChartTicks.Tick> xTicks = ChartTicks.timeMs(
(long) viewMinT, (long) viewMaxT, 8);
List<ChartTicks.Tick> 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);
}
}

View File

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

View File

@@ -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<SessionDocument> 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<String> 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<SessionDocument> 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<Integer>) 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<SessionDocument> selectedSessions(ListView<String> list) {
List<SessionDocument> 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) {

View File

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

View File

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