1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00

crash handler snapshot, unverified

This commit is contained in:
Anton Afanasyeu
2026-05-20 14:31:55 +02:00
parent a4107797fa
commit d420c3e94a
77 changed files with 3932 additions and 87 deletions

View File

@@ -0,0 +1,19 @@
# Crash reporter roadmap
## Done (v0)
- Android `:crashwatcher` process uploads pending JSON
- Anonymous schema v1 (device, app, java/native stacks, fingerprint)
- PHP receiver + SQLite default + MariaDB option
- Admin console (login, reports list, grouping by fingerprint)
- `settings.json` crash section overrides
## Next
- [ ] User registration + email verification
- [ ] RBAC editor UI (delegate roles per module)
- [ ] Symbolication (ndk-stack / retrace) for native/Java stacks
- [ ] MQTT ingest endpoint (parallel to HTTP POST)
- [ ] Rate limiting + API keys per app build
- [ ] Export reports (CSV/JSON bundle)
- [ ] Alerting (webhook when new fingerprint appears)

View File

@@ -0,0 +1,83 @@
# Android Cast — crash reporter backend (PHP)
Anonymous crash JSON receiver and web console for
`https://f0xx.org/app/androidcast_project/crashes/`
## Requirements
- PHP 8.1+ with **PDO** (`pdo_sqlite` for default, `pdo_mysql` for MariaDB)
- `password_hash` / `session` (standard PHP build)
## Quick start (SQLite, development)
```bash
cd examples/crash_reporter/backend
cp config/config.example.php config/config.php
# For `php -S`, set 'base_path' => '' in config.php
mkdir -p data storage
sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql
php -S 127.0.0.1:8080 -t public
```
Open http://127.0.0.1:8080/ — login **admin** / **admin**.
Upload test payload:
```bash
curl -X POST http://127.0.0.1:8080/api/upload.php \
-H 'Content-Type: application/json' \
-d @../sample_crash_java.json
```
## Production (nginx + PHP-FPM)
1. Copy `backend/` to `/var/www/androidcast_crashes/`
2. Point nginx `root` to `.../public` (see `nginx.conf`)
3. Choose DB:
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
- **MariaDB:** create DB, run `sql/schema.mariadb.sql`, set `DB_DRIVER=mysql`
4. `chown -R www-data:www-data data storage`
5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed
## Android app configuration
In app-private `files/settings.json` (see `examples/settings.json`):
```json
"crash": {
"enabled": "true",
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
"prune_after_upload": "true",
"upload_interval": "5m"
}
```
Enable **Send anonymous crash logs** in the app settings drawer.
## Report JSON schema (v1)
See `sample_crash_java.json`. Key fields:
| Field | Purpose |
|-------|---------|
| `schema_version` | Always `1` for now |
| `report_id` | UUID from device |
| `generated_at_epoch_ms` | Device timestamp |
| `crash_type` | `java` or `native` |
| `fingerprint` | Grouping hash (top frames) |
| `device` | Manufacturer, model, SDK, ABIs |
| `app` | Package, version |
| `java` | Exception + `stack_frames[]` |
| `native` | Signal + `backtrace[]` |
Backend stores full JSON in `reports.payload_json` for faithful replay in the UI.
## Default accounts
| User | Password | Role |
|------|----------|------|
| admin | admin | root (all permissions) |
Change the admin password in production (update `users.password_hash`).

View File

@@ -0,0 +1,19 @@
<?php
return [
'app_name' => 'Android Cast Crashes',
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
'base_path' => '/app/androidcast_project/crashes',
'db' => [
'driver' => 'sqlite', // sqlite | mysql
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'androidcast_crashes',
'username' => 'androidcast',
'password' => 'change-me',
'charset' => 'utf8mb4',
],
],
'session_name' => 'ac_crash_sess',
];

View File

@@ -0,0 +1,18 @@
<?php
return [
'app_name' => 'Android Cast Crashes',
'base_path' => '/app/androidcast_project/crashes',
'db' => [
'driver' => 'sqlite', // sqlite | mysql
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'androidcast_crashes',
'username' => 'androidcast',
'password' => 'change-me',
'charset' => 'utf8mb4',
],
],
'session_name' => 'ac_crash_sess',
];

Binary file not shown.

View File

