mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
crash handler snapshot, unverified
This commit is contained in:
19
examples/crash_reporter/ROADMAP.md
Normal file
19
examples/crash_reporter/ROADMAP.md
Normal 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)
|
||||
83
examples/crash_reporter/backend/README.md
Normal file
83
examples/crash_reporter/backend/README.md
Normal 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`).
|
||||
19
examples/crash_reporter/backend/config/config.example.php
Normal file
19
examples/crash_reporter/backend/config/config.example.php
Normal 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',
|
||||
];
|
||||
18
examples/crash_reporter/backend/config/config.php
Normal file
18
examples/crash_reporter/backend/config/config.php
Normal 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',
|
||||
];
|
||||
BIN
examples/crash_reporter/backend/data/crashes.sqlite
Normal file
BIN
examples/crash_reporter/backend/data/crashes.sqlite
Normal file
Binary file not shown.
40
examples/crash_reporter/backend/nginx.conf
Normal file
40
examples/crash_reporter/backend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
21
examples/crash_reporter/backend/public/api/upload.php
Normal file
21
examples/crash_reporter/backend/public/api/upload.php
Normal 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);
|
||||
}
|
||||
122
examples/crash_reporter/backend/public/assets/css/app.css
Normal file
122
examples/crash_reporter/backend/public/assets/css/app.css
Normal 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; }
|
||||
6
examples/crash_reporter/backend/public/assets/js/app.js
Normal file
6
examples/crash_reporter/backend/public/assets/js/app.js
Normal 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'));
|
||||
});
|
||||
59
examples/crash_reporter/backend/public/index.php
Normal file
59
examples/crash_reporter/backend/public/index.php
Normal 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';
|
||||
30
examples/crash_reporter/backend/sql/schema.mariadb.sql
Normal file
30
examples/crash_reporter/backend/sql/schema.mariadb.sql
Normal 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');
|
||||
29
examples/crash_reporter/backend/sql/schema.sqlite.sql
Normal file
29
examples/crash_reporter/backend/sql/schema.sqlite.sql
Normal 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
|
||||
52
examples/crash_reporter/backend/src/Auth.php
Normal file
52
examples/crash_reporter/backend/src/Auth.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
39
examples/crash_reporter/backend/src/Database.php
Normal file
39
examples/crash_reporter/backend/src/Database.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
examples/crash_reporter/backend/src/ReportRepository.php
Normal file
57
examples/crash_reporter/backend/src/ReportRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
examples/crash_reporter/backend/src/bootstrap.php
Normal file
41
examples/crash_reporter/backend/src/bootstrap.php
Normal 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;
|
||||
}
|
||||
82
examples/crash_reporter/backend/views/layout.php
Normal file
82
examples/crash_reporter/backend/views/layout.php
Normal 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&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&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>
|
||||
23
examples/crash_reporter/backend/views/login.php
Normal file
23
examples/crash_reporter/backend/views/login.php
Normal 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>
|
||||
50
examples/crash_reporter/backend/views/report_detail.php
Normal file
50
examples/crash_reporter/backend/views/report_detail.php
Normal 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>
|
||||
36
examples/crash_reporter/sample_crash_java.json
Normal file
36
examples/crash_reporter/sample_crash_java.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
examples/ota/mqtt-topics.md
Normal file
17
examples/ota/mqtt-topics.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# MQTT OTA topics (retained payloads)
|
||||
|
||||
Publish retained payloads for these topics:
|
||||
|
||||
- `v0/ota/channel/stable.json` -> content of `examples/ota/v0/ota/channel/stable.json`
|
||||
- `v0/ota/channel/current.json` -> content of `examples/ota/v0/ota/channel/current.json`
|
||||
- `v0/ota/channel/next.json` -> content of `examples/ota/v0/ota/channel/next.json`
|
||||
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json`
|
||||
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json`
|
||||
- `v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg`
|
||||
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json`
|
||||
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json`
|
||||
- `v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg`
|
||||
|
||||
Example source URL in app settings:
|
||||
|
||||
- `mqtt://foxx.org:1883/v0/ota/channel/stable.json`
|
||||
@@ -0,0 +1,2 @@
|
||||
PLACEHOLDER PACKAGE
|
||||
Replace this with the real release artifact bytes.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"build": 0,
|
||||
"versionName": "0.1.0",
|
||||
"apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00.otapkg",
|
||||
"signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_sign.json",
|
||||
"sizeBytes": 10485760,
|
||||
"mandatory": false,
|
||||
"releaseNotes": "Current production baseline."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
PLACEHOLDER PACKAGE
|
||||
Replace this with the next OTA candidate artifact bytes.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": 0,
|
||||
"minor": 1,
|
||||
"build": 1,
|
||||
"versionName": "0.1.1",
|
||||
"apkUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01.otapkg",
|
||||
"signUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_sign.json",
|
||||
"sizeBytes": 11534336,
|
||||
"mandatory": false,
|
||||
"releaseNotes": "Next OTA candidate with service-based updater."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/current.json
Normal file
4
examples/ota/v0/ota/channel/current.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.00/android_cast_00.01.00.00_manifest.json"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/next.json
Normal file
4
examples/ota/v0/ota/channel/next.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json"
|
||||
}
|
||||
4
examples/ota/v0/ota/channel/stable.json
Normal file
4
examples/ota/v0/ota/channel/stable.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema": "v0",
|
||||
"manifestUrl": "https://foxx.org/v0/ota/00/00.01/00.01.01/android_cast_00.01.01.01_manifest.json"
|
||||
}
|
||||
19
examples/settings.json
Normal file
19
examples/settings.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"ota": {
|
||||
"base_url": "https://foxx.org/v0/ota/channel/stable.json",
|
||||
"base_urls": [
|
||||
"https://foxx.org/v0/ota/channel/stable.json",
|
||||
"https://mirror.foxx.org/v0/ota/channel/stable.json",
|
||||
"mqtt://foxx.org:8443/ota/v0/channel/stable.json"
|
||||
],
|
||||
"trusted": "true",
|
||||
"check_interval": "1m"
|
||||
},
|
||||
"crash": {
|
||||
"enabled": "true",
|
||||
"upload_url": "https://f0xx.org/app/androidcast_project/crashes/api/upload.php",
|
||||
"prune_after_upload": "true",
|
||||
"upload_interval": "5m",
|
||||
"max_pending": 32
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user