letfixit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +33 -0
  2. package/README.md +226 -0
  3. package/adapters/android/build.gradle +28 -0
  4. package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
  5. package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
  6. package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
  7. package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
  8. package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
  9. package/adapters/flutter/lib/perf_analyzer.dart +73 -0
  10. package/adapters/flutter/lib/src/frame_observer.dart +45 -0
  11. package/adapters/flutter/lib/src/network_observer.dart +72 -0
  12. package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
  13. package/adapters/flutter/lib/src/universal_event.dart +50 -0
  14. package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
  15. package/adapters/flutter/pubspec.lock +184 -0
  16. package/adapters/flutter/pubspec.yaml +13 -0
  17. package/config/model.manifest.example.json +108 -0
  18. package/config/model.manifest.json +108 -0
  19. package/core/schema/universal_event.dart +63 -0
  20. package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
  21. package/dashboard/dist/index.html +118 -0
  22. package/package.json +62 -0
  23. package/scripts/encrypt-model.js +86 -0
  24. package/scripts/fill-manifest.js +67 -0
  25. package/scripts/load-env.js +20 -0
  26. package/scripts/model_crypto.js +14 -0
  27. package/scripts/setup-model.js +269 -0
  28. package/server/adb_bridge.js +201 -0
  29. package/server/adb_score.js +77 -0
  30. package/server/ai_proxy.js +373 -0
  31. package/server/analysis_engine.js +222 -0
  32. package/server/android_bridge.js +124 -0
  33. package/server/android_live.js +167 -0
  34. package/server/data/device_gpu_db.json +465 -0
  35. package/server/device_db.js +94 -0
  36. package/server/device_review.js +146 -0
  37. package/server/finding_generator.js +56 -0
  38. package/server/flutter_bridge.js +414 -0
  39. package/server/index.js +619 -0
  40. package/server/ios_bridge.js +181 -0
  41. package/server/model_config.js +54 -0
  42. package/server/report_template.js +169 -0
  43. package/server/session_manager.js +370 -0
  44. package/server/widget_gpu_classifier.js +77 -0
  45. package/server/widget_triage.js +78 -0
  46. package/setup.sh +160 -0
