mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
snap
This commit is contained in:
@@ -93,6 +93,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
|
||||
@@ -64,6 +64,22 @@
|
||||
android:label="@string/developer_settings_title"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".stats.ui.SessionStatsAnalyzerActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/session_stats_title"
|
||||
android:parentActivityName=".DeveloperSettingsActivity" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".receiver.ReceiverPlaybackActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -106,7 +106,7 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_DEBUG_OVERLAY, show).apply();
|
||||
}
|
||||
|
||||
/** Developer: write anonymous session_*.json files (default on). */
|
||||
/** Developer: write anonymous session_send_* / session_recv_* JSON (default on). */
|
||||
public static boolean isGrabSessionStats(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_GRAB_SESSION_STATS, true);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.widget.Toast;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
|
||||
|
||||
/** Hidden developer settings (opened via version label easter egg). */
|
||||
public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
@@ -31,6 +32,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
|
||||
refreshFromPreferences(debugOverlay, codecScores, sessionStats);
|
||||
|
||||
findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
|
||||
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
|
||||
|
||||
reloadCodecs.setOnClickListener(v -> {
|
||||
String summary = CodecPriorityCatalog.reload(this);
|
||||
Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
|
||||
|
||||
@@ -42,8 +42,15 @@ public final class RgbToYuv420 {
|
||||
int height = image.getHeight();
|
||||
int need = bufferSize(width, height);
|
||||
byte[] out = scratch != null && scratch.length >= need ? scratch : new byte[need];
|
||||
Image.Plane plane = image.getPlanes()[0];
|
||||
Image.Plane[] planes = image.getPlanes();
|
||||
if (planes == null || planes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
Image.Plane plane = planes[0];
|
||||
java.nio.ByteBuffer buffer = plane.getBuffer();
|
||||
if (buffer == null) {
|
||||
return null;
|
||||
}
|
||||
int rowStride = plane.getRowStride();
|
||||
int pixelStride = plane.getPixelStride();
|
||||
int[] argb = new int[width * height];
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.foxx.androidcast.media.codec.jni.VpxEncodedFrame;
|
||||
import com.foxx.androidcast.sender.VideoEncoder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Software VP8/VP9 via libvpx when {@link NativeCodecBridge#isLibvpxAvailable()}.
|
||||
@@ -30,17 +32,15 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
private final VideoEncoder hwFallback = new VideoEncoder();
|
||||
|
||||
private Callback callback;
|
||||
private long nativeHandle;
|
||||
private boolean nativeActive;
|
||||
private final AtomicLong nativeHandle = new AtomicLong();
|
||||
private int width;
|
||||
private int height;
|
||||
private String mime;
|
||||
private byte[] yuvScratch;
|
||||
private boolean forceKeyframe;
|
||||
|
||||
private HandlerThread captureThread;
|
||||
private Handler captureHandler;
|
||||
private ImageReader imageReader;
|
||||
/** Active capture pipeline; {@link #releaseCapture()} swaps it out via {@code getAndSet(null)}. */
|
||||
private final AtomicReference<CapturePipeline> capturePipeline = new AtomicReference<>();
|
||||
|
||||
public LibvpxVideoEncoderSink(CastSettings.VideoCodec preference) {
|
||||
this.preference = preference;
|
||||
@@ -48,16 +48,17 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
|
||||
@Override
|
||||
public Surface getInputSurface() {
|
||||
if (nativeActive && imageReader != null) {
|
||||
return imageReader.getSurface();
|
||||
CapturePipeline pipe = capturePipeline.get();
|
||||
if (nativeHandle.get() != 0L && pipe != null) {
|
||||
return pipe.reader.getSurface();
|
||||
}
|
||||
return hwFallback.getInputSurface();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestKeyframe() {
|
||||
if (nativeActive) {
|
||||
NativeCodecBridge.vpxRequestKeyframe(nativeHandle);
|
||||
if (nativeHandle.get() != 0L) {
|
||||
NativeCodecBridge.vpxRequestKeyframe(nativeHandle.get());
|
||||
forceKeyframe = true;
|
||||
} else {
|
||||
hwFallback.requestKeyframe();
|
||||
@@ -68,6 +69,7 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback) throws IOException {
|
||||
releaseCapture();
|
||||
releaseNativeEncoder();
|
||||
this.callback = callback;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
@@ -75,38 +77,36 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
||||
|
||||
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
||||
nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||
nativeActive = nativeHandle != 0L;
|
||||
if (!nativeActive) {
|
||||
long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||
if (handle == 0L) {
|
||||
Log.w(TAG, "libvpx encoder init failed; using MediaCodec for surface capture");
|
||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
|
||||
return hwFallback.prepare(width, height, mime, params, callback);
|
||||
}
|
||||
nativeHandle.set(handle);
|
||||
|
||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
||||
captureThread = new HandlerThread("libvpx-capture");
|
||||
captureThread.start();
|
||||
captureHandler = new Handler(captureThread.getLooper());
|
||||
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
|
||||
imageReader.setOnImageAvailableListener(this::onImageAvailable, captureHandler);
|
||||
CapturePipeline pipe = CapturePipeline.create(width, height, this::onImageAvailable);
|
||||
capturePipeline.set(pipe);
|
||||
callback.onConfig(width, height, null, null);
|
||||
Log.i(TAG, "libvpx surface encoder " + width + "x" + height + " mime=" + mime);
|
||||
return imageReader.getSurface();
|
||||
return pipe.reader.getSurface();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||
Callback callback) throws IOException {
|
||||
releaseCapture();
|
||||
releaseNativeEncoder();
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.mime = mime;
|
||||
this.callback = callback;
|
||||
yuvScratch = new byte[RgbToYuv420.bufferSize(width, height)];
|
||||
int bitrateKbps = Math.max(500, params.videoBitrate / 1000);
|
||||
nativeHandle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||
nativeActive = nativeHandle != 0L;
|
||||
if (nativeActive) {
|
||||
long handle = NativeCodecBridge.vpxEncoderCreate(mime, width, height, bitrateKbps);
|
||||
if (handle != 0L) {
|
||||
nativeHandle.set(handle);
|
||||
CodecSessionRegistry.setVideoBackend(VideoCodecBackend.LIBVPX_NATIVE, mime);
|
||||
callback.onConfig(width, height, null, null);
|
||||
return;
|
||||
@@ -116,26 +116,40 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
}
|
||||
|
||||
private void onImageAvailable(ImageReader reader) {
|
||||
if (!nativeActive || callback == null) {
|
||||
CapturePipeline pipe = capturePipeline.get();
|
||||
if (pipe == null || pipe.reader != reader || callback == null) {
|
||||
return;
|
||||
}
|
||||
long handle = nativeHandle.get();
|
||||
if (handle == 0L) {
|
||||
return;
|
||||
}
|
||||
try (Image image = reader.acquireLatestImage()) {
|
||||
if (image == null) {
|
||||
return;
|
||||
}
|
||||
if (image.getWidth() != width || image.getHeight() != height) {
|
||||
return;
|
||||
}
|
||||
byte[] yuv = RgbToYuv420.fromRgbaImage(image, yuvScratch);
|
||||
if (yuv == null) {
|
||||
return;
|
||||
}
|
||||
if (capturePipeline.get() != pipe) {
|
||||
return;
|
||||
}
|
||||
long ptsUs = image.getTimestamp() / 1000L;
|
||||
emitEncodedFrame(yuv, ptsUs);
|
||||
emitEncodedFrame(handle, yuv, ptsUs);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Capture frame skipped: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void emitEncodedFrame(byte[] yuv, long ptsUs) {
|
||||
VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(nativeHandle, yuv, ptsUs, forceKeyframe);
|
||||
private void emitEncodedFrame(long handle, byte[] yuv, long ptsUs) {
|
||||
if (nativeHandle.get() != handle) {
|
||||
return;
|
||||
}
|
||||
VpxEncodedFrame frame = NativeCodecBridge.vpxEncodeYuvFrame(handle, yuv, ptsUs, forceKeyframe);
|
||||
forceKeyframe = false;
|
||||
if (frame != null && frame.data != null && callback != null) {
|
||||
callback.onEncodedFrame(ptsUs, frame.keyFrame, frame.data);
|
||||
@@ -144,43 +158,73 @@ public final class LibvpxVideoEncoderSink implements VideoEncoderSink {
|
||||
|
||||
@Override
|
||||
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
|
||||
if (!nativeActive) {
|
||||
long handle = nativeHandle.get();
|
||||
if (handle == 0L) {
|
||||
hwFallback.queueBitmapFrame(bitmap, ptsUs);
|
||||
return;
|
||||
}
|
||||
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, RgbToYuv420.Layout.I420);
|
||||
emitEncodedFrame(yuv, ptsUs);
|
||||
emitEncodedFrame(handle, yuv, ptsUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void queueYuvFrame(byte[] yuv, long ptsUs) {
|
||||
if (!nativeActive) {
|
||||
long handle = nativeHandle.get();
|
||||
if (handle == 0L) {
|
||||
hwFallback.queueYuvFrame(yuv, ptsUs);
|
||||
return;
|
||||
}
|
||||
emitEncodedFrame(yuv, ptsUs);
|
||||
emitEncodedFrame(handle, yuv, ptsUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (nativeActive) {
|
||||
NativeCodecBridge.vpxEncoderRelease(nativeHandle);
|
||||
nativeHandle = 0L;
|
||||
nativeActive = false;
|
||||
}
|
||||
releaseCapture();
|
||||
releaseNativeEncoder();
|
||||
hwFallback.stop();
|
||||
}
|
||||
|
||||
private void releaseCapture() {
|
||||
if (imageReader != null) {
|
||||
imageReader.close();
|
||||
imageReader = null;
|
||||
CapturePipeline old = capturePipeline.getAndSet(null);
|
||||
if (old != null) {
|
||||
old.release();
|
||||
}
|
||||
if (captureThread != null) {
|
||||
captureThread.quitSafely();
|
||||
captureThread = null;
|
||||
captureHandler = null;
|
||||
}
|
||||
|
||||
private void releaseNativeEncoder() {
|
||||
long handle = nativeHandle.getAndSet(0L);
|
||||
if (handle != 0L) {
|
||||
NativeCodecBridge.vpxEncoderRelease(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CapturePipeline {
|
||||
final ImageReader reader;
|
||||
final HandlerThread thread;
|
||||
|
||||
private CapturePipeline(ImageReader reader, HandlerThread thread) {
|
||||
this.reader = reader;
|
||||
this.thread = thread;
|
||||
}
|
||||
|
||||
static CapturePipeline create(int width, int height, ImageReader.OnImageAvailableListener listener) {
|
||||
HandlerThread thread = new HandlerThread("libvpx-capture");
|
||||
thread.start();
|
||||
Handler handler = new Handler(thread.getLooper());
|
||||
ImageReader reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);
|
||||
reader.setOnImageAvailableListener(listener, handler);
|
||||
return new CapturePipeline(reader, thread);
|
||||
}
|
||||
|
||||
void release() {
|
||||
reader.setOnImageAvailableListener(null, null);
|
||||
reader.close();
|
||||
thread.quitSafely();
|
||||
try {
|
||||
thread.join(2000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -55,20 +56,28 @@ public class CastSession {
|
||||
}
|
||||
|
||||
public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
|
||||
transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
|
||||
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
|
||||
CastProtocol.Message reply = transport.receive(10_000);
|
||||
byte[] hello = CastProtocol.helloPayload(deviceName);
|
||||
transport.send(CastProtocol.MSG_HELLO, hello);
|
||||
recordOutbound(CastProtocol.MSG_HELLO, hello);
|
||||
byte[] auth = CastProtocol.authPayload(pin);
|
||||
transport.send(CastProtocol.MSG_AUTH, auth);
|
||||
recordOutbound(CastProtocol.MSG_AUTH, auth);
|
||||
CastProtocol.Message reply = receive(10_000);
|
||||
if (reply == null || reply.type != CastProtocol.MSG_AUTH_OK) {
|
||||
throw new IOException("Authentication failed");
|
||||
}
|
||||
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
||||
byte[] castSettings = CastProtocol.castSettingsPayload(settings);
|
||||
transport.send(CastProtocol.MSG_CAST_SETTINGS, castSettings);
|
||||
recordOutbound(CastProtocol.MSG_CAST_SETTINGS, castSettings);
|
||||
byte[] codecCaps = CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes());
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, codecCaps);
|
||||
recordOutbound(CastProtocol.MSG_CODEC_CAPS, codecCaps);
|
||||
|
||||
String negotiated = null;
|
||||
CastSettings receiverAdvertisement = null;
|
||||
long deadline = System.currentTimeMillis() + 15_000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
CastProtocol.Message msg = transport.receive(2_000);
|
||||
CastProtocol.Message msg = receive(2_000);
|
||||
if (msg == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -94,7 +103,7 @@ public class CastSession {
|
||||
List<String> senderCaps = null;
|
||||
long deadline = System.currentTimeMillis() + 30_000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
CastProtocol.Message msg = transport.receive(2_000);
|
||||
CastProtocol.Message msg = receive(2_000);
|
||||
if (msg == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -106,8 +115,10 @@ public class CastSession {
|
||||
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
|
||||
authed = true;
|
||||
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||
} else {
|
||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
throw new IOException("Bad PIN");
|
||||
}
|
||||
break;
|
||||
@@ -130,13 +141,18 @@ public class CastSession {
|
||||
receiverAdvert.setStreamProtection(
|
||||
com.foxx.androidcast.network.transport.StreamProtectionCapability
|
||||
.handshakeAdvertisement());
|
||||
transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS,
|
||||
CastProtocol.castSettingsPayload(receiverAdvert));
|
||||
byte[] recvSettings = CastProtocol.castSettingsPayload(receiverAdvert);
|
||||
transport.send(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
|
||||
recordOutbound(CastProtocol.MSG_RECEIVER_CAST_SETTINGS, recvSettings);
|
||||
List<String> receiverCaps = CodecCatalog.localDecoderMimes();
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
|
||||
byte[] recvCaps = CastProtocol.codecCapsPayload(receiverCaps);
|
||||
transport.send(CastProtocol.MSG_CODEC_CAPS, recvCaps);
|
||||
recordOutbound(CastProtocol.MSG_CODEC_CAPS, recvCaps);
|
||||
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps,
|
||||
PassthroughCodecPolicy.forNegotiation(remote.getVideoCodec()), remote, "receiver");
|
||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
||||
byte[] selected = CastProtocol.codecSelectedPayload(mime);
|
||||
transport.send(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||
recordOutbound(CastProtocol.MSG_CODEC_SELECTED, selected);
|
||||
remote.setNegotiatedVideoMime(mime);
|
||||
return new HandshakeResult(senderName, remote, receiverAdvert, mime);
|
||||
}
|
||||
@@ -148,8 +164,30 @@ public class CastSession {
|
||||
transport.send(type, payload);
|
||||
}
|
||||
|
||||
private static void recordOutbound(byte type, byte[] payload) {
|
||||
int size = payload != null ? payload.length : 0;
|
||||
SessionStatsContext.recordOutboundMessage(type, false, size);
|
||||
}
|
||||
|
||||
private static void recordInbound(CastProtocol.Message msg) {
|
||||
if (msg == null) {
|
||||
return;
|
||||
}
|
||||
boolean keyFrame = false;
|
||||
if (msg.type == CastProtocol.MSG_VIDEO_FRAME) {
|
||||
try {
|
||||
keyFrame = CastProtocol.parseVideoFrame(msg.payload).keyFrame;
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
int size = msg.payload != null ? msg.payload.length : 0;
|
||||
SessionStatsContext.recordInboundMessage(msg.type, keyFrame, size);
|
||||
}
|
||||
|
||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||
return transport.receive(timeoutMs);
|
||||
CastProtocol.Message msg = transport.receive(timeoutMs);
|
||||
recordInbound(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
public void releaseConnection() {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
||||
import com.foxx.androidcast.network.transport.fec.FecEncodedGroup;
|
||||
import com.foxx.androidcast.network.transport.fec.FecShardWire;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -242,6 +243,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
||||
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
||||
socket.send(dp);
|
||||
SessionStatsContext.onDatagramSent(packet.length);
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsSent++;
|
||||
}
|
||||
@@ -266,6 +268,7 @@ public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
||||
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
||||
socket.receive(dp);
|
||||
SessionStatsContext.onDatagramReceived(dp.getLength());
|
||||
synchronized (lossStats) {
|
||||
lossStats.datagramsReceived++;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
@@ -255,6 +256,17 @@ public class ReceiverCastService extends Service {
|
||||
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCastSessionStarting() {
|
||||
if (AppPreferences.isGrabSessionStats(ReceiverCastService.this)) {
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("recv",
|
||||
settings.getTransport());
|
||||
SessionStatsContext.bind(sessionStatsRecorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStatus(String s) {
|
||||
updateStatus(s);
|
||||
@@ -393,7 +405,8 @@ public class ReceiverCastService extends Service {
|
||||
castEnded = false;
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
|
||||
sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
|
||||
SessionStatsContext.bind(sessionStatsRecorder);
|
||||
sessionStatsRecorder.setClientCount(1);
|
||||
}
|
||||
sessionStatsRecorder.mergeSettings(remoteSettings);
|
||||
@@ -460,7 +473,8 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
|
||||
private void checkStreamIdle() {
|
||||
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
|
||||
if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0
|
||||
|| awaitingKeyframe || reconfiguring) {
|
||||
return;
|
||||
}
|
||||
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
|
||||
@@ -487,14 +501,14 @@ public class ReceiverCastService extends Service {
|
||||
/** Stops decode, shows robot overlay; keeps TCP session if still connected. */
|
||||
private void enterAwaitingMode(int messageResId, boolean fullReset) {
|
||||
streamIdle = true;
|
||||
hasPendingConfig = false;
|
||||
enterAwaitingKeyframe();
|
||||
resetDecoderState();
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
videoDecoder = VideoDecoderFactory.createDecoder();
|
||||
}
|
||||
if (fullReset) {
|
||||
hasPendingConfig = false;
|
||||
resetDecoderState();
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
videoDecoder = VideoDecoderFactory.createDecoder();
|
||||
}
|
||||
streamMetrics.reset();
|
||||
}
|
||||
broadcastStreamIdle();
|
||||
@@ -576,6 +590,7 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
reconfiguring = true;
|
||||
enterAwaitingKeyframe();
|
||||
lastVideoFrameMs = System.currentTimeMillis();
|
||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||
pendingWidth = size.width;
|
||||
pendingHeight = size.height;
|
||||
@@ -684,7 +699,6 @@ public class ReceiverCastService extends Service {
|
||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
||||
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
||||
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
||||
reconfiguring = false;
|
||||
return;
|
||||
}
|
||||
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
||||
@@ -865,6 +879,10 @@ public class ReceiverCastService extends Service {
|
||||
videoDecoder.release();
|
||||
videoDecoder = VideoDecoderFactory.createDecoder();
|
||||
}
|
||||
if (hasPendingConfig && phase == Phase.STREAMING) {
|
||||
enterAwaitingKeyframe();
|
||||
scheduleDecoderConfigureRetries();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(String s) {
|
||||
|
||||
@@ -73,7 +73,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
private boolean bound;
|
||||
private boolean autoStartListening;
|
||||
private boolean listeningCancelled;
|
||||
private boolean autoPipAttempted;
|
||||
|
||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
||||
@Override
|
||||
@@ -249,7 +248,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
mainHandler.post(diagnosticsRefresh);
|
||||
updateDiagnosticsVisibility();
|
||||
updatePipUi();
|
||||
maybeAutoEnterPip();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -272,7 +270,6 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
||||
updatePipUi();
|
||||
maybeAutoEnterPip();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +486,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||
}
|
||||
applyTextureFitCenter();
|
||||
// Rotation often recreates the texture; re-bind decoder to the new Surface.
|
||||
surfaceSentToService = false;
|
||||
attachSurfaceIfReady();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -562,32 +562,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto PiP when leaving the app during an active cast, not while waiting for a sender. */
|
||||
private boolean shouldAutoEnterPipOnLeave() {
|
||||
if (!AppPreferences.isReceiverPipEnabled(this)
|
||||
|| !PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
return false;
|
||||
}
|
||||
if (castSessionActive || streamRendering) {
|
||||
return true;
|
||||
}
|
||||
return bound && awaitingOverlay != null
|
||||
&& awaitingOverlay.getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
private void maybeAutoEnterPip() {
|
||||
if (!autoStartListening || autoPipAttempted || inPictureInPicture) {
|
||||
return;
|
||||
}
|
||||
if (!AppPreferences.isReceiverPipEnabled(this)
|
||||
|| !PictureInPictureHelper.isSupported()) {
|
||||
return;
|
||||
}
|
||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
return;
|
||||
}
|
||||
autoPipAttempted = true;
|
||||
mainHandler.post(() -> tryEnterPictureInPicture(false));
|
||||
return castSessionActive || streamRendering;
|
||||
}
|
||||
|
||||
private void tryEnterPictureInPicture(boolean userInitiated) {
|
||||
|
||||
@@ -54,6 +54,9 @@ public class ReceiverSession {
|
||||
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||
void onListening();
|
||||
|
||||
/** About to accept a sender and run handshake (bind session stats before I/O). */
|
||||
void onCastSessionStarting();
|
||||
|
||||
void onNetworkAdviceApplied();
|
||||
|
||||
void onPeerNetworkStats(NetworkStatsSnapshot stats);
|
||||
@@ -157,6 +160,7 @@ public class ReceiverSession {
|
||||
session = new CastSession(listenTransport);
|
||||
}
|
||||
inboundSessionGate.reset();
|
||||
listener.onCastSessionStarting();
|
||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||
if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||
((PassThroughNetworkControlPlane) networkFeedback.getPlane())
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -53,6 +54,11 @@ public final class CastFanoutPump {
|
||||
return videoQueue.size() + audioQueue.size();
|
||||
}
|
||||
|
||||
/** Drops stale video frames (e.g. after capture resize / rotation). */
|
||||
public void flushVideoQueue() {
|
||||
videoQueue.clear();
|
||||
}
|
||||
|
||||
public void start(List<CastSession> sessionList, Runnable onSendFailed,
|
||||
PassThroughNetworkControlPlane statsPlane) {
|
||||
setSessions(sessionList);
|
||||
@@ -144,7 +150,7 @@ public final class CastFanoutPump {
|
||||
continue;
|
||||
}
|
||||
byte[] payload = item.buildPayload();
|
||||
fanOut(item.type, payload);
|
||||
fanOut(item.type, payload, item.keyFrame);
|
||||
} catch (InterruptedException e) {
|
||||
if (!running.get()) {
|
||||
break;
|
||||
@@ -158,7 +164,9 @@ public final class CastFanoutPump {
|
||||
}
|
||||
}
|
||||
|
||||
private void fanOut(byte type, byte[] payload) {
|
||||
private void fanOut(byte type, byte[] payload, boolean keyFrame) {
|
||||
int payloadSize = payload != null ? payload.length : 0;
|
||||
SessionStatsContext.recordOutboundMessage(type, keyFrame, payloadSize);
|
||||
int peer = 0;
|
||||
for (CastSession session : sessions) {
|
||||
peer++;
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastSession;
|
||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
@@ -39,6 +40,11 @@ public final class CastSendPump {
|
||||
return videoQueue.size() + audioQueue.size();
|
||||
}
|
||||
|
||||
/** Drops stale video frames (e.g. after capture resize / rotation). */
|
||||
public void flushVideoQueue() {
|
||||
videoQueue.clear();
|
||||
}
|
||||
|
||||
public void start(CastSession session, Runnable onSendFailed) {
|
||||
start(session, onSendFailed, null);
|
||||
}
|
||||
@@ -133,6 +139,8 @@ public final class CastSendPump {
|
||||
}
|
||||
byte[] payload = item.buildPayload();
|
||||
session.send(item.type, payload);
|
||||
int payloadSize = payload != null ? payload.length : 0;
|
||||
SessionStatsContext.recordOutboundMessage(item.type, item.keyFrame, payloadSize);
|
||||
if (statsPlane != null) {
|
||||
statsPlane.recordSendBytes(payload.length + 5);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.media.codec.VideoCodecFactory;
|
||||
import com.foxx.androidcast.media.codec.VideoEncoderSink;
|
||||
import com.foxx.androidcast.media.codec.AudioEncoderSink;
|
||||
import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||
|
||||
@@ -240,6 +241,10 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
}));
|
||||
updateStatus(getString(R.string.connecting_to, host));
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
|
||||
SessionStatsContext.bind(sessionStatsRecorder);
|
||||
}
|
||||
multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
|
||||
List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers();
|
||||
if (peers.isEmpty()) {
|
||||
@@ -267,7 +272,10 @@ public class ScreenCastService extends Service implements
|
||||
CastTrayNotifier.refresh(this);
|
||||
activeSettings = settings;
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
|
||||
SessionStatsContext.bind(sessionStatsRecorder);
|
||||
}
|
||||
sessionStatsRecorder.mergeSettings(settings);
|
||||
sessionStatsRecorder.setClientCount(peers.size());
|
||||
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
||||
@@ -461,6 +469,11 @@ public class ScreenCastService extends Service implements
|
||||
videoEncoder.stop();
|
||||
videoEncoder = null;
|
||||
}
|
||||
if (fanoutPump != null) {
|
||||
fanoutPump.flushVideoQueue();
|
||||
} else if (sendPump != null) {
|
||||
sendPump.flushVideoQueue();
|
||||
}
|
||||
|
||||
captureWidth = size.width;
|
||||
captureHeight = size.height;
|
||||
|
||||
@@ -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 {
|
||||
private final long startedMs = System.currentTimeMillis();
|
||||
/** {@code send} or {@code recv}. */
|
||||
private final String direction;
|
||||
private final String role;
|
||||
private final String transport;
|
||||
private final SessionEventJournal journal;
|
||||
private String videoCodec = "";
|
||||
private String audioCodec = "AAC";
|
||||
private String captureMode = "";
|
||||
@@ -40,9 +43,56 @@ public final class SessionStatsRecorder {
|
||||
private final JSONArray samples = new JSONArray();
|
||||
private long lastSampleMs;
|
||||
|
||||
public SessionStatsRecorder(String role, String transport) {
|
||||
this.role = role;
|
||||
/** @param direction {@code send} or {@code recv}. */
|
||||
public SessionStatsRecorder(String direction, String transport) {
|
||||
this.direction = "recv".equals(direction) ? "recv" : "send";
|
||||
this.role = "recv".equals(this.direction) ? "receiver" : "sender";
|
||||
this.transport = transport != null ? transport : "unknown";
|
||||
this.journal = new SessionEventJournal(startedMs);
|
||||
}
|
||||
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public boolean isSendDirection() {
|
||||
return "send".equals(direction);
|
||||
}
|
||||
|
||||
public void recordOutboundMessage(byte type, boolean keyFrame, int payloadBytes) {
|
||||
if (!isSendDirection() || payloadBytes <= 0) {
|
||||
return;
|
||||
}
|
||||
String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
|
||||
if (eventType != null) {
|
||||
journal.record(eventType, payloadBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void recordInboundMessage(byte type, boolean keyFrame, int payloadBytes) {
|
||||
if (isSendDirection() || payloadBytes <= 0) {
|
||||
return;
|
||||
}
|
||||
String eventType = SessionStatsEventMapper.eventTypeForMessage(type, keyFrame);
|
||||
if (eventType != null) {
|
||||
journal.record(eventType, payloadBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void onDatagramSent(int wireBytes) {
|
||||
if (isSendDirection()) {
|
||||
journal.addGeneralBytes(wireBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void onDatagramReceived(int wireBytes) {
|
||||
if (!isSendDirection()) {
|
||||
journal.addGeneralBytes(wireBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public void flushGeneralIfDue() {
|
||||
journal.flushGeneralIfDue();
|
||||
}
|
||||
|
||||
public void setClientCount(int count) {
|
||||
@@ -80,6 +130,7 @@ public final class SessionStatsRecorder {
|
||||
}
|
||||
|
||||
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
|
||||
flushGeneralIfDue();
|
||||
networkSamples++;
|
||||
NetworkStatsSnapshot s = local != null ? local : peer;
|
||||
if (s == null) {
|
||||
@@ -130,7 +181,15 @@ public final class SessionStatsRecorder {
|
||||
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||
lossTotals.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||
lossTotals.protectionMode = loss.protectionMode;
|
||||
lossTotals.fecPacketsEncoded = loss.fecPacketsEncoded;
|
||||
lossTotals.fecPacketsDecoded = loss.fecPacketsDecoded;
|
||||
lossTotals.fecDecodeFailures = loss.fecDecodeFailures;
|
||||
lossTotals.nackRequestsSent = loss.nackRequestsSent;
|
||||
lossTotals.nackRequestsReceived = loss.nackRequestsReceived;
|
||||
lossTotals.nackRetransmits = loss.nackRetransmits;
|
||||
lossTotals.nackCacheMisses = loss.nackCacheMisses;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,13 +237,16 @@ public final class SessionStatsRecorder {
|
||||
}
|
||||
|
||||
public void finish(Context context) {
|
||||
SessionStatsContext.unbind(this);
|
||||
if (!AppPreferences.isGrabSessionStats(context)) {
|
||||
return;
|
||||
}
|
||||
journal.flushGeneralFinal();
|
||||
long endedMs = System.currentTimeMillis();
|
||||
try {
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("schema_version", 1);
|
||||
root.put("schema_version", 2);
|
||||
root.put("direction", direction);
|
||||
root.put("started_at_epoch_ms", startedMs);
|
||||
root.put("ended_at_epoch_ms", endedMs);
|
||||
root.put("duration_ms", endedMs - startedMs);
|
||||
@@ -199,6 +261,11 @@ public final class SessionStatsRecorder {
|
||||
root.put("resolution", resolution);
|
||||
root.put("network_samples", networkSamples);
|
||||
|
||||
JSONArray journalEvents = journal.copyEvents();
|
||||
if (journalEvents.length() > 0) {
|
||||
root.put(direction, journalEvents);
|
||||
}
|
||||
|
||||
if (networkSamples > 0) {
|
||||
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
|
||||
@@ -244,13 +311,20 @@ public final class SessionStatsRecorder {
|
||||
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||
udp.put("fec_packets_encoded", lossTotals.fecPacketsEncoded);
|
||||
udp.put("fec_packets_decoded", lossTotals.fecPacketsDecoded);
|
||||
udp.put("fec_decode_failures", lossTotals.fecDecodeFailures);
|
||||
udp.put("nack_requests_sent", lossTotals.nackRequestsSent);
|
||||
udp.put("nack_requests_received", lossTotals.nackRequestsReceived);
|
||||
udp.put("nack_retransmits", lossTotals.nackRetransmits);
|
||||
udp.put("nack_cache_misses", lossTotals.nackCacheMisses);
|
||||
}
|
||||
root.put("udp", udp);
|
||||
if (samples.length() > 0) {
|
||||
root.put("samples", samples);
|
||||
}
|
||||
|
||||
SessionStatsStore.saveSession(context, startedMs, root);
|
||||
SessionStatsStore.saveSession(context, startedMs, direction, root);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ import java.util.Locale;
|
||||
/** Rotating flat JSON session logs in app-private storage. */
|
||||
public final class SessionStatsStore {
|
||||
private static final String DIR_NAME = "session_stats";
|
||||
private static final String PREFIX = "session_";
|
||||
/** Legacy captures before schema v2. */
|
||||
private static final String PREFIX_LEGACY = "session_";
|
||||
public static final String PREFIX_SEND = "session_send_";
|
||||
public static final String PREFIX_RECV = "session_recv_";
|
||||
private static final String SUFFIX = ".json";
|
||||
|
||||
private SessionStatsStore() {}
|
||||
@@ -29,6 +32,31 @@ public final class SessionStatsStore {
|
||||
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
|
||||
}
|
||||
|
||||
private static boolean isSessionJson(String name) {
|
||||
if (!name.endsWith(SUFFIX)) {
|
||||
return false;
|
||||
}
|
||||
return name.startsWith(PREFIX_LEGACY)
|
||||
|| name.startsWith(PREFIX_SEND)
|
||||
|| name.startsWith(PREFIX_RECV);
|
||||
}
|
||||
|
||||
/** Session JSON files, newest first (by filename / start time). */
|
||||
public static List<File> listSessionFiles(Context context) {
|
||||
File dir = statsDir(context);
|
||||
if (!dir.exists()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
File[] files = dir.listFiles((d, name) -> isSessionJson(name));
|
||||
if (files == null || files.length == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<File> list = new ArrayList<>();
|
||||
Collections.addAll(list, files);
|
||||
Collections.sort(list, (a, b) -> b.getName().compareTo(a.getName()));
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
|
||||
public static void pruneOnAppStart(Context context) {
|
||||
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
|
||||
@@ -39,7 +67,7 @@ public final class SessionStatsStore {
|
||||
if (dir == null || !dir.exists()) {
|
||||
return;
|
||||
}
|
||||
File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
|
||||
File[] files = dir.listFiles((d, name) -> isSessionJson(name));
|
||||
if (files == null || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -54,12 +82,14 @@ public final class SessionStatsStore {
|
||||
}
|
||||
}
|
||||
|
||||
public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
|
||||
public static void saveSession(Context context, long startedMs, String direction, JSONObject json)
|
||||
throws Exception {
|
||||
File dir = statsDir(context);
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
throw new IllegalStateException("Cannot create session_stats dir");
|
||||
}
|
||||
String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
|
||||
String prefix = prefixForDirection(direction);
|
||||
String name = prefix + formatTimestamp(startedMs) + SUFFIX;
|
||||
File out = new File(dir, name);
|
||||
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
|
||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||
@@ -67,6 +97,13 @@ public final class SessionStatsStore {
|
||||
}
|
||||
}
|
||||
|
||||
static String prefixForDirection(String direction) {
|
||||
if ("recv".equals(direction)) {
|
||||
return PREFIX_RECV;
|
||||
}
|
||||
return PREFIX_SEND;
|
||||
}
|
||||
|
||||
private static String formatTimestamp(long epochMs) {
|
||||
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reload_codecs_json"
|
||||
android:id="@+id/btn_open_session_stats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/session_stats_open" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reload_codecs_json"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/dev_reload_codecs_json" />
|
||||
|
||||
<TextView
|
||||
|
||||
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_codec_priority_scores">Show codec priority scores in settings</string>
|
||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||
<string name="session_stats_title">Session statistics</string>
|
||||
<string name="session_stats_subtitle">Charts from session_send_* / session_recv_* JSON (enable grab in developer settings, then cast).</string>
|
||||
<string name="session_stats_open">Open statistics analyzer</string>
|
||||
<string name="session_stats_refresh">Refresh</string>
|
||||
<string name="session_stats_share">Share JSON</string>
|
||||
<string name="session_stats_empty">No session files yet. Cast with “Grab session stats” enabled.</string>
|
||||
<string name="session_stats_disabled_hint">Session grab is off — enable it here to record new sessions.</string>
|
||||
<string name="session_stats_counters_title">UDP & protection counters</string>
|
||||
<string name="session_stats_share_failed">Could not share session file</string>
|
||||
<string name="session_stats_metric_bitrate">Bitrate</string>
|
||||
<string name="session_stats_metric_loss">Loss %</string>
|
||||
<string name="session_stats_metric_fps">Encode FPS</string>
|
||||
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
|
||||
<string name="dev_reload_codecs_json">Reload codecs.json</string>
|
||||
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
|
||||
<string name="codec_priority_score_suffix"> · %1$d</string>
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@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
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.GraphicsContext;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.ScrollEvent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.FontWeight;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Multi-series line chart with hover tooltip for session samples. */
|
||||
/** Multi-session line chart: zoom, grid, legend, hover marker + tooltip. */
|
||||
public final class SessionChartPane extends StackPane {
|
||||
private static final Color[] SERIES_COLORS = {
|
||||
Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET
|
||||
public enum Metric {
|
||||
BITRATE("Video bitrate (kbps)"),
|
||||
LOSS("Packet loss (%)"),
|
||||
ENCODE_FPS("Encode FPS");
|
||||
|
||||
final String axisLabel;
|
||||
|
||||
Metric(String axisLabel) {
|
||||
this.axisLabel = axisLabel;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Color[] SESSION_COLORS = {
|
||||
Color.web("#64B5F6"), Color.web("#FF8A65"), Color.web("#81C784"),
|
||||
Color.web("#FFD54F"), Color.web("#BA68C8"), Color.web("#4DD0E1")
|
||||
};
|
||||
|
||||
private static final double PAD_LEFT = 64;
|
||||
private static final double PAD_RIGHT = 16;
|
||||
private static final double PAD_TOP = 16;
|
||||
private static final double PAD_BOTTOM = 40;
|
||||
private static final Color GRID_COLOR = Color.web("#3a3a3a");
|
||||
private static final Color AXIS_COLOR = Color.web("#aaaaaa");
|
||||
|
||||
private final Canvas canvas = new Canvas(800, 480);
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private boolean absoluteTime = true;
|
||||
private boolean showBitrate = true;
|
||||
private boolean showRtt;
|
||||
private boolean showLoss;
|
||||
private String hoverText = "";
|
||||
private Metric metric = Metric.BITRATE;
|
||||
|
||||
private double viewMinT;
|
||||
private double viewMaxT = 1;
|
||||
private double viewMinY;
|
||||
private double viewMaxY = 1;
|
||||
private boolean viewInitialized;
|
||||
|
||||
private double hoverMouseX = -1;
|
||||
private double hoverMouseY = -1;
|
||||
private int hoverSessionIndex = -1;
|
||||
private SessionDocument.SamplePoint hoverPoint;
|
||||
private double hoverPlotX;
|
||||
private double hoverPlotY;
|
||||
|
||||
public SessionChartPane() {
|
||||
getChildren().add(canvas);
|
||||
canvas.widthProperty().bind(widthProperty());
|
||||
canvas.heightProperty().bind(heightProperty().subtract(0));
|
||||
canvas.heightProperty().bind(heightProperty());
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved);
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> {
|
||||
hoverText = "";
|
||||
draw();
|
||||
});
|
||||
canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover());
|
||||
canvas.addEventHandler(ScrollEvent.SCROLL, this::onScroll);
|
||||
}
|
||||
|
||||
public void setAbsoluteTime(boolean absoluteTime) {
|
||||
this.absoluteTime = absoluteTime;
|
||||
}
|
||||
|
||||
public void render(List<SessionDocument> docs, boolean bitrate, boolean rtt, boolean loss) {
|
||||
public void render(List<SessionDocument> docs, Metric metric) {
|
||||
sessions.clear();
|
||||
if (docs != null) {
|
||||
sessions.addAll(docs);
|
||||
}
|
||||
showBitrate = bitrate;
|
||||
showRtt = rtt;
|
||||
showLoss = loss;
|
||||
this.metric = metric != null ? metric : Metric.BITRATE;
|
||||
resetView();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void onMouseMoved(MouseEvent e) {
|
||||
if (sessions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double w = canvas.getWidth() - pad * 2;
|
||||
double h = canvas.getHeight() - pad * 2;
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
long tMax = maxTimeMs();
|
||||
if (tMax <= 0) {
|
||||
return;
|
||||
}
|
||||
double relX = (e.getX() - pad) / w;
|
||||
if (relX < 0 || relX > 1) {
|
||||
hoverText = "";
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
long tQuery = (long) (relX * tMax);
|
||||
SessionDocument doc = sessions.get(0);
|
||||
SessionDocument.SamplePoint nearest = null;
|
||||
long best = Long.MAX_VALUE;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
long d = Math.abs(p.tMs - tQuery);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = p;
|
||||
}
|
||||
}
|
||||
if (nearest != null) {
|
||||
hoverText = String.format("t=%d ms bitrate=%.0f kbps rtt=%d ms loss=%.3f",
|
||||
nearest.tMs, nearest.videoBitrateKbps, nearest.rttMs, nearest.lossRatio);
|
||||
}
|
||||
draw();
|
||||
private void resetView() {
|
||||
viewMinT = 0;
|
||||
viewMaxT = Math.max(1, dataMaxTimeMs());
|
||||
double[] y = dataYRange();
|
||||
viewMinY = y[0];
|
||||
viewMaxY = y[1];
|
||||
viewInitialized = sessions.stream().anyMatch(d -> d.samples.size() >= 2);
|
||||
}
|
||||
|
||||
private long maxTimeMs() {
|
||||
private long dataMaxTimeMs() {
|
||||
long max = 1;
|
||||
for (SessionDocument doc : sessions) {
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
max = Math.max(max, p.tMs);
|
||||
}
|
||||
if (!absoluteTime && doc.durationMs > max) {
|
||||
max = doc.durationMs;
|
||||
}
|
||||
max = Math.max(max, doc.durationMs);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private double[] dataYRange() {
|
||||
double min = Double.POSITIVE_INFINITY;
|
||||
double max = Double.NEGATIVE_INFINITY;
|
||||
for (SessionDocument doc : sessions) {
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
double v = metricValue(p);
|
||||
if (Double.isFinite(v)) {
|
||||
min = Math.min(min, v);
|
||||
max = Math.max(max, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Double.isFinite(min)) {
|
||||
min = 0;
|
||||
max = 1;
|
||||
}
|
||||
if (max <= min) {
|
||||
max = min + 1;
|
||||
}
|
||||
double pad = (max - min) * 0.06;
|
||||
return new double[] {min - pad, max + pad};
|
||||
}
|
||||
|
||||
private void onScroll(ScrollEvent e) {
|
||||
if (!viewInitialized) {
|
||||
return;
|
||||
}
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 0 || plotH <= 0) {
|
||||
return;
|
||||
}
|
||||
double factor = e.getDeltaY() > 0 ? 0.85 : 1.0 / 0.85;
|
||||
double mx = e.getX();
|
||||
double my = e.getY();
|
||||
if (mx >= PAD_LEFT && mx <= PAD_LEFT + plotW && my >= PAD_TOP && my <= PAD_TOP + plotH) {
|
||||
double relX = (mx - PAD_LEFT) / plotW;
|
||||
double relY = 1.0 - (my - PAD_TOP) / plotH;
|
||||
double tAnchor = viewMinT + relX * (viewMaxT - viewMinT);
|
||||
double yAnchor = viewMinY + relY * (viewMaxY - viewMinY);
|
||||
zoomTime(tAnchor, factor);
|
||||
zoomY(yAnchor, factor);
|
||||
} else {
|
||||
zoomTime((viewMinT + viewMaxT) / 2, factor);
|
||||
zoomY((viewMinY + viewMaxY) / 2, factor);
|
||||
}
|
||||
e.consume();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void zoomTime(double anchor, double factor) {
|
||||
double span = viewMaxT - viewMinT;
|
||||
if (span <= 0) {
|
||||
return;
|
||||
}
|
||||
double newSpan = Math.max(50, span * factor);
|
||||
viewMinT = anchor - (anchor - viewMinT) * (newSpan / span);
|
||||
viewMaxT = viewMinT + newSpan;
|
||||
}
|
||||
|
||||
private void zoomY(double anchor, double factor) {
|
||||
double span = viewMaxY - viewMinY;
|
||||
if (span <= 0) {
|
||||
return;
|
||||
}
|
||||
double newSpan = Math.max(1, span * factor);
|
||||
viewMinY = anchor - (anchor - viewMinY) * (newSpan / span);
|
||||
viewMaxY = viewMinY + newSpan;
|
||||
}
|
||||
|
||||
private void onMouseMoved(MouseEvent e) {
|
||||
hoverMouseX = e.getX();
|
||||
hoverMouseY = e.getY();
|
||||
updateHoverHit();
|
||||
draw();
|
||||
}
|
||||
|
||||
private void clearHover() {
|
||||
hoverMouseX = -1;
|
||||
hoverMouseY = -1;
|
||||
hoverSessionIndex = -1;
|
||||
hoverPoint = null;
|
||||
draw();
|
||||
}
|
||||
|
||||
private void updateHoverHit() {
|
||||
hoverSessionIndex = -1;
|
||||
hoverPoint = null;
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 0 || plotH <= 0 || sessions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (hoverMouseX < PAD_LEFT || hoverMouseX > PAD_LEFT + plotW
|
||||
|| hoverMouseY < PAD_TOP || hoverMouseY > PAD_TOP + plotH) {
|
||||
return;
|
||||
}
|
||||
double bestDist = 24;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
if (p.tMs < viewMinT || p.tMs > viewMaxT) {
|
||||
continue;
|
||||
}
|
||||
double v = metricValue(p);
|
||||
if (v < viewMinY || v > viewMaxY) {
|
||||
continue;
|
||||
}
|
||||
double x = toScreenX(p.tMs);
|
||||
double y = toScreenY(v);
|
||||
double d = Math.hypot(hoverMouseX - x, hoverMouseY - y);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
hoverSessionIndex = si;
|
||||
hoverPoint = p;
|
||||
hoverPlotX = x;
|
||||
hoverPlotY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double plotWidth() {
|
||||
return Math.max(0, canvas.getWidth() - PAD_LEFT - PAD_RIGHT);
|
||||
}
|
||||
|
||||
private double plotHeight() {
|
||||
return Math.max(0, canvas.getHeight() - PAD_TOP - PAD_BOTTOM);
|
||||
}
|
||||
|
||||
private double toScreenX(double tMs) {
|
||||
double plotW = plotWidth();
|
||||
double span = viewMaxT - viewMinT;
|
||||
if (span <= 0) {
|
||||
return PAD_LEFT;
|
||||
}
|
||||
return PAD_LEFT + ((tMs - viewMinT) / span) * plotW;
|
||||
}
|
||||
|
||||
private double toScreenY(double v) {
|
||||
double plotH = plotHeight();
|
||||
double span = viewMaxY - viewMinY;
|
||||
if (span <= 0) {
|
||||
return PAD_TOP + plotH;
|
||||
}
|
||||
return PAD_TOP + plotH - ((v - viewMinY) / span) * plotH;
|
||||
}
|
||||
|
||||
private double metricValue(SessionDocument.SamplePoint p) {
|
||||
switch (metric) {
|
||||
case LOSS:
|
||||
return p.lossRatio * 100.0;
|
||||
case ENCODE_FPS:
|
||||
return p.encodeFps;
|
||||
case BITRATE:
|
||||
default:
|
||||
return p.videoBitrateKbps;
|
||||
}
|
||||
}
|
||||
|
||||
private void draw() {
|
||||
GraphicsContext g = canvas.getGraphicsContext2D();
|
||||
double w = canvas.getWidth();
|
||||
double h = canvas.getHeight();
|
||||
g.setFill(Color.web("#1a1a1a"));
|
||||
g.setFill(Color.web("#121212"));
|
||||
g.fillRect(0, 0, w, h);
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(14));
|
||||
g.fillText("Open session_*.json (enable Grab session stats on device)", 24, 40);
|
||||
g.fillText("Open session_send_* / session_recv_* JSON (Grab session stats on device)", 24, 40);
|
||||
return;
|
||||
}
|
||||
double pad = 48;
|
||||
double plotW = w - pad * 2;
|
||||
double plotH = h - pad * 2;
|
||||
long tMax = maxTimeMs();
|
||||
g.setStroke(Color.GRAY);
|
||||
g.strokeRect(pad, pad, plotW, plotH);
|
||||
|
||||
int series = 0;
|
||||
double plotW = plotWidth();
|
||||
double plotH = plotHeight();
|
||||
if (plotW <= 1 || plotH <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ChartTicks.Tick> xTicks = ChartTicks.timeMs(
|
||||
(long) viewMinT, (long) viewMaxT, 8);
|
||||
List<ChartTicks.Tick> yTicks = ChartTicks.linear(viewMinY, viewMaxY, 6);
|
||||
|
||||
g.setStroke(GRID_COLOR);
|
||||
g.setLineWidth(1);
|
||||
for (ChartTicks.Tick t : xTicks) {
|
||||
double x = toScreenX(t.value);
|
||||
g.strokeLine(x, PAD_TOP, x, PAD_TOP + plotH);
|
||||
}
|
||||
for (ChartTicks.Tick t : yTicks) {
|
||||
double y = toScreenY(t.value);
|
||||
g.strokeLine(PAD_LEFT, y, PAD_LEFT + plotW, y);
|
||||
}
|
||||
|
||||
g.setStroke(AXIS_COLOR);
|
||||
g.setLineWidth(1.5);
|
||||
g.strokeLine(PAD_LEFT, PAD_TOP + plotH, PAD_LEFT + plotW, PAD_TOP + plotH);
|
||||
g.strokeLine(PAD_LEFT, PAD_TOP, PAD_LEFT, PAD_TOP + plotH);
|
||||
|
||||
g.setFill(AXIS_COLOR);
|
||||
g.setFont(Font.font(10));
|
||||
for (ChartTicks.Tick t : xTicks) {
|
||||
double x = toScreenX(t.value);
|
||||
g.fillText(t.label, x - 16, PAD_TOP + plotH + 14);
|
||||
}
|
||||
for (ChartTicks.Tick t : yTicks) {
|
||||
double y = toScreenY(t.value);
|
||||
g.fillText(t.label, 4, y + 4);
|
||||
}
|
||||
|
||||
g.setFill(Color.web("#cccccc"));
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText("Time", PAD_LEFT + plotW / 2 - 16, h - 4);
|
||||
g.save();
|
||||
g.translate(12, PAD_TOP + plotH / 2);
|
||||
g.rotate(-90);
|
||||
g.fillText(metric.axisLabel, 0, 0);
|
||||
g.restore();
|
||||
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
if (doc.samples.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
Color color = SERIES_COLORS[si % SERIES_COLORS.length];
|
||||
if (showBitrate) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color,
|
||||
p -> p.videoBitrateKbps, 0, 20_000);
|
||||
series++;
|
||||
}
|
||||
if (showRtt) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(0, 1, 1, 0.6),
|
||||
p -> p.rttMs, 0, 500);
|
||||
}
|
||||
if (showLoss) {
|
||||
drawSeries(g, doc, pad, plotW, plotH, tMax, color.deriveColor(30, 1, 1, 0.6),
|
||||
p -> p.lossRatio * 1000, 0, 1000);
|
||||
}
|
||||
drawSessionLine(g, doc, SESSION_COLORS[si % SESSION_COLORS.length]);
|
||||
}
|
||||
if (!hoverText.isEmpty()) {
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.75));
|
||||
g.fillRoundRect(56, 12, Math.min(plotW, 420), 28, 8, 8);
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font(12));
|
||||
g.fillText(hoverText, 64, 30);
|
||||
|
||||
drawLegend(g, plotW);
|
||||
|
||||
if (hoverPoint != null && hoverSessionIndex >= 0) {
|
||||
Color c = SESSION_COLORS[hoverSessionIndex % SESSION_COLORS.length];
|
||||
g.setFill(c);
|
||||
g.fillOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
|
||||
g.setStroke(Color.WHITE);
|
||||
g.setLineWidth(1.5);
|
||||
g.strokeOval(hoverPlotX - 5, hoverPlotY - 5, 10, 10);
|
||||
drawHoverPopup(g, c);
|
||||
}
|
||||
g.setFill(Color.LIGHTGRAY);
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText(absoluteTime ? "X: ms from session start" : "X: relative (multi-session)", pad, h - 12);
|
||||
}
|
||||
|
||||
private interface ValueFn {
|
||||
double value(SessionDocument.SamplePoint p);
|
||||
}
|
||||
|
||||
private void drawSeries(GraphicsContext g, SessionDocument doc, double pad, double plotW, double plotH,
|
||||
long tMax, Color color, ValueFn fn, double minY, double maxY) {
|
||||
if (doc.samples.size() < 2) {
|
||||
return;
|
||||
}
|
||||
private void drawSessionLine(GraphicsContext g, SessionDocument doc, Color color) {
|
||||
g.setStroke(color);
|
||||
g.setLineWidth(2);
|
||||
g.beginPath();
|
||||
boolean first = true;
|
||||
for (SessionDocument.SamplePoint p : doc.samples) {
|
||||
double x = pad + (p.tMs / (double) tMax) * plotW;
|
||||
double v = fn.value(p);
|
||||
double y = pad + plotH - ((v - minY) / (maxY - minY)) * plotH;
|
||||
if (p.tMs < viewMinT || p.tMs > viewMaxT) {
|
||||
continue;
|
||||
}
|
||||
double v = metricValue(p);
|
||||
if (v < viewMinY || v > viewMaxY) {
|
||||
continue;
|
||||
}
|
||||
double x = toScreenX(p.tMs);
|
||||
double y = toScreenY(v);
|
||||
if (first) {
|
||||
g.moveTo(x, y);
|
||||
first = false;
|
||||
@@ -178,4 +360,78 @@ public final class SessionChartPane extends StackPane {
|
||||
}
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
private void drawLegend(GraphicsContext g, double plotW) {
|
||||
double x0 = PAD_LEFT + plotW - 8;
|
||||
double y = PAD_TOP + 8;
|
||||
double rowH = 18;
|
||||
double boxW = 14;
|
||||
int rows = 0;
|
||||
for (SessionDocument doc : sessions) {
|
||||
if (!doc.samples.isEmpty()) {
|
||||
rows++;
|
||||
}
|
||||
}
|
||||
if (rows == 0) {
|
||||
return;
|
||||
}
|
||||
double legendW = 220;
|
||||
double legendH = rows * rowH + 12;
|
||||
x0 -= legendW;
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.72));
|
||||
g.fillRoundRect(x0, y, legendW, legendH, 6, 6);
|
||||
g.setStroke(Color.web("#888888"));
|
||||
g.strokeRoundRect(x0, y, legendW, legendH, 6, 6);
|
||||
|
||||
int row = 0;
|
||||
for (int si = 0; si < sessions.size(); si++) {
|
||||
SessionDocument doc = sessions.get(si);
|
||||
if (doc.samples.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Color c = SESSION_COLORS[si % SESSION_COLORS.length];
|
||||
double ry = y + 8 + row * rowH;
|
||||
g.setFill(c);
|
||||
g.fillRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
|
||||
g.setStroke(Color.web("#bbbbbb"));
|
||||
g.strokeRoundRect(x0 + 8, ry, boxW, boxW - 2, 2, 2);
|
||||
boolean bold = si == hoverSessionIndex;
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font("System", bold ? FontWeight.BOLD : FontWeight.NORMAL, 11));
|
||||
String name = doc.path;
|
||||
if (name.length() > 26) {
|
||||
name = "…" + name.substring(name.length() - 25);
|
||||
}
|
||||
g.fillText(name, x0 + 8 + boxW + 6, ry + 11);
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
private void drawHoverPopup(GraphicsContext g, Color accent) {
|
||||
SessionDocument doc = sessions.get(hoverSessionIndex);
|
||||
String text = String.format(Locale.US,
|
||||
"t=%d ms bitrate=%.0f kbps loss=%.2f%% fps=%.1f",
|
||||
hoverPoint.tMs,
|
||||
hoverPoint.videoBitrateKbps,
|
||||
hoverPoint.lossRatio * 100.0,
|
||||
hoverPoint.encodeFps);
|
||||
double tw = Math.min(plotWidth() - 20, g.getFont().getSize() * 0.55 * text.length() + 20);
|
||||
double th = 28;
|
||||
double px = hoverPlotX + 12;
|
||||
double py = hoverPlotY - th - 12;
|
||||
if (px + tw > PAD_LEFT + plotWidth()) {
|
||||
px = hoverPlotX - tw - 12;
|
||||
}
|
||||
if (py < PAD_TOP) {
|
||||
py = hoverPlotY + 12;
|
||||
}
|
||||
g.setFill(Color.rgb(0, 0, 0, 0.88));
|
||||
g.fillRoundRect(px, py, tw, th, 6, 6);
|
||||
g.setStroke(accent);
|
||||
g.setLineWidth(1);
|
||||
g.strokeRoundRect(px, py, tw, th, 6, 6);
|
||||
g.setFill(Color.WHITE);
|
||||
g.setFont(Font.font(11));
|
||||
g.fillText(text, px + 8, py + 18);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Parsed session_*.json (aggregate fields + optional time series). */
|
||||
/** Parsed session JSON (aggregate fields + optional time series). */
|
||||
public final class SessionDocument {
|
||||
public final String path;
|
||||
public final String direction;
|
||||
public final String role;
|
||||
public final String transport;
|
||||
public final long startedMs;
|
||||
@@ -20,7 +21,8 @@ public final class SessionDocument {
|
||||
|
||||
private SessionDocument(String path, JSONObject root) {
|
||||
this.path = path;
|
||||
role = root.optString("role", "?");
|
||||
direction = inferDirection(path, root);
|
||||
role = root.optString("role", "recv".equals(direction) ? "receiver" : "sender");
|
||||
transport = root.optString("transport", "?");
|
||||
startedMs = root.optLong("started_at_epoch_ms", 0L);
|
||||
durationMs = root.optLong("duration_ms", 0L);
|
||||
@@ -42,7 +44,23 @@ public final class SessionDocument {
|
||||
}
|
||||
|
||||
public String title() {
|
||||
return path + " (" + role + ", " + transport + ")";
|
||||
return path + " (" + direction + ", " + transport + ")";
|
||||
}
|
||||
|
||||
private static String inferDirection(String fileName, JSONObject root) {
|
||||
String d = root.optString("direction", "");
|
||||
if ("send".equals(d) || "recv".equals(d)) {
|
||||
return d;
|
||||
}
|
||||
if (fileName != null) {
|
||||
if (fileName.startsWith("session_send_")) {
|
||||
return "send";
|
||||
}
|
||||
if (fileName.startsWith("session_recv_")) {
|
||||
return "recv";
|
||||
}
|
||||
}
|
||||
return "receiver".equals(root.optString("role", "")) ? "recv" : "send";
|
||||
}
|
||||
|
||||
public static final class SamplePoint {
|
||||
@@ -50,12 +68,17 @@ public final class SessionDocument {
|
||||
public final double videoBitrateKbps;
|
||||
public final int rttMs;
|
||||
public final double lossRatio;
|
||||
public final double encodeFps;
|
||||
public final int congestionLevel;
|
||||
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) {
|
||||
SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio,
|
||||
double encodeFps, int congestionLevel) {
|
||||
this.tMs = tMs;
|
||||
this.videoBitrateKbps = videoBitrateKbps;
|
||||
this.rttMs = rttMs;
|
||||
this.lossRatio = lossRatio;
|
||||
this.encodeFps = encodeFps;
|
||||
this.congestionLevel = congestionLevel;
|
||||
}
|
||||
|
||||
static SamplePoint fromJson(JSONObject o) {
|
||||
@@ -63,7 +86,9 @@ public final class SessionDocument {
|
||||
o.optLong("t_ms", 0L),
|
||||
o.optDouble("video_bitrate_kbps", 0.0),
|
||||
o.optInt("rtt_ms", 0),
|
||||
o.optDouble("loss_ratio", 0.0));
|
||||
o.optDouble("loss_ratio", 0.0),
|
||||
o.optDouble("encode_fps", 0.0),
|
||||
o.optInt("congestion_level", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.foxx.androidcast.studio;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -19,45 +20,71 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Linux desktop session log viewer (foundation for full Android Cast parity on desktop).
|
||||
* Open one or more session_*.json files; charts use {@code samples[]} when present (schema v1+).
|
||||
* Desktop session log viewer — open session_send_* / session_recv_* JSON, overlay charts.
|
||||
*/
|
||||
public class SessionStudioApp extends Application {
|
||||
|
||||
private final List<SessionDocument> sessions = new ArrayList<>();
|
||||
private final SessionChartPane chartPane = new SessionChartPane();
|
||||
private final Label statusLabel = new Label("Open session_*.json files from the device.");
|
||||
private final Label statusLabel = new Label("Open session_send_* / session_recv_* JSON from the device.");
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
Button openBtn = new Button("Open sessions…");
|
||||
|
||||
CheckBox absTime = new CheckBox("Absolute time (single session)");
|
||||
absTime.setSelected(true);
|
||||
absTime.setOnAction(e -> chartPane.setAbsoluteTime(absTime.isSelected()));
|
||||
|
||||
ToggleGroup metrics = new ToggleGroup();
|
||||
CheckBox metricBitrate = metricToggle(metrics, "Video bitrate", true);
|
||||
CheckBox metricRtt = metricToggle(metrics, "RTT", false);
|
||||
CheckBox metricLoss = metricToggle(metrics, "Loss ratio", false);
|
||||
Runnable refresh = () -> chartPane.render(sessions,
|
||||
metricBitrate.isSelected(), metricRtt.isSelected(), metricLoss.isSelected());
|
||||
metricBitrate.setOnAction(e -> refresh.run());
|
||||
metricRtt.setOnAction(e -> refresh.run());
|
||||
metricLoss.setOnAction(e -> refresh.run());
|
||||
absTime.setOnAction(e -> {
|
||||
chartPane.setAbsoluteTime(absTime.isSelected());
|
||||
refresh.run();
|
||||
});
|
||||
CheckBox metricBitrate = new CheckBox("Video bitrate");
|
||||
metricBitrate.setSelected(true);
|
||||
CheckBox metricLoss = new CheckBox("Packet loss %");
|
||||
CheckBox metricFps = new CheckBox("Encode FPS");
|
||||
|
||||
ListView<String> sessionList = new ListView<>();
|
||||
sessionList.setPrefHeight(120);
|
||||
sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run());
|
||||
sessionList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
sessionList.setPrefHeight(100);
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss);
|
||||
Runnable refresh = () -> {
|
||||
List<SessionDocument> selected = selectedSessions(sessionList);
|
||||
SessionChartPane.Metric metric = SessionChartPane.Metric.BITRATE;
|
||||
if (metricLoss.isSelected()) {
|
||||
metric = SessionChartPane.Metric.LOSS;
|
||||
} else if (metricFps.isSelected()) {
|
||||
metric = SessionChartPane.Metric.ENCODE_FPS;
|
||||
} else if (metricBitrate.isSelected()) {
|
||||
metric = SessionChartPane.Metric.BITRATE;
|
||||
}
|
||||
chartPane.render(selected, metric);
|
||||
};
|
||||
|
||||
metricBitrate.setOnAction(e -> {
|
||||
if (metricBitrate.isSelected()) {
|
||||
metricLoss.setSelected(false);
|
||||
metricFps.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
metricLoss.setOnAction(e -> {
|
||||
if (metricLoss.isSelected()) {
|
||||
metricBitrate.setSelected(false);
|
||||
metricFps.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
metricFps.setOnAction(e -> {
|
||||
if (metricFps.isSelected()) {
|
||||
metricBitrate.setSelected(false);
|
||||
metricLoss.setSelected(false);
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
sessionList.getSelectionModel().getSelectedIndices()
|
||||
.addListener((ListChangeListener<Integer>) c -> refresh.run());
|
||||
|
||||
HBox toolbar = new HBox(12, openBtn, metricBitrate, metricLoss, metricFps);
|
||||
toolbar.setStyle("-fx-padding: 8;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, sessionList, statusLabel);
|
||||
Label hint = new Label("Scroll wheel over chart to zoom X+Y. Hover a line for values. RTT removed — use loss % or bitrate.");
|
||||
hint.setStyle("-fx-text-fill: #888; -fx-font-size: 11;");
|
||||
|
||||
VBox top = new VBox(8, toolbar, hint, sessionList, statusLabel);
|
||||
top.setStyle("-fx-padding: 8;");
|
||||
|
||||
BorderPane root = new BorderPane();
|
||||
@@ -67,7 +94,10 @@ public class SessionStudioApp extends Application {
|
||||
openFiles(stage);
|
||||
sessionList.getItems().clear();
|
||||
for (SessionDocument doc : sessions) {
|
||||
sessionList.getItems().add(doc.title());
|
||||
sessionList.getItems().add(doc.path);
|
||||
}
|
||||
if (!sessions.isEmpty()) {
|
||||
sessionList.getSelectionModel().selectAll();
|
||||
}
|
||||
refresh.run();
|
||||
});
|
||||
@@ -77,10 +107,17 @@ public class SessionStudioApp extends Application {
|
||||
stage.show();
|
||||
}
|
||||
|
||||
private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) {
|
||||
CheckBox box = new CheckBox(text);
|
||||
box.setSelected(selected);
|
||||
return box;
|
||||
private List<SessionDocument> selectedSessions(ListView<String> list) {
|
||||
List<SessionDocument> out = new ArrayList<>();
|
||||
for (int idx : list.getSelectionModel().getSelectedIndices()) {
|
||||
if (idx >= 0 && idx < sessions.size()) {
|
||||
out.add(sessions.get(idx));
|
||||
}
|
||||
}
|
||||
if (out.isEmpty() && !sessions.isEmpty()) {
|
||||
out.addAll(sessions);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void openFiles(Stage stage) {
|
||||
@@ -101,7 +138,7 @@ public class SessionStudioApp extends Application {
|
||||
return;
|
||||
}
|
||||
}
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s).");
|
||||
statusLabel.setText("Loaded " + sessions.size() + " session file(s). Select rows to overlay.");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -9,6 +9,25 @@ filter_out() {
|
||||
return 0
|
||||
}
|
||||
|
||||
LOG_ROOT="log_capture/session_stats/"
|
||||
CLEAN_LOGS="no"
|
||||
|
||||
while true; do
|
||||
read -p "clean logs on device after fetch? [Y/n]" yn
|
||||
case $yn in
|
||||
[Y|Yes|y|yes]* )
|
||||
CLEAN_LOGS="yes"
|
||||
break
|
||||
;;
|
||||
[N|No|n|no]* )
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "answer Y(es) or N(no) (short y/n or yes/no)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
|
||||
SERIAL="$(echo "${DEV}" | awk '{print $1}')"
|
||||
filter_out SERIAL
|
||||
@@ -16,6 +35,14 @@ echo "${DEVICES_LIST}" | while IFS= read -r DEV; do
|
||||
# echo "Device: ${DEV}"
|
||||
# echo "Serial: ${SERIAL}"
|
||||
adb -s "${SERIAL}" shell run-as com.foxx.androidcast ls files/session_stats/
|
||||
adb -s "${SERIAL}" shell run-as com.foxx.androidcast cat files/session_stats/session_*.json
|
||||
LOG_PATH="${LOG_ROOT}/${SERIAL}/"
|
||||
echo "------------ dumping in ${LOG_PATH}"
|
||||
mkdir -p "${LOG_PATH}"
|
||||
for f in $(adb -s ${SERIAL} shell run-as com.foxx.androidcast ls files/session_stats/); do
|
||||
adb -s ${SERIAL} exec-out run-as com.foxx.androidcast cat "files/session_stats/$f" > "${LOG_PATH}/$f"
|
||||
[[ "no" != "${CLEAN_LOGS}" ]] && adb -s ${SERIAL} shell run-as com.foxx.androidcast rm -f "files/session_stats/$f"
|
||||
done
|
||||
done
|
||||
|
||||
echo "run './gradlew :session-studio:run' to analyze logs; find the latest logs in ${LOG_ROOT}"
|
||||
|
||||
|
||||
46
rebuild.sh
46
rebuild.sh
@@ -5,16 +5,37 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Space-separated host:port list (wireless debugging). Edit as needed.
|
||||
CONNECTIONS_LIST=""
|
||||
# Hints for wireless debugging. Edit as needed.
|
||||
## CONNECTIONS_LIST_HINTS[IP_WITH_UNDERLINES]="hint string"
|
||||
declare -A CONNECTIONS_LIST_HINTS
|
||||
## android tv projector / android 13
|
||||
CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.153:5555"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
|
||||
## tab g6 max / android 16
|
||||
CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.39:5555"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
|
||||
## bl6000pro main / android 11
|
||||
CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.106:5555"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
|
||||
## bl6000pro aux / android 11
|
||||
CONNECTIONS_LIST="${CONNECTIONS_LIST} 192.168.33.173:5555"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
|
||||
|
||||
get_ip_for_hint_key() {
|
||||
local ip=${1}
|
||||
ip="$(echo "${ip}" | sed -e s'@_@\.@g' -e s'@:.*@@' | tr -d '\n' )"
|
||||
printf "%s" "${ip}"
|
||||
}
|
||||
|
||||
get_hint_key_for_ip() {
|
||||
local ip=${1}
|
||||
ip="$(echo "${ip}" | sed -e s'@\.@_@g' -e s'@:.*@@' | tr -d '\n' )"
|
||||
printf "%s" "${ip}"
|
||||
}
|
||||
|
||||
get_hint() {
|
||||
local ip_mask="${1}"
|
||||
local -n hint
|
||||
ip_mask="$(get_hint_key_for_ip "${ip_mask}")"
|
||||
hint=CONNECTIONS_LIST_HINTS[${ip_mask}]
|
||||
printf "%s" "${hint}"
|
||||
}
|
||||
|
||||
APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk"
|
||||
PACKAGE="com.foxx.androidcast"
|
||||
@@ -33,7 +54,7 @@ for arg in "$@"; do
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--skip-tests] [--java-home=PATH]"
|
||||
echo " Builds debug APK, runs unit tests (unless skipped), reconnects devices in CONNECTIONS_LIST,"
|
||||
echo " Builds debug APK, runs unit tests (unless skipped), reconnects devices in CONNECTIONS_LIST_HINTS,"
|
||||
echo " installs on all adb devices in 'device' state, and launches the app."
|
||||
exit 0
|
||||
;;
|
||||
@@ -79,6 +100,10 @@ reconnect_endpoint() {
|
||||
local endpoint="$1"
|
||||
[[ -z "$endpoint" ]] && return 1
|
||||
local attempt out
|
||||
local hint=""
|
||||
hint="$(get_hint "${endpoint}")"
|
||||
[[ -n "${hint}" ]] && hint="(having hint: ${hint})"
|
||||
echo " trying to connect to $endpoint ${hint}"
|
||||
adb disconnect "$endpoint" >/dev/null 2>&1 || true
|
||||
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
|
||||
out="$(adb connect "$endpoint" 2>&1)" || true
|
||||
@@ -101,8 +126,8 @@ reconnect_known_devices() {
|
||||
adb start-server >/dev/null 2>&1 || true
|
||||
adb reconnect offline >/dev/null 2>&1 || true
|
||||
local endpoint
|
||||
for endpoint in $CONNECTIONS_LIST; do
|
||||
reconnect_endpoint "$endpoint" || true
|
||||
for endpoint in "${!CONNECTIONS_LIST_HINTS[@]}"; do
|
||||
reconnect_endpoint "$(get_ip_for_hint_key "$endpoint")" || true
|
||||
done
|
||||
adb devices -l
|
||||
}
|
||||
@@ -133,7 +158,8 @@ done
|
||||
|
||||
echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)"
|
||||
./gradlew --stop
|
||||
./gradlew clean assembleDebug
|
||||
./gradlew clean
|
||||
./gradlew --build-cache assembleDebug
|
||||
|
||||
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
||||
echo "==> Running unit tests (./gradlew testDebugUnitTest)"
|
||||
|
||||
Reference in New Issue
Block a user