diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 92a3a51..1318d7d 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -69,9 +69,11 @@ public class DiscoveryManager { public final String pinAuth; /** Shown from {@link PinnedReceiversStore} at top of sender list. */ public boolean pinnedRecent; + /** LAN discovery device UUID when advertised by peer. */ + public final String deviceUuid; public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) { - this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, ""); + this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "", ""); } public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs, @@ -81,6 +83,13 @@ public class DiscoveryManager { public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs, int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) { + this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion, + pinAuth, ""); + } + + public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs, + int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth, + String deviceUuid) { this.name = name; this.host = host; this.port = port; @@ -91,6 +100,7 @@ public class DiscoveryManager { this.placeholderId = placeholderId; this.protoVersion = protoVersion; this.pinAuth = pinAuth != null ? pinAuth : ""; + this.deviceUuid = deviceUuid != null ? deviceUuid : ""; } public boolean pinMatches(String pin) { @@ -431,7 +441,8 @@ public class DiscoveryManager { String pinAuth = json.optString("pin_auth", ""); String key = host + ":" + port; DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, - System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth); + System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth, + remoteUuid); devices.put(key, device); notifyListener(); } catch (Exception ignored) { diff --git a/app/src/main/java/com/foxx/androidcast/live/LiveCastControlPlane.java b/app/src/main/java/com/foxx/androidcast/live/LiveCastControlPlane.java new file mode 100644 index 0000000..404651d --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/live/LiveCastControlPlane.java @@ -0,0 +1,183 @@ +package com.foxx.androidcast.live; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.BuildConfig; +import com.foxx.androidcast.DeviceInfo; +import com.foxx.androidcast.crash.CrashSettingsStore; +import com.foxx.androidcast.network.BackendEndpoints; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Keeps BE live cast session state updated (intent/live/closed + 60s keepalive). */ +public final class LiveCastControlPlane { + private static final String TAG = "LiveCastControl"; + private static final int HTTP_TIMEOUT_MS = 20_000; + private static final long HEARTBEAT_INTERVAL_SEC = 60L; + private static final AtomicReference SESSION_ID = new AtomicReference<>(""); + private static final ScheduledExecutorService HEARTBEAT = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "live-cast-heartbeat"); + t.setDaemon(true); + return t; + }); + + static { + HEARTBEAT.scheduleAtFixedRate(() -> { + String sid = SESSION_ID.get(); + if (sid == null || sid.isEmpty()) { + return; + } + try { + postHeartbeat(sid, "active", "", "", ""); + } catch (Exception e) { + Log.w(TAG, "heartbeat failed: " + e.getMessage()); + } + }, HEARTBEAT_INTERVAL_SEC, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS); + } + + private LiveCastControlPlane() {} + + public static void onCastStart(Context context, String platform, String codecVideo, String codecAudio, String bwMode) { + if (context == null) { + return; + } + try { + String sid = SESSION_ID.get(); + if (sid == null || sid.isEmpty()) { + sid = createIntent(context.getApplicationContext(), platform, codecVideo, codecAudio, bwMode); + if (sid != null && !sid.isEmpty()) { + SESSION_ID.set(sid); + } + } else { + postHeartbeat(sid, "active", codecVideo, codecAudio, bwMode); + } + } catch (Exception e) { + Log.w(TAG, "onCastStart failed: " + e.getMessage()); + } + } + + public static void onCastStop(String codecVideo, String codecAudio, String bwMode) { + String sid = SESSION_ID.getAndSet(""); + if (sid == null || sid.isEmpty()) { + return; + } + try { + postHeartbeat(sid, "ended", codecVideo, codecAudio, bwMode); + } catch (Exception e) { + Log.w(TAG, "onCastStop failed: " + e.getMessage()); + } + } + + private static String createIntent(Context app, String platform, String codecVideo, String codecAudio, String bwMode) + throws Exception { + JSONObject req = new JSONObject(); + req.put("action", "create_intent"); + req.put("platform", platform == null || platform.isEmpty() ? "android" : platform); + req.put("device_id", DeviceInfo.getDeviceUuid(app)); + req.put("max_duration_s", 300); + if (codecVideo != null && !codecVideo.isEmpty()) { + req.put("codec_video", codecVideo); + } + if (codecAudio != null && !codecAudio.isEmpty()) { + req.put("codec_audio", codecAudio); + } + if (bwMode != null && !bwMode.isEmpty()) { + req.put("bw_mode", bwMode); + } + JSONObject resp = postJson(resolveLiveCastUrl(app), req.toString()); + if (!resp.optBoolean("ok", false)) { + return ""; + } + JSONObject session = resp.optJSONObject("session"); + return session != null ? session.optString("session_id", "") : ""; + } + + private static void postHeartbeat(String sid, String status, String codecVideo, String codecAudio, String bwMode) + throws Exception { + JSONObject req = new JSONObject(); + req.put("action", "heartbeat"); + req.put("session_id", sid); + req.put("status", status); + if (codecVideo != null && !codecVideo.isEmpty()) { + req.put("codec_video", codecVideo); + } + if (codecAudio != null && !codecAudio.isEmpty()) { + req.put("codec_audio", codecAudio); + } + if (bwMode != null && !bwMode.isEmpty()) { + req.put("bw_mode", bwMode); + } + JSONObject resp = postJson(resolveLiveCastUrl(null), req.toString()); + if (!resp.optBoolean("ok", false)) { + throw new IllegalStateException("heartbeat rejected"); + } + } + + private static String resolveLiveCastUrl(Context context) { + String crashesUpload = context != null ? CrashSettingsStore.load(context).uploadUrl : ""; + if (crashesUpload != null && !crashesUpload.trim().isEmpty()) { + String base = BackendEndpoints.normalizeCrashesUrl(crashesUpload.trim()); + if (base.contains("/api/upload.php")) { + return base.replace("/api/upload.php", "/api/live_cast.php"); + } + if (base.endsWith("/")) { + return base + "api/live_cast.php"; + } + return base + "/api/live_cast.php"; + } + return BackendEndpoints.CRASHES_BASE + "/api/live_cast.php"; + } + + private static JSONObject postJson(String url, String body) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setRequestMethod("POST"); + c.setConnectTimeout(HTTP_TIMEOUT_MS); + c.setReadTimeout(HTTP_TIMEOUT_MS); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + if (BuildConfig.GIT_COMMIT_SHORT != null) { + c.setRequestProperty("X-AndroidCast", BuildConfig.GIT_COMMIT_SHORT); + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + try (OutputStream os = c.getOutputStream()) { + os.write(bytes); + } + int code = c.getResponseCode(); + String text = readBody(c); + c.disconnect(); + if (code < 200 || code >= 300) { + throw new IllegalStateException("http_" + code + ":" + text); + } + return new JSONObject(text); + } + + private static String readBody(HttpURLConnection c) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + if (sb.length() > 8192) { + break; + } + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } +} + diff --git a/app/src/main/java/com/foxx/androidcast/live/LiveCastPeerTracker.java b/app/src/main/java/com/foxx/androidcast/live/LiveCastPeerTracker.java new file mode 100644 index 0000000..f3b37d0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/live/LiveCastPeerTracker.java @@ -0,0 +1,154 @@ +package com.foxx.androidcast.live; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.crash.CrashSettingsStore; +import com.foxx.androidcast.network.BackendEndpoints; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Polls BE for active live cast sessions to highlight casting peers in discovery UI. */ +public final class LiveCastPeerTracker { + private static final String TAG = "LiveCastPeerTracker"; + private static final long POLL_INTERVAL_SEC = 45L; + private static final int HTTP_TIMEOUT_MS = 15_000; + + public interface Listener { + void onLivePeersUpdated(Set liveDeviceIds, Set liveUsernames); + } + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "live-cast-peer-poll"); + t.setDaemon(true); + return t; + }); + private static final AtomicReference> POLL_TASK = new AtomicReference<>(); + private static final AtomicReference LISTENER = new AtomicReference<>(); + private static final AtomicReference API_URL = new AtomicReference<>(""); + + private LiveCastPeerTracker() {} + + public static void start(Context context, Listener listener) { + if (context == null || listener == null) { + return; + } + LISTENER.set(listener); + API_URL.set(resolveActiveUrl(context.getApplicationContext())); + ScheduledFuture prev = POLL_TASK.getAndSet( + EXECUTOR.scheduleAtFixedRate(LiveCastPeerTracker::pollOnce, 0L, POLL_INTERVAL_SEC, TimeUnit.SECONDS)); + if (prev != null) { + prev.cancel(false); + } + } + + public static void stop() { + ScheduledFuture prev = POLL_TASK.getAndSet(null); + if (prev != null) { + prev.cancel(false); + } + LISTENER.set(null); + } + + private static void pollOnce() { + Listener listener = LISTENER.get(); + String url = API_URL.get(); + if (listener == null || url == null || url.isEmpty()) { + return; + } + try { + Set deviceIds = new HashSet<>(); + Set usernames = new HashSet<>(); + JSONObject payload = getJson(url + "?action=active"); + if (payload != null && payload.optBoolean("ok", false)) { + JSONArray sessions = payload.optJSONArray("sessions"); + if (sessions != null) { + for (int i = 0; i < sessions.length(); i++) { + JSONObject row = sessions.optJSONObject(i); + if (row == null) { + continue; + } + String status = row.optString("status", ""); + if (!"live".equals(status) && !"intent".equals(status)) { + continue; + } + String deviceId = row.optString("device_id", ""); + if (!deviceId.isEmpty()) { + deviceIds.add(deviceId); + } + String username = row.optString("owner_username", ""); + if (!username.isEmpty()) { + usernames.add(username.toLowerCase(Locale.US)); + } + } + } + } + listener.onLivePeersUpdated(Collections.unmodifiableSet(deviceIds), + Collections.unmodifiableSet(usernames)); + } catch (Exception e) { + Log.w(TAG, "poll failed: " + e.getMessage()); + } + } + + private static String resolveActiveUrl(Context context) { + String upload = CrashSettingsStore.load(context).uploadUrl; + if (upload != null && !upload.trim().isEmpty()) { + String base = BackendEndpoints.normalizeCrashesUrl(upload.trim()); + if (base.contains("/api/upload.php")) { + return base.replace("/api/upload.php", "/api/live_cast.php"); + } + if (base.endsWith("/")) { + return base + "api/live_cast.php"; + } + return base + "/api/live_cast.php"; + } + return BackendEndpoints.CRASHES_BASE + "/api/live_cast.php"; + } + + private static JSONObject getJson(String url) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); + c.setRequestMethod("GET"); + c.setConnectTimeout(HTTP_TIMEOUT_MS); + c.setReadTimeout(HTTP_TIMEOUT_MS); + int code = c.getResponseCode(); + String text = readBody(c); + c.disconnect(); + if (code < 200 || code >= 300) { + throw new IllegalStateException("http_" + code); + } + return new JSONObject(text); + } + + private static String readBody(HttpURLConnection c) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + if (sb.length() > 16384) { + break; + } + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index d042b5e..1e05550 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -44,6 +44,7 @@ import com.foxx.androidcast.media.codec.VideoCodecBackend; import com.foxx.androidcast.media.codec.VideoDecoderFactory; import com.foxx.androidcast.media.codec.VideoDecoderSink; import com.foxx.androidcast.discovery.DiscoveryManager; +import com.foxx.androidcast.live.LiveCastControlPlane; import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.transport.TransportLossStatsBridge; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; @@ -433,6 +434,9 @@ public class ReceiverCastService extends Service { sessionStatsRecorder.setCodecs(videoMime, "AAC"); } negotiatedVideoMime = videoMime; + String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? "AAC" : "none"; + LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec, + settings.getBitrateMode().name().toLowerCase()); remoteCastSettings = remoteSettings; streamIdle = false; streamMetrics.reset(); @@ -882,6 +886,10 @@ public class ReceiverCastService extends Service { } private void finishSessionStatsAsync() { + String video = negotiatedVideoMime != null ? negotiatedVideoMime : ""; + String audio = Boolean.TRUE.equals(audioAccepted) ? "AAC" : "none"; + String bwMode = settings != null ? settings.getBitrateMode().name().toLowerCase() : ""; + LiveCastControlPlane.onCastStop(video, audio, bwMode); final SessionStatsRecorder recorder = sessionStatsRecorder; sessionStatsRecorder = null; if (recorder == null) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java index 7984be7..a4ba14d 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java +++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java @@ -23,8 +23,10 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.discovery.DiscoveryManager; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** Device list with single or multi selection. */ @@ -35,6 +37,8 @@ public class DeviceListAdapter extends BaseAdapter { private final List devices = new ArrayList<>(); private int selectedPosition = -1; private final Set selectedPositions = new LinkedHashSet<>(); + private Set liveDeviceIds = Collections.emptySet(); + private Set liveUsernames = Collections.emptySet(); public DeviceListAdapter(Context context, boolean multiSelect) { this.context = context; @@ -99,6 +103,25 @@ public class DeviceListAdapter extends BaseAdapter { return list.isEmpty() ? null : list.get(0); } + public void setLiveIndicators(Set deviceIds, Set usernames) { + liveDeviceIds = deviceIds != null ? deviceIds : Collections.emptySet(); + liveUsernames = usernames != null ? usernames : Collections.emptySet(); + notifyDataSetChanged(); + } + + private boolean isLiveCasting(DiscoveryManager.DiscoveredDevice d) { + if (d == null) { + return false; + } + if (d.deviceUuid != null && !d.deviceUuid.isEmpty() && liveDeviceIds.contains(d.deviceUuid)) { + return true; + } + if (d.name != null && !d.name.isEmpty()) { + return liveUsernames.contains(d.name.toLowerCase(Locale.US)); + } + return false; + } + @Override public int getCount() { return devices.size(); @@ -129,6 +152,11 @@ public class DeviceListAdapter extends BaseAdapter { row.setActivated(selected); row.setSelected(selected); label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL); + if (isLiveCasting(d)) { + label.setBackgroundResource(R.drawable.device_item_live_border); + } else { + label.setBackgroundResource(R.drawable.device_item_background); + } return row; } } 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 57c63c2..804074e 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -62,6 +62,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.live.LiveCastControlPlane; import com.foxx.androidcast.stats.SessionStatsContext; import com.foxx.androidcast.stats.SessionStatsRecorder; import com.foxx.androidcast.stats.SessionStatsStore; @@ -298,6 +299,10 @@ public class ScreenCastService extends Service implements casting.set(true); CastActiveState.setSenderCasting(true); CastTrayNotifier.refresh(this); + LiveCastControlPlane.onCastStart(this, "android_sender", + settings.getNegotiatedVideoMime(), + PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(settings)), + settings.getBitrateMode().name().toLowerCase()); activeSettings = settings; if (AppPreferences.isGrabSessionStats(this)) { if (sessionStatsRecorder == null) { @@ -1053,6 +1058,14 @@ public class ScreenCastService extends Service implements } private void finishSessionStatsAsync() { + String vCodec = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : ""; + String aCodec = activeSettings != null + ? PassthroughCodecPolicy.displayAudioPreference(AudioNegotiator.effective(activeSettings)) + : ""; + String bwMode = activeSettings != null + ? activeSettings.getBitrateMode().name().toLowerCase() + : ""; + LiveCastControlPlane.onCastStop(vCodec, aCodec, bwMode); final SessionStatsRecorder recorder = sessionStatsRecorder; sessionStatsRecorder = null; if (recorder == null) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index c41d554..d4b06e0 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -40,6 +40,7 @@ import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.PlatformCastSupport; import com.foxx.androidcast.R; +import com.foxx.androidcast.live.LiveCastPeerTracker; import com.foxx.androidcast.discovery.DiscoveryManager; import com.foxx.androidcast.discovery.PinnedReceiversStore; import com.foxx.androidcast.discovery.ReceiverCompatibility; @@ -218,6 +219,11 @@ public class SenderActivity extends DrawerHostActivity { findViewById(R.id.panel_preview).post(this::maybeRequestPreviewConsent); } PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled()); + LiveCastPeerTracker.start(this, (deviceIds, usernames) -> runOnUiThread(() -> { + if (deviceAdapter != null) { + deviceAdapter.setLiveIndicators(deviceIds, usernames); + } + })); } private void publishFilteredDevices() { @@ -277,6 +283,7 @@ public class SenderActivity extends DrawerHostActivity { @Override protected void onPause() { + LiveCastPeerTracker.stop(); if (capturePreview != null) { capturePreview.pause(); } diff --git a/app/src/main/res/drawable/device_item_live_border.xml b/app/src/main/res/drawable/device_item_live_border.xml new file mode 100644 index 0000000..3e8cd03 --- /dev/null +++ b/app/src/main/res/drawable/device_item_live_border.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e788592..39edf8f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,6 +2,7 @@ #1565C0 #331565C0 + #FF34D399 #FF1565C0 #FF212121 diff --git a/docs/DRs/20260620_live_cast_control_plane.md b/docs/DRs/20260620_live_cast_control_plane.md new file mode 100644 index 0000000..72bf6b9 --- /dev/null +++ b/docs/DRs/20260620_live_cast_control_plane.md @@ -0,0 +1,40 @@ +# Live Cast Control Plane (P0-P2 kickoff) + +## Scope implemented + +- New BE API `POST/GET .../api/live_cast.php`: + - `create_intent`, `heartbeat` (60s KA), `join_event` + - `GET action=active|analytics|session|list` + - Public (no login): `session`, `join_event`, education `create_intent`/`heartbeat` +- New BE repository `LiveCastRepository` with auto-schema: + - `live_cast_sessions`, `live_cast_join_events` +- Expiry rule: sessions without heartbeat for 60s become `expired`. +- Short links + QR per session (join + direct) via internal short-links service. +- Analytics payload includes `live_cast` section; shell label **Analytics**. +- Web pages: + - `/live/join` — viewer entry, join_event tracking, player placeholder + - `/live/education` — 5-minute browser screen-share demo + - `?view=live_sessions` — session tree (read-only, auto-refresh) +- Cookie consent banner (`cookie_consent.js`) gates GA4 until user accepts. +- Mobile: + - `LiveCastControlPlane` — start/heartbeat/stop from sender/receiver services + - `LiveCastPeerTracker` — polls `active` sessions; green border on discovery list + +## Non-goals (remaining) + +- WebRTC/SFU media plane and real HTML5 playback. +- Push notification delivery (Web Push / FCM). +- Gravatar / refined avatar chrome on web lists. +- Short-link click counter migration in url-shortener submodule. + +## Data model notes + +`live_cast_sessions` stores owner/device/platform, state transitions, short URLs, codec/BW hints. +`live_cast_join_events` stores open/join/leave events with platform/channel/direct flags and basic anon dimensions. + +## Follow-up phases + +1. Integrate SFU/web viewer when media-plane branch is ready. +2. Browser/mobile notification fan-out on cast start. +3. Gravatar + green-border on web peer lists. +4. Shortener click stats joined into live session analytics. diff --git a/examples/crash_reporter/backend/public/api/live_cast.php b/examples/crash_reporter/backend/public/api/live_cast.php new file mode 100644 index 0000000..fd6fe0c --- /dev/null +++ b/examples/crash_reporter/backend/public/api/live_cast.php @@ -0,0 +1,114 @@ + false, 'error' => 'invalid_json'], 400); + } + $action = trim((string) ($body['action'] ?? '')); +} + +$public = ($method === 'GET' && $action === 'session') + || ($method === 'POST' && $action === 'join_event'); + +if ($method === 'POST' && in_array($action, ['create_intent', 'heartbeat'], true)) { + $platform = strtolower(trim((string) ($body['platform'] ?? ''))); + if ($action === 'create_intent' && str_starts_with($platform, 'education')) { + $public = true; + } + if ($action === 'heartbeat') { + $sid = trim((string) ($body['session_id'] ?? '')); + if ($sid !== '') { + $existing = LiveCastRepository::sessionById($sid); + $existingPlatform = strtolower((string) ($existing['platform'] ?? '')); + if ($existing !== null && str_starts_with($existingPlatform, 'education')) { + $public = true; + } + } + } +} + +if (!$public) { + Auth::check(); +} +$actor = Auth::user() ?? []; + +if ($method === 'GET') { + if ($action === 'active') { + $platform = trim((string) ($_GET['platform'] ?? '')); + $rows = LiveCastRepository::activeSessions($actor, $platform !== '' ? $platform : null); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'analytics') { + $days = (int) ($_GET['days'] ?? 14); + $payload = LiveCastRepository::analytics($days, $actor); + json_out(['ok' => true, 'data' => $payload]); + } + if ($action === 'list') { + $days = (int) ($_GET['days'] ?? 14); + $limit = (int) ($_GET['limit'] ?? 200); + $rows = LiveCastRepository::listSessions($days, $actor, $limit); + json_out(['ok' => true, 'sessions' => $rows]); + } + if ($action === 'session') { + $sessionId = trim((string) ($_GET['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::publicSession($sessionId); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); + } + json_out(['ok' => false, 'error' => 'unknown_action'], 400); +} + +if ($method !== 'POST') { + json_out(['ok' => false, 'error' => 'method_not_allowed'], 405); +} + +if ($action === 'create_intent') { + $row = LiveCastRepository::createIntent($body, $actor); + if ($row === []) { + json_out(['ok' => false, 'error' => 'create_failed'], 500); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'heartbeat') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $row = LiveCastRepository::heartbeat($sessionId, $body, $actor); + if ($row === null) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true, 'session' => $row]); +} + +if ($action === 'join_event') { + $sessionId = trim((string) ($body['session_id'] ?? '')); + if ($sessionId === '') { + json_out(['ok' => false, 'error' => 'missing_session_id'], 400); + } + $ok = LiveCastRepository::registerJoin($sessionId, $body, $actor); + if (!$ok) { + json_out(['ok' => false, 'error' => 'not_found'], 404); + } + json_out(['ok' => true]); +} + +json_out(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index e2d7007..40e4839 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -2217,3 +2217,147 @@ button.report-tag--filter:hover { max-width: 72ch; line-height: 1.5; } + +/* Live cast pages */ +.live-page { + min-height: 100vh; + background: var(--bg-shell, #0f1419); +} +.live-shell { + max-width: 880px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.live-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.live-brand { + font-weight: 600; + color: var(--text-primary, #e2e8f0); + text-decoration: none; +} +.live-card h1 { + margin-top: 0; +} +.live-meta-list { + list-style: none; + padding: 0; + margin: 12px 0; +} +.live-meta-list li { + padding: 4px 0; + color: var(--text-muted, #94a3b8); +} +.live-player-wrap, +.live-player-shell { + margin-top: 16px; + min-height: 200px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.55); + padding: 16px; +} +.live-player-placeholder { + font-size: 1.1rem; + margin: 0 0 8px; +} +.live-preview { + width: 100%; + max-height: 360px; + border-radius: 10px; + background: #000; + margin: 12px 0; +} +.live-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +.live-timer-label { + font-variant-numeric: tabular-nums; +} + +/* Cookie consent */ +.cookie-consent { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1200; + padding: 12px; + pointer-events: none; +} +.cookie-consent-inner { + pointer-events: auto; + max-width: 720px; + margin: 0 auto; + padding: 16px 18px; + border-radius: 14px; + background: var(--card-bg, rgba(30, 41, 59, 0.96)); + border: 1px solid rgba(148, 163, 184, 0.25); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} +.cookie-consent-title { + margin: 0 0 6px; + font-weight: 600; +} +.cookie-consent-text { + margin: 0 0 12px; + font-size: 0.92rem; + line-height: 1.45; +} +.cookie-consent-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Nav icons: live + education */ +.nav-icon--live::before { + content: ''; + position: absolute; + inset: 7px 11px 11px 7px; + border: 2px solid currentColor; + border-radius: 4px; +} +.nav-icon--live::after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + background: #34d399; + top: 6px; + right: 6px; + box-shadow: 0 0 0 2px var(--nav-bg, #111827); +} +.nav-icon--education::before { + content: ''; + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + height: 10px; + border: 2px solid currentColor; + border-top: none; + border-radius: 0 0 4px 4px; +} +.nav-icon--education::after { + content: ''; + position: absolute; + left: 10px; + right: 10px; + top: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 8px 8px 0 0; +} + +.tag-pill--ok { + background: rgba(52, 211, 153, 0.15); + color: #6ee7b7; +} diff --git a/examples/crash_reporter/backend/public/assets/js/analytics.js b/examples/crash_reporter/backend/public/assets/js/analytics.js index 6613e9b..be542e6 100644 --- a/examples/crash_reporter/backend/public/assets/js/analytics.js +++ b/examples/crash_reporter/backend/public/assets/js/analytics.js @@ -18,6 +18,12 @@ } function enabled() { + if (global.AndroidCastCookieConsent && !global.AndroidCastCookieConsent.allowsAnalytics()) { + var choice = global.AndroidCastCookieConsent.getChoice && global.AndroidCastCookieConsent.getChoice(); + if (choice && choice !== 'all') { + return false; + } + } return measurementId.length > 0 && /^G-[A-Z0-9]+$/i.test(measurementId); } @@ -120,17 +126,23 @@ function init() { if (!enabled()) { - log('disabled (no measurement id)'); + log('disabled (no measurement id or cookie consent)'); return; } pageView(); - if (platform === 'crashes') { + if (platform === 'crashes' || String(platform).indexOf('live_') === 0) { bindConsoleView(); } else if (platform === 'hub') { bindHubInteractions(); } } + global.addEventListener('ac-cookie-consent', function () { + if (enabled()) { + init(); + } + }); + global.AndroidCastAnalytics = { enabled: enabled, pageView: pageView, diff --git a/examples/crash_reporter/backend/public/assets/js/cookie_consent.js b/examples/crash_reporter/backend/public/assets/js/cookie_consent.js new file mode 100644 index 0000000..dd73075 --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/cookie_consent.js @@ -0,0 +1,79 @@ +/** + * Cookie consent banner — stores choice in localStorage (ac_cookie_consent). + * Values: all | necessary | reject + */ +(function (global) { + 'use strict'; + + var STORAGE_KEY = 'ac_cookie_consent'; + var banner = null; + + function getChoice() { + try { + var v = localStorage.getItem(STORAGE_KEY); + if (v === 'all' || v === 'necessary' || v === 'reject') { + return v; + } + } catch (e) { /* ignore */ } + return null; + } + + function setChoice(value) { + try { + localStorage.setItem(STORAGE_KEY, value); + } catch (e) { /* ignore */ } + global.dispatchEvent(new CustomEvent('ac-cookie-consent', { detail: { choice: value } })); + hideBanner(); + } + + function allowsAnalytics() { + return getChoice() === 'all'; + } + + function allowsPreferenceCookies() { + var c = getChoice(); + return c === 'all' || c === 'necessary'; + } + + function hideBanner() { + if (banner) { + banner.hidden = true; + } + } + + function ensureBanner() { + if (banner || getChoice()) { + return; + } + banner = document.getElementById('cookie-consent-banner'); + if (!banner) { + return; + } + banner.hidden = false; + var allBtn = document.getElementById('cookie-consent-all'); + var necBtn = document.getElementById('cookie-consent-necessary'); + var rejBtn = document.getElementById('cookie-consent-reject'); + if (allBtn) { + allBtn.addEventListener('click', function () { setChoice('all'); }); + } + if (necBtn) { + necBtn.addEventListener('click', function () { setChoice('necessary'); }); + } + if (rejBtn) { + rejBtn.addEventListener('click', function () { setChoice('reject'); }); + } + } + + global.AndroidCastCookieConsent = { + getChoice: getChoice, + allowsAnalytics: allowsAnalytics, + allowsPreferenceCookies: allowsPreferenceCookies, + setChoice: setChoice + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', ensureBanner); + } else { + ensureBanner(); + } +})(typeof window !== 'undefined' ? window : this); diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js index 6c4b902..01fcd2f 100644 --- a/examples/crash_reporter/backend/public/assets/js/graphs.js +++ b/examples/crash_reporter/backend/public/assets/js/graphs.js @@ -19,9 +19,18 @@ 'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' }, fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top issue fingerprints' }, 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' }, + 'live-casts': { type: 'line', field: 'casts_per_day', title: 'Live casts / day', color: COLORS[0] }, + 'live-avg-length': { type: 'line', field: 'avg_length_s_per_day', title: 'Avg cast length (s) / day', color: COLORS[2] }, + 'live-platform-pie': { type: 'pie', field: 'platform_mix', title: 'Cast platforms' }, + 'live-join-platform-pie': { type: 'pie', field: 'joins_platform_mix', title: 'Join platforms' }, + 'live-video-codec-pie': { type: 'pie', field: 'video_codec_mix', title: 'Video codecs' }, + 'live-audio-codec-pie': { type: 'pie', field: 'audio_codec_mix', title: 'Audio codecs' }, + 'live-bw-pie': { type: 'pie', field: 'bw_mode_mix', title: 'Bandwidth modes' }, + 'live-top-users': { type: 'toplist', field: 'top_users', title: 'Top casters' }, }; let graphScopes = { user: null, slug: null, platform: null }; + let liveCastScope = null; let detailState = null; function basePath() { @@ -84,12 +93,20 @@ function parseCanvasId(id) { const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/); - if (!m) return null; - const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; - return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null }; + if (m) { + const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; + return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null }; + } + const live = String(id || '').match(/^graph-live-(.+)$/); + if (live) { + const suffix = 'live-' + live[1]; + return { scopeKey: 'live_cast', suffix: suffix, def: BRICK_DEFS[suffix] || null }; + } + return null; } function scopeData(scopeKey) { + if (scopeKey === 'live_cast') return liveCastScope; if (scopeKey === 'user') return graphScopes.user; if (scopeKey === 'slug') return graphScopes.slug; return graphScopes.platform; @@ -446,6 +463,24 @@ drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version); } + function renderLiveCast(scope) { + if (!scope) { + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = true; + return; + } + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = false; + drawLineChart(el('graph-live-casts'), scope.casts_per_day, COLORS[0]); + drawLineChart(el('graph-live-avg-length'), scope.avg_length_s_per_day, COLORS[2]); + drawPie(el('graph-live-platform-pie'), scope.platform_mix); + drawPie(el('graph-live-join-platform-pie'), scope.joins_platform_mix); + drawPie(el('graph-live-video-codec-pie'), scope.video_codec_mix); + drawPie(el('graph-live-audio-codec-pie'), scope.audio_codec_mix); + drawPie(el('graph-live-bw-pie'), scope.bw_mode_mix); + renderTopList(el('graph-live-top-users'), scope.top_users); + } + function setScopeVisibility(viewer) { document.querySelectorAll('[data-graph-scope]').forEach((section) => { const need = section.getAttribute('data-graph-scope'); @@ -536,6 +571,9 @@ const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; const def = BRICK_DEFS[m[2]]; empty = !fieldHasData(def, scopeData(scopeKey)); + } else if (brickId.startsWith('live-')) { + const def = BRICK_DEFS[brickId]; + empty = !fieldHasData(def, liveCastScope); } else if (canvas && canvas.id) { const parsed = parseCanvasId(canvas.id); empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey)); @@ -1013,10 +1051,12 @@ graphScopes.user = data.user || null; graphScopes.slug = data.slug_admin || null; graphScopes.platform = data.platform_admin || null; + liveCastScope = data.live_cast || null; setScopeVisibility(viewer); renderScope('user', graphScopes.user); if (graphScopes.slug) renderScope('slug', graphScopes.slug); if (graphScopes.platform) renderScope('platform', graphScopes.platform); + renderLiveCast(liveCastScope); markGraphBricks(); hideEmptyBricks(); applyBrickOrder(); diff --git a/examples/crash_reporter/backend/public/assets/js/live_education.js b/examples/crash_reporter/backend/public/assets/js/live_education.js new file mode 100644 index 0000000..0e16f6d --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/live_education.js @@ -0,0 +1,165 @@ +(function () { + 'use strict'; + + var MAX_DEMO_S = 300; + var timer = null; + var secondsLeft = MAX_DEMO_S; + var mediaStream = null; + var sessionId = ''; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJson(body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(JSON.stringify(body)); + } + + function setStatus(text) { + var node = el('edu-status'); + if (node) node.textContent = text; + } + + function updateTimer() { + var node = el('edu-timer'); + if (!node) return; + var m = Math.floor(secondsLeft / 60); + var s = secondsLeft % 60; + node.textContent = m + ':' + (s < 10 ? '0' : '') + s; + } + + function stopDemo() { + if (timer) { + clearInterval(timer); + timer = null; + } + if (mediaStream) { + mediaStream.getTracks().forEach(function (t) { t.stop(); }); + mediaStream = null; + } + var preview = el('edu-preview'); + if (preview) preview.srcObject = null; + if (sessionId) { + postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {}); + sessionId = ''; + } + el('edu-start').disabled = false; + el('edu-stop').disabled = true; + setStatus('Demo ended. Thanks for trying AndroidCast education mode.'); + } + + function startDemo() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { + setStatus('Screen sharing is not supported in this browser.'); + return; + } + setStatus('Requesting screen share permission…'); + navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }) + .then(function (stream) { + mediaStream = stream; + var preview = el('edu-preview'); + if (preview) { + preview.hidden = false; + preview.srcObject = stream; + preview.play().catch(function () {}); + } + stream.getVideoTracks()[0].addEventListener('ended', stopDemo); + setStatus('Creating demo session…'); + postJson({ + action: 'create_intent', + platform: 'education_' + detectPlatform(), + max_duration_s: MAX_DEMO_S, + codec_video: 'browser_screen', + codec_audio: 'none', + bw_mode: 'demo' + }, function (resp) { + if (!resp || !resp.ok || !resp.session) { + setStatus('Could not register demo session. Sign in or try again later.'); + stopDemo(); + return; + } + sessionId = resp.session.session_id || ''; + secondsLeft = MAX_DEMO_S; + updateTimer(); + el('edu-start').disabled = true; + el('edu-stop').disabled = false; + setStatus('Live demo — max ' + MAX_DEMO_S / 60 + ' minutes.'); + timer = setInterval(function () { + secondsLeft -= 1; + updateTimer(); + if (sessionId && secondsLeft % 60 === 0) { + postJson({ action: 'heartbeat', session_id: sessionId, status: 'active' }, function () {}); + } + if (secondsLeft <= 0) { + stopDemo(); + } + }, 1000); + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() }); + } + }); + }) + .catch(function () { + setStatus('Screen share permission denied or cancelled.'); + }); + } + + function boot() { + if (!el('live-education-app')) return; + updateTimer(); + var startBtn = el('edu-start'); + var stopBtn = el('edu-stop'); + if (startBtn) startBtn.addEventListener('click', startDemo); + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.addEventListener('click', stopDemo); + } + var notifBtn = el('edu-notify'); + if (notifBtn && 'Notification' in window) { + notifBtn.addEventListener('click', function () { + Notification.requestPermission().then(function (perm) { + setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.'); + }); + }); + } else if (notifBtn) { + notifBtn.hidden = true; + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/examples/crash_reporter/backend/public/assets/js/live_join.js b/examples/crash_reporter/backend/public/assets/js/live_join.js new file mode 100644 index 0000000..e6fb0ab --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/live_join.js @@ -0,0 +1,171 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Android/i.test(ua)) return 'android'; + if (/iPhone|iPad|iPod/i.test(ua)) return 'ios'; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function anonId() { + var key = 'ac_live_anon_id'; + try { + var existing = localStorage.getItem(key); + if (existing) return existing; + var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + localStorage.setItem(key, id); + return id; + } catch (e) { + return 'anon-' + Date.now(); + } + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJoinEvent(sessionId, eventType, direct) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.send(JSON.stringify({ + action: 'join_event', + session_id: sessionId, + event_type: eventType, + platform: detectPlatform(), + channel: direct ? 'direct_link' : 'viewer_link', + direct: !!direct, + anon_id: anonId() + })); + } + + function fetchSession(sessionId, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload && payload.ok ? payload.session : null); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(); + } + + function formatStatus(session) { + if (!session) return 'Unknown'; + var st = session.status || ''; + if (st === 'live' || st === 'intent') return 'Live'; + if (st === 'closed') return 'Ended'; + if (st === 'expired') return 'Expired'; + return st; + } + + function boot() { + var root = el('live-join-app'); + if (!root) return; + + var params = new URLSearchParams(window.location.search); + var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || ''; + var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1'; + var role = params.get('role') || (direct ? 'direct' : 'viewer'); + + if (!sessionId) { + el('live-join-status').textContent = 'Missing session_id in URL.'; + return; + } + + postJoinEvent(sessionId, 'open', direct); + + var statusEl = el('live-join-status'); + var metaEl = el('live-join-meta'); + var playerEl = el('live-join-player'); + + function render(session) { + if (!session) { + statusEl.textContent = 'Session not found or no longer available.'; + return; + } + var live = session.status === 'live' || session.status === 'intent'; + statusEl.textContent = formatStatus(session) + (live ? ' — waiting for stream' : ''); + var owner = session.owner_username || 'host'; + var lines = [ + 'Host: ' + owner, + 'Platform: ' + (session.platform || '—'), + 'Video: ' + (session.codec_video || '—'), + 'Audio: ' + (session.codec_audio || '—'), + 'Bandwidth: ' + (session.bw_mode || '—') + ]; + metaEl.innerHTML = lines.map(function (l) { + return '
  • ' + escapeHtml(l) + '
  • '; + }).join(''); + + if (direct && /android|ios/i.test(detectPlatform())) { + playerEl.innerHTML = + '

    Open in the AndroidCast app to join this P2P session.

    ' + + '

    androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1

    '; + postJoinEvent(sessionId, 'join', true); + } else if (role === 'viewer' || !direct) { + playerEl.innerHTML = + '
    ' + + '

    HTML5 viewer placeholder

    ' + + '

    WebRTC media plane is not wired yet. Session control plane is active.

    ' + + '
    '; + postJoinEvent(sessionId, 'join', false); + } else { + playerEl.innerHTML = '

    Use a mobile device with direct=1 for P2P join.

    '; + } + + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_join_view', { + session_id: sessionId, + direct: direct ? 1 : 0, + platform: detectPlatform(), + status: session.status || '' + }); + } + } + + fetchSession(sessionId, render); + setInterval(function () { + fetchSession(sessionId, function (session) { + if (session && (session.status === 'live' || session.status === 'intent')) { + statusEl.textContent = formatStatus(session) + ' — waiting for stream'; + } else if (session) { + statusEl.textContent = formatStatus(session); + } + }); + }, 30000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/examples/crash_reporter/backend/public/assets/js/live_sessions.js b/examples/crash_reporter/backend/public/assets/js/live_sessions.js new file mode 100644 index 0000000..8167f4d --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/live_sessions.js @@ -0,0 +1,113 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatMs(ms) { + if (!ms) return '—'; + try { + return new Date(ms).toLocaleString(); + } catch (e) { + return String(ms); + } + } + + function durationS(startMs, endMs) { + if (!startMs) return '—'; + var end = endMs || Date.now(); + var sec = Math.max(0, Math.round((end - startMs) / 1000)); + if (sec < 60) return sec + 's'; + return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's'; + } + + function statusClass(status) { + if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok'; + if (status === 'expired') return 'tag-pill tag-pill--warn'; + return 'tag-pill'; + } + + function renderRows(sessions) { + var tbody = el('live-sessions-tbody'); + if (!tbody) return; + if (!sessions || !sessions.length) { + tbody.innerHTML = 'No sessions in this window.'; + return; + } + tbody.innerHTML = sessions.map(function (s) { + var joinUrl = s.join_short_url || ''; + var joinCell = joinUrl + ? 'Open' + : '—'; + return ( + '' + + '' + escapeHtml(s.status) + '' + + '' + escapeHtml(s.owner_username || '—') + '' + + '' + escapeHtml(s.platform || '—') + '' + + '' + escapeHtml(s.codec_video || '—') + '' + + '' + durationS(s.started_at_ms, s.ended_at_ms) + '' + + '' + formatMs(s.last_heartbeat_ms) + '' + + '' + Number(s.join_opens || 0) + '' + + '' + joinCell + '' + + '' + ); + }).join(''); + } + + function load() { + var status = el('live-sessions-status'); + var daysSel = el('live-sessions-days'); + var days = daysSel && daysSel.value ? daysSel.value : '14'; + if (status) status.textContent = 'Loading…'; + var xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + if (status) status.textContent = 'Invalid response'; + return; + } + if (!payload || !payload.ok) { + if (status) status.textContent = (payload && payload.error) || 'Failed'; + return; + } + renderRows(payload.sessions || []); + if (status) { + status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(); + } + + function boot() { + if (!el('live-sessions-app')) return; + var daysSel = el('live-sessions-days'); + if (daysSel) daysSel.addEventListener('change', load); + load(); + setInterval(load, 60000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index 57d8d13..26b02c9 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -105,6 +105,21 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac exit; } +if ($route === '/api/live_cast.php' || str_ends_with($route, '/api/live_cast.php')) { + require __DIR__ . '/api/live_cast.php'; + exit; +} + +if ($route === '/live/join' || str_ends_with($route, '/live/join')) { + require __DIR__ . '/../views/live_join.php'; + exit; +} + +if ($route === '/live/education' || str_ends_with($route, '/live/education')) { + require __DIR__ . '/../views/live_education.php'; + exit; +} + if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) { require __DIR__ . '/api/short_links.php'; exit; @@ -375,7 +390,8 @@ if ($view === 'ticket' && isset($_GET['id'])) { $pageTitle = match ($view) { 'home' => 'Home', 'tickets' => 'Tickets', - 'graphs' => 'Graphs', + 'graphs' => 'Analytics', + 'live_sessions' => 'Live sessions', 'remote_access' => 'Remote access', 'short_links' => 'Short links', 'rbac' => 'Access control', diff --git a/examples/crash_reporter/backend/scripts/test_live_cast_api.sh b/examples/crash_reporter/backend/scripts/test_live_cast_api.sh new file mode 100755 index 0000000..43590b3 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/test_live_cast_api.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Smoke-test live cast API (public join + authenticated list). +# +# ./scripts/test_live_cast_api.sh +# COOKIE='ac_crash_sess=…' CRASH_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_live_cast_api.sh +set -euo pipefail + +CRASH_BASE="${CRASH_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}" +API_URL="${CRASH_BASE}/api/live_cast.php" +COOKIE_JAR="${TMPDIR:-/tmp}/live_cast_cookies_$$.txt" +trap 'rm -f "$COOKIE_JAR"' EXIT + +CURL_AUTH=() +if [[ -n "${COOKIE:-}" ]]; then + CURL_AUTH=(-H "Cookie: ${COOKIE}") +else + user="${CRASH_USER:-admin}" + pass="${CRASH_PASS:-admin}" + curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \ + -d "username=${user}&password=${pass}" \ + "${CRASH_BASE}/login" -o /dev/null + CURL_AUTH=(-b "$COOKIE_JAR") +fi + +sid="smoke-live-$(date +%s)" +echo "POST create_intent (${sid})" +create_code="$(curl -sS -o /tmp/live_create.json -w '%{http_code}' \ + "${CURL_AUTH[@]}" \ + -H 'Content-Type: application/json' \ + -d "{\"action\":\"create_intent\",\"platform\":\"android\",\"device_id\":\"smoke-device\",\"max_duration_s\":120}" \ + "${API_URL}")" +echo "create HTTP ${create_code}" +cat /tmp/live_create.json +echo + +session_id="$(python3 - <<'PY' +import json +try: + d=json.load(open("/tmp/live_create.json")) + print(d.get("session",{}).get("session_id","")) +except Exception: + print("") +PY +)" +if [[ -z "${session_id}" ]]; then + echo "FAIL: no session_id in create response" >&2 + exit 1 +fi + +echo "GET public session" +pub_code="$(curl -sS -o /tmp/live_session.json -w '%{http_code}' \ + "${API_URL}?action=session&session_id=${session_id}")" +echo "session HTTP ${pub_code}" +head -c 300 /tmp/live_session.json +echo + +echo "POST public join_event" +join_code="$(curl -sS -o /tmp/live_join.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"action\":\"join_event\",\"session_id\":\"${session_id}\",\"event_type\":\"open\",\"platform\":\"chrome\"}" \ + "${API_URL}")" +echo "join HTTP ${join_code}" +cat /tmp/live_join.json +echo + +echo "GET list (auth)" +list_code="$(curl -sS -o /tmp/live_list.json -w '%{http_code}' \ + "${CURL_AUTH[@]}" \ + "${API_URL}?action=list&days=7")" +echo "list HTTP ${list_code}" +head -c 300 /tmp/live_list.json +echo + +for code in "${create_code}" "${pub_code}" "${join_code}" "${list_code}"; do + if [[ "${code}" != "200" ]]; then + echo "FAIL: expected HTTP 200" >&2 + exit 1 + fi +done +echo "OK" diff --git a/examples/crash_reporter/backend/src/GraphRepository.php b/examples/crash_reporter/backend/src/GraphRepository.php index cbbb214..19ade6a 100644 --- a/examples/crash_reporter/backend/src/GraphRepository.php +++ b/examples/crash_reporter/backend/src/GraphRepository.php @@ -302,6 +302,7 @@ final class GraphRepository { 'viewer' => $viewer, 'links' => self::serviceLinks(), ]; + $payload['live_cast'] = LiveCastRepository::analytics($days, $user ?? []); $payload['user'] = self::buildScope($days, $activeCompanyId, null); if ($viewer === 'user') { return $payload; diff --git a/examples/crash_reporter/backend/src/LiveCastRepository.php b/examples/crash_reporter/backend/src/LiveCastRepository.php new file mode 100644 index 0000000..f8b7953 --- /dev/null +++ b/examples/crash_reporter/backend/src/LiveCastRepository.php @@ -0,0 +1,583 @@ +exec( + "CREATE TABLE live_cast_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + owner_user_id INT UNSIGNED NULL, + owner_username VARCHAR(64) NULL, + device_id VARCHAR(128) NULL, + platform VARCHAR(24) NOT NULL DEFAULT 'android', + status ENUM('intent','live','closed','expired') NOT NULL DEFAULT 'intent', + started_at_ms BIGINT NOT NULL, + last_heartbeat_ms BIGINT NOT NULL, + ended_at_ms BIGINT NULL, + max_duration_s INT NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug VARCHAR(32) NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug VARCHAR(32) NULL, + codec_video VARCHAR(32) NULL, + codec_audio VARCHAR(32) NULL, + bw_mode VARCHAR(24) NULL, + metadata_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_live_cast_session_id (session_id), + KEY idx_live_cast_company_status (company_id, status), + KEY idx_live_cast_last_hb (last_heartbeat_ms), + KEY idx_live_cast_owner (owner_user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + owner_user_id INTEGER NULL, + owner_username TEXT NULL, + device_id TEXT NULL, + platform TEXT NOT NULL DEFAULT 'android', + status TEXT NOT NULL DEFAULT 'intent', + started_at_ms INTEGER NOT NULL, + last_heartbeat_ms INTEGER NOT NULL, + ended_at_ms INTEGER NULL, + max_duration_s INTEGER NOT NULL DEFAULT 300, + join_short_url TEXT NULL, + join_qr_url TEXT NULL, + join_slug TEXT NULL, + direct_short_url TEXT NULL, + direct_qr_url TEXT NULL, + direct_slug TEXT NULL, + codec_video TEXT NULL, + codec_audio TEXT NULL, + bw_mode TEXT NULL, + metadata_json TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + + if (!Database::tableExists($pdo, 'live_cast_join_events')) { + if (Database::isMysql()) { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + event_type ENUM('open','join','leave') NOT NULL DEFAULT 'open', + platform VARCHAR(24) NOT NULL DEFAULT 'unknown', + channel VARCHAR(24) NOT NULL DEFAULT 'short_link', + direct TINYINT(1) NOT NULL DEFAULT 0, + user_id INT UNSIGNED NULL, + anon_id VARCHAR(96) NULL, + user_agent VARCHAR(255) NULL, + ip_hash CHAR(64) NULL, + created_at_ms BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_live_join_session_time (session_id, created_at_ms), + KEY idx_live_join_company_time (company_id, created_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + $pdo->exec( + "CREATE TABLE live_cast_join_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + event_type TEXT NOT NULL DEFAULT 'open', + platform TEXT NOT NULL DEFAULT 'unknown', + channel TEXT NOT NULL DEFAULT 'short_link', + direct INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NULL, + anon_id TEXT NULL, + user_agent TEXT NULL, + ip_hash TEXT NULL, + created_at_ms INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); + } + } + } + + private static function ensureIndexes(PDO $pdo): void { + if (Database::isMysql()) { + return; + } + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_company_status ON live_cast_sessions (company_id, status)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_cast_last_hb ON live_cast_sessions (last_heartbeat_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_session_time ON live_cast_join_events (session_id, created_at_ms)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_live_join_company_time ON live_cast_join_events (company_id, created_at_ms)'); + } + + /** @return array */ + public static function createIntent(array $input, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $userId = (int) ($actor['id'] ?? 0); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $ownerUsername = trim((string) ($actor['username'] ?? '')); + $platform = strtolower(trim((string) ($input['platform'] ?? 'android'))); + if ($platform === '') { + $platform = 'android'; + } + $deviceId = trim((string) ($input['device_id'] ?? '')); + $maxDuration = (int) ($input['max_duration_s'] ?? 300); + $maxDuration = max(60, min(1800, $maxDuration)); + if (str_starts_with($platform, 'education')) { + $maxDuration = min($maxDuration, 300); + } + + $sessionId = self::newSessionId($now, $companyId, $userId); + $joinLongUrl = self::joinLongUrl($sessionId, false); + $directLongUrl = self::joinLongUrl($sessionId, true); + + $links = self::makeShortLinks($joinLongUrl, $directLongUrl); + $meta = is_array($input['meta'] ?? null) ? $input['meta'] : []; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_sessions ( + session_id, company_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, + join_short_url, join_qr_url, join_slug, direct_short_url, direct_qr_url, direct_slug, + codec_video, codec_audio, bw_mode, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $userId > 0 ? $userId : null, + $ownerUsername !== '' ? $ownerUsername : null, + $deviceId !== '' ? $deviceId : null, + $platform, + 'intent', + $now, + $now, + $maxDuration, + $links['join_short_url'], + $links['join_qr_url'], + $links['join_slug'], + $links['direct_short_url'], + $links['direct_qr_url'], + $links['direct_slug'], + trim((string) ($input['codec_video'] ?? '')) ?: null, + trim((string) ($input['codec_audio'] ?? '')) ?: null, + trim((string) ($input['bw_mode'] ?? '')) ?: null, + safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ]); + + return self::sessionById($sessionId) ?? []; + } + + /** @return array|null */ + public static function heartbeat(string $sessionId, array $input, array $actor): ?array { + self::ensureSchema(); + self::expireStale(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return null; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $status = strtolower(trim((string) ($input['status'] ?? 'active'))); + if ($status !== 'ended' && $status !== 'active') { + $status = 'active'; + } + $nextState = $status === 'ended' ? 'closed' : 'live'; + $stmt = $pdo->prepare( + 'UPDATE live_cast_sessions + SET status = ?, last_heartbeat_ms = ?, ended_at_ms = CASE WHEN ? = ? THEN ? ELSE ended_at_ms END, + codec_video = COALESCE(NULLIF(?, \'\'), codec_video), + codec_audio = COALESCE(NULLIF(?, \'\'), codec_audio), + bw_mode = COALESCE(NULLIF(?, \'\'), bw_mode) + WHERE session_id = ? AND company_id = ?' + ); + $stmt->execute([ + $nextState, + $now, + $nextState, + 'closed', + $now, + trim((string) ($input['codec_video'] ?? '')), + trim((string) ($input['codec_audio'] ?? '')), + trim((string) ($input['bw_mode'] ?? '')), + $sessionId, + $companyId, + ]); + if ($stmt->rowCount() < 1) { + return null; + } + return self::sessionById($sessionId); + } + + /** @return list> */ + public static function activeSessions(array $actor, ?string $platform = null): array { + self::ensureSchema(); + self::expireStale(); + $pdo = Database::pdo(); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $where = ['company_id = ?', "status IN ('intent','live')"]; + $params = [$companyId]; + if ($platform !== null && trim($platform) !== '') { + $where[] = 'platform = ?'; + $params[] = strtolower(trim($platform)); + } + $sql = 'SELECT session_id, owner_user_id, owner_username, device_id, platform, status, + started_at_ms, last_heartbeat_ms, max_duration_s, ended_at_ms, + join_short_url, join_qr_url, direct_short_url, direct_qr_url, + codec_video, codec_audio, bw_mode + FROM live_cast_sessions + WHERE ' . implode(' AND ', $where) . ' + ORDER BY last_heartbeat_ms DESC LIMIT 200'; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = self::normalizeSessionRow($row); + } + return $out; + } + + /** @return array */ + public static function analytics(int $days, array $actor): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $pdo = Database::pdo(); + + $castsPerDay = self::countByDay($pdo, 'live_cast_sessions', 'started_at_ms', $startMs, $companyId); + $avgDurationPerDay = self::avgDurationByDay($pdo, $startMs, $companyId); + $platformMix = self::breakdown($pdo, 'live_cast_sessions', 'platform', $startMs, $companyId); + $videoCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_video', $startMs, $companyId); + $audioCodecMix = self::breakdown($pdo, 'live_cast_sessions', 'codec_audio', $startMs, $companyId); + $bwModeMix = self::breakdown($pdo, 'live_cast_sessions', 'bw_mode', $startMs, $companyId); + $joinsPerPlatform = self::breakdown($pdo, 'live_cast_join_events', 'platform', $startMs, $companyId); + + $topUsersStmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(owner_username, \'\'), \'unknown\') AS u, COUNT(*) AS c + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY u ORDER BY c DESC LIMIT 20' + ); + $topUsersStmt->execute([$companyId, $startMs]); + $topUsers = []; + foreach ($topUsersStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $topUsers[] = ['label' => (string) ($row['u'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + + return [ + 'window_days' => $days, + 'casts_per_day' => $castsPerDay, + 'avg_length_s_per_day' => $avgDurationPerDay, + 'platform_mix' => $platformMix, + 'joins_platform_mix' => $joinsPerPlatform, + 'video_codec_mix' => $videoCodecMix, + 'audio_codec_mix' => $audioCodecMix, + 'bw_mode_mix' => $bwModeMix, + 'top_users' => $topUsers, + ]; + } + + public static function registerJoin(string $sessionId, array $payload, array $actor): bool { + self::ensureSchema(); + $sessionId = trim($sessionId); + if ($sessionId === '') { + return false; + } + $session = self::sessionById($sessionId); + if ($session === null) { + return false; + } + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $companyId = (int) ($session['company_id'] ?? Rbac::defaultCompanyId()); + $eventType = strtolower(trim((string) ($payload['event_type'] ?? 'open'))); + if (!in_array($eventType, ['open', 'join', 'leave'], true)) { + $eventType = 'open'; + } + $platform = strtolower(trim((string) ($payload['platform'] ?? 'unknown'))); + if ($platform === '') { + $platform = 'unknown'; + } + $channel = strtolower(trim((string) ($payload['channel'] ?? 'short_link'))); + if ($channel === '') { + $channel = 'short_link'; + } + $direct = !empty($payload['direct']) ? 1 : 0; + $userId = (int) ($actor['id'] ?? 0); + $anon = trim((string) ($payload['anon_id'] ?? '')); + $ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + $ip = (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? '')); + $ipHash = $ip !== '' ? hash('sha256', $ip . '|ac-live-cast') : null; + + $stmt = $pdo->prepare( + 'INSERT INTO live_cast_join_events + (session_id, company_id, event_type, platform, channel, direct, user_id, anon_id, user_agent, ip_hash, created_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $sessionId, + $companyId, + $eventType, + $platform, + $channel, + $direct, + $userId > 0 ? $userId : null, + $anon !== '' ? $anon : null, + $ua !== '' ? substr($ua, 0, 255) : null, + $ipHash, + $now, + ]); + return true; + } + + /** @return array|null */ + public static function sessionById(string $sessionId): ?array { + self::ensureSchema(); + $stmt = Database::pdo()->prepare( + 'SELECT * FROM live_cast_sessions WHERE session_id = ? LIMIT 1' + ); + $stmt->execute([$sessionId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + return self::normalizeSessionRow($row); + } + + private static function expireStale(): void { + $pdo = Database::pdo(); + $now = (int) floor(microtime(true) * 1000); + $staleBefore = $now - 60_000; + $sql = "UPDATE live_cast_sessions + SET status = 'expired', ended_at_ms = COALESCE(ended_at_ms, ?) + WHERE status IN ('intent','live') + AND last_heartbeat_ms < ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$now, $staleBefore]); + } + + /** @return array */ + private static function normalizeSessionRow(array $row): array { + return [ + 'session_id' => (string) ($row['session_id'] ?? ''), + 'company_id' => (int) ($row['company_id'] ?? 0), + 'owner_user_id' => isset($row['owner_user_id']) ? (int) $row['owner_user_id'] : null, + 'owner_username' => (string) ($row['owner_username'] ?? ''), + 'device_id' => (string) ($row['device_id'] ?? ''), + 'platform' => (string) ($row['platform'] ?? ''), + 'status' => (string) ($row['status'] ?? ''), + 'started_at_ms' => (int) ($row['started_at_ms'] ?? 0), + 'last_heartbeat_ms' => (int) ($row['last_heartbeat_ms'] ?? 0), + 'ended_at_ms' => isset($row['ended_at_ms']) ? (int) $row['ended_at_ms'] : null, + 'max_duration_s' => (int) ($row['max_duration_s'] ?? 0), + 'join_short_url' => (string) ($row['join_short_url'] ?? ''), + 'join_qr_url' => (string) ($row['join_qr_url'] ?? ''), + 'direct_short_url' => (string) ($row['direct_short_url'] ?? ''), + 'direct_qr_url' => (string) ($row['direct_qr_url'] ?? ''), + 'codec_video' => (string) ($row['codec_video'] ?? ''), + 'codec_audio' => (string) ($row['codec_audio'] ?? ''), + 'bw_mode' => (string) ($row['bw_mode'] ?? ''), + ]; + } + + private static function newSessionId(int $nowMs, int $companyId, int $userId): string { + $seed = $nowMs . ':' . $companyId . ':' . $userId . ':' . bin2hex(random_bytes(6)); + return 'live-' . substr(hash('sha256', $seed), 0, 20); + } + + private static function joinLongUrl(string $sessionId, bool $direct): string { + $base = rtrim((string) cfg('base_path', '/app/androidcast_project/crashes'), '/'); + $qs = 'session_id=' . rawurlencode($sessionId); + if ($direct) { + $qs .= '&direct=1'; + } + return 'https://apps.f0xx.org' . $base . '/live/join?' . $qs; + } + + /** @return array */ + private static function makeShortLinks(string $joinLongUrl, string $directLongUrl): array { + $out = [ + 'join_short_url' => null, + 'join_qr_url' => null, + 'join_slug' => null, + 'direct_short_url' => null, + 'direct_qr_url' => null, + 'direct_slug' => null, + ]; + if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { + return $out; + } + $bearers = ShortLinksRepository::listBearers(); + if ($bearers === []) { + return $out; + } + $bearerId = (int) ($bearers[0]['id'] ?? 0); + if ($bearerId <= 0) { + return $out; + } + $join = ShortLinksRepository::createLink($bearerId, $joinLongUrl, 3600); + if (!empty($join['ok'])) { + $out['join_short_url'] = (string) ($join['short_url'] ?? ''); + $out['join_qr_url'] = (string) ($join['qr_url'] ?? ''); + $out['join_slug'] = (string) ($join['slug'] ?? ''); + } + $direct = ShortLinksRepository::createLink($bearerId, $directLongUrl, 3600); + if (!empty($direct['ok'])) { + $out['direct_short_url'] = (string) ($direct['short_url'] ?? ''); + $out['direct_qr_url'] = (string) ($direct['qr_url'] ?? ''); + $out['direct_slug'] = (string) ($direct['slug'] ?? ''); + } + return $out; + } + + /** @return list */ + private static function countByDay(PDO $pdo, string $table, string $msColumn, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(' . $msColumn . ' / 1000)))' + : "strftime('%Y-%m-%d', " . $msColumn . " / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, COUNT(*) AS c FROM ' . $table . ' + WHERE company_id = ? AND ' . $msColumn . ' >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => (int) ($row['c'] ?? 0)]; + } + return $out; + } + + /** @return list */ + private static function avgDurationByDay(PDO $pdo, int $startMs, int $companyId): array { + $day = Database::isMysql() + ? 'DATE(FROM_UNIXTIME(FLOOR(started_at_ms / 1000)))' + : "strftime('%Y-%m-%d', started_at_ms / 1000, 'unixepoch')"; + $stmt = $pdo->prepare( + 'SELECT ' . $day . ' AS d, + AVG(CASE WHEN ended_at_ms IS NOT NULL AND ended_at_ms >= started_at_ms + THEN (ended_at_ms - started_at_ms) / 1000.0 ELSE 0 END) AS v + FROM live_cast_sessions + WHERE company_id = ? AND started_at_ms >= ? + GROUP BY d ORDER BY d' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['x' => (string) ($row['d'] ?? ''), 'y' => round((float) ($row['v'] ?? 0), 1)]; + } + return $out; + } + + /** @return list> */ + public static function listSessions(int $days, array $actor, int $limit = 200): array { + self::ensureSchema(); + self::expireStale(); + $days = max(1, min(90, $days)); + $startMs = (int) ((time() - ($days * 86400)) * 1000); + $companyId = Rbac::activeCompanyId($actor) ?? Rbac::defaultCompanyId(); + $limit = max(1, min(500, $limit)); + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'SELECT s.session_id, s.owner_user_id, s.owner_username, s.device_id, s.platform, s.status, + s.started_at_ms, s.last_heartbeat_ms, s.ended_at_ms, s.max_duration_s, + s.join_short_url, s.direct_short_url, s.codec_video, s.codec_audio, s.bw_mode, + (SELECT COUNT(*) FROM live_cast_join_events j + WHERE j.session_id = s.session_id AND j.event_type IN (\'open\', \'join\')) AS join_opens + FROM live_cast_sessions s + WHERE s.company_id = ? AND s.started_at_ms >= ? + ORDER BY s.last_heartbeat_ms DESC + LIMIT ' . (int) $limit + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $normalized = self::normalizeSessionRow($row); + $normalized['join_opens'] = (int) ($row['join_opens'] ?? 0); + $out[] = $normalized; + } + return $out; + } + + /** @return array|null */ + public static function publicSession(string $sessionId): ?array { + $row = self::sessionById($sessionId); + if ($row === null) { + return null; + } + return [ + 'session_id' => $row['session_id'], + 'owner_username' => $row['owner_username'], + 'platform' => $row['platform'], + 'status' => $row['status'], + 'started_at_ms' => $row['started_at_ms'], + 'last_heartbeat_ms' => $row['last_heartbeat_ms'], + 'ended_at_ms' => $row['ended_at_ms'], + 'max_duration_s' => $row['max_duration_s'], + 'codec_video' => $row['codec_video'], + 'codec_audio' => $row['codec_audio'], + 'bw_mode' => $row['bw_mode'], + ]; + } + + /** @return list */ + private static function breakdown(PDO $pdo, string $table, string $column, int $startMs, int $companyId): array { + $allowedTables = ['live_cast_sessions', 'live_cast_join_events']; + if (!in_array($table, $allowedTables, true)) { + return []; + } + $allowedCols = ['platform', 'codec_video', 'codec_audio', 'bw_mode']; + if ($table === 'live_cast_join_events') { + $allowedCols = ['platform']; + } + if (!in_array($column, $allowedCols, true)) { + return []; + } + $timeColumn = $table === 'live_cast_sessions' ? 'started_at_ms' : 'created_at_ms'; + $stmt = $pdo->prepare( + 'SELECT COALESCE(NULLIF(' . $column . ', \'\'), \'unknown\') AS k, COUNT(*) AS c + FROM ' . $table . ' + WHERE company_id = ? AND ' . $timeColumn . ' >= ? + GROUP BY k ORDER BY c DESC' + ); + $stmt->execute([$companyId, $startMs]); + $out = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)]; + } + return $out; + } +} + diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index 207b651..f1987dc 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -64,6 +64,7 @@ require_once __DIR__ . '/UserRepository.php'; require_once __DIR__ . '/TicketRepository.php'; require_once __DIR__ . '/GraphRepository.php'; require_once __DIR__ . '/RemoteAccessRepository.php'; +require_once __DIR__ . '/LiveCastRepository.php'; require_once __DIR__ . '/UrlShortenerDatabase.php'; require_once __DIR__ . '/ShortLinksRepository.php'; require_once __DIR__ . '/WireGuardPeerProvisioner.php'; diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index efb6579..3f1d51b 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -29,6 +29,7 @@ + @@ -36,6 +37,9 @@ + + + @@ -106,15 +110,39 @@
  • + aria-label="Analytics" + title="Analytics"> - Graphs + Analytics Sessions, issues, and device activity.
  • +
  • + + + + Live sessions + Cast intents, join stats, and session history. + + +
  • +
  • + + + + Education demo + Browser screen-share trial (5 min). + + +
  • -

    AndroidCast graphs

    +

    AndroidCast analytics

    +
    +

    Live cast

    +
    +

    Live casts / day

    +

    Avg cast length (s) / day

    +

    Cast platforms

    +

    Join platforms

    +

    Video codecs

    +

    Audio codecs

    +

    Bandwidth modes

    +

    Top casters

    +
    +
    +
    + +
    + + +

    Loading…

    +

    Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).

    +
    + + + + + + + + + + + + + + +
    StatusUserPlatformVideoDurationLast heartbeatJoin opensLink
    +
    +
    @@ -739,6 +819,7 @@ + diff --git a/examples/crash_reporter/backend/views/live_education.php b/examples/crash_reporter/backend/views/live_education.php new file mode 100644 index 0000000..37b5ea8 --- /dev/null +++ b/examples/crash_reporter/backend/views/live_education.php @@ -0,0 +1,47 @@ + + + + + + + Education demo — <?= h(cfg('app_name')) ?> + + + + + + + +
    +
    + + Education demo +
    +
    +

    Try screen sharing in your browser

    +

    Free demo up to 5 minutes. No install required — great for first-time visitors.

    +

    Ready when you are.

    +

    Time left: 5:00

    + +
    + + + +
    +

    + Media is previewed locally only until the WebRTC viewer ships. Session metadata is recorded for analytics. +

    +
    +
    + + + + diff --git a/examples/crash_reporter/backend/views/live_join.php b/examples/crash_reporter/backend/views/live_join.php new file mode 100644 index 0000000..6a2dab3 --- /dev/null +++ b/examples/crash_reporter/backend/views/live_join.php @@ -0,0 +1,45 @@ + + + + + + + Join live cast — <?= h(cfg('app_name')) ?> + + + + + + + +
    +
    + + Live viewer +
    +
    +

    Join live cast

    +

    Loading session…

    +
      +
      +
      +
      + + + + diff --git a/examples/crash_reporter/backend/views/partials/cookie_consent.php b/examples/crash_reporter/backend/views/partials/cookie_consent.php new file mode 100644 index 0000000..0a0e5ed --- /dev/null +++ b/examples/crash_reporter/backend/views/partials/cookie_consent.php @@ -0,0 +1,15 @@ + +