mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:20:00 +03:00
snap
This commit is contained in:
@@ -93,6 +93,7 @@ dependencies {
|
|||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.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'
|
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
|||||||
@@ -64,6 +64,22 @@
|
|||||||
android:label="@string/developer_settings_title"
|
android:label="@string/developer_settings_title"
|
||||||
android:parentActivityName=".MainActivity" />
|
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
|
<activity
|
||||||
android:name=".receiver.ReceiverPlaybackActivity"
|
android:name=".receiver.ReceiverPlaybackActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public final class AppPreferences {
|
|||||||
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, show).apply();
|
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) {
|
public static boolean isGrabSessionStats(Context context) {
|
||||||
return prefs(context).getBoolean(KEY_DEV_GRAB_SESSION_STATS, true);
|
return prefs(context).getBoolean(KEY_DEV_GRAB_SESSION_STATS, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.widget.Toast;
|
|||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
|
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||||
|
|
||||||
/** Hidden developer settings (opened via version label easter egg). */
|
/** Hidden developer settings (opened via version label easter egg). */
|
||||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||||
@@ -31,6 +32,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
refreshFromPreferences(debugOverlay, codecScores, sessionStats);
|
refreshFromPreferences(debugOverlay, codecScores, sessionStats);
|
||||||
|
|
||||||
|
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
||||||
|
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
||||||
|
|
||||||
reloadCodecs.setOnClickListener(v -> {
|
reloadCodecs.setOnClickListener(v -> {
|
||||||
String summary = CodecPriorityCatalog.reload(this);
|
String summary = CodecPriorityCatalog.reload(this);
|
||||||
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
||||||
|
|||||||
@@ -42,8 +42,15 @@ public final class RgbToYuv420 {
|
|||||||
int height = image.getHeight();
|
int height = image.getHeight();
|
||||||
int need = bufferSize(width, height);
|
int need = bufferSize(width, height);
|
||||||
byte[] out = scratch != null && scratch.length >= need ? scratch : new byte[need];
|
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();
|
java.nio.ByteBuffer buffer = plane.getBuffer();
|
||||||
|
if (buffer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
int rowStride = plane.getRowStride();
|
int rowStride = plane.getRowStride();
|
||||||
int pixelStride = plane.getPixelStride();
|
int pixelStride = plane.getPixelStride();
|
||||||
int[] argb = new int[width * height];
|
int[] argb = new int[width * height];
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import com.foxx.androidcast.media.codec.jni.VpxEncodedFrame;
|
|||||||
import com.foxx.androidcast.sender.VideoEncoder;
|
import com.foxx.androidcast.sender.VideoEncoder;
|
||||||
|
|
||||||
import java.io.IOException;
|
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()}.
|
* 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 final VideoEncoder hwFallback = new VideoEncoder();
|
||||||
|
|
||||||
private Callback callback;
|
private Callback callback;
|
||||||
private long nativeHandle;
|
private final AtomicLong nativeHandle = new AtomicLong();
|
||||||
private boolean nativeActive;
|
|
||||||
private int width;
|
private int width;
|
||||||
private int height;
|
private int height;
|
||||||
private String mime;
|
private String mime;
|
||||||
private byte[] yuvScratch;
|
private byte[] yuvScratch;
|
||||||
private boolean forceKeyframe;
|
private boolean forceKeyframe;
|
||||||
|
|
||||||
private HandlerThread captureThread;
|
/** Active capture pipeline; {@link #releaseCapture()} swaps it out via {@code getAndSet(null)}. */
|
||||||
private Handler captureHandler;
|
private final AtomicReference<CapturePipeline> capturePipeline = new AtomicReference<>();
|
||||||
private ImageReader imageReader;
|
|
||||||
|
|
||||||
public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) {
|
public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) {
|
||||||
this.preference = preference;
|
this.preference = preference;
|
||||||
@@ -48,16 +48,17 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Surface getInputSurface() {
|
public Surface getInputSurface() {
|
||||||
if (nativeActive && imageReader != null) {
|
CapturePipeline pipe = capturePipeline.get();
|
||||||
return imageReader.getSurface();
|
if (nativeHandle.get() != 0L && pipe != null) {
|
||||||
|
return pipe.reader.getSurface();
|
||||||
}
|
}
|
||||||
return hwFallback.getInputSurface();
|
return hwFallback.getInputSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void requestKeyframe() {
|
public void requestKeyframe() {
|
||||||
if (nativeActive) {
|
if (nativeHandle.get() != 0L) {
|
||||||
NativeCodecBridge.vpxRequestKeyframe(nativeHandle);
|
NativeCodecBridge.vpxRequestKeyframe(nativeHandle.get());
|
||||||
forceKeyframe = true;
|
forceKeyframe = true;
|
||||||
} else {
|
} else {
|
||||||
hwFallback.requestKeyframe();
|
hwFallback.requestKeyframe();
|
||||||
@@ -68,6 +69,7 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
|||||||
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
Callback callback) throws IOException {
|
Callback callback) throws IOException {
|
||||||
releaseCapture();
|
releaseCapture();
|
||||||
|
releaseNativeEncoder();
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
@@ -75,38 +77,36 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
|||||||
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
||||||
|
|
||||||
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
||||||
nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||||
nativeActive = nativeHandle != 0L;
|
if (handle == 0L) {
|
||||||
if (!nativeActive) {
|
|
||||||
Log.w(TAG, "libvpx encoder init failed; using MediaCodec for surface capture");
|
Log.w(TAG, "libvpx encoder init failed; using MediaCodec for surface capture");
|
||||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
|
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
|
||||||
return hwFallback.prepare(width, height, mime, params, callback);
|
return hwFallback.prepare(width, height, mime, params, callback);
|
||||||
}
|
}
|
||||||
|
nativeHandle.set(handle);
|
||||||
|
|
||||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
||||||
captureThread = new HandlerThread("libvpx-capture");
|
CapturePipeline pipe = CapturePipeline.create(width, height, this::onImageAvailable);
|
||||||
captureThread.start();
|
capturePipeline.set(pipe);
|
||||||
captureHandler = new Handler(captureThread.getLooper());
|
|
||||||
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
|
|
||||||
imageReader.setOnImageAvailableListener(this::onImageAvailable, captureHandler);
|
|
||||||
callback.onConfig(width, height, null, null);
|
callback.onConfig(width, height, null, null);
|
||||||
Log.i(TAG, "libvpx surface encoder " + width + "x" + height + " mime=" + mime);
|
Log.i(TAG, "libvpx surface encoder " + width + "x" + height + " mime=" + mime);
|
||||||
return imageReader.getSurface();
|
return pipe.reader.getSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
Callback callback) throws IOException {
|
Callback callback) throws IOException {
|
||||||
releaseCapture();
|
releaseCapture();
|
||||||
|
releaseNativeEncoder();
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.mime = mime;
|
this.mime = mime;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
||||||
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
||||||
nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||||
nativeActive = nativeHandle != 0L;
|
if (handle != 0L) {
|
||||||
if (nativeActive) {
|
nativeHandle.set(handle);
|
||||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
||||||
callback.onConfig(width, height, null, null);
|
callback.onConfig(width, height, null, null);
|
||||||
return;
|
return;
|
||||||
@@ -116,26 +116,40 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void onImageAvailable(ImageReader reader) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try (Image image = reader.acquireLatestImage()) {
|
try (Image image = reader.acquireLatestImage()) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (image.getWidth() != width || image.getHeight() != height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
byte[] yuv = RgbToYuv420.fromRgbaImage(image, yuvScratch);
|
byte[] yuv = RgbToYuv420.fromRgbaImage(image, yuvScratch);
|
||||||
if (yuv == null) {
|
if (yuv == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (capturePipeline.get() != pipe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
long ptsUs = image.getTimestamp() / 1000L;
|
long ptsUs = image.getTimestamp() / 1000L;
|
||||||
emitEncodedFrame(yuv, ptsUs);
|
emitEncodedFrame(handle, yuv, ptsUs);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Capture frame skipped: " + e.getMessage());
|
Log.w(TAG, "Capture frame skipped: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void emitEncodedFrame(byte[] yuv, long ptsUs) {
|
private void emitEncodedFrame(long handle, byte[] yuv, long ptsUs) {
|
||||||
VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(nativeHandle, yuv, ptsUs, forceKeyframe);
|
if (nativeHandle.get() != handle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(handle, yuv, ptsUs, forceKeyframe);
|
||||||
forceKeyframe = false;
|
forceKeyframe = false;
|
||||||
if (frame != null && frame.data != null && callback != null) {
|
if (frame != null && frame.data != null && callback != null) {
|
||||||
callback.onEncodedFrame(ptsUs, frame.keyFrame, frame.data);
|
callback.onEncodedFrame(ptsUs, frame.keyFrame, frame.data);
|
||||||
@@ -144,43 +158,73 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
|
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
|
||||||
if (!nativeActive) {
|
long handle = nativeHandle.get();
|
||||||
|
if (handle == 0L) {
|
||||||
hwFallback.queueBitmapFrame(bitmap, ptsUs);
|
hwFallback.queueBitmapFrame(bitmap, ptsUs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420);
|
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420);
|
||||||
emitEncodedFrame(yuv, ptsUs);
|
emitEncodedFrame(handle, yuv, ptsUs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void queueYuvFrame(byte[] yuv, long ptsUs) {
|
public void queueYuvFrame(byte[] yuv, long ptsUs) {
|
||||||
if (!nativeActive) {
|
long handle = nativeHandle.get();
|
||||||
|
if (handle == 0L) {
|
||||||
hwFallback.queueYuvFrame(yuv, ptsUs);
|
hwFallback.queueYuvFrame(yuv, ptsUs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitEncodedFrame(yuv, ptsUs);
|
emitEncodedFrame(handle, yuv, ptsUs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
if (nativeActive) {
|
|
||||||
NativeCodecBridge.vpxEncoderRelease(nativeHandle);
|
|
||||||
nativeHandle = 0L;
|
|
||||||
nativeActive = false;
|
|
||||||
}
|
|
||||||
releaseCapture();
|
releaseCapture();
|
||||||
|
releaseNativeEncoder();
|
||||||
hwFallback.stop();
|
hwFallback.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void releaseCapture() {
|
private void releaseCapture() {
|
||||||
if (imageReader != null) {
|
CapturePipeline old = capturePipeline.getAndSet(null);
|
||||||
imageReader.close();
|
if (old != null) {
|
||||||
imageReader = null;
|
old.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
if (captureThread != null) {
|
|
||||||
captureThread.quitSafely();
|
|
||||||
captureThread = null;
|
|
||||||
captureHandler = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.foxx.androidcast.media.CodecNegotiator;
|
|||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||||
import com.foxx.androidcast.sender.CodecCatalog;
|
import com.foxx.androidcast.sender.CodecCatalog;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -55,20 +56,28 @@ public class CastSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
|
public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
|
||||||
transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
|
byte[] hello = CastProtocol.helloPayload(deviceName);
|
||||||
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
|
transport.send(CastProtocol.MSG_HELLO, hello);
|
||||||
CastProtocol.Message reply = transport.receive(10_000);
|
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) {
|
if (reply == null || reply.type != CastProtocol.MSG_AUTH_OK) {
|
||||||
throw new IOException("Authentication failed");
|
throw new IOException("Authentication failed");
|
||||||
}
|
}
|
||||||
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
|
byte[] castSettings = CastProtocol.castSettingsPayload(settings);
|
||||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
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;
|
String negotiated = null;
|
||||||
CastSettings receiverAdvertisement = null;
|
CastSettings receiverAdvertisement = null;
|
||||||
long deadline = System.currentTimeMillis() + 15_000;
|
long deadline = System.currentTimeMillis() + 15_000;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
CastProtocol.Message msg = transport.receive(2_000);
|
CastProtocol.Message msg = receive(2_000);
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -94,7 +103,7 @@ public class CastSession {
|
|||||||
List<String> senderCaps = null;
|
List<String> senderCaps = null;
|
||||||
long deadline = System.currentTimeMillis() + 30_000;
|
long deadline = System.currentTimeMillis() + 30_000;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
CastProtocol.Message msg = transport.receive(2_000);
|
CastProtocol.Message msg = receive(2_000);
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -106,8 +115,10 @@ public class CastSession {
|
|||||||
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
|
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
|
||||||
authed = true;
|
authed = true;
|
||||||
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||||
|
recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||||
} else {
|
} else {
|
||||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||||
|
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||||
throw new IOException("Bad PIN");
|
throw new IOException("Bad PIN");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -130,13 +141,18 @@ public class CastSession {
|
|||||||
receiverAdvert.setStreamProtection(
|
receiverAdvert.setStreamProtection(
|
||||||
com.foxx.androidcast.network.transport.StreamProtectionCapability
|
com.foxx.androidcast.network.transport.StreamProtectionCapability
|
||||||
.handshakeAdvertisement());
|
.handshakeAdvertisement());
|
||||||
transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS,
|
byte[] recvSettings = CastProtocol.castSettingsPayload(receiverAdvert);
|
||||||
CastProtocol.castSettingsPayload(receiverAdvert));
|
transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
|
||||||
|
recordOutbound(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
|
||||||
List<String> receiverCaps = CodecCatalog.localDecoderMimes();
|
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,
|
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
||||||
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
||||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
byte[] selected = CastProtocol.codecSelectedPayload(mime);
|
||||||
|
transport.send(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||||
|
recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||||
remote.setNegotiatedVideoMime(mime);
|
remote.setNegotiatedVideoMime(mime);
|
||||||
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
|
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
|
||||||
}
|
}
|
||||||
@@ -148,8 +164,30 @@ public class CastSession {
|
|||||||
transport.send(type, payload);
|
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 {
|
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() {
|
public void releaseConnection() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.foxx.androidcast.network.transport.TransportLossStats;
|
|||||||
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
||||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||||
import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
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);
|
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
||||||
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
||||||
socket.send(dp);
|
socket.send(dp);
|
||||||
|
SessionStatsContext.onDatagramSent(packet.length);
|
||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.datagramsSent++;
|
lossStats.datagramsSent++;
|
||||||
}
|
}
|
||||||
@@ -266,6 +268,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
|||||||
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
||||||
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
||||||
socket.receive(dp);
|
socket.receive(dp);
|
||||||
|
SessionStatsContext.onDatagramReceived(dp.getLength());
|
||||||
synchronized (lossStats) {
|
synchronized (lossStats) {
|
||||||
lossStats.datagramsReceived++;
|
lossStats.datagramsReceived++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
|||||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
@@ -255,6 +256,17 @@ public class ReceiverCastService extends Service {
|
|||||||
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
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
|
@Override
|
||||||
public void onStatus(String s) {
|
public void onStatus(String s) {
|
||||||
updateStatus(s);
|
updateStatus(s);
|
||||||
@@ -393,7 +405,8 @@ public class ReceiverCastService extends Service {
|
|||||||
castEnded = false;
|
castEnded = false;
|
||||||
if (AppPreferences.isGrabSessionStats(this)) {
|
if (AppPreferences.isGrabSessionStats(this)) {
|
||||||
if (sessionStatsRecorder == null) {
|
if (sessionStatsRecorder == null) {
|
||||||
sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
|
sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
|
||||||
|
SessionStatsContext.bind(sessionStatsRecorder);
|
||||||
sessionStatsRecorder.setClientCount(1);
|
sessionStatsRecorder.setClientCount(1);
|
||||||
}
|
}
|
||||||
sessionStatsRecorder.mergeSettings(remoteSettings);
|
sessionStatsRecorder.mergeSettings(remoteSettings);
|
||||||
@@ -460,7 +473,8 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void checkStreamIdle() {
|
private void checkStreamIdle() {
|
||||||
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
|
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0
|
||||||
|
|| awaitingKeyframe || reconfiguring) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
|
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. */
|
/** Stops decode, shows robot overlay; keeps TCP session if still connected. */
|
||||||
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
||||||
streamIdle = true;
|
streamIdle = true;
|
||||||
hasPendingConfig = false;
|
|
||||||
enterAwaitingKeyframe();
|
enterAwaitingKeyframe();
|
||||||
|
if (fullReset) {
|
||||||
|
hasPendingConfig = false;
|
||||||
resetDecoderState();
|
resetDecoderState();
|
||||||
if (videoDecoder != null) {
|
if (videoDecoder != null) {
|
||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
videoDecoder = VideoDecoderFactory.createDecoder();
|
videoDecoder = VideoDecoderFactory.createDecoder();
|
||||||
}
|
}
|
||||||
if (fullReset) {
|
|
||||||
streamMetrics.reset();
|
streamMetrics.reset();
|
||||||
}
|
}
|
||||||
broadcastStreamIdle();
|
broadcastStreamIdle();
|
||||||
@@ -576,6 +590,7 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
reconfiguring = true;
|
reconfiguring = true;
|
||||||
enterAwaitingKeyframe();
|
enterAwaitingKeyframe();
|
||||||
|
lastVideoFrameMs = System.currentTimeMillis();
|
||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
pendingWidth = size.width;
|
pendingWidth = size.width;
|
||||||
pendingHeight = size.height;
|
pendingHeight = size.height;
|
||||||
@@ -684,7 +699,6 @@ public class ReceiverCastService extends Service {
|
|||||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
||||||
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
||||||
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
||||||
reconfiguring = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
||||||
@@ -865,6 +879,10 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
videoDecoder = VideoDecoderFactory.createDecoder();
|
videoDecoder = VideoDecoderFactory.createDecoder();
|
||||||
}
|
}
|
||||||
|
if (hasPendingConfig && phase == Phase.STREAMING) {
|
||||||
|
enterAwaitingKeyframe();
|
||||||
|
scheduleDecoderConfigureRetries();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateStatus(String s) {
|
private void updateStatus(String s) {
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
private boolean bound;
|
private boolean bound;
|
||||||
private boolean autoStartListening;
|
private boolean autoStartListening;
|
||||||
private boolean listeningCancelled;
|
private boolean listeningCancelled;
|
||||||
private boolean autoPipAttempted;
|
|
||||||
|
|
||||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -249,7 +248,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
mainHandler.post(diagnosticsRefresh);
|
mainHandler.post(diagnosticsRefresh);
|
||||||
updateDiagnosticsVisibility();
|
updateDiagnosticsVisibility();
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
maybeAutoEnterPip();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -272,7 +270,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
maybeAutoEnterPip();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +486,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||||
}
|
}
|
||||||
applyTextureFitCenter();
|
applyTextureFitCenter();
|
||||||
|
// Rotation often recreates the texture; re-bind decoder to the new Surface.
|
||||||
|
surfaceSentToService = false;
|
||||||
|
attachSurfaceIfReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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() {
|
private boolean shouldAutoEnterPipOnLeave() {
|
||||||
if (!AppPreferences.isReceiverPipEnabled(this)
|
if (!AppPreferences.isReceiverPipEnabled(this)
|
||||||
|| !PermissionHelper.hasReceiverPlaybackReady(this)) {
|
|| !PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (castSessionActive || streamRendering) {
|
return 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tryEnterPictureInPicture(boolean userInitiated) {
|
private void tryEnterPictureInPicture(boolean userInitiated) {
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public class ReceiverSession {
|
|||||||
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||||
void onListening();
|
void onListening();
|
||||||
|
|
||||||
|
/** About to accept a sender and run handshake (bind session stats before I/O). */
|
||||||
|
void onCastSessionStarting();
|
||||||
|
|
||||||
void onNetworkAdviceApplied();
|
void onNetworkAdviceApplied();
|
||||||
|
|
||||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||||
@@ -157,6 +160,7 @@ public class ReceiverSession {
|
|||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
}
|
}
|
||||||
inboundSessionGate.reset();
|
inboundSessionGate.reset();
|
||||||
|
listener.onCastSessionStarting();
|
||||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
|||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -53,6 +54,11 @@ public final class CastFanoutPump {
|
|||||||
return videoQueue.size() + audioQueue.size();
|
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,
|
public void start(List<CastSession> sessionList, Runnable onSendFailed,
|
||||||
PassThroughNetworkControlPlane statsPlane) {
|
PassThroughNetworkControlPlane statsPlane) {
|
||||||
setSessions(sessionList);
|
setSessions(sessionList);
|
||||||
@@ -144,7 +150,7 @@ public final class CastFanoutPump {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byte[] payload = item.buildPayload();
|
byte[] payload = item.buildPayload();
|
||||||
fanOut(item.type, payload);
|
fanOut(item.type, payload, item.keyFrame);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
if (!running.get()) {
|
if (!running.get()) {
|
||||||
break;
|
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;
|
int peer = 0;
|
||||||
for (CastSession session : sessions) {
|
for (CastSession session : sessions) {
|
||||||
peer++;
|
peer++;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
|||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
@@ -39,6 +40,11 @@ public final class CastSendPump {
|
|||||||
return videoQueue.size() + audioQueue.size();
|
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) {
|
public void start(CastSession session, Runnable onSendFailed) {
|
||||||
start(session, onSendFailed, null);
|
start(session, onSendFailed, null);
|
||||||
}
|
}
|
||||||
@@ -133,6 +139,8 @@ public final class CastSendPump {
|
|||||||
}
|
}
|
||||||
byte[] payload = item.buildPayload();
|
byte[] payload = item.buildPayload();
|
||||||
session.send(item.type, payload);
|
session.send(item.type, payload);
|
||||||
|
int payloadSize = payload != null ? payload.length : 0;
|
||||||
|
SessionStatsContext.recordOutboundMessage(item.type, item.keyFrame, payloadSize);
|
||||||
if (statsPlane != null) {
|
if (statsPlane != null) {
|
||||||
statsPlane.recordSendBytes(payload.length + 5);
|
statsPlane.recordSendBytes(payload.length + 5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
|||||||
import com.foxx.androidcast.media.codec.VideoCodecFactory;
|
import com.foxx.androidcast.media.codec.VideoCodecFactory;
|
||||||
import com.foxx.androidcast.media.codec.VideoEncoderSink;
|
import com.foxx.androidcast.media.codec.VideoEncoderSink;
|
||||||
import com.foxx.androidcast.media.codec.AudioEncoderSink;
|
import com.foxx.androidcast.media.codec.AudioEncoderSink;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
|
|
||||||
@@ -240,6 +241,10 @@ public class ScreenCastService extends Service implements
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
updateStatus(getString(R.string.connecting_to, host));
|
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);
|
multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
|
||||||
List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers();
|
List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers();
|
||||||
if (peers.isEmpty()) {
|
if (peers.isEmpty()) {
|
||||||
@@ -267,7 +272,10 @@ public class ScreenCastService extends Service implements
|
|||||||
CastTrayNotifier.refresh(this);
|
CastTrayNotifier.refresh(this);
|
||||||
activeSettings = settings;
|
activeSettings = settings;
|
||||||
if (AppPreferences.isGrabSessionStats(this)) {
|
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.mergeSettings(settings);
|
||||||
sessionStatsRecorder.setClientCount(peers.size());
|
sessionStatsRecorder.setClientCount(peers.size());
|
||||||
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
||||||
@@ -461,6 +469,11 @@ public class ScreenCastService extends Service implements
|
|||||||
videoEncoder.stop();
|
videoEncoder.stop();
|
||||||
videoEncoder = null;
|
videoEncoder = null;
|
||||||
}
|
}
|
||||||
|
if (fanoutPump != null) {
|
||||||
|
fanoutPump.flushVideoQueue();
|
||||||
|
} else if (sendPump != null) {
|
||||||
|
sendPump.flushVideoQueue();
|
||||||
|
}
|
||||||
|
|
||||||
captureWidth = size.width;
|
captureWidth = size.width;
|
||||||
captureHeight = size.height;
|
captureHeight = size.height;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,11 @@ import org.json.JSONObject;
|
|||||||
*/
|
*/
|
||||||
public final class SessionStatsRecorder {
|
public final class SessionStatsRecorder {
|
||||||
private final long startedMs = System.currentTimeMillis();
|
private final long startedMs = System.currentTimeMillis();
|
||||||
|
/** {@code send} or {@code recv}. */
|
||||||
|
private final String direction;
|
||||||
private final String role;
|
private final String role;
|
||||||
private final String transport;
|
private final String transport;
|
||||||
|
private final SessionEventJournal journal;
|
||||||
private String videoCodec = "";
|
private String videoCodec = "";
|
||||||
private String audioCodec = "AAC";
|
private String audioCodec = "AAC";
|
||||||
private String captureMode = "";
|
private String captureMode = "";
|
||||||
@@ -40,9 +43,56 @@ public final class SessionStatsRecorder {
|
|||||||
private final JSONArray samples = new JSONArray();
|
private final JSONArray samples = new JSONArray();
|
||||||
private long lastSampleMs;
|
private long lastSampleMs;
|
||||||
|
|
||||||
public SessionStatsRecorder(String role, String transport) {
|
/** @param direction {@code send} or {@code recv}. */
|
||||||
this.role = role;
|
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.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) {
|
public void setClientCount(int count) {
|
||||||
@@ -80,6 +130,7 @@ public final class SessionStatsRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
|
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
|
||||||
|
flushGeneralIfDue();
|
||||||
networkSamples++;
|
networkSamples++;
|
||||||
NetworkStatsSnapshot s = local != null ? local : peer;
|
NetworkStatsSnapshot s = local != null ? local : peer;
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
@@ -130,7 +181,15 @@ public final class SessionStatsRecorder {
|
|||||||
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||||
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||||
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||||
|
lossTotals.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||||
lossTotals.protectionMode = loss.protectionMode;
|
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) {
|
public void finish(Context context) {
|
||||||
|
SessionStatsContext.unbind(this);
|
||||||
if (!AppPreferences.isGrabSessionStats(context)) {
|
if (!AppPreferences.isGrabSessionStats(context)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
journal.flushGeneralFinal();
|
||||||
long endedMs = System.currentTimeMillis();
|
long endedMs = System.currentTimeMillis();
|
||||||
try {
|
try {
|
||||||
JSONObject root = new JSONObject();
|
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("started_at_epoch_ms", startedMs);
|
||||||
root.put("ended_at_epoch_ms", endedMs);
|
root.put("ended_at_epoch_ms", endedMs);
|
||||||
root.put("duration_ms", endedMs - startedMs);
|
root.put("duration_ms", endedMs - startedMs);
|
||||||
@@ -199,6 +261,11 @@ public final class SessionStatsRecorder {
|
|||||||
root.put("resolution", resolution);
|
root.put("resolution", resolution);
|
||||||
root.put("network_samples", networkSamples);
|
root.put("network_samples", networkSamples);
|
||||||
|
|
||||||
|
JSONArray journalEvents = journal.copyEvents();
|
||||||
|
if (journalEvents.length() > 0) {
|
||||||
|
root.put(direction, journalEvents);
|
||||||
|
}
|
||||||
|
|
||||||
if (networkSamples > 0) {
|
if (networkSamples > 0) {
|
||||||
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||||
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * 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_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||||
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
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);
|
root.put("udp", udp);
|
||||||
if (samples.length() > 0) {
|
if (samples.length() > 0) {
|
||||||
root.put("samples", samples);
|
root.put("samples", samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionStatsStore.saveSession(context, startedMs, root);
|
SessionStatsStore.saveSession(context, startedMs, direction, root);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import java.util.Locale;
|
|||||||
/** Rotating flat JSON session logs in app-private storage. */
|
/** Rotating flat JSON session logs in app-private storage. */
|
||||||
public final class SessionStatsStore {
|
public final class SessionStatsStore {
|
||||||
private static final String DIR_NAME = "session_stats";
|
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 static final String SUFFIX = ".json";
|
||||||
|
|
||||||
private SessionStatsStore() {}
|
private SessionStatsStore() {}
|
||||||
@@ -29,6 +32,31 @@ public final class SessionStatsStore {
|
|||||||
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
|
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. */
|
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
|
||||||
public static void pruneOnAppStart(Context context) {
|
public static void pruneOnAppStart(Context context) {
|
||||||
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
|
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
|
||||||
@@ -39,7 +67,7 @@ public final class SessionStatsStore {
|
|||||||
if (dir == null || !dir.exists()) {
|
if (dir == null || !dir.exists()) {
|
||||||
return;
|
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) {
|
if (files == null || files.length == 0) {
|
||||||
return;
|
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);
|
File dir = statsDir(context);
|
||||||
if (!dir.exists() && !dir.mkdirs()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
throw new IllegalStateException("Cannot create session_stats dir");
|
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);
|
File out = new File(dir, name);
|
||||||
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
|
||||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
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) {
|
private static String formatTimestamp(long epochMs) {
|
||||||
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
|
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,10 +39,17 @@
|
|||||||
android:text="@string/dev_grab_session_stats" />
|
android:text="@string/dev_grab_session_stats" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btn_reload_codecs_json"
|
android:id="@+id/btn_open_session_stats"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="20dp"
|
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" />
|
android:text="@string/dev_reload_codecs_json" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
128
app/src/main/res/layout/activity_session_stats_analyzer.xml
Normal file
128
app/src/main/res/layout/activity_session_stats_analyzer.xml
Normal 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>
|
||||||
37
app/src/main/res/layout/item_session_stats.xml
Normal file
37
app/src/main/res/layout/item_session_stats.xml
Normal 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>
|
||||||
@@ -201,6 +201,19 @@
|
|||||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
<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_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
<string name="dev_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 & 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">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="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>
|
<string name="codec_priority_score_suffix"> · %1$d</string>
|
||||||
|
|||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path name="session_stats" path="session_stats/" />
|
||||||
|
</paths>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,4 +39,32 @@ public class SessionStatsStoreTest {
|
|||||||
}
|
}
|
||||||
assertTrue(dir.delete());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ JavaFX desktop app for **session log analysis** (Linux `.jar` or native image la
|
|||||||
./gradlew :session-studio:run
|
./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)
|
### Roadmap (full mobile parity + analytics)
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,172 +3,354 @@ package com.foxx.androidcast.studio;
|
|||||||
import javafx.scene.canvas.Canvas;
|
import javafx.scene.canvas.Canvas;
|
||||||
import javafx.scene.canvas.GraphicsContext;
|
import javafx.scene.canvas.GraphicsContext;
|
||||||
import javafx.scene.input.MouseEvent;
|
import javafx.scene.input.MouseEvent;
|
||||||
|
import javafx.scene.input.ScrollEvent;
|
||||||
import javafx.scene.layout.StackPane;
|
import javafx.scene.layout.StackPane;
|
||||||
import javafx.scene.paint.Color;
|
import javafx.scene.paint.Color;
|
||||||
import javafx.scene.text.Font;
|
import javafx.scene.text.Font;
|
||||||
|
import javafx.scene.text.FontWeight;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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 {
|
public final class SessionChartPane extends StackPane {
|
||||||
private static final Color[] SERIES_COLORS = {
|
public enum Metric {
|
||||||
Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
|
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 Canvas canvas = new Canvas(800, 480);
|
||||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||||
private boolean absoluteTime = true;
|
private Metric metric = Metric.BITRATE;
|
||||||
private boolean showBitrate = true;
|
|
||||||
private boolean showRtt;
|
private double viewMinT;
|
||||||
private boolean showLoss;
|
private double viewMaxT = 1;
|
||||||
private String hoverText = "";
|
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() {
|
public SessionChartPane() {
|
||||||
getChildren().add(canvas);
|
getChildren().add(canvas);
|
||||||
canvas.widthProperty().bind(widthProperty());
|
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_MOVED, this::onMouseMoved);
|
||||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
|
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
|
||||||
hoverText = "";
|
canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
|
||||||
draw();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAbsoluteTime(boolean absoluteTime) {
|
public void render(List<SessionDocument> docs, Metric metric) {
|
||||||
this.absoluteTime = absoluteTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void render(List<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
|
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
if (docs != null) {
|
if (docs != null) {
|
||||||
sessions.addAll(docs);
|
sessions.addAll(docs);
|
||||||
}
|
}
|
||||||
showBitrate = bitrate;
|
this.metric = metric != null ? metric : Metric.BITRATE;
|
||||||
showRtt = rtt;
|
resetView();
|
||||||
showLoss = loss;
|
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onMouseMoved(MouseEvent e) {
|
private void resetView() {
|
||||||
if (sessions.isEmpty()) {
|
viewMinT = 0;
|
||||||
return;
|
viewMaxT = Math.max(1, dataMaxTimeMs());
|
||||||
}
|
double[] y = dataYRange();
|
||||||
double pad = 48;
|
viewMinY = y[0];
|
||||||
double w = canvas.getWidth() - pad * 2;
|
viewMaxY = y[1];
|
||||||
double h = canvas.getHeight() - pad * 2;
|
viewInitialized = sessions.stream().anyMatch(d -> d.samples.size() >= 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 long maxTimeMs() {
|
private long dataMaxTimeMs() {
|
||||||
long max = 1;
|
long max = 1;
|
||||||
for (SessionDocument doc : sessions) {
|
for (SessionDocument doc : sessions) {
|
||||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||||
max = Math.max(max, p.tMs);
|
max = Math.max(max, p.tMs);
|
||||||
}
|
}
|
||||||
if (!absoluteTime && doc.durationMs > max) {
|
max = Math.max(max, doc.durationMs);
|
||||||
max = doc.durationMs;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return max;
|
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() {
|
private void draw() {
|
||||||
GraphicsContext g = canvas.getGraphicsContext2D();
|
GraphicsContext g = canvas.getGraphicsContext2D();
|
||||||
double w = canvas.getWidth();
|
double w = canvas.getWidth();
|
||||||
double h = canvas.getHeight();
|
double h = canvas.getHeight();
|
||||||
g.setFill(Color.web("#1a1a1a"));
|
g.setFill(Color.web("#121212"));
|
||||||
g.fillRect(0, 0, w, h);
|
g.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
if (sessions.isEmpty()) {
|
if (sessions.isEmpty()) {
|
||||||
g.setFill(Color.LIGHTGRAY);
|
g.setFill(Color.LIGHTGRAY);
|
||||||
g.setFont(Font.font(14));
|
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;
|
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++) {
|
for (int si = 0; si < sessions.size(); si++) {
|
||||||
SessionDocument doc = sessions.get(si);
|
SessionDocument doc = sessions.get(si);
|
||||||
if (doc.samples.isEmpty()) {
|
if (doc.samples.size() < 2) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Color color = SERIES_COLORS[si % SERIES_COLORS.length];
|
drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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 {
|
drawLegend(g, plotW);
|
||||||
double value(SessionDocument.SamplePoint p);
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawSeries(GraphicsContext g, SessionDocument doc, double pad, double plotW, double plotH,
|
private void drawSessionLine(GraphicsContext g, SessionDocument doc, Color color) {
|
||||||
long tMax, Color color, ValueFn fn, double minY, double maxY) {
|
|
||||||
if (doc.samples.size() < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
g.setStroke(color);
|
g.setStroke(color);
|
||||||
|
g.setLineWidth(2);
|
||||||
g.beginPath();
|
g.beginPath();
|
||||||
boolean first = true;
|
boolean first = true;
|
||||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||||
double x = pad + (p.tMs / (double) tMax) * plotW;
|
if (p.tMs < viewMinT || p.tMs > viewMaxT) {
|
||||||
double v = fn.value(p);
|
continue;
|
||||||
double y = pad + plotH - ((v - minY) / (maxY - minY)) * plotH;
|
}
|
||||||
|
double v = metricValue(p);
|
||||||
|
if (v < viewMinY || v > viewMaxY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double x = toScreenX(p.tMs);
|
||||||
|
double y = toScreenY(v);
|
||||||
if (first) {
|
if (first) {
|
||||||
g.moveTo(x, y);
|
g.moveTo(x, y);
|
||||||
first = false;
|
first = false;
|
||||||
@@ -178,4 +360,78 @@ public final class SessionChartPane extends StackPane {
|
|||||||
}
|
}
|
||||||
g.stroke();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import java.nio.file.Path;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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 class SessionDocument {
|
||||||
public final String path;
|
public final String path;
|
||||||
|
public final String direction;
|
||||||
public final String role;
|
public final String role;
|
||||||
public final String transport;
|
public final String transport;
|
||||||
public final long startedMs;
|
public final long startedMs;
|
||||||
@@ -20,7 +21,8 @@ public final class SessionDocument {
|
|||||||
|
|
||||||
private SessionDocument(String path, JSONObject root) {
|
private SessionDocument(String path, JSONObject root) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
role = root.optString("role", "?");
|
direction = inferDirection(path, root);
|
||||||
|
role = root.optString("role", "recv".equals(direction) ? "receiver" : "sender");
|
||||||
transport = root.optString("transport", "?");
|
transport = root.optString("transport", "?");
|
||||||
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
||||||
durationMs = root.optLong("duration_ms", 0L);
|
durationMs = root.optLong("duration_ms", 0L);
|
||||||
@@ -42,7 +44,23 @@ public final class SessionDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String title() {
|
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 {
|
public static final class SamplePoint {
|
||||||
@@ -50,12 +68,17 @@ public final class SessionDocument {
|
|||||||
public final double videoBitrateKbps;
|
public final double videoBitrateKbps;
|
||||||
public final int rttMs;
|
public final int rttMs;
|
||||||
public final double lossRatio;
|
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.tMs = tMs;
|
||||||
this.videoBitrateKbps = videoBitrateKbps;
|
this.videoBitrateKbps = videoBitrateKbps;
|
||||||
this.rttMs = rttMs;
|
this.rttMs = rttMs;
|
||||||
this.lossRatio = lossRatio;
|
this.lossRatio = lossRatio;
|
||||||
|
this.encodeFps = encodeFps;
|
||||||
|
this.congestionLevel = congestionLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
static SamplePoint fromJson(JSONObject o) {
|
static SamplePoint fromJson(JSONObject o) {
|
||||||
@@ -63,7 +86,9 @@ public final class SessionDocument {
|
|||||||
o.optLong("t_ms", 0L),
|
o.optLong("t_ms", 0L),
|
||||||
o.optDouble("video_bitrate_kbps", 0.0),
|
o.optDouble("video_bitrate_kbps", 0.0),
|
||||||
o.optInt("rtt_ms", 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package com.foxx.androidcast.studio;
|
package com.foxx.androidcast.studio;
|
||||||
|
|
||||||
import javafx.application.Application;
|
import javafx.application.Application;
|
||||||
|
import javafx.collections.ListChangeListener;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.CheckBox;
|
import javafx.scene.control.CheckBox;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.control.ListView;
|
import javafx.scene.control.ListView;
|
||||||
import javafx.scene.control.ToggleGroup;
|
import javafx.scene.control.SelectionMode;
|
||||||
import javafx.scene.layout.BorderPane;
|
import javafx.scene.layout.BorderPane;
|
||||||
import javafx.scene.layout.HBox;
|
import javafx.scene.layout.HBox;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
@@ -19,45 +20,71 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Linux desktop session log viewer (foundation for full Android Cast parity on desktop).
|
* Desktop session log viewer — open session_send_* / session_recv_* JSON, overlay charts.
|
||||||
* Open one or more session_*.json files; charts use {@code samples[]} when present (schema v1+).
|
|
||||||
*/
|
*/
|
||||||
public class SessionStudioApp extends Application {
|
public class SessionStudioApp extends Application {
|
||||||
|
|
||||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||||
private final SessionChartPane chartPane = new SessionChartPane();
|
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
|
@Override
|
||||||
public void start(Stage stage) {
|
public void start(Stage stage) {
|
||||||
Button openBtn = new Button("Open sessions…");
|
Button openBtn = new Button("Open sessions…");
|
||||||
|
|
||||||
CheckBox absTime = new CheckBox("Absolute time (single session)");
|
CheckBox metricBitrate = new CheckBox("Video bitrate");
|
||||||
absTime.setSelected(true);
|
metricBitrate.setSelected(true);
|
||||||
absTime.setOnAction(e -> chartPane.setAbsoluteTime(absTime.isSelected()));
|
CheckBox metricLoss = new CheckBox("Packet loss %");
|
||||||
|
CheckBox metricFps = new CheckBox("Encode FPS");
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
ListView<String> sessionList = new ListView<>();
|
ListView<String> sessionList = new ListView<>();
|
||||||
sessionList.setPrefHeight(120);
|
sessionList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||||
sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
|
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;");
|
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;");
|
top.setStyle("-fx-padding: 8;");
|
||||||
|
|
||||||
BorderPane root = new BorderPane();
|
BorderPane root = new BorderPane();
|
||||||
@@ -67,7 +94,10 @@ public class SessionStudioApp extends Application {
|
|||||||
openFiles(stage);
|
openFiles(stage);
|
||||||
sessionList.getItems().clear();
|
sessionList.getItems().clear();
|
||||||
for (SessionDocument doc : sessions) {
|
for (SessionDocument doc : sessions) {
|
||||||
sessionList.getItems().add(doc.title());
|
sessionList.getItems().add(doc.path);
|
||||||
|
}
|
||||||
|
if (!sessions.isEmpty()) {
|
||||||
|
sessionList.getSelectionModel().selectAll();
|
||||||
}
|
}
|
||||||
refresh.run();
|
refresh.run();
|
||||||
});
|
});
|
||||||
@@ -77,10 +107,17 @@ public class SessionStudioApp extends Application {
|
|||||||
stage.show();
|
stage.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
|
private List<SessionDocument> selectedSessions(ListView<String> list) {
|
||||||
CheckBox box = new CheckBox(text);
|
List<SessionDocument> out = new ArrayList<>();
|
||||||
box.setSelected(selected);
|
for (int idx : list.getSelectionModel().getSelectedIndices()) {
|
||||||
return box;
|
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) {
|
private void openFiles(Stage stage) {
|
||||||
@@ -101,7 +138,7 @@ public class SessionStudioApp extends Application {
|
|||||||
return;
|
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) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ filter_out() {
|
|||||||
return 0
|
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
|
echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
|
||||||
SERIAL="$(echo "${DEV}" | awk '{print $1}')"
|
SERIAL="$(echo "${DEV}" | awk '{print $1}')"
|
||||||
filter_out SERIAL
|
filter_out SERIAL
|
||||||
@@ -16,6 +35,14 @@ echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
|
|||||||
# echo "Device: ${DEV}"
|
# echo "Device: ${DEV}"
|
||||||
# echo "Serial: ${SERIAL}"
|
# 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 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
|
done
|
||||||
|
|
||||||
|
echo "run './gradlew :session-studio:run' to analyze logs; find the latest logs in ${LOG_ROOT}"
|
||||||
|
|
||||||
|
|||||||
46
rebuild.sh
46
rebuild.sh
@@ -5,16 +5,37 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
# Space-separated host:port list (wireless debugging). Edit as needed.
|
# Hints for wireless debugging. Edit as needed.
|
||||||
CONNECTIONS_LIST=""
|
## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
|
||||||
|
declare -A CONNECTIONS_LIST_HINTS
|
||||||
## android tv projector / android 13
|
## 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
|
## 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
|
## 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
|
## 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"
|
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
PACKAGE="com.foxx.androidcast"
|
PACKAGE="com.foxx.androidcast"
|
||||||
@@ -33,7 +54,7 @@ for arg in "$@"; do
|
|||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: $0 [--skip-tests] [--java-home=PATH]"
|
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."
|
echo " installs on all adb devices in 'device' state, and launches the app."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -79,6 +100,10 @@ reconnect_endpoint() {
|
|||||||
local endpoint="$1"
|
local endpoint="$1"
|
||||||
[[ -z "$endpoint" ]] && return 1
|
[[ -z "$endpoint" ]] && return 1
|
||||||
local attempt out
|
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
|
adb disconnect "$endpoint" >/dev/null 2>&1 || true
|
||||||
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
|
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
|
||||||
out="$(adb connect "$endpoint" 2>&1)" || true
|
out="$(adb connect "$endpoint" 2>&1)" || true
|
||||||
@@ -101,8 +126,8 @@ reconnect_known_devices() {
|
|||||||
adb start-server >/dev/null 2>&1 || true
|
adb start-server >/dev/null 2>&1 || true
|
||||||
adb reconnect offline >/dev/null 2>&1 || true
|
adb reconnect offline >/dev/null 2>&1 || true
|
||||||
local endpoint
|
local endpoint
|
||||||
for endpoint in $CONNECTIONS_LIST; do
|
for endpoint in "${!CONNECTIONS_LIST_HINTS[@]}"; do
|
||||||
reconnect_endpoint "$endpoint" || true
|
reconnect_endpoint "$(get_ip_for_hint_key "$endpoint")" || true
|
||||||
done
|
done
|
||||||
adb devices -l
|
adb devices -l
|
||||||
}
|
}
|
||||||
@@ -133,7 +158,8 @@ done
|
|||||||
|
|
||||||
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
||||||
./gradlew --stop
|
./gradlew --stop
|
||||||
./gradlew clean assembleDebug
|
./gradlew clean
|
||||||
|
./gradlew --build-cache assembleDebug
|
||||||
|
|
||||||
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
||||||
echo "==> Running unit tests (./gradlew testDebugUnitTest)"
|
echo "==> Running unit tests (./gradlew testDebugUnitTest)"
|
||||||
|
|||||||
Reference in New Issue
Block a user