diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b5c90c..2cd271b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,7 +27,8 @@ + android:exported="true" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density"> @@ -37,7 +38,7 @@ + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" /> { try { - callbacks.getBroadcastItem(i).onStatus(s); + cb.onStatus(s); } catch (RemoteException ignored) { } - } - callbacks.finishBroadcast(); + }); } private void broadcastAuthenticated(String name) { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + broadcastToCallbacks(cb -> { try { - callbacks.getBroadcastItem(i).onAuthenticated(name); + cb.onAuthenticated(name); } catch (RemoteException ignored) { } - } - callbacks.finishBroadcast(); + }); } private void broadcastStreamStarted(int w, int h) { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + broadcastToCallbacks(cb -> { try { - callbacks.getBroadcastItem(i).onStreamStarted(w, h); + cb.onStreamStarted(w, h); } catch (RemoteException ignored) { } - } - callbacks.finishBroadcast(); + }); } private void broadcastVideoRendering() { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + broadcastToCallbacks(cb -> { try { - callbacks.getBroadcastItem(i).onVideoRendering(); + cb.onVideoRendering(); } catch (RemoteException ignored) { } - } - callbacks.finishBroadcast(); + }); } private void broadcastStreamIdle() { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + broadcastToCallbacks(cb -> { try { - callbacks.getBroadcastItem(i).onStreamIdle(); + cb.onStreamIdle(); } catch (RemoteException ignored) { } - } - callbacks.finishBroadcast(); + }); } private void broadcastDisconnected() { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + broadcastToCallbacks(cb -> { try { - callbacks.getBroadcastItem(i).onDisconnected(); + cb.onDisconnected(); } catch (RemoteException ignored) { } + }); + } + + private interface CallbackAction { + void dispatch(ICastStatusCallback callback) throws RemoteException; + } + + private void broadcastToCallbacks(CallbackAction action) { + synchronized (callbacks) { + int n = callbacks.beginBroadcast(); + try { + for (int i = 0; i < n; i++) { + try { + action.dispatch(callbacks.getBroadcastItem(i)); + } catch (RemoteException ignored) { + } + } + } finally { + callbacks.finishBroadcast(); + } } - callbacks.finishBroadcast(); } private void releaseAll() { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index 20882e5..48e3702 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -71,6 +71,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { private ICastReceiverService receiverService; private boolean bound; + private boolean autoStartListening; + private boolean listeningCancelled; private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() { @Override @@ -186,7 +188,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { applyAspectRatio(); updatePipUi(); - if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) { + autoStartListening = getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false); + if (autoStartListening) { startListeningService(); showAwaitingOverlay(R.string.playback_listening); } else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { @@ -291,11 +294,21 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { @Override protected void onStart() { super.onStart(); + setPlaybackUiVisible(true); bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE); } @Override protected void onStop() { + setPlaybackUiVisible(false); + if (autoStartListening && !listeningCancelled && !castSessionActive && !streamRendering + && bound && receiverService != null) { + try { + receiverService.stopReceiving(); + listeningCancelled = true; + } catch (RemoteException ignored) { + } + } if (bound && receiverService != null) { try { receiverService.unregisterCallback(statusCallback); @@ -311,6 +324,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity { super.onStop(); } + private void setPlaybackUiVisible(boolean visible) { + Intent intent = new Intent(this, ReceiverCastService.class); + intent.setAction(ReceiverCastService.ACTION_PLAYBACK_UI); + intent.putExtra(ReceiverCastService.EXTRA_PLAYBACK_UI_VISIBLE, visible); + startService(intent); + } + private void onStreamLost() { streamRendering = false; showAwaitingOverlay(R.string.playback_stream_lost); diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 15240da..afbb9aa 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -955,14 +955,19 @@ public class ScreenCastService extends Service implements } private void broadcastStatus(String s) { - int n = callbacks.beginBroadcast(); - for (int i = 0; i < n; i++) { + synchronized (callbacks) { + int n = callbacks.beginBroadcast(); try { - callbacks.getBroadcastItem(i).onStatus(s); - } catch (RemoteException ignored) { + for (int i = 0; i < n; i++) { + try { + callbacks.getBroadcastItem(i).onStatus(s); + } catch (RemoteException ignored) { + } + } + } finally { + callbacks.finishBroadcast(); } } - callbacks.finishBroadcast(); } private VideoEncoderSink.Callback buildVideoEncoderCallback(String mime) { diff --git a/app/src/main/java/com/foxx/androidcast/ui/SettingsStringArrayAdapter.java b/app/src/main/java/com/foxx/androidcast/ui/SettingsStringArrayAdapter.java index 33022da..e13c1f2 100644 --- a/app/src/main/java/com/foxx/androidcast/ui/SettingsStringArrayAdapter.java +++ b/app/src/main/java/com/foxx/androidcast/ui/SettingsStringArrayAdapter.java @@ -44,7 +44,14 @@ public class SettingsStringArrayAdapter extends ArrayAdapter { @Override @NonNull public View getView(int position, View convertView, @NonNull ViewGroup parent) { - TextView tv = (TextView) super.getView(position, convertView, parent); + // Spinner may recycle dropdown rows (FrameLayout) into the closed row — never cast blindly. + TextView tv; + if (convertView instanceof TextView) { + tv = (TextView) convertView; + } else { + tv = (TextView) LayoutInflater.from(getContext()) + .inflate(R.layout.spinner_item_settings, parent, false); + } tv.setText(getItem(position)); tv.setAlpha(isEnabled(position) ? 1f : 0.45f); return tv; diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..a12099d --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,21 @@ +# Android Cast desktop + +## Session Studio (`session-studio/`) + +JavaFX desktop app for **session log analysis** (Linux `.jar` or native image later). + +```bash +./gradlew :session-studio:run +``` + +Open `session_*.json` from the device (`files/session_stats/`). New captures include a `samples[]` time series (~2 s) for charts. + +### Roadmap (full mobile parity + analytics) + +| Phase | Scope | Estimate | +|-------|--------|----------| +| 1 | Session Studio charts (overlay, multi-session, export) | 2–3 months | +| 2 | Desktop sender (screen capture) + receiver window | 4–6 months | +| 3 | Transport/codecs/FEC parity with mobile | 3–4 months | + +Packaging: prefer **jpackage AppImage** over raw JAR for Linux users; JAR remains fine for developers. diff --git a/desktop/session-studio/build.gradle b/desktop/session-studio/build.gradle new file mode 100644 index 0000000..22baf2c --- /dev/null +++ b/desktop/session-studio/build.gradle @@ -0,0 +1,23 @@ +plugins { + id 'application' + id 'org.openjfx.javafxplugin' version '0.1.0' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +javafx { + version = '21' + modules = ['javafx.controls'] +} + +application { + mainClass = 'com.foxx.androidcast.studio.SessionStudioApp' +} + +dependencies { + implementation 'org.json:json:20240303' +} diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java new file mode 100644 index 0000000..025ac60 --- /dev/null +++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionChartPane.java @@ -0,0 +1,181 @@ +package com.foxx.androidcast.studio; + +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +import java.util.ArrayList; +import java.util.List; + +/** Multi-series line chart with hover tooltip for session samples. */ +public final class SessionChartPane extends StackPane { + private static final Color[] SERIES_COLORS = { + Color.CORNFLOWERBLUE, Color.ORANGERED, Color.MEDIUMSEAGREEN, Color.GOLD, Color.VIOLET + }; + + private final Canvas canvas = new Canvas(800, 480); + private final List sessions = new ArrayList<>(); + private boolean absoluteTime = true; + private boolean showBitrate = true; + private boolean showRtt; + private boolean showLoss; + private String hoverText = ""; + + public SessionChartPane() { + getChildren().add(canvas); + canvas.widthProperty().bind(widthProperty()); + canvas.heightProperty().bind(heightProperty().subtract(0)); + canvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::onMouseMoved); + canvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> { + hoverText = ""; + draw(); + }); + } + + public void setAbsoluteTime(boolean absoluteTime) { + this.absoluteTime = absoluteTime; + } + + public void render(List docs, boolean bitrate, boolean rtt, boolean loss) { + sessions.clear(); + if (docs != null) { + sessions.addAll(docs); + } + showBitrate = bitrate; + showRtt = rtt; + showLoss = loss; + 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 long maxTimeMs() { + 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; + } + } + return max; + } + + private void draw() { + GraphicsContext g = canvas.getGraphicsContext2D(); + double w = canvas.getWidth(); + double h = canvas.getHeight(); + g.setFill(Color.web("#1a1a1a")); + 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); + 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; + for (int si = 0; si < sessions.size(); si++) { + SessionDocument doc = sessions.get(si); + if (doc.samples.isEmpty()) { + 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); + } + } + if (!hoverText.isEmpty()) { + g.setFill(Color.rgb(0, 0, 0, 0.75)); + g.fillRoundRect(56, 12, Math.min(plotW, 420), 28, 8, 8); + g.setFill(Color.WHITE); + g.setFont(Font.font(12)); + g.fillText(hoverText, 64, 30); + } + g.setFill(Color.LIGHTGRAY); + g.setFont(Font.font(11)); + g.fillText(absoluteTime ? "X: ms from session start" : "X: relative (multi-session)", pad, h - 12); + } + + private interface ValueFn { + 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; + } + g.setStroke(color); + 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 (first) { + g.moveTo(x, y); + first = false; + } else { + g.lineTo(x, y); + } + } + g.stroke(); + } +} diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java new file mode 100644 index 0000000..ab8f358 --- /dev/null +++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionDocument.java @@ -0,0 +1,69 @@ +package com.foxx.androidcast.studio; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Parsed session_*.json (aggregate fields + optional time series). */ +public final class SessionDocument { + public final String path; + public final String role; + public final String transport; + public final long startedMs; + public final long durationMs; + public final List samples = new ArrayList<>(); + + private SessionDocument(String path, JSONObject root) { + this.path = path; + role = root.optString("role", "?"); + transport = root.optString("transport", "?"); + startedMs = root.optLong("started_at_epoch_ms", 0L); + durationMs = root.optLong("duration_ms", 0L); + JSONArray arr = root.optJSONArray("samples"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + JSONObject o = arr.optJSONObject(i); + if (o != null) { + samples.add(SamplePoint.fromJson(o)); + } + } + } + } + + public static SessionDocument load(Path file) throws Exception { + String text = Files.readString(file, StandardCharsets.UTF_8); + JSONObject root = new JSONObject(text); + return new SessionDocument(file.getFileName().toString(), root); + } + + public String title() { + return path + " (" + role + ", " + transport + ")"; + } + + public static final class SamplePoint { + public final long tMs; + public final double videoBitrateKbps; + public final int rttMs; + public final double lossRatio; + + SamplePoint(long tMs, double videoBitrateKbps, int rttMs, double lossRatio) { + this.tMs = tMs; + this.videoBitrateKbps = videoBitrateKbps; + this.rttMs = rttMs; + this.lossRatio = lossRatio; + } + + 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)); + } + } +} diff --git a/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java new file mode 100644 index 0000000..a1183ed --- /dev/null +++ b/desktop/session-studio/src/main/java/com/foxx/androidcast/studio/SessionStudioApp.java @@ -0,0 +1,110 @@ +package com.foxx.androidcast.studio; + +import javafx.application.Application; +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.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; +import java.nio.file.Path; +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+). + */ +public class SessionStudioApp extends Application { + + private final List sessions = new ArrayList<>(); + private final SessionChartPane chartPane = new SessionChartPane(); + private final Label statusLabel = new Label("Open session_*.json files from the device."); + + @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(); + }); + + ListView sessionList = new ListView<>(); + sessionList.setPrefHeight(120); + sessionList.getSelectionModel().selectedIndexProperty().addListener((o, a, b) -> refresh.run()); + + HBox toolbar = new HBox(12, openBtn, absTime, metricBitrate, metricRtt, metricLoss); + toolbar.setStyle("-fx-padding: 8;"); + + VBox top = new VBox(8, toolbar, sessionList, statusLabel); + top.setStyle("-fx-padding: 8;"); + + BorderPane root = new BorderPane(); + root.setTop(top); + root.setCenter(chartPane); + openBtn.setOnAction(e -> { + openFiles(stage); + sessionList.getItems().clear(); + for (SessionDocument doc : sessions) { + sessionList.getItems().add(doc.title()); + } + refresh.run(); + }); + + stage.setTitle("Android Cast Session Studio"); + stage.setScene(new Scene(root, 960, 640)); + stage.show(); + } + + private static CheckBox metricToggle(ToggleGroup group, String text, boolean selected) { + CheckBox box = new CheckBox(text); + box.setSelected(selected); + return box; + } + + private void openFiles(Stage stage) { + FileChooser chooser = new FileChooser(); + chooser.setTitle("Session JSON files"); + chooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Session JSON", "*.json")); + List files = chooser.showOpenMultipleDialog(stage); + if (files == null || files.isEmpty()) { + return; + } + sessions.clear(); + for (File f : files) { + try { + sessions.add(SessionDocument.load(Path.of(f.getAbsolutePath()))); + } catch (Exception ex) { + statusLabel.setText("Failed: " + f.getName() + " — " + ex.getMessage()); + return; + } + } + statusLabel.setText("Loaded " + sessions.size() + " session file(s)."); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/rebuild.sh b/rebuild.sh index a504f46..71acce8 100755 --- a/rebuild.sh +++ b/rebuild.sh @@ -124,6 +124,7 @@ for ARCH_ABI in ${ARCH_ABIS}; do done echo "==> Building debug APK (JAVA_HOME=$JAVA_HOME)" +./gradlew --stop ./gradlew clean assembleDebug if [[ "$RUN_UNIT_TESTS" == "1" ]]; then