From e2d51fbb27d4fe6ddddc89e32847612a74ab7ab4 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 21 May 2026 11:21:56 +0200 Subject: [PATCH] snap --- AV_QUALITY_QA_SESSION.md | 275 ++++++++++++++++++ .../androidcast/crash/CrashReportBuilder.java | 20 +- .../androidcast/crash/CrashReportStore.java | 25 +- .../foxx/androidcast/crash/CrashReporter.java | 11 +- .../crash/CrashRuntimeContext.java | 108 +++++++ .../crash/CrashWatcherService.java | 12 +- .../crash/CrashReportStoreTest.java | 29 ++ docs/CRASH_REPORTER.md | 12 +- .../crash_reporter/sample_crash_java.json | 11 + pull_stats.sh | 74 +++-- scripts/header-post-commit.py | 52 ++++ 11 files changed, 591 insertions(+), 38 deletions(-) create mode 100644 AV_QUALITY_QA_SESSION.md create mode 100644 app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java create mode 100644 app/src/test/java/com/foxx/androidcast/crash/CrashReportStoreTest.java create mode 100755 scripts/header-post-commit.py diff --git a/AV_QUALITY_QA_SESSION.md b/AV_QUALITY_QA_SESSION.md new file mode 100644 index 0000000..2e52522 --- /dev/null +++ b/AV_QUALITY_QA_SESSION.md @@ -0,0 +1,275 @@ +# AV quality enhancement — Q&A session notes + +Research and recommendations for **movie cast** vs **video cast** quality enhancement in the Android Cast project. Combines user requirements, comparison tables, algorithm choices, placement (sender vs receiver), and fit with the current codebase. + +**Status:** Planning / research only — not implemented. +**Related code (today):** sender `ScreenCastService` → `VideoEncoder` / `AudioEncoder` → network; receiver network → `VideoDecoder` / `LibvpxCapableVideoDecoder` / `AudioDecoder` → `TextureView` / `AudioTrack`. + +--- + +## 1. User requirements (preliminary conditions) + +1. Content is either **movie cast** or **video cast** (live screen/camera); **both** must be supported with different tuning. +2. **Both** video and audio need quality enhancement. +3. Two processing shapes: + - **2.1 Temporal (N=2 frames/windows):** small gap between sequential video frames → predict/enhance using deltas between frames; same idea for audio (overlapping buffers). + - **2.2 Single frame/chunk:** static image from sink/source; one video frame; one audio PCM chunk. +4. **2.3 Reassembly:** output stream must keep **original PTS/DTS** (or fixed delay with uniform compensation); avoid changing frame/sample count on live paths unless explicitly designed. + +**Comparison dimensions requested:** implementation details, difficulty, realtime on modern ARM (GPU Android/Linux where applicable), input vs output quality, single vs 2-frame vs stream (with delay), multithreading. + +--- + +## 2. Mode overview + +| Mode | Video input | Audio input | Typical delay | Best when | +|------|-------------|-------------|---------------|-----------| +| **2.2 Single** | One buffer / frame | 10–40 ms PCM chunk | **0–1 frame** (~0–33 ms @ 30 fps) | Live **video cast**, UI, games | +| **2.1 Two-frame (N=2)** | Pair with small temporal gap | Overlapping windows | **~1 frame** (+ overlap) | Motion; mild temporal help | +| **Stream (3–8)** | Short FIFO | Ring + STFT state | **100–400 ms+** | **Movie cast**, VOD-like | +| **Heavy stream ML** | Long context | Streaming neural | **200 ms–2 s+** | Offline / non-live only | + +**Quality (typical):** stream/temporal > 2-frame > single frame +**Latency (best first):** single frame > 2-frame > stream + +**Policy split:** + +| Cast type | Video | Audio | +|-----------|-------|-------| +| **Video cast (live)** | 2.2 default; optional light 2.1 (+1 frame max) | 2.2 (**RNNoise** + limiter) | +| **Movie cast** | 2.1 or 3–4 frame **TNR** / **MCTF-lite** | 2-window spectral or **DeepFilterNet2** streaming | + +--- + +## 3. Video — method comparison + +| Method | Implementation | Difficulty | ARM @ 720p30 | ARM @ 1080p30 | GPU (Android/Linux) | Quality vs input | PTS/DTS | Multithread | +|--------|----------------|------------|--------------|---------------|---------------------|------------------|---------|-------------| +| **Single: bilateral / guided filter denoise** | GLES/Vulkan on YUV; NEON fallback | Low | Yes | Yes (tuned) | **High** | Small–medium | Trivial | Yes (tiles) | +| **Single: tone / CLAHE-lite** | LUT + luma histogram tiles | Low–med | Yes | Yes | Medium | Medium in flat scenes | Trivial | Yes | +| **Single: light SR (ESPCN-class tiny CNN)** | TFLite/NCNN → NNAPI/GPU | Medium | Maybe | Tight | High | Medium (edges/text) | Trivial | Yes (async infer) | +| **Single: heavy SR (Real-ESRGAN, SwinIR)** | Large CNN | High | No | No | BW-bound | High but slow | Trivial | Limited | +| **2-frame: DIS / Farneback flow + warp blend** | Flow on downscaled luma → warp prev | Medium | Yes | Maybe | Med–high | Medium | **+1 frame** | Yes | +| **2-frame: residual / delta enhance** | `enhanced = f(curr) + α·(curr − warped_prev)` | Medium | Yes | Maybe | Medium | Med–high on repetitive motion | **+1 frame** | Yes | +| **2-frame: RIFE-lite / FILM-small** | Interpolate between frames | Med–high | Borderline 720p | Unlikely 1080p30 | High if custom | High for large gaps | Timing care | Yes | +| **Stream: IIR TNR / motion-mask accumulate** | 3–4 frame buffer | Medium | Yes | Maybe | High | High static; trail risk | **+2–3 frames** | Yes | +| **Stream: ML temporal (VRT/RVRT-class)** | Stateful temporal CNN | Very high | No live | No | Medium | Very high | Buffer delay | Some | + +--- + +## 4. Audio — method comparison + +| Method | Implementation | Difficulty | ARM realtime 48 kHz | GPU | Quality vs input | PTS/DTS | Multithread | +|--------|----------------|------------|---------------------|-----|------------------|---------|-------------| +| **Single: DC block + EQ + limiter** | Biquads per chunk | Low | Yes | Low | Small | Preserve samples | Yes | +| **Single: RNNoise / DeepFilterNet (10 ms)** | TFLite/ONNX streaming | Medium | Yes mono; maybe stereo | NNAPI | High for noise | +10–20 ms | Yes (infer thread) | +| **2-window: Wiener / min-stats STFT** | 20 ms, 50% hop | Medium | Yes | Low | Med–high | +1 hop (~10 ms) | Yes | +| **2-window: PLC** | Predict from last 2 frames | Low–med | Yes | Low | High only on loss | Keep clock | Yes | +| **Stream: multiband + AGC** | Ring buffer envelopes | Low–med | Yes | Low | Medium intelligibility | 5–30 ms | Yes | +| **Stream: DeepFilterNet2** | Stateful STFT+GRU | Med–high | Yes modern ARM | NNAPI | High | 20–40 ms | Yes | + +--- + +## 5. Single vs 2-frame vs stream — decision matrix + +| Criterion | Single (2.2) | 2-frame (2.1) | Stream (3–8) | +|-----------|--------------|---------------|--------------| +| Implementation complexity | Lowest | Medium | Highest | +| Encoder integration | Easiest | +1 frame pipeline | Delay queue | +| ARM realtime 720p30 | **Best** | Good | Good (TNR); poor heavy ML | +| ARM realtime 1080p30 | Good | Marginal | Marginal | +| GPU fit | **Best** | Good (warp) | Good (TNR) | +| Quality gain (video) | Low–medium | Medium + motion | High static / medium action | +| Quality gain (audio) | Medium | Med–high | High steady noise | +| Artifact risk | Banding, oversharpen | Ghosting, warp error | Trails, smear | +| PTS/DTS | Trivial | Fixed +1 frame delay | Uniform delay all outputs | +| Multithread | Capture ∥ GPU ∥ encode | Flow ∥ enhance | Buffer thread + pool | +| **Movie cast** | OK quick polish | **Recommended** | **Recommended** if delay OK | +| **Video cast** | **Recommended** | Optional 1-frame | **Not recommended** live | + +--- + +## 6. PTS/DTS reassembly (2.3) + +| Approach | Description | Modes | +|----------|-------------|-------| +| **In-place payload replace** | Same PTS/DTS, duration; swap enhanced payload | Single | +| **Delay compensation** | Queue enhanced frames; release with **original PTS** | 2-frame, stream | +| **Composition timestamp** | `presentationTimeUs` from source → enhanced buffer (MediaCodec) | All on Android | +| **Audio** | Keep **sample count** constant → PTS unchanged | Prefer for live | + +**Rule:** Do not change frame/sample count on live cast unless explicit insert/drop policy exists. + +--- + +## 7. Pipeline placement: sender (2.1) vs receiver (2.2) + +### User question + +- **2.1 Sender:** enhancement between capture (camera/screen) and encoder. +- **2.2 Receiver:** enhancement between decoded stream and renderer. +- **User intuition:** 2.1 has limited value because encoder lossy output destroys pre-enhance detail; increases sender load. + +### Assessment + +| Claim | Verdict | +|-------|---------| +| Encoder “kills” sharpen/SR/local contrast pushed pre-encode | **True** | +| 2.1 is pointless | **False** for **denoise-before-encode** (helps bitrate/artifacts at same rate) | +| 2.1 burns sender CPU for little gain | **Often true** on thermally limited phones | + +### Preference (consensus from session) + +| Placement | Role | +|-----------|------| +| **Receiver (2.2)** | **Primary perceived quality** — deblock, denoise, mild sharpen; optional 2-frame for movie | +| **Sender (2.1)** | **Optional compression prep only** (bilateral/NLMeans denoise), not main beauty pipeline | +| **Both** | Receiver polishes; sender light or off on live video cast | + +**Current app hooks:** + +| Pipeline | Integration point | Difficulty (1=easy, 5=hard) | +|----------|-------------------|------------------------------| +| **2.2 Video receiver** | After `VideoDecoder` / `LibvpxCapableVideoDecoder`, before `TextureView` | **3–4** (new GLES: decode → FBO → display) | +| **2.2 Audio receiver** | `AudioDecoder` PCM before `AudioTrack` | **2** | +| **2.1 Video sender** | Between capture and `VideoEncoder.prepare()` Surface | **4–5** (intermediate EGL Surface, rotation) | +| **2.1 Audio sender** | Before `AudioEncoder` | **2–3** | +| **2-frame video** | Ring buffer +1 frame delay | **+1** on above | + +**Recommended implementation fork:** decoded-buffer → **GLES** → Surface (consistent for MediaCodec and libvpx VP9), not only `TextureView` overlay. + +--- + +## 8. Named algorithm pipelines (best practices) + +### A. Receiver — live video cast (default) + +**Video (2.2, GPU-first):** + +1. Post-decode **deblocking filter** (lite H.264-style / mild dering; VP9 loop filter already in decode) +2. **Guided filter** or **bilateral filter** (edge-preserving denoise) on luma +3. Mild **unsharp mask** (limit on blocky regions) +4. Optional: **gamma** / **BT.1886** + **CLAHE-lite** + +**Audio (2.2):** + +1. **RNNoise** (10 ms frames) +2. **Limiter** + high-pass (DC removal) +3. Optional: **SpeexDSP** `preprocess` (AGC, mild denoise) + +### B. Receiver — movie cast (+1 frame delay) + +**Video (2.1):** + +1. **DIS** or **Farneback** optical flow (downscaled) +2. **MCTF-lite** or **IIR temporal denoise** on stable regions +3. Spatial chain from A (guided/bilateral + mild unsharp) + +**Audio (2.1):** + +1. **STFT overlap-add** + **Wiener filter**, or **DeepFilterNet2** (streaming) +2. **Multiband compressor** + +### C. Sender — optional compression prep only + +**Video:** **Bilateral** or fast **NLMeans** only when bitrate-starved — **no** Real-ESRGAN / strong sharpen pre-encode. + +**Audio:** **SpeexDSP preprocess** or light **RNNoise** before AAC. + +### D. Avoid for realtime in this project + +| Algorithm | Reason | +|-----------|--------| +| **Real-ESRGAN**, **SwinIR**, **BasicVSR++** | Too heavy ARM @ 720p–1080p30 live | +| **RIFE** / **FILM** @ 1080p30 full rate | Borderline; movie-only, lower rate | +| **BM3D**, **VRT/RVRT** | Offline-tier | +| Heavy **2.1 sender SR** before VP9 | Encoder erases; thermals | + +--- + +## 9. Ranked preferences (quality benefit vs implementation in android cast) + +Descending order — build in this sequence: + +| Rank | Choice | Placement | Core algorithms | Quality benefit | Impl difficulty (this app) | +|------|--------|-----------|-----------------|-----------------|---------------------------| +| **1** | Receiver video **2.2** (GPU) | 2.2 | Guided/bilateral + deblock + mild unsharp | High vs blocky stream | **3–4** | +| **2** | Receiver audio **2.2** | 2.2 | RNNoise + limiter (+ SpeexDSP) | High for noise | **2** | +| **3** | Receiver video **2.1** (movie) | 2.2 + 1f delay | DIS/Farneback + MCTF-lite / IIR TNR + spatial | Higher static/grain | **4** | +| **4** | Receiver audio **2.1** (movie) | 2.2 | DeepFilterNet2 or Wiener STFT | Medium–high | **3** | +| **5** | Sender audio light prep | 2.1 | SpeexDSP / RNNoise → AAC | Low–medium | **2–3** | +| **6** | Sender video denoise only | 2.1 | Bilateral / fast NLMeans (not SR) | Low–medium (bitrate) | **4–5** | +| **7** | Sender video **2.1** beauty | 2.1 | Flow + enhance pre-encode | Low (washed by codec) | **5** | +| **8** | Heavy SR either side | either | Real-ESRGAN class | High offline only | **5**, not realtime | + +--- + +## 10. Multithreading pipeline (target architecture) + +``` +[Capture / Network decode] → queue → [Enhance worker(s)] → queue → [Encode / Render] + ↑ + N=2: previous frame + Stream: ring buffer +``` + +| Stage | Threads | Notes | +|-------|---------|-------| +| Capture / decode callback | 1 | Non-blocking | +| Enhance (GPU) | 1–2 | Double-buffered FBO / AHardwareBuffer | +| CPU fallback (NEON) | 2–4 | Tile-based | +| ML audio | 1 | Async from video | +| Encode / display | 1 | MediaCodec async; TextureView main | + +**Golden rule:** max **one frame** enhancement queue for **video cast**; **2–4 frames** for **movie cast**. + +Receiver already has `videoDecodeThread` in `ReceiverCastService` — natural host for GL enhance stage. + +--- + +## 11. Short answers to numbered conditions + +| # | Requirement | Preference | +|---|-------------|------------| +| 1 | Movie or video cast | Different latency policies; same codebase, different presets | +| 2 | Process both A+V | Shared audio chain; video policy switches by cast mode | +| 2.1.1 | 2-frame video deltas | **DIS/Farneback + residual** — not full RIFE on ARM live | +| 2.1.2 | 2-frame audio | Overlap-add STFT or streaming **DeepFilterNet** | +| 2.2.1 | Single image video | **GPU bilateral/guided + tone**; tiny SR @ 720p only if ever | +| 2.2.2 | Single chunk audio | **RNNoise + limiter** | +| 2.3 | PTS/DTS reassembly | In-place or fixed-delay queue; no timeline shift on live | + +--- + +## 12. Bottom line + +| Goal | Best option | +|------|-------------| +| Lowest latency + ARM-safe | **Single frame receiver (2.2)** + GPU shaders | +| Best quality per ms delay | **2-frame receiver (2.1)** + 2-window/stream audio | +| Best absolute quality (movie) | Small **stream buffer (3–4)** video TNR + streaming audio ML | +| Avoid realtime cast | Heavy per-frame SR, RIFE @ 1080p30, deep temporal video CNN | +| **Placement** | **Receiver-first**; sender **denoise-only** at most | + +**User’s encoder observation is correct for “beauty” enhancement pre-encode;** denoise-before-encode remains the only strong sender-side exception. + +--- + +## 13. Other work in same project session (context only) + +Unrelated to AV enhancement but done in parallel on this branch: + +- OTA optional `.otabundle.zip`, crash reporter (`:crashwatcher`), HEADER.txt automation, PHP crash backend — see `docs/OTA.md`, `docs/CRASH_REPORTER.md`, `examples/crash_reporter/`. + +--- + +## 14. Open decisions when implementation starts + +1. Enhance on **display path** (`TextureView`) vs **decode buffer → GLES → Surface** (recommended: latter). +2. User-toggle vs automatic preset for movie vs video cast. +3. Whether sender **denoise-only** preset is worth thermals on low-end devices. +4. GPU API: **OpenGL ES 3.x compute** vs **Vulkan** (ES3 often faster to integrate in Android media apps). + +--- + +*Document generated from AV quality Q&A session. Update as prototypes prove FPS/latency on target devices.* diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java index 5ef9646..48cf999 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReportBuilder.java @@ -25,7 +25,6 @@ import org.json.JSONObject; import java.security.MessageDigest; import java.util.Locale; -import java.util.UUID; /** Builds anonymous crash report JSON (schema v1). */ public final class CrashReportBuilder { @@ -85,19 +84,34 @@ public final class CrashReportBuilder { JSONObject root = new JSONObject(); try { long now = System.currentTimeMillis(); + String stamp = CrashReportStore.formatTimestamp(now); root.put("schema_version", 1); - root.put("report_id", UUID.randomUUID().toString()); + root.put("report_id", stamp); root.put("generated_at_epoch_ms", now); root.put("crash_type", crashType); - root.put("process", "main"); + root.put("scenario", CrashRuntimeContext.scenarioLabel()); + root.put("process", currentProcessName()); root.put("device", deviceJson()); root.put("app", appJson(context)); root.put("build", buildJson()); + root.put("runtime_context", CrashRuntimeContext.snapshot(context)); } catch (Exception ignored) { } return root; } + private static String currentProcessName() { + try { + Class at = Class.forName("android.app.ActivityThread"); + Object proc = at.getMethod("currentProcessName").invoke(null); + if (proc instanceof String && !((String) proc).isEmpty()) { + return (String) proc; + } + } catch (Exception ignored) { + } + return "main"; + } + private static JSONObject deviceJson() throws Exception { JSONObject d = new JSONObject(); d.put("manufacturer", Build.MANUFACTURER); diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java index 7c159e5..3363cd4 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReportStore.java @@ -17,16 +17,22 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.List; +import java.util.Locale; /** Pending/uploaded crash JSON in app-private storage (shared across processes). */ public final class CrashReportStore { private static final String ROOT = "crash_reports"; private static final String PENDING = "pending"; private static final String UPLOADED = "uploaded"; + /** Same stamp as {@code session_send_yyyyMMdd_HHmmss.json} / {@code session_recv_*.json}. */ + public static final String PREFIX = "crash_"; + private static final String SUFFIX = ".json"; private CrashReportStore() {} @@ -46,8 +52,21 @@ public final class CrashReportStore { return dir; } - public static File newPendingFile(File filesDir, String reportId) { - return new File(pendingDir(filesDir), "crash_" + reportId + ".json"); + public static String formatTimestamp(long epochMs) { + return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs)); + } + + /** {@code crash_yyyyMMdd_HHmmss.json} (suffix {@code _N} if the second collides). */ + public static File newPendingFile(File filesDir, long generatedAtMs) { + String base = PREFIX + formatTimestamp(generatedAtMs); + File dir = pendingDir(filesDir); + File candidate = new File(dir, base + SUFFIX); + int n = 1; + while (candidate.exists()) { + candidate = new File(dir, base + "_" + n + SUFFIX); + n++; + } + return candidate; } public static void writeJson(File file, JSONObject json) throws Exception { @@ -71,7 +90,7 @@ public final class CrashReportStore { public static List listPending(File filesDir) { File dir = pendingDir(filesDir); - File[] files = dir.listFiles((d, n) -> n.endsWith(".json")); + File[] files = dir.listFiles((d, n) -> n.startsWith(PREFIX) && n.endsWith(SUFFIX)); if (files == null || files.length == 0) { return Collections.emptyList(); } diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java index 9e8aea0..8f6aa35 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashReporter.java @@ -29,6 +29,9 @@ public final class CrashReporter { public static void install(Context context) { Context app = context.getApplicationContext(); + if (app instanceof android.app.Application) { + CrashRuntimeContext.init((android.app.Application) app); + } CrashNativeBridge.init(app); final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); @@ -70,8 +73,12 @@ public final class CrashReporter { return; } try { - String id = report.optString("report_id", String.valueOf(System.currentTimeMillis())); - File file = CrashReportStore.newPendingFile(context.getFilesDir(), id); + long generatedAt = report.optLong("generated_at_epoch_ms", System.currentTimeMillis()); + File file = CrashReportStore.newPendingFile(context.getFilesDir(), generatedAt); + report.put("report_id", file.getName().substring( + CrashReportStore.PREFIX.length(), + file.getName().length() - ".json".length())); + report.put("report_file", file.getName()); CrashReportStore.writeJson(file, report); CrashReportStore.pruneOldestPending(context.getFilesDir(), CrashSettingsStore.load(context).maxPendingFiles); diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java b/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java new file mode 100644 index 0000000..1f23f83 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashRuntimeContext.java @@ -0,0 +1,108 @@ +package com.foxx.androidcast.crash; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Bundle; + +import com.foxx.androidcast.CastActiveState; +import com.foxx.androidcast.stats.SessionStatsContext; +import com.foxx.androidcast.stats.SessionStatsRecorder; +import com.foxx.androidcast.stats.SessionStatsStore; + +import org.json.JSONObject; + +import java.io.File; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * App-wide runtime snapshot for crash reports (cast idle, UI foreground, last session file). + * Updated from {@link AndroidCastApplication} lifecycle and cast services. + */ +public final class CrashRuntimeContext { + private static final AtomicLong APP_STARTED_MS = new AtomicLong(0L); + private static final AtomicReference LAST_ACTIVITY = new AtomicReference<>(""); + private CrashRuntimeContext() {} + + public static void init(Application app) { + APP_STARTED_MS.compareAndSet(0L, System.currentTimeMillis()); + app.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { + @Override + public void onActivityResumed(Activity activity) { + LAST_ACTIVITY.set(activity.getClass().getSimpleName()); + } + + @Override + public void onActivityCreated(Activity a, Bundle b) {} + + @Override + public void onActivityStarted(Activity a) {} + + @Override + public void onActivityPaused(Activity a) {} + + @Override + public void onActivityStopped(Activity a) {} + + @Override + public void onActivitySaveInstanceState(Activity a, Bundle b) {} + + @Override + public void onActivityDestroyed(Activity a) {} + }); + } + + public static boolean isCastSessionActive() { + return CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening(); + } + + /** JSON attached to every crash report (with or without an active cast stats recorder). */ + public static JSONObject snapshot(Context context) { + JSONObject runtime = new JSONObject(); + try { + long started = APP_STARTED_MS.get(); + if (started <= 0L) { + started = System.currentTimeMillis(); + } + runtime.put("app_started_epoch_ms", started); + runtime.put("last_activity", LAST_ACTIVITY.get()); + runtime.put("sender_cast_active", CastActiveState.isSenderCasting()); + runtime.put("receiver_cast_active", CastActiveState.isReceiverListening()); + runtime.put("cast_active", isCastSessionActive()); + + SessionStatsRecorder active = SessionStatsContext.get(); + runtime.put("session_stats_active", active != null); + if (active != null) { + runtime.put("session_direction", active.getDirection()); + } + + String lastSession = lastSessionFileName(context); + if (!lastSession.isEmpty()) { + runtime.put("last_session_file", lastSession); + } + } catch (Exception ignored) { + } + return runtime; + } + + public static String scenarioLabel() { + if (SessionStatsContext.get() != null || isCastSessionActive()) { + return "cast_session"; + } + return "app_runtime"; + } + + private static String lastSessionFileName(Context context) { + try { + List files = SessionStatsStore.listSessionFiles(context); + if (files.isEmpty()) { + return ""; + } + return files.get(0).getName(); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java b/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java index 41af441..25c7bb2 100644 --- a/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java +++ b/app/src/main/java/com/foxx/androidcast/crash/CrashWatcherService.java @@ -127,8 +127,16 @@ public final class CrashWatcherService extends Service { signal = text.substring(sigIdx + 7, end > sigIdx ? end : text.length()).trim(); } JSONObject report = CrashReportBuilder.buildNativeCrash(this, signal, text); - String id = report.optString("report_id", stub.getName()); - CrashReportStore.writeJson(CrashReportStore.newPendingFile(filesDir, id), report); + long generatedAt = report.optLong("generated_at_epoch_ms", stub.lastModified()); + if (generatedAt <= 0L) { + generatedAt = System.currentTimeMillis(); + } + File pending = CrashReportStore.newPendingFile(filesDir, generatedAt); + report.put("report_id", pending.getName().substring( + CrashReportStore.PREFIX.length(), + pending.getName().length() - ".json".length())); + report.put("report_file", pending.getName()); + CrashReportStore.writeJson(pending, report); stub.delete(); } catch (Exception e) { Log.w(TAG, "ingest native stub failed: " + stub.getName()); diff --git a/app/src/test/java/com/foxx/androidcast/crash/CrashReportStoreTest.java b/app/src/test/java/com/foxx/androidcast/crash/CrashReportStoreTest.java new file mode 100644 index 0000000..00147f7 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/crash/CrashReportStoreTest.java @@ -0,0 +1,29 @@ +package com.foxx.androidcast.crash; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; + +public class CrashReportStoreTest { + @org.junit.Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void pendingFile_usesSessionStyleTimestamp() throws Exception { + File files = tmp.newFolder("files"); + long ms = 1_704_067_200_000L; // 2024-01-01 00:00:00 UTC — pattern only + File f = CrashReportStore.newPendingFile(files, ms); + assertTrue(f.getName().matches("crash_\\d{8}_\\d{6}\\.json")); + } + + @Test + public void formatTimestamp_matchesSessionStatsPattern() { + String stamp = CrashReportStore.formatTimestamp(171_000_000_0000L); + assertEquals(15, stamp.length()); + assertTrue(stamp.contains("_")); + } +} diff --git a/docs/CRASH_REPORTER.md b/docs/CRASH_REPORTER.md index 9e615ab..eceebfa 100644 --- a/docs/CRASH_REPORTER.md +++ b/docs/CRASH_REPORTER.md @@ -2,7 +2,8 @@ ## Android -- **Main process:** `CrashReporter.install()` — Java uncaught exceptions → JSON under `files/crash_reports/pending/`, session stats flushed with `globals.reason: "crash"`. +- **Main process:** `CrashReporter.install()` — any uncaught Java exception (cast or idle UI) → `files/crash_reports/pending/crash_yyyyMMdd_HHmmss.json` (same stamp as `session_*.json`). Active cast session stats are flushed with `globals.reason: "crash"` when applicable. +- **Runtime context:** every report includes `runtime_context` (foreground activity, `CastActiveState`, last `session_*.json` name) and `scenario` (`cast_session` vs `app_runtime`). - **Watcher process:** `CrashWatcherService` in `:crashwatcher` — reads `settings.json` → `crash`, uploads pending reports, ingests native stub files. - **Native:** `crash_hook.c` (unwind + `dladdr`) writes stubs to `crash_reports/native/`; watcher converts to schema v1 JSON. - **User toggle:** Settings drawer → **Send anonymous crash logs** (`AppPreferences.send_anonymous_crash_logs`, default on). @@ -24,3 +25,12 @@ Deploy tuning via app-private `files/settings.json` (see `examples/settings.json PHP console and ingest API: `examples/crash_reporter/backend/` — see [README](../examples/crash_reporter/backend/README.md). Roadmap: `examples/crash_reporter/ROADMAP.md`. + +## Report files + +| Pattern | Example | +|---------|---------| +| Pending crash JSON | `crash_20260520_153045.json` | +| Session stats (compare) | `session_send_20260520_120000.json` | + +JSON fields: `schema_version`, `report_id`, `report_file`, `generated_at_epoch_ms`, `scenario`, `runtime_context`, optional `session_context`, `java` / `native`, `device`, `app`, `build`, `fingerprint`. diff --git a/examples/crash_reporter/sample_crash_java.json b/examples/crash_reporter/sample_crash_java.json index 142096e..2666495 100644 --- a/examples/crash_reporter/sample_crash_java.json +++ b/examples/crash_reporter/sample_crash_java.json @@ -3,8 +3,19 @@ "report_id": "00000000-0000-4000-8000-000000000001", "generated_at_epoch_ms": 1710000000000, "crash_type": "java", + "scenario": "app_runtime", "process": "main", + "report_file": "crash_20260520_153000.json", "fingerprint": "abc123", + "runtime_context": { + "app_started_epoch_ms": 1710000000000, + "last_activity": "MainActivity", + "sender_cast_active": false, + "receiver_cast_active": false, + "cast_active": false, + "session_stats_active": false, + "last_session_file": "session_send_20260520_120000.json" + }, "device": { "manufacturer": "Doogee", "brand": "DOOGEE", diff --git a/pull_stats.sh b/pull_stats.sh index 5fe894f..b81632e 100755 --- a/pull_stats.sh +++ b/pull_stats.sh @@ -1,48 +1,68 @@ #!/bin/bash +set -u + DEVICES_LIST="$(adb devices -l)" echo "${DEVICES_LIST}" - -filter_out() { - local -n dev_string=${1} - [[ ${dev_string} =~ List* ]] && dev_string="" - return 0 -} - LOG_ROOT="log_capture/session_stats/" CLEAN_LOGS="no" while true; do - read -p "clean logs on device after fetch? [Y/n]" yn - case $yn in - [Y|Yes|y|yes]* ) + yn="" + read -rp "clean logs on device after fetch? [Y/n] " yn + yn="${yn,,}" + case "${yn}" in + y|yes) CLEAN_LOGS="yes" break - ;; - [N|No|n|no]* ) + ;; + n|no) break - ;; + ;; + "") + CLEAN_LOGS="yes" + break + ;; *) - echo "answer Y(es) or N(no) (short y/n or yes/no)" - ;; + echo "answer Y(es) or N(o) (y/n or yes/no)" + ;; esac done -echo "${DEVICES_LIST}" | while IFS= read -r DEV; do - SERIAL="$(echo "${DEV}" | awk '{print $1}')" - filter_out SERIAL - [ -z "${SERIAL}" ] && continue -# echo "Device: ${DEV}" -# echo "Serial: ${SERIAL}" - adb -s "${SERIAL}" shell run-as com.foxx.androidcast ls files/session_stats/ +echo "Known devices list:" +echo "${DEVICES_LIST}" + +while IFS= read -r DEV; do + [[ -z "${DEV}" ]] && continue + [[ "${DEV}" == List* ]] && continue + + SERIAL="$(awk '{print $1}' <<<"${DEV}")" + STATE="$(awk '{print $2}' <<<"${DEV}")" + [[ "${STATE}" != "device" ]] && continue + LOG_PATH="${LOG_ROOT}/${SERIAL}/" echo "------------ dumping in ${LOG_PATH}" mkdir -p "${LOG_PATH}" - for f in $(adb -s ${SERIAL} shell run-as com.foxx.androidcast ls files/session_stats/); do - adb -s ${SERIAL} exec-out run-as com.foxx.androidcast cat "files/session_stats/$f" > "${LOG_PATH}/$f" - [[ "no" != "${CLEAN_LOGS}" ]] && adb -s ${SERIAL} shell run-as com.foxx.androidcast rm -f "files/session_stats/$f" + + mapfile -t FILES < <(adb -s "${SERIAL}" shell run-as com.foxx.androidcast ls files/session_stats/ "${LOG_PATH}/${f}" + if [[ "${CLEAN_LOGS}" == "yes" ]]; then + adb -s "${SERIAL}" shell run-as com.foxx.androidcast rm -f "files/session_stats/${f}" int: + if hook_skip_requested(): + return 0 + + sha = git_head_sha() + if not sha: + return 0 + + touched: list[Path] = [] + for path in files_in_git_commit("HEAD"): + text = path.read_text(encoding="utf-8", errors="replace") + new_text = update_header_commit(text, path, sha) + if new_text != text: + path.write_text(new_text, encoding="utf-8", newline="\n") + touched.append(path) + + if not touched: + return 0 + + env = os.environ.copy() + env["HEADER_HOOK_SKIP"] = "1" + for path in touched: + rel = path.relative_to(REPO_ROOT).as_posix() + subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT, env=env) + subprocess.check_call( + ["git", "commit", "--amend", "--no-edit", "--no-verify"], + cwd=REPO_ROOT, + env=env, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())