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.
- package/.env.example +33 -0
- package/README.md +226 -0
- package/adapters/android/build.gradle +28 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
- package/adapters/flutter/lib/perf_analyzer.dart +73 -0
- package/adapters/flutter/lib/src/frame_observer.dart +45 -0
- package/adapters/flutter/lib/src/network_observer.dart +72 -0
- package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
- package/adapters/flutter/lib/src/universal_event.dart +50 -0
- package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
- package/adapters/flutter/pubspec.lock +184 -0
- package/adapters/flutter/pubspec.yaml +13 -0
- package/config/model.manifest.example.json +108 -0
- package/config/model.manifest.json +108 -0
- package/core/schema/universal_event.dart +63 -0
- package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
- package/dashboard/dist/index.html +118 -0
- package/package.json +62 -0
- package/scripts/encrypt-model.js +86 -0
- package/scripts/fill-manifest.js +67 -0
- package/scripts/load-env.js +20 -0
- package/scripts/model_crypto.js +14 -0
- package/scripts/setup-model.js +269 -0
- package/server/adb_bridge.js +201 -0
- package/server/adb_score.js +77 -0
- package/server/ai_proxy.js +373 -0
- package/server/analysis_engine.js +222 -0
- package/server/android_bridge.js +124 -0
- package/server/android_live.js +167 -0
- package/server/data/device_gpu_db.json +465 -0
- package/server/device_db.js +94 -0
- package/server/device_review.js +146 -0
- package/server/finding_generator.js +56 -0
- package/server/flutter_bridge.js +414 -0
- package/server/index.js +619 -0
- package/server/ios_bridge.js +181 -0
- package/server/model_config.js +54 -0
- package/server/report_template.js +169 -0
- package/server/session_manager.js +370 -0
- package/server/widget_gpu_classifier.js +77 -0
- package/server/widget_triage.js +78 -0
- package/setup.sh +160 -0
package/.env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ── Cloud AI fallback keys (optional — Nemotron runs locally by default) ──────
|
|
2
|
+
# Get a free Groq key at console.groq.com — fastest cloud fallback
|
|
3
|
+
GROQ_API_KEY=
|
|
4
|
+
GEMINI_API_KEY=
|
|
5
|
+
CLAUDE_API_KEY=
|
|
6
|
+
|
|
7
|
+
# ── Local AI ───────────────────────────────────────────────────────────────────
|
|
8
|
+
ENABLE_LOCAL_AI=true
|
|
9
|
+
LOCAL_AI_PORT=8080
|
|
10
|
+
|
|
11
|
+
# ── Dashboard ──────────────────────────────────────────────────────────────────
|
|
12
|
+
DASHBOARD_PORT=8081
|
|
13
|
+
|
|
14
|
+
# ── Flutter VM Service ─────────────────────────────────────────────────────────
|
|
15
|
+
# Set this to skip the port prompt on `npm start`.
|
|
16
|
+
# Run Flutter with: flutter run --vm-service-port=8181 --disable-service-auth-codes
|
|
17
|
+
FLUTTER_VM_URL=
|
|
18
|
+
|
|
19
|
+
# ── Ultron n0-beta model download (encrypted chunks) ───────────────────────────
|
|
20
|
+
# Decryption passphrase for the model chunks. NEVER commit this — set it locally
|
|
21
|
+
# and store it as a GitHub Actions secret for CI releases. Without it, `npm run
|
|
22
|
+
# setup` skips the automatic download and prints instructions.
|
|
23
|
+
PARITYFIX_MODEL_KEY=
|
|
24
|
+
# Optional: path to a chunk manifest (defaults to config/model.manifest.json).
|
|
25
|
+
PARITYFIX_MODEL_MANIFEST=
|
|
26
|
+
# Optional: max size per encrypted chunk in MB (default 300). Only affects the
|
|
27
|
+
# maintainer's `npm run encrypt-model` step — install reads whatever the manifest lists.
|
|
28
|
+
PARITYFIX_CHUNK_MB=300
|
|
29
|
+
|
|
30
|
+
# ── Team model cache (optional, legacy setup.sh path) ──────────────────────────
|
|
31
|
+
# Upload the model to Google Drive, share publicly (Anyone → Viewer), then paste
|
|
32
|
+
# the file ID here. Used by ./setup.sh (HuggingFace fallback path).
|
|
33
|
+
GDRIVE_MODEL_ID=
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# ParityFix v0.1.0 — AI-based realtime performance analysis & suggestions
|
|
2
|
+
|
|
3
|
+
A lightweight developer tool that connects to a running Flutter or Android app, collects real-time performance data, analyzes it with configurable rules, and shows AI-powered suggestions on a local dashboard at `http://localhost:7000`.
|
|
4
|
+
|
|
5
|
+
**Smart, low-CPU AI:** widgets are pre-scored deterministically in code first — only the genuinely expensive or ambiguous ones are escalated to the local LLM. Cheap widgets are auto-cleared without ever touching the model, and the local model is capped to half your CPU cores, so realtime profiling stays responsive.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install & model setup
|
|
10
|
+
|
|
11
|
+
**From npm (published):**
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g letfixit # or: npx letfixit
|
|
14
|
+
export PARITYFIX_MODEL_KEY=… # the decryption passphrase (enables the local model)
|
|
15
|
+
letfixit # branded startup → "Ultron n0-beta initializing…" → analyze menu
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**From source (clone):**
|
|
19
|
+
```bash
|
|
20
|
+
npm install # runs postinstall → sets up the local model automatically
|
|
21
|
+
npm start # branded startup → "Ultron n0-beta initializing…" → analyze menu
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The chunk manifest ships inside the package, so installs only need `PARITYFIX_MODEL_KEY`
|
|
25
|
+
to fetch + decrypt the weights. Without it, ParityFix still runs (cloud-fallback AI, or
|
|
26
|
+
no suggestions) — the model download is skipped, never fatal.
|
|
27
|
+
|
|
28
|
+
The local model — **Ultron n0-beta** — ships as **N AES-256-GCM-encrypted chunks of ≤300 MB each** (a ~2.6 GB model → ~10 chunks). Chunks can live on **multiple hosts** (each chunk has a primary `url` + optional `mirrors`), and downloads are **resumable** — a dropped connection continues from where it left off (HTTP Range), tries mirrors in order, and retries per chunk. On install, ParityFix downloads them (live progress bar), verifies each SHA-256, decrypts, merges, verifies the full-model checksum, and finalizes `bin/ultron/ultron-n0-beta.gguf`, then prints the model info + a short reliability disclaimer.
|
|
29
|
+
|
|
30
|
+
Chunk size is configurable via `PARITYFIX_CHUNK_MB` (default 300) at encrypt time.
|
|
31
|
+
|
|
32
|
+
Setup is **non-fatal**: if the chunk manifest or key isn't configured it prints instructions and continues (you can still use cloud fallback or `./setup.sh`). Retry any time with `npm run setup`.
|
|
33
|
+
|
|
34
|
+
**Two things must be present for the automatic download:**
|
|
35
|
+
- `config/model.manifest.json` — chunk URLs + IVs/checksums. Copy `config/model.manifest.example.json` and fill it in.
|
|
36
|
+
- `PARITYFIX_MODEL_KEY` — the decryption passphrase (env var). **Never commit it.**
|
|
37
|
+
|
|
38
|
+
**Maintainers — producing a release** (from a source `.gguf`):
|
|
39
|
+
```bash
|
|
40
|
+
PARITYFIX_MODEL_KEY='your-strong-passphrase' npm run encrypt-model -- path/to/model.gguf
|
|
41
|
+
# → dist/model-chunks/chunk.{0,1,2}.enc + model.manifest.json
|
|
42
|
+
# Upload the chunks (Drive / GitHub Release), paste their URLs into the manifest,
|
|
43
|
+
# copy it to config/model.manifest.json, and store the key as a GitHub Actions secret.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Security note:** a decryption key shipped inside a public package is only obfuscation, not real protection. The key is kept out of the repo (env var + CI secret); for true gating you'd deliver it from a backend at install time. The chunking + encryption here raises the bar and keeps the weights off the public repo, which is the practical goal for now.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Flutter Quick Start
|
|
51
|
+
|
|
52
|
+
**1. Add the package** to your `pubspec.yaml`:
|
|
53
|
+
```yaml
|
|
54
|
+
dependencies:
|
|
55
|
+
perf_analyzer:
|
|
56
|
+
path: /path/to/perf_analyzer/adapters/flutter
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**2. Start the bridge server** (run once, keep it running):
|
|
60
|
+
```bash
|
|
61
|
+
npm start
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**3. Call `start()` in your `main()`**:
|
|
65
|
+
```dart
|
|
66
|
+
import 'package:perf_analyzer/perf_analyzer.dart';
|
|
67
|
+
|
|
68
|
+
Future<void> main() async {
|
|
69
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
70
|
+
await PerfAnalyzer.start();
|
|
71
|
+
runApp(const MyApp());
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**4. Launch your app** with the VM service flag:
|
|
76
|
+
```bash
|
|
77
|
+
flutter run --enable-vmservice
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Open `http://localhost:7000` — data starts flowing immediately.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Android Quick Start
|
|
85
|
+
|
|
86
|
+
**1. Add the dependency** to your app `build.gradle`:
|
|
87
|
+
```groovy
|
|
88
|
+
dependencies {
|
|
89
|
+
implementation project(':perf_analyzer')
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**2. Start the bridge server**:
|
|
94
|
+
```bash
|
|
95
|
+
npm start
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**3. Initialize in your `Application` class**:
|
|
99
|
+
```kotlin
|
|
100
|
+
class MyApp : Application() {
|
|
101
|
+
override fun onCreate() {
|
|
102
|
+
super.onCreate()
|
|
103
|
+
PerfAnalyzer.start(this)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**4. (Optional) Add the OkHttp interceptor** to capture API calls:
|
|
109
|
+
```kotlin
|
|
110
|
+
val client = OkHttpClient.Builder()
|
|
111
|
+
.addInterceptor(PerfInterceptor())
|
|
112
|
+
.build()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Android native — live debug monitor (over adb, no SDK)
|
|
118
|
+
|
|
119
|
+
The Android counterpart to Flutter's live dashboard: attach to a **running app over adb** and stream metrics in real time — **no in-app SDK, no code changes**. Great for a debug build you're actively exercising.
|
|
120
|
+
|
|
121
|
+
**Requirements:** `adb` on PATH + a device/emulator with USB debugging enabled, and the app already running (or about to be).
|
|
122
|
+
|
|
123
|
+
**Run it:**
|
|
124
|
+
```bash
|
|
125
|
+
npm start # then choose option 2 — "Android native — live debug monitor"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Pick the device, confirm the package (the currently-foreground app is offered as the default), then open the dashboard and flip the platform toggle to **Android**. Live panels populate from:
|
|
129
|
+
- **Frames** — `dumpsys gfxinfo <pkg> framestats` parsed into real per-frame timings → FPS strip, frame-time chart (build vs raster), jank.
|
|
130
|
+
- **Memory** — `dumpsys meminfo <pkg>` (TOTAL PSS) → live memory chart.
|
|
131
|
+
- **Errors** — `logcat --pid=<pid> *:E` → crashes/exceptions in the error feed (FATAL EXCEPTION / ANR flagged critical).
|
|
132
|
+
|
|
133
|
+
The passive rule engine runs on this stream too, so jank / memory-growth / crash-pattern findings appear live in the Junk Feed. (API calls need the OkHttp interceptor — see the SDK section — since adb can't observe them.)
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Connected Device Performance Test (Android & iOS — no debug build needed)
|
|
138
|
+
|
|
139
|
+
Profile **any installed app** on a mobile plugged into the laptop — no SDK integration, no debuggable build. The laptop drives a launch → perform-flow → restart loop and analyzes GPU / render / raster.
|
|
140
|
+
|
|
141
|
+
- **Android** — reads the OS-side `dumpsys` counters (`gfxinfo`, `meminfo`, `cpuinfo`) Android keeps for every process. Captures render (jank, frame percentiles), memory (PSS), and CPU.
|
|
142
|
+
- **iOS** (physical device via `xcrun devicectl`, or the Simulator via `xcrun simctl`) — records an Instruments **Core Animation** trace per iteration (`xcrun xctrace`) and derives FPS → frame-time percentiles + jank. Memory/CPU show **n/a** (the render template doesn't carry them).
|
|
143
|
+
|
|
144
|
+
**Requirements:**
|
|
145
|
+
- Android: `adb` on PATH (platform-tools) + a device with **USB debugging** enabled & authorized. (`ADB_PATH` in `.env` overrides the adb location.)
|
|
146
|
+
- iOS: Xcode command-line tools (`xcrun`); a paired device or a booted Simulator.
|
|
147
|
+
|
|
148
|
+
**Run it:**
|
|
149
|
+
```bash
|
|
150
|
+
npm start # then choose option 2 — "Connected Device Performance Test"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The flow:
|
|
154
|
+
1. Pick a device — Android + iOS devices/simulators are merged into one list, tagged by platform.
|
|
155
|
+
2. Enter the app **bundle id** (`com.example.app` / `com.example.MyApp`) and a **module name** (`Login Flow`).
|
|
156
|
+
3. Choose iteration count (default **8**). Each iteration the app launches, **you perform the flow**, then press **Enter** to capture — stats are reset per run. The app is force-stopped/restarted between runs.
|
|
157
|
+
4. Metrics are **scored in code** (0–100; the overall renormalizes over whichever of render/memory/CPU were captured), issues detected, and — only if issues exist — the **local LLM** suggests concrete platform-specific fixes.
|
|
158
|
+
5. A self-contained **HTML report** (Chart.js charts, score, suggestions, metrics table) is written to `server/reports/` and opened in your browser. Also served at `http://localhost:8081/reports/<file>`.
|
|
159
|
+
|
|
160
|
+
> **iOS note:** the `xctrace` capture + trace-export parsing follows the Xcode 15+ CLI but hasn't been validated against live hardware from this build — the export schema varies by Xcode version, so `parseFpsExport()` in `ios_bridge.js` is the most likely spot to need a small tweak on your machine. If a trace yields no FPS data, iOS metrics degrade to `n/a` and the report still renders.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## AI Config (all optional)
|
|
165
|
+
|
|
166
|
+
Copy `.env.example` to `.env` and fill in the keys you have. All are optional — the dashboard works without any AI configured.
|
|
167
|
+
|
|
168
|
+
```env
|
|
169
|
+
GROQ_API_KEY=gsk_...
|
|
170
|
+
GEMINI_API_KEY=AIza...
|
|
171
|
+
CLAUDE_API_KEY=sk-ant-...
|
|
172
|
+
ENABLE_LOCAL_AI=true
|
|
173
|
+
LOCAL_AI_PORT=8080
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
AI routing order: **SmolLM2 (local)** → **Groq** → **Gemini** → **Claude**. The first available provider wins.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Local AI Setup (SmolLM2 360M — zero dependencies)
|
|
181
|
+
|
|
182
|
+
`./setup.sh` handles everything automatically. It downloads a single self-contained [llamafile](https://github.com/Mozilla-Ocho/llamafile) (~350MB, once) to `bin/smollm/smollm2.llamafile`. No external runtimes, no Homebrew, no CUDA drivers needed.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
./setup.sh # downloads SmolLM2 if not present
|
|
186
|
+
npm start # starts SmolLM2 on :8080 + bridge server on :7000
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`npm start` auto-spawns the llamafile in the background and kills it on `Ctrl+C`.
|
|
190
|
+
|
|
191
|
+
To skip local AI entirely, set `ENABLE_LOCAL_AI=false` in `.env` — the server will route directly to your configured cloud API key.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Dashboard Panels
|
|
196
|
+
|
|
197
|
+
| Panel | Platform | What it shows |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| **FPS Strip** | Both | Rolling 60-frame bar chart, current FPS, jank count |
|
|
200
|
+
| **Memory Monitor** | Both | Heap over time (line chart), growth rate badge |
|
|
201
|
+
| **Error Feed** | Both | Exceptions and crashes, newest first |
|
|
202
|
+
| **API Monitor** | Both | Endpoint, method, payload size, duration, status |
|
|
203
|
+
| **Rebuild Tracker** | Flutter | Widget rebuild counts/s, sorted by frequency |
|
|
204
|
+
| **Overdraw Map** | Android | View invalidation heatmap |
|
|
205
|
+
| **AI Suggestion Cards** | Both | CAUSE / FIX / CODE / GAIN per finding |
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## V0 Limitations
|
|
210
|
+
|
|
211
|
+
- Flutter adapter requires `--enable-vmservice` flag (not for production builds).
|
|
212
|
+
- Android WebSocket adapter uses `javax.websocket` — add the dependency manually for apps targeting API < 26.
|
|
213
|
+
- Local AI quality depends on quantization; cloud APIs give better suggestions.
|
|
214
|
+
- No persistence — findings reset when the server restarts.
|
|
215
|
+
- iOS not yet supported.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## V1 Roadmap
|
|
220
|
+
|
|
221
|
+
- [ ] iOS adapter (Xcode Instruments bridge)
|
|
222
|
+
- [ ] Persistent SQLite finding history
|
|
223
|
+
- [ ] pub.dev / Maven Central release
|
|
224
|
+
- [ ] `flutter pub run perf_analyzer` CLI setup command
|
|
225
|
+
- [ ] CI integration — fail build on critical findings
|
|
226
|
+
- [ ] Flame chart overlay for frame deep-dives
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'org.jetbrains.kotlin.android'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
android {
|
|
7
|
+
namespace 'dev.perfanalyzer'
|
|
8
|
+
compileSdk 34
|
|
9
|
+
|
|
10
|
+
defaultConfig {
|
|
11
|
+
minSdk 21
|
|
12
|
+
targetSdk 34
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
compileOptions {
|
|
16
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
17
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
kotlinOptions {
|
|
21
|
+
jvmTarget = '1.8'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
dependencies {
|
|
26
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
27
|
+
compileOnly 'com.squareup.okhttp3:okhttp:4.12.0'
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package dev.perfanalyzer
|
|
2
|
+
|
|
3
|
+
import android.view.Choreographer
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
class ChoreographerAdapter(private val onEvent: (JSONObject) -> Unit) : Choreographer.FrameCallback {
|
|
7
|
+
private var lastFrameTimeNanos: Long = 0
|
|
8
|
+
private var running = false
|
|
9
|
+
|
|
10
|
+
fun start() {
|
|
11
|
+
running = true
|
|
12
|
+
Choreographer.getInstance().postFrameCallback(this)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fun stop() {
|
|
16
|
+
running = false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun doFrame(frameTimeNanos: Long) {
|
|
20
|
+
if (!running) return
|
|
21
|
+
|
|
22
|
+
if (lastFrameTimeNanos != 0L) {
|
|
23
|
+
val durationMs = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000.0
|
|
24
|
+
val severity = when {
|
|
25
|
+
durationMs > 33 -> "critical"
|
|
26
|
+
durationMs > 16.6 -> "high"
|
|
27
|
+
else -> "low"
|
|
28
|
+
}
|
|
29
|
+
val event = JSONObject().apply {
|
|
30
|
+
put("platform", "android")
|
|
31
|
+
put("event", "frame")
|
|
32
|
+
put("timestamp", System.currentTimeMillis())
|
|
33
|
+
put("severity", severity)
|
|
34
|
+
put("type", "frame")
|
|
35
|
+
put("durationMs", durationMs)
|
|
36
|
+
}
|
|
37
|
+
onEvent(event)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
lastFrameTimeNanos = frameTimeNanos
|
|
41
|
+
Choreographer.getInstance().postFrameCallback(this)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package dev.perfanalyzer
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.io.BufferedReader
|
|
6
|
+
import java.io.InputStreamReader
|
|
7
|
+
|
|
8
|
+
class LogcatReader(private val onEvent: (JSONObject) -> Unit) {
|
|
9
|
+
private val tag = "PerfAnalyzer.LogcatReader"
|
|
10
|
+
private var process: Process? = null
|
|
11
|
+
private var thread: Thread? = null
|
|
12
|
+
private var running = false
|
|
13
|
+
|
|
14
|
+
fun start() {
|
|
15
|
+
running = true
|
|
16
|
+
thread = Thread {
|
|
17
|
+
try {
|
|
18
|
+
process = Runtime.getRuntime().exec(arrayOf("logcat", "-v", "time", "-s", "AndroidRuntime:E", "*:F"))
|
|
19
|
+
val reader = BufferedReader(InputStreamReader(process!!.inputStream))
|
|
20
|
+
var line: String?
|
|
21
|
+
while (running && reader.readLine().also { line = it } != null) {
|
|
22
|
+
line?.let { parseLine(it) }
|
|
23
|
+
}
|
|
24
|
+
} catch (e: Exception) {
|
|
25
|
+
Log.w(tag, "Logcat reader stopped: ${e.message}")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
thread!!.isDaemon = true
|
|
29
|
+
thread!!.start()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fun stop() {
|
|
33
|
+
running = false
|
|
34
|
+
process?.destroy()
|
|
35
|
+
thread?.interrupt()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private fun parseLine(line: String) {
|
|
39
|
+
val isException = line.contains("Exception") || line.contains("FATAL EXCEPTION")
|
|
40
|
+
val isError = line.contains("Error:") && !line.contains("No error")
|
|
41
|
+
if (!isException && !isError) return
|
|
42
|
+
|
|
43
|
+
val fatal = line.contains("FATAL")
|
|
44
|
+
val className = extractClassName(line)
|
|
45
|
+
val message = line.trim()
|
|
46
|
+
|
|
47
|
+
val event = JSONObject().apply {
|
|
48
|
+
put("type", "error")
|
|
49
|
+
put("platform", "android")
|
|
50
|
+
put("timestamp", System.currentTimeMillis())
|
|
51
|
+
put("severity", if (fatal) "critical" else "high")
|
|
52
|
+
put("fatal", fatal)
|
|
53
|
+
put("className", className)
|
|
54
|
+
put("message", message.take(200))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onEvent(event)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun extractClassName(line: String): String {
|
|
61
|
+
val exceptionPattern = Regex("""([A-Za-z.]+Exception|[A-Za-z.]+Error)""")
|
|
62
|
+
return exceptionPattern.find(line)?.value?.substringAfterLast('.') ?: "UnknownError"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package dev.perfanalyzer
|
|
2
|
+
|
|
3
|
+
import android.os.Debug
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.util.concurrent.Executors
|
|
6
|
+
import java.util.concurrent.ScheduledFuture
|
|
7
|
+
import java.util.concurrent.TimeUnit
|
|
8
|
+
|
|
9
|
+
class MemoryObserver(private val onEvent: (JSONObject) -> Unit) {
|
|
10
|
+
private val scheduler = Executors.newSingleThreadScheduledExecutor()
|
|
11
|
+
private var future: ScheduledFuture<*>? = null
|
|
12
|
+
|
|
13
|
+
fun start() {
|
|
14
|
+
future = scheduler.scheduleAtFixedRate({
|
|
15
|
+
val memInfo = Debug.MemoryInfo()
|
|
16
|
+
Debug.getMemoryInfo(memInfo)
|
|
17
|
+
|
|
18
|
+
val totalPss = memInfo.totalPss
|
|
19
|
+
val dalvikHeap = memInfo.dalvikPss
|
|
20
|
+
val nativeHeap = memInfo.nativePss
|
|
21
|
+
|
|
22
|
+
val severity = when {
|
|
23
|
+
totalPss > 300_000 -> "critical"
|
|
24
|
+
totalPss > 150_000 -> "high"
|
|
25
|
+
else -> "low"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
val event = JSONObject().apply {
|
|
29
|
+
put("type", "memory")
|
|
30
|
+
put("platform", "android")
|
|
31
|
+
put("timestamp", System.currentTimeMillis())
|
|
32
|
+
put("severity", severity)
|
|
33
|
+
put("totalPss", totalPss)
|
|
34
|
+
put("dalvikHeap", dalvikHeap)
|
|
35
|
+
put("nativeHeap", nativeHeap)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onEvent(event)
|
|
39
|
+
}, 0, 2, TimeUnit.SECONDS)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fun stop() {
|
|
43
|
+
future?.cancel(false)
|
|
44
|
+
scheduler.shutdown()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package dev.perfanalyzer
|
|
2
|
+
|
|
3
|
+
import okhttp3.Interceptor
|
|
4
|
+
import okhttp3.Response
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
|
|
7
|
+
class PerfInterceptor : Interceptor {
|
|
8
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
9
|
+
val request = chain.request()
|
|
10
|
+
val start = System.currentTimeMillis()
|
|
11
|
+
|
|
12
|
+
val requestBodySize = try {
|
|
13
|
+
val copy = request.newBuilder().build()
|
|
14
|
+
val buffer = okio.Buffer()
|
|
15
|
+
copy.body?.writeTo(buffer)
|
|
16
|
+
buffer.size
|
|
17
|
+
} catch (_: Exception) {
|
|
18
|
+
0L
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
val response = chain.proceed(request)
|
|
22
|
+
val durationMs = System.currentTimeMillis() - start
|
|
23
|
+
|
|
24
|
+
val responseBody = response.body
|
|
25
|
+
val responseBytes = responseBody?.bytes() ?: ByteArray(0)
|
|
26
|
+
val responseBodySize = responseBytes.size.toLong()
|
|
27
|
+
|
|
28
|
+
val severity = when {
|
|
29
|
+
responseBodySize > 512000 -> "high"
|
|
30
|
+
durationMs > 500 -> "medium"
|
|
31
|
+
else -> "low"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
val event = JSONObject().apply {
|
|
35
|
+
put("type", "api")
|
|
36
|
+
put("platform", "android")
|
|
37
|
+
put("timestamp", System.currentTimeMillis())
|
|
38
|
+
put("severity", severity)
|
|
39
|
+
put("url", request.url.toString())
|
|
40
|
+
put("method", request.method)
|
|
41
|
+
put("requestBodySize", requestBodySize)
|
|
42
|
+
put("responseBodySize", responseBodySize)
|
|
43
|
+
put("durationMs", durationMs)
|
|
44
|
+
put("statusCode", response.code)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
PerfAnalyzer.sendEvent(event)
|
|
48
|
+
|
|
49
|
+
return response.newBuilder()
|
|
50
|
+
.body(okhttp3.ResponseBody.create(responseBody?.contentType(), responseBytes))
|
|
51
|
+
.build()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package dev.perfanalyzer
|
|
2
|
+
|
|
3
|
+
import android.app.Application
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
import java.net.URI
|
|
9
|
+
import javax.websocket.*
|
|
10
|
+
|
|
11
|
+
data class PerfConfig(
|
|
12
|
+
val groqKey: String? = null,
|
|
13
|
+
val geminiKey: String? = null,
|
|
14
|
+
val claudeKey: String? = null,
|
|
15
|
+
val port: Int = 7000,
|
|
16
|
+
val enableLocalAI: Boolean = true
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
object PerfAnalyzer {
|
|
20
|
+
private const val TAG = "PerfAnalyzer"
|
|
21
|
+
private var ws: Session? = null
|
|
22
|
+
private var config: PerfConfig = PerfConfig()
|
|
23
|
+
private var choreographerAdapter: ChoreographerAdapter? = null
|
|
24
|
+
private var memoryObserver: MemoryObserver? = null
|
|
25
|
+
private var logcatReader: LogcatReader? = null
|
|
26
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
27
|
+
private var running = false
|
|
28
|
+
|
|
29
|
+
fun start(context: Application, config: PerfConfig = PerfConfig()) {
|
|
30
|
+
if (running) return
|
|
31
|
+
running = true
|
|
32
|
+
PerfAnalyzer.config = config
|
|
33
|
+
|
|
34
|
+
connectWebSocket()
|
|
35
|
+
|
|
36
|
+
choreographerAdapter = ChoreographerAdapter { event -> sendEvent(event) }
|
|
37
|
+
mainHandler.post { choreographerAdapter!!.start() }
|
|
38
|
+
|
|
39
|
+
memoryObserver = MemoryObserver { event -> sendEvent(event) }
|
|
40
|
+
memoryObserver!!.start()
|
|
41
|
+
|
|
42
|
+
logcatReader = LogcatReader { event -> sendEvent(event) }
|
|
43
|
+
logcatReader!!.start()
|
|
44
|
+
|
|
45
|
+
Log.i(TAG, "🔍 Perf Analyzer connected")
|
|
46
|
+
Log.i(TAG, "📊 Dashboard → http://localhost:${config.port}")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun stop() {
|
|
50
|
+
running = false
|
|
51
|
+
choreographerAdapter?.stop()
|
|
52
|
+
memoryObserver?.stop()
|
|
53
|
+
logcatReader?.stop()
|
|
54
|
+
ws?.close()
|
|
55
|
+
ws = null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
internal fun sendEvent(event: JSONObject) {
|
|
59
|
+
try {
|
|
60
|
+
val session = ws
|
|
61
|
+
if (session != null && session.isOpen) {
|
|
62
|
+
session.basicRemote.sendText(event.toString())
|
|
63
|
+
}
|
|
64
|
+
} catch (e: Exception) {
|
|
65
|
+
Log.w(TAG, "Failed to send event: ${e.message}")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun connectWebSocket() {
|
|
70
|
+
Thread {
|
|
71
|
+
try {
|
|
72
|
+
val container = ContainerProvider.getWebSocketContainer()
|
|
73
|
+
val uri = URI("ws://localhost:7001")
|
|
74
|
+
ws = container.connectToServer(object : Endpoint() {
|
|
75
|
+
override fun onOpen(session: Session, config: EndpointConfig) {
|
|
76
|
+
Log.i(TAG, "WebSocket connected to bridge")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun onClose(session: Session, closeReason: CloseReason) {
|
|
80
|
+
Log.w(TAG, "WebSocket closed: $closeReason")
|
|
81
|
+
if (running) {
|
|
82
|
+
Thread.sleep(3000)
|
|
83
|
+
connectWebSocket()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun onError(session: Session, thr: Throwable) {
|
|
88
|
+
Log.e(TAG, "WebSocket error: ${thr.message}")
|
|
89
|
+
}
|
|
90
|
+
}, ClientEndpointConfig.Builder.create().build(), uri)
|
|
91
|
+
} catch (e: Exception) {
|
|
92
|
+
Log.e(TAG, "WebSocket connection failed: ${e.message}")
|
|
93
|
+
if (running) {
|
|
94
|
+
Thread.sleep(3000)
|
|
95
|
+
connectWebSocket()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}.start()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:convert';
|
|
3
|
+
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
4
|
+
import 'src/universal_event.dart';
|
|
5
|
+
import 'src/vm_service_adapter.dart';
|
|
6
|
+
import 'src/frame_observer.dart';
|
|
7
|
+
import 'src/rebuild_observer.dart';
|
|
8
|
+
import 'src/network_observer.dart';
|
|
9
|
+
|
|
10
|
+
export 'src/universal_event.dart';
|
|
11
|
+
export 'src/network_observer.dart' show PerfHttpClient;
|
|
12
|
+
|
|
13
|
+
class PerfAnalyzer {
|
|
14
|
+
static VMServiceAdapter? _vmAdapter;
|
|
15
|
+
static FrameObserver? _frameObserver;
|
|
16
|
+
static RebuildObserver? _rebuildObserver;
|
|
17
|
+
static NetworkObserver? _networkObserver;
|
|
18
|
+
static WebSocketChannel? _channel;
|
|
19
|
+
static final List<StreamSubscription> _subs = [];
|
|
20
|
+
|
|
21
|
+
static Future<void> start({PerfConfig config = const PerfConfig()}) async {
|
|
22
|
+
final uri = Uri.parse('ws://127.0.0.1:${config.port}/ws');
|
|
23
|
+
_channel = WebSocketChannel.connect(uri);
|
|
24
|
+
|
|
25
|
+
void send(UniversalEvent event) {
|
|
26
|
+
try {
|
|
27
|
+
_channel?.sink.add(jsonEncode(event.toJson()));
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_vmAdapter = VMServiceAdapter();
|
|
32
|
+
try {
|
|
33
|
+
await _vmAdapter!.connect();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
print('⚠️ VM Service unavailable: $e');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_frameObserver = FrameObserver(_vmAdapter!);
|
|
39
|
+
_rebuildObserver = RebuildObserver(_vmAdapter!);
|
|
40
|
+
_networkObserver = NetworkObserver();
|
|
41
|
+
|
|
42
|
+
_subs.add(_frameObserver!.events.listen(send));
|
|
43
|
+
_subs.add(_rebuildObserver!.events.listen(send));
|
|
44
|
+
_subs.add(_networkObserver!.events.listen(send));
|
|
45
|
+
|
|
46
|
+
// Also forward raw VM events for memory and error events
|
|
47
|
+
_subs.add(_vmAdapter!.eventStream
|
|
48
|
+
.where((e) => e.event != 'frame' && e.event != 'rebuild')
|
|
49
|
+
.listen(send));
|
|
50
|
+
|
|
51
|
+
// ignore: avoid_print
|
|
52
|
+
print('🔍 Perf Analyzer connected');
|
|
53
|
+
// ignore: avoid_print
|
|
54
|
+
print('📊 Dashboard → http://localhost:${config.port}');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static void stop() {
|
|
58
|
+
for (final sub in _subs) {
|
|
59
|
+
sub.cancel();
|
|
60
|
+
}
|
|
61
|
+
_subs.clear();
|
|
62
|
+
_frameObserver?.dispose();
|
|
63
|
+
_rebuildObserver?.dispose();
|
|
64
|
+
_networkObserver?.dispose();
|
|
65
|
+
_vmAdapter?.dispose();
|
|
66
|
+
_channel?.sink.close();
|
|
67
|
+
_frameObserver = null;
|
|
68
|
+
_rebuildObserver = null;
|
|
69
|
+
_networkObserver = null;
|
|
70
|
+
_vmAdapter = null;
|
|
71
|
+
_channel = null;
|
|
72
|
+
}
|
|
73
|
+
}
|