@@ -0,0 +1,40 @@
# Example nginx site for https://f0xx.org/app/androidcast_project/crashes/
# Adjust paths and upstream socket to your host.
server {
listen 443 ssl http2;
server_name f0xx.org;
# ssl_certificate /etc/letsencrypt/live/f0xx.org/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/f0xx.org/privkey.pem;
root /var/www/androidcast_crashes/public;
index index.php;
location /app/androidcast_project/crashes/ {
alias /var/www/androidcast_crashes/public/;
try_files $uri $uri/ /app/androidcast_project/crashes/index.php?$query_string;
}
location ~ ^/app/androidcast_project/crashes/api/upload\.php$ {
alias /var/www/androidcast_crashes/public/api/upload.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/androidcast_crashes/public/api/upload.php;
fastcgi_pass unix:/run/php-fpm/www.sock;
client_max_body_size 4m;
}
location ~ ^/app/androidcast_project/crashes/.+\.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
location ~ ^/app/androidcast_project/crashes/ {
try_files $uri /app/androidcast_project/crashes/index.php?$query_string;
}
location ~ /\. {
deny all;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty body'], 400);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
if ((int) ($data['schema_version'] ?? 0) !== 1) {
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
}
try {
ReportRepository::insert($data);
json_out(['ok' => true, 'report_id' => $data['report_id'] ?? null]);
} catch (Throwable $e) {
json_out(['ok' => false, 'error' => 'store failed'], 500);
}

View File

@@ -0,0 +1,122 @@
:root {
--bg: #0f1419;
--surface: #1a2332;
--border: #2d3a4f;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #3d8bfd;
--accent2: #5eead4;
--danger: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.shell { display: flex; min-height: calc(100vh - 40px); }
.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; }
.nav-pane {
width: 56px;
background: var(--surface);
border-right: 1px solid var(--border);
transition: width .2s ease;
overflow: hidden;
position: relative;
}
.nav-pane.open, .nav-pane:hover { width: 220px; }
.nav-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: ew-resize;
background: var(--border);
}
.nav-pane ul { list-style: none; margin: 48px 0 0; padding: 0 12px; }
.nav-pane li { margin: 8px 0; }
.nav-pane a {
display: block;
padding: 10px 12px;
border-radius: 8px;
color: var(--text);
white-space: nowrap;
}
.nav-pane a.active, .nav-pane a:hover {
background: rgba(61, 139, 253, .2);
text-decoration: none;
}
.nav-user {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
font-size: 12px;
color: var(--muted);
}
.main-pane { flex: 1; padding: 24px 28px; }
.bottom-bar {
height: 40px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 16px;
font-size: 12px;
color: var(--muted);
}
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
width: min(400px, 92vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.login-card input {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.login-card button {
padding: 12px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
.btn {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
margin-left: 8px;
}
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: left; }
.badge { background: var(--accent2); color: #042; padding: 2px 8px; border-radius: 999px; font-weight: 700; }
.stack { background: #0a0e14; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
.exc { color: var(--danger); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.muted { color: var(--muted); }
.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; }

View File

@@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', () => {
const nav = document.getElementById('nav-pane');
const handle = document.getElementById('nav-handle');
if (!nav || !handle) return;
handle.addEventListener('click', () => nav.classList.toggle('open'));
});

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/bootstrap.php';
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$base = Auth::basePath();
if ($base !== '' && str_starts_with($uri, $base)) {
$uri = substr($uri, strlen($base)) ?: '/';
}
$route = rtrim($uri, '/') ?: '/';
if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) {
require __DIR__ . '/api/upload.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
exit;
}
if ($route === '/login' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$user = trim($_POST['username'] ?? '');
$pass = $_POST['password'] ?? '';
if (Auth::login($user, $pass)) {
header('Location: ' . $base . '/');
exit;
}
$loginError = 'Invalid credentials';
require __DIR__ . '/../views/login.php';
exit;
}
if ($route === '/login') {
require __DIR__ . '/../views/login.php';
exit;
}
Auth::check();
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
$view = $_GET['view'] ?? 'home';
if ($view === 'report' && isset($_GET['id'])) {
$report = ReportRepository::getById((int) $_GET['id']);
if (!$report) {
http_response_code(404);
echo 'Not found';
exit;
}
$pageTitle = 'Report';
require __DIR__ . '/../views/layout.php';
exit;
}
$pageTitle = $view === 'home' ? 'Home' : 'Crash reports';
$reports = $view === 'reports' ? ReportRepository::listReports($grouped) : [];
require __DIR__ . '/../views/layout.php';

View File

@@ -0,0 +1,30 @@
CREATE DATABASE IF NOT EXISTS androidcast_crashes
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root','admin','viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
report_id CHAR(36) NOT NULL UNIQUE,
fingerprint CHAR(64) NOT NULL,
crash_type ENUM('java','native') NOT NULL,
generated_at_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
device_model VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
payload_json JSON NOT NULL,
KEY idx_reports_generated (generated_at_ms DESC),
KEY idx_reports_received (received_at_ms DESC),
KEY idx_reports_fingerprint (fingerprint)
) ENGINE=InnoDB;
INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');

View File

@@ -0,0 +1,29 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL UNIQUE,
fingerprint TEXT NOT NULL,
crash_type TEXT NOT NULL,
generated_at_ms INTEGER NOT NULL,
received_at_ms INTEGER NOT NULL,
device_model TEXT,
app_version TEXT,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint);
INSERT OR IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
-- password: admin

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
final class Auth {
public static function user(): ?array {
return $_SESSION['user'] ?? null;
}
public static function check(): void {
if (!self::user()) {
header('Location: ' . self::basePath() . '/login');
exit;
}
}
public static function can(string $action): bool {
$u = self::user();
if (!$u) {
return false;
}
$role = $u['role'] ?? 'viewer';
if ($role === 'root' || $role === 'admin') {
return true;
}
return $action === 'view';
}
public static function login(string $username, string $password): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
$stmt->execute([$username]);
$row = $stmt->fetch();
if (!$row || !password_verify($password, $row['password_hash'])) {
return false;
}
$_SESSION['user'] = [
'id' => (int) $row['id'],
'username' => $row['username'],
'role' => $row['role'],
];
return true;
}
public static function logout(): void {
unset($_SESSION['user']);
}
public static function basePath(): string {
$bp = cfg('base_path', '');
return rtrim($bp, '/');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
final class Database {
private static ?PDO $pdo = null;
public static function pdo(): PDO {
if (self::$pdo !== null) {
return self::$pdo;
}
$driver = cfg('db.driver', 'sqlite');
if ($driver === 'mysql') {
$m = cfg('db.mysql', []);
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$m['database'] ?? 'androidcast_crashes',
$m['charset'] ?? 'utf8mb4'
);
self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return self::$pdo;
}
$path = cfg('db.sqlite_path');
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
self::$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::$pdo->exec('PRAGMA foreign_keys = ON');
return self::$pdo;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
final class ReportRepository {
public static function insert(array $payload): void {
$pdo = Database::pdo();
$device = $payload['device'] ?? [];
$app = $payload['app'] ?? [];
$stmt = $pdo->prepare(
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$payload['report_id'] ?? uniqid('rpt_', true),
$payload['fingerprint'] ?? 'unknown',
$payload['crash_type'] ?? 'java',
(int) ($payload['generated_at_epoch_ms'] ?? 0),
(int) round(microtime(true) * 1000),
$device['model'] ?? null,
$app['version_name'] ?? null,
json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
]);
}
public static function listReports(bool $grouped): array {
$pdo = Database::pdo();
if ($grouped) {
$sql = 'SELECT fingerprint, crash_type,
MIN(device_model) AS device_model,
MAX(app_version) AS app_version,
COUNT(*) AS cnt,
MAX(generated_at_ms) AS last_generated_ms,
MAX(received_at_ms) AS last_received_ms
FROM reports
GROUP BY fingerprint, crash_type
ORDER BY cnt DESC, last_generated_ms DESC';
return $pdo->query($sql)->fetchAll();
}
$sql = 'SELECT id, report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version
FROM reports
ORDER BY generated_at_ms DESC, received_at_ms DESC
LIMIT 500';
return $pdo->query($sql)->fetchAll();
}
public static function getById(int $id): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM reports WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
$row['payload'] = json_decode($row['payload_json'], true);
return $row;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
$configPath = __DIR__ . '/../config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/../config/config.example.php';
}
$config = require $configPath;
session_name($config['session_name'] ?? 'ac_crash_sess');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/ReportRepository.php';
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
function h(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function json_out(array $data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
</head>
<body>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane">
<div class="nav-handle" id="nav-handle" title="Navigation"></div>
<ul>
<li><a href="<?= h(Auth::basePath()) ?>/?view=home" class="<?= ($view ?? '') === 'home' ? 'active' : '' ?>">Home</a></li>
<li><a href="<?= h(Auth::basePath()) ?>/?view=reports" class="<?= ($view ?? '') === 'reports' ? 'active' : '' ?>">Reports</a></li>
</ul>
<div class="nav-user">
<span><?= h(Auth::user()['username'] ?? '') ?></span>
<a href="<?= h(Auth::basePath()) ?>/logout">Logout</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<h1>Crash console</h1>
<p>Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
<ul>
<li>Upload API: <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li>Schema: <code>schema_version: 1</code></li>
</ul>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'reports'): ?>
<div class="toolbar">
<h1>Crash reports</h1>
<div class="toolbar-actions">
<a class="btn <?= empty($grouped) ? 'active' : '' ?>" href="<?= h(Auth::basePath()) ?>/?view=reports">By time</a>
<a class="btn <?= !empty($grouped) ? 'active' : '' ?>" href="<?= h(Auth::basePath()) ?>/?view=reports&amp;group=1">Grouped</a>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<?php if (!empty($grouped)): ?>
<th>Count</th><th>Fingerprint</th><th>Type</th><th>Last generated</th><th>Last received</th>
<?php else: ?>
<th>Generated</th><th>Received</th><th>Type</th><th>Device</th><th>App</th><th></th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($reports as $r): ?>
<tr>
<?php if (!empty($grouped)): ?>
<td><span class="badge"><?= (int) $r['cnt'] ?></span></td>
<td><code class="fp"><?= h(substr($r['fingerprint'], 0, 12)) ?>…</code></td>
<td><?= h($r['crash_type']) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['last_generated_ms']/1000))) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['last_received_ms']/1000))) ?></td>
<?php else: ?>
<td><?= h(date('Y-m-d H:i:s', (int)($r['generated_at_ms']/1000))) ?></td>
<td><?= h(date('Y-m-d H:i:s', (int)($r['received_at_ms']/1000))) ?></td>
<td><?= h($r['crash_type']) ?></td>
<td><?= h($r['device_model'] ?? '') ?></td>
<td><?= h($r['app_version'] ?? '') ?></td>
<td><a href="<?= h(Auth::basePath()) ?>/?view=report&amp;id=<?= (int)$r['id'] ?>">Open</a></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($reports)): ?>
<tr><td colspan="6" class="muted">No reports yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</div>
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
</head>
<body class="login-page">
<form class="login-card" method="post" action="<?= h(Auth::basePath()) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted">Sign in to view anonymous crash reports</p>
<?php if (!empty($loginError)): ?>
<div class="alert"><?= h($loginError) ?></div>
<?php endif; ?>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<p class="hint">Default: admin / admin</p>
<p class="hint"><a href="#">Register</a> (coming soon)</p>
</form>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<?php
$p = $report['payload'] ?? [];
$device = $p['device'] ?? [];
$app = $p['app'] ?? [];
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
$view = 'reports';
?>
<h1>Crash report</h1>
<p class="muted">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
<div class="cards">
<div class="card"><h3>Device</h3>
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
<?php if (!empty($device['abis'])): ?>
<p class="muted">ABIs: <?= h(implode(', ', (array)$device['abis'])) ?></p>
<?php endif; ?>
</div>
<div class="card"><h3>App</h3>
<p><?= h($app['package'] ?? '') ?></p>
<p>v<?= h($app['version_name'] ?? '') ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php if (!empty($p['build'])): $b = $p['build']; ?>
<p class="muted">Build: <?= h($b['git_commit'] ?? $b['git'] ?? '') ?></p>
<?php endif; ?>
</div>
<div class="card"><h3>Timing</h3>
<p>Generated: <?= h(date('c', (int)(($p['generated_at_epoch_ms'] ?? 0)/1000))) ?></p>
<p>Received: <?= h(date('c', (int)(($report['received_at_ms'] ?? 0)/1000))) ?></p>
<p class="muted">Fingerprint: <code><?= h($p['fingerprint'] ?? $report['fingerprint'] ?? '') ?></code></p>
</div>
</div>
<?php if (!empty($p['java'])): $j = $p['java']; ?>
<section class="stack-block">
<h2>Java exception</h2>
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></p>
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>
<?php endif; ?>
<?php if (!empty($p['native'])): $n = $p['native']; ?>
<section class="stack-block">
<h2>Native crash</h2>
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>
<?php endif; ?>
<?php if (!empty($p['session_context'])): ?>
<details><summary>Session context (crash)</summary><pre class="stack"><?= h(json_encode($p['session_context'], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?></pre></details>
<?php endif; ?>
<details><summary>Raw JSON</summary><pre class="raw-json stack"><?= h(json_encode($p, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) ?></pre></details>
<p><a href="<?= h(Auth::basePath()) ?>/?view=reports">← Back to list</a></p>

View File

@@ -0,0 +1,36 @@
{
"schema_version": 1,
"report_id": "00000000-0000-4000-8000-000000000001",
"generated_at_epoch_ms": 1710000000000,
"crash_type": "java",
"process": "main",
"fingerprint": "abc123",
"device": {
"manufacturer": "Doogee",
"brand": "DOOGEE",
"model": "Tab G6 Max",
"device": "TabG6Max",
"product": "TabG6Max",
"sdk_int": 34,
"release": "14",
"abis": ["arm64-v8a", "armeabi-v7a"]
},
"app": {
"package": "com.foxx.androidcast",
"version_name": "0.1.0",
"version_code": 100
},
"build": {
"debug": true,
"git_commit": "deadbeef"
},
"java": {
"thread": "main",
"exception": "java.lang.NullPointerException",
"message": "sample",
"stack_frames": [
"at com.foxx.androidcast.sender.ScreenCastService.onCreate(ScreenCastService.java:120)",
"at android.app.ActivityThread.handleCreateService(ActivityThread.java:1234)"
]
}
}