1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:39:09 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-21 11:21:56 +02:00
parent ee4947a348
commit e2d51fbb27
11 changed files with 591 additions and 38 deletions

View File

@@ -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);

View File

@@ -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<File> 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();
}

View File

@@ -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);

View File

@@ -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<String> 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<File> files = SessionStatsStore.listSessionFiles(context);
if (files.isEmpty()) {
return "";
}
return files.get(0).getName();
} catch (Exception e) {
return "";
}
}
}

View File

@@ -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());

View File

@@ -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("_"));
}
}