@@ -0,0 +1,222 @@
1
+ const { v4: uuidv4 } = require('uuid');
2
+
3
+ const THRESHOLDS = {
4
+ flutter: {
5
+ jank_ms: 16.6,
6
+ rebuild_per_second: 10,
7
+ api_limit_bytes: 512000,
8
+ memory_growth_mb: 5,
9
+ memory_growth_window_ms: 10000,
10
+ error_repeat_count: 3,
11
+ error_repeat_window_ms: 60000,
12
+ state_overnotify_per_second: 20,
13
+ },
14
+ android: {
15
+ jank_ms: 16.6,
16
+ anr_ms: 5000,
17
+ overdraw_per_second: 15,
18
+ api_limit_bytes: 512000,
19
+ crash_pattern_count: 2,
20
+ memory_leak_window_ms: 30000,
21
+ },
22
+ };
23
+
24
+ class AnalysisEngine {
25
+ constructor() {
26
+ this._rebuildCounts = new Map();
27
+ this._errorCounts = new Map();
28
+ this._stateCounts = new Map();
29
+ this._overdrawCounts = new Map();
30
+ this._memoryHistory = [];
31
+ this._androidMemoryHistory = [];
32
+ this._androidCrashCounts = new Map();
33
+ this._lastSecondFlush = Date.now();
34
+ }
35
+
36
+ processEvent(event) {
37
+ this._maybeFlushPerSecondCounters();
38
+
39
+ const p = event.platform;
40
+ if (p === 'flutter') return this._analyzeFlutter(event);
41
+ if (p === 'android') return this._analyzeAndroid(event);
42
+ return null;
43
+ }
44
+
45
+ _analyzeFlutter(e) {
46
+ if (e.event === 'frame') {
47
+ const ms = e.metric.value;
48
+ if (ms > THRESHOLDS.flutter.jank_ms) {
49
+ return this._finding('flutter', 'jank_detected', ms > 33 ? 'critical' : 'high', e,
50
+ 'Jank detected: frame build+layout+paint exceeded 16.6ms budget',
51
+ `Frame took ${ms}ms (${(ms - 16.6).toFixed(1)}ms over budget). Build: ${e.metric.build}ms, Layout: ${e.metric.layout}ms, Paint: ${e.metric.paint}ms. This causes dropped frames visible to users.`);
52
+ }
53
+ }
54
+
55
+ if (e.event === 'rebuild') {
56
+ const widget = e.widget || 'Unknown';
57
+ const count = (this._rebuildCounts.get(widget) || 0) + 1;
58
+ this._rebuildCounts.set(widget, count);
59
+ if (count > THRESHOLDS.flutter.rebuild_per_second) {
60
+ return this._finding('flutter', 'rebuild_storm', 'high', e,
61
+ `Rebuild storm: ${widget} rebuilt ${count} times per second`,
62
+ `Widget "${widget}" is rebuilding excessively (${count}x/s, budget: 10/s). Common causes: missing const constructors, setState called too high in the tree, or unoptimized ChangeNotifier.`);
63
+ }
64
+ }
65
+
66
+ if (e.event === 'api') {
67
+ const bytes = e.metric.value;
68
+ if (bytes > THRESHOLDS.flutter.api_limit_bytes) {
69
+ const kb = (bytes / 1024).toFixed(1);
70
+ return this._finding('flutter', 'api_bloat', 'high', e,
71
+ `API bloat: ${e.metric.url || 'endpoint'} returned ${kb}KB payload`,
72
+ `Response payload of ${kb}KB exceeds 500KB budget. Large payloads block the main thread during JSON parsing and inflate memory usage.`);
73
+ }
74
+ }
75
+
76
+ if (e.event === 'memory') {
77
+ const now = Date.now();
78
+ this._memoryHistory.push({ ts: now, value: e.metric.value });
79
+ this._memoryHistory = this._memoryHistory.filter(
80
+ (x) => now - x.ts <= THRESHOLDS.flutter.memory_growth_window_ms
81
+ );
82
+ if (this._memoryHistory.length >= 2) {
83
+ const oldest = this._memoryHistory[0];
84
+ const newest = this._memoryHistory[this._memoryHistory.length - 1];
85
+ const growthMb = (newest.value - oldest.value) / (1024 * 1024);
86
+ if (growthMb > THRESHOLDS.flutter.memory_growth_mb) {
87
+ return this._finding('flutter', 'memory_growth', 'high', e,
88
+ `Memory growth: heap grew ${growthMb.toFixed(1)}MB in 10 seconds`,
89
+ `Heap increased by ${growthMb.toFixed(1)}MB over 10s (budget: 5MB). Check for retained widget trees, unclosed streams, or image cache bloat.`);
90
+ }
91
+ }
92
+ }
93
+
94
+ if (e.event === 'error') {
95
+ const key = e.metric.message || e.widget || 'unknown';
96
+ const entry = this._errorCounts.get(key) || { count: 0, first: Date.now() };
97
+ entry.count += 1;
98
+ this._errorCounts.set(key, entry);
99
+ const windowAge = Date.now() - entry.first;
100
+ if (entry.count > THRESHOLDS.flutter.error_repeat_count && windowAge <= THRESHOLDS.flutter.error_repeat_window_ms) {
101
+ return this._finding('flutter', 'error_repeat', 'critical', e,
102
+ `Recurring error: "${key}" seen ${entry.count} times in 60s`,
103
+ `Exception "${key}" is repeating (${entry.count} times, budget: 3/min). This likely indicates an unhandled async error or a broken state machine.`);
104
+ }
105
+ }
106
+
107
+ if (e.event === 'state') {
108
+ const key = e.widget || 'global';
109
+ const count = (this._stateCounts.get(key) || 0) + 1;
110
+ this._stateCounts.set(key, count);
111
+ if (count > THRESHOLDS.flutter.state_overnotify_per_second) {
112
+ return this._finding('flutter', 'state_overnotify', 'high', e,
113
+ `State overnotify: ${key} fired ${count} state events/second`,
114
+ `Provider/ChangeNotifier "${key}" is notifying listeners ${count} times per second (budget: 20/s). Use select() or Selector to limit rebuilds to relevant state slices.`);
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ _analyzeAndroid(e) {
122
+ if (e.event === 'frame') {
123
+ const ms = e.metric.value;
124
+ if (ms > THRESHOLDS.android.jank_ms) {
125
+ return this._finding('android', 'jank_detected', ms > 33 ? 'critical' : 'high', e,
126
+ `Jank detected: Choreographer frame took ${ms}ms`,
127
+ `Frame duration ${ms}ms exceeds 16.6ms budget (${(ms - 16.6).toFixed(1)}ms over). Check for expensive work on the main thread: measure, layout, or draw calls in onDraw().`);
128
+ }
129
+ if (ms > THRESHOLDS.android.anr_ms) {
130
+ return this._finding('android', 'anr_risk', 'critical', e,
131
+ `ANR risk: main thread blocked for ${ms}ms`,
132
+ `Main thread blocked ${ms}ms (budget: 5000ms). ANR dialog will appear. Move disk I/O, network calls, or heavy computation to a background coroutine or thread.`);
133
+ }
134
+ }
135
+
136
+ if (e.event === 'frame' && e.widget) {
137
+ const view = e.widget;
138
+ const count = (this._overdrawCounts.get(view) || 0) + 1;
139
+ this._overdrawCounts.set(view, count);
140
+ if (count > THRESHOLDS.android.overdraw_per_second) {
141
+ return this._finding('android', 'overdraw', 'medium', e,
142
+ `Overdraw: view "${view}" invalidated ${count} times/second`,
143
+ `View "${view}" is being invalidated ${count} times/second (budget: 15/s). Use hardware layers or consolidate invalidation calls to reduce GPU overdraw.`);
144
+ }
145
+ }
146
+
147
+ if (e.event === 'memory') {
148
+ const now = Date.now();
149
+ this._androidMemoryHistory.push({ ts: now, value: e.metric.value });
150
+ this._androidMemoryHistory = this._androidMemoryHistory.filter(
151
+ (x) => now - x.ts <= THRESHOLDS.android.memory_leak_window_ms
152
+ );
153
+ if (this._androidMemoryHistory.length >= 3) {
154
+ const isGrowing = this._androidMemoryHistory.every(
155
+ (x, i, arr) => i === 0 || x.value >= arr[i - 1].value
156
+ );
157
+ if (isGrowing) {
158
+ const first = this._androidMemoryHistory[0];
159
+ const last = this._androidMemoryHistory[this._androidMemoryHistory.length - 1];
160
+ // Memory events carry PSS in BYTES (aligned with the Flutter path and the
161
+ // dashboard), so convert to MB before comparing against the 5MB budget.
162
+ const growthMb = (last.value - first.value) / (1024 * 1024);
163
+ if (growthMb > 5) {
164
+ return this._finding('android', 'memory_leak', 'critical', e,
165
+ `Memory leak: heap grew continuously for 30 seconds (+${growthMb.toFixed(1)}MB)`,
166
+ `PSS heap has grown monotonically by ${growthMb.toFixed(1)}MB over 30s. Check for Activity/Context leaks, unclosed cursors, or static references to views.`);
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ if (e.event === 'api') {
173
+ const bytes = e.metric.value;
174
+ if (bytes > THRESHOLDS.android.api_limit_bytes) {
175
+ const kb = (bytes / 1024).toFixed(1);
176
+ return this._finding('android', 'api_bloat', 'high', e,
177
+ `API bloat: ${e.metric.url || 'endpoint'} returned ${kb}KB`,
178
+ `Response payload ${kb}KB exceeds 500KB budget. Large JSON responses block Gson/Moshi parsing on the calling thread and inflate heap usage.`);
179
+ }
180
+ }
181
+
182
+ if (e.event === 'error') {
183
+ const key = e.widget || e.metric.message || 'unknown';
184
+ const count = (this._androidCrashCounts.get(key) || 0) + 1;
185
+ this._androidCrashCounts.set(key, count);
186
+ if (count > THRESHOLDS.android.crash_pattern_count) {
187
+ return this._finding('android', 'crash_pattern', 'critical', e,
188
+ `Crash pattern: "${key}" crashed ${count} times this session`,
189
+ `Exception type "${key}" has occurred ${count} times (budget: 2/session). Add a try/catch with proper error reporting, or fix the underlying null-safety or state issue.`);
190
+ }
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ _finding(platform, rule, severity, event, title, hypothesis) {
197
+ return {
198
+ id: uuidv4(),
199
+ platform,
200
+ rule,
201
+ severity,
202
+ widget: event.widget || null,
203
+ title,
204
+ metric: event.metric,
205
+ hypothesis,
206
+ timestamp: Date.now(),
207
+ aiSuggestion: null,
208
+ };
209
+ }
210
+
211
+ _maybeFlushPerSecondCounters() {
212
+ const now = Date.now();
213
+ if (now - this._lastSecondFlush >= 1000) {
214
+ this._rebuildCounts.clear();
215
+ this._stateCounts.clear();
216
+ this._overdrawCounts.clear();
217
+ this._lastSecondFlush = now;
218
+ }
219
+ }
220
+ }
221
+
222
+ module.exports = new AnalysisEngine();
@@ -0,0 +1,124 @@
1
+ const { WebSocketServer } = require('ws');
2
+ const { EventEmitter } = require('events');
3
+
4
+ const ANDROID_PORT = 7001;
5
+
6
+ class AndroidBridge extends EventEmitter {
7
+ constructor() {
8
+ super();
9
+ this._wss = null;
10
+ }
11
+
12
+ start() {
13
+ this._wss = new WebSocketServer({ port: ANDROID_PORT });
14
+
15
+ this._wss.on('listening', () => {
16
+ console.log(` 🤖 Android bridge listening on ws://localhost:${ANDROID_PORT}`);
17
+ });
18
+
19
+ this._wss.on('connection', (ws) => {
20
+ console.log(' ✅ Android SDK connected');
21
+ this.emit('status', { connected: true });
22
+ ws.on('message', (raw) => {
23
+ try {
24
+ const data = JSON.parse(raw);
25
+ const normalized = this._normalize(data);
26
+ if (normalized) this.emit('event', normalized);
27
+ } catch (_) {}
28
+ });
29
+ ws.on('close', () => {
30
+ console.log(' ⚠️ Android SDK disconnected');
31
+ this.emit('status', { connected: false });
32
+ });
33
+ });
34
+
35
+ this._wss.on('error', (err) => {
36
+ console.error(' ❌ Android bridge error:', err.message);
37
+ });
38
+ }
39
+
40
+ _normalize(data) {
41
+ const type = data.type || data.event;
42
+
43
+ if (type === 'frame') {
44
+ return {
45
+ platform: 'android',
46
+ event: 'frame',
47
+ timestamp: data.timestamp || Date.now(),
48
+ severity: data.durationMs > 33 ? 'critical' : data.durationMs > 16.6 ? 'high' : 'low',
49
+ widget: data.view || null,
50
+ metric: {
51
+ value: data.durationMs,
52
+ unit: 'ms',
53
+ budget: 16.6,
54
+ exceededBy: data.durationMs > 16.6 ? parseFloat((data.durationMs - 16.6).toFixed(2)) : 0,
55
+ },
56
+ context: { device: data.device, osVersion: data.osVersion },
57
+ raw: data,
58
+ };
59
+ }
60
+
61
+ if (type === 'memory') {
62
+ return {
63
+ platform: 'android',
64
+ event: 'memory',
65
+ timestamp: data.timestamp || Date.now(),
66
+ severity: data.totalPss > 300000 ? 'high' : 'low',
67
+ widget: null,
68
+ metric: {
69
+ // Emit bytes to match the Flutter path, the dashboard, and the engine's
70
+ // MB-based leak rule. Incoming totalPss is in KB.
71
+ value: (data.totalPss || 0) * 1024,
72
+ unit: 'bytes',
73
+ dalvikHeap: data.dalvikHeap,
74
+ nativeHeap: data.nativeHeap,
75
+ },
76
+ context: {},
77
+ raw: data,
78
+ };
79
+ }
80
+
81
+ if (type === 'api') {
82
+ return {
83
+ platform: 'android',
84
+ event: 'api',
85
+ timestamp: data.timestamp || Date.now(),
86
+ severity: data.responseBodySize > 512000 ? 'high' : data.durationMs > 500 ? 'medium' : 'low',
87
+ widget: null,
88
+ metric: {
89
+ value: data.responseBodySize,
90
+ unit: 'bytes',
91
+ durationMs: data.durationMs,
92
+ budget: 512000,
93
+ exceededBy: data.responseBodySize > 512000 ? data.responseBodySize - 512000 : 0,
94
+ url: data.url,
95
+ method: data.method,
96
+ },
97
+ context: {},
98
+ raw: data,
99
+ };
100
+ }
101
+
102
+ if (type === 'error') {
103
+ return {
104
+ platform: 'android',
105
+ event: 'error',
106
+ timestamp: data.timestamp || Date.now(),
107
+ severity: data.fatal ? 'critical' : 'high',
108
+ widget: data.className || null,
109
+ metric: {
110
+ value: 1,
111
+ unit: 'occurrence',
112
+ message: data.message,
113
+ stackTrace: data.stackTrace,
114
+ },
115
+ context: {},
116
+ raw: data,
117
+ };
118
+ }
119
+
120
+ return null;
121
+ }
122
+ }
123
+
124
+ module.exports = new AndroidBridge();
@@ -0,0 +1,167 @@
1
+ // ── Android native live monitor (over adb) ───────────────────────────────────
2
+ // The Android equivalent of the Flutter VM-Service dashboard: attach to a
3
+ // running debuggable app over adb and STREAM live metrics to the dashboard —
4
+ // no in-app SDK required. Emits the same normalized `event` objects the Flutter
5
+ // bridge does (platform:'android'), so the existing dashboard panels light up.
6
+ //
7
+ // • frames — poll `dumpsys gfxinfo <pkg> framestats` (real per-frame timings)
8
+ // • memory — poll `dumpsys meminfo <pkg>` (TOTAL PSS)
9
+ // • errors — tail `logcat --pid=<pid> *:E` (crashes / exceptions)
10
+ const { spawn } = require('child_process');
11
+ const { EventEmitter } = require('events');
12
+ const adb = require('./adb_bridge');
13
+
14
+ const FRAME_POLL_MS = 1000;
15
+ const MEM_POLL_MS = 2000;
16
+
17
+ class AndroidLive extends EventEmitter {
18
+ constructor() {
19
+ super();
20
+ this._reset();
21
+ }
22
+
23
+ _reset() {
24
+ this._serial = null;
25
+ this._pkg = null;
26
+ this._connected = false;
27
+ this._lastVsync = 0;
28
+ this._pid = null;
29
+ this._frameTimer = null;
30
+ this._memTimer = null;
31
+ this._logcat = null;
32
+ this._recentErrors = [];
33
+ }
34
+
35
+ get isConnected() { return this._connected; }
36
+
37
+ async start(serial, pkg) {
38
+ this.stop();
39
+ this._reset();
40
+ this._serial = serial;
41
+ this._pkg = pkg;
42
+ this._connected = true;
43
+ this.emit('status', { connected: true, pkg, serial });
44
+ console.log(` 📡 Android live monitor attached to ${pkg} (${serial}) — streaming frames/memory/errors`);
45
+
46
+ // Establish a fresh frame-stats baseline so we don't replay old frames.
47
+ await adb.resetGfx(serial, pkg).catch(() => {});
48
+ await this._ensureLogcat();
49
+
50
+ this._frameTimer = setInterval(() => this._pollFrames().catch(() => {}), FRAME_POLL_MS);
51
+ this._memTimer = setInterval(() => this._pollMemory().catch(() => {}), MEM_POLL_MS);
52
+ this._frameTimer.unref?.();
53
+ this._memTimer.unref?.();
54
+ }
55
+
56
+ stop() {
57
+ if (this._frameTimer) clearInterval(this._frameTimer);
58
+ if (this._memTimer) clearInterval(this._memTimer);
59
+ if (this._logcat) { try { this._logcat.kill(); } catch (_) {} }
60
+ if (this._connected) this.emit('status', { connected: false });
61
+ this._reset();
62
+ }
63
+
64
+ // ── Frames ──────────────────────────────────────────────────────────────────
65
+ async _pollFrames() {
66
+ if (!this._connected) return;
67
+ const out = await adb.shell(this._serial, ['dumpsys', 'gfxinfo', this._pkg, 'framestats']).catch(() => '');
68
+ if (!out) return;
69
+ const frames = adb.parseFramestats(out);
70
+ for (const f of frames) {
71
+ if (f.intendedVsync <= this._lastVsync) continue; // already emitted
72
+ this._lastVsync = f.intendedVsync;
73
+ const totalMs = f.totalMs;
74
+ this.emit('event', {
75
+ platform: 'android',
76
+ event: 'frame',
77
+ timestamp: Date.now(),
78
+ severity: totalMs > 33 ? 'critical' : totalMs > 16.6 ? 'high' : 'low',
79
+ widget: null,
80
+ metric: {
81
+ value: totalMs, unit: 'ms',
82
+ fps: totalMs > 0 ? Math.min(60, Math.round(1000 / totalMs)) : 60,
83
+ budget: 16.6,
84
+ exceededBy: totalMs > 16.6 ? parseFloat((totalMs - 16.6).toFixed(2)) : 0,
85
+ build: f.buildMs, raster: f.rasterMs,
86
+ },
87
+ context: {},
88
+ });
89
+ }
90
+ }
91
+
92
+ // ── Memory (bytes, to match the Flutter path + dashboard) ─────────────────────
93
+ async _pollMemory() {
94
+ if (!this._connected) return;
95
+ const out = await adb.shell(this._serial, ['dumpsys', 'meminfo', this._pkg]).catch(() => '');
96
+ if (!out) return;
97
+ const { pssMb } = adb.parseMeminfo(out);
98
+ if (pssMb == null) return;
99
+ this.emit('event', {
100
+ platform: 'android',
101
+ event: 'memory',
102
+ timestamp: Date.now(),
103
+ severity: 'low',
104
+ widget: null,
105
+ metric: { value: Math.round(pssMb * 1024 * 1024), unit: 'bytes', pssMb },
106
+ context: {},
107
+ });
108
+ }
109
+
110
+ // ── Errors (logcat) ───────────────────────────────────────────────────────────
111
+ async _ensureLogcat() {
112
+ if (this._logcat) return;
113
+ this._pid = await adb.pidOf(this._serial, this._pkg);
114
+ // Clear the backlog so we only stream errors from now on.
115
+ await adb.shell(this._serial, ['logcat', '-c']).catch(() => {});
116
+
117
+ // Prefer per-pid filtering when the app is running; else error-level for all.
118
+ const args = [...adb.target(this._serial), 'logcat', '-v', 'brief'];
119
+ if (this._pid) args.push(`--pid=${this._pid}`);
120
+ args.push('*:E');
121
+
122
+ const proc = spawn(adb.ADB, args, { stdio: ['ignore', 'pipe', 'ignore'] });
123
+ this._logcat = proc;
124
+ let buf = '';
125
+ proc.stdout.on('data', (chunk) => {
126
+ buf += chunk.toString();
127
+ const lines = buf.split('\n');
128
+ buf = lines.pop();
129
+ for (const line of lines) this._handleLogLine(line);
130
+ });
131
+ proc.on('error', () => { this._logcat = null; });
132
+ proc.on('exit', () => { this._logcat = null; });
133
+
134
+ // If the app wasn't running yet, retry pid + reattach shortly.
135
+ if (!this._pid) setTimeout(() => { if (this._connected) { this._logcat && this._logcat.kill(); this._logcat = null; this._ensureLogcat(); } }, 4000);
136
+ }
137
+
138
+ _handleLogLine(raw) {
139
+ const line = raw.trim();
140
+ if (!line || /^-+ beginning of/.test(line)) return;
141
+ // "E/AndroidRuntime( 1234): FATAL EXCEPTION: main" → strip tag/pid prefix.
142
+ const m = line.match(/^[VDIWEF]\/([^(]+)\(\s*\d+\):\s*(.*)$/);
143
+ const tag = m ? m[1].trim() : null;
144
+ const msg = (m ? m[2] : line).trim();
145
+ if (!msg) return;
146
+
147
+ const fatal = /FATAL EXCEPTION|ANR in|has died|FATAL SIGNAL/i.test(line) || tag === 'AndroidRuntime';
148
+ // Dedup identical messages within a short window.
149
+ const now = Date.now();
150
+ this._recentErrors = this._recentErrors.filter((e) => now - e.ts < 3000);
151
+ if (this._recentErrors.some((e) => e.msg === msg)) return;
152
+ this._recentErrors.push({ msg, ts: now });
153
+
154
+ this.emit('event', {
155
+ platform: 'android',
156
+ event: 'error',
157
+ timestamp: now,
158
+ severity: fatal ? 'critical' : 'high',
159
+ widget: tag,
160
+ title: msg.length > 120 ? msg.slice(0, 117) + '…' : msg,
161
+ metric: { message: msg, unit: 'error' },
162
+ context: {},
163
+ });
164
+ }
165
+ }
166
+
167
+ module.exports = new AndroidLive();