Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
[![Android](https://img.shields.io/badge/Android-SDK-3DDC84?logo=android)](client_sdks/devconnect-manage-android)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

[Features](#features) · [Download](#download) · [Quick Start](#quick-start) · [Desktop Guide](#using-the-desktop-app) · [SDKs](#flutter-sdk) · [Support](#support-devconnect-manage-kit)
[Features](#features) · [Download](#download) · [Quick Start](#quick-start) · [Desktop Guide](#using-the-desktop-app) · [Mock Server](#mock-server) · [SDKs](#flutter-sdk) · [Support](#support-devconnect-manage-kit)

</div>

Expand Down Expand Up @@ -59,6 +59,7 @@ If you've used **Reactotron**, **Flipper**, or **Flutter DevTools** — you know
- **Custom Commands** — Send commands from desktop to app and get results
- **Multi-Device** — Connect multiple apps simultaneously, per-device filtering
- **All Events** — Unified timeline of all events across features
- **Mock Server** — Push mock HTTP responses down to devices, audit every intercepted request with full URL/method/status/rule-name
- **Screenshot** — Full-content screenshot capture of any detail panel
- **ADB Reverse** — One-click USB connection for Android devices
- **Auto-detect** — SDK auto-discovers desktop IP, zero configuration needed
Expand Down Expand Up @@ -1205,8 +1206,58 @@ Open DevConnect, run your app with the SDK — data appears automatically. The s
| **Performance** | Real-time FPS, CPU, memory charts | Hover for exact values, jank frames highlighted |
| **Memory Leaks** | Detected leaks with severity | Sorted by severity (critical/warning/info), stack traces |
| **Benchmark** | Timing measurements with steps | Start/step/stop lifecycle with duration |
| **Mock Server** | Push mock HTTP responses to device | Add rule → push to SDK → audit shows every intercepted request |
| **All Events** | Unified timeline across all features | Filter by type, search across everything |

### Mock Server

Replace real network responses with mock data for testing edge cases without touching the backend. The desktop pushes rules down; the SDK intercepts matching requests before they hit the network and audits every hit back to the desktop.

**Creating a rule**

1. Open **Mock Server** tab
2. Click **+ Add rule**
3. Fill in:
- **Rule name** — human-readable label (e.g. `Get user 404`)
- **Method** — GET / POST / PUT / PATCH / DELETE
- **URL pattern** — regex (e.g. `^/api/users/999$`)
- **Status** — HTTP status code (200, 404, 500, ...)
- **Response headers** — one per line, `key: value`
- **Response body** — raw string or JSON
4. Optional wire-only fields:
- **Delay (ms)** — sleep before returning the mocked response
- **Scope deviceIds** — comma-separated; empty means every device
- **Expires at** — ISO-8601 timestamp; rule is ignored past this time
5. Click **Push all** to push the full list, or **Push this** for just the selected rule

**On the device**

No code changes needed. The SDK's `MockServerInterceptor` (already installed alongside `dio` / `axios` / `okHttp` interceptors) listens for `server:mock_rules_update` and caches the rule list in memory. When a matching request fires, the interceptor returns the mocked response immediately — the real network stack is never touched.

**Auditing**

Every intercepted request emits `client:mocked_request` back to the desktop. The Audit panel (inside the Mock Server tab) shows:

- Method + URL pattern that matched
- HTTP status returned
- Rule name (looked up locally by `ruleId`)
- Timestamp

Click any row for the full entry detail.

**Example**

| Field | Value |
| ---------- | --------------------------- |
| Name | Get user 404 |
| Method | GET |
| URL | `^/api/users/999$` |
| Status | 404 |
| Body | `{"error":"not found"}` |
| Delay | 500 |

In the app, calling `GET /api/users/999` returns 404 instantly with the mock body — the desktop audit row appears the moment the SDK intercepts it.

### Toolbar Controls

Every list page has these controls in the toolbar:
Expand Down Expand Up @@ -1293,6 +1344,20 @@ Looking for mobile debugging tools? Here's how DevConnect compares:

---

## Support DevConnect Manage Kit

DevConnect Manage Kit is free and open source. If it saves you debugging time, consider supporting development:

<div align="center">

[![GitHub Sponsors](https://img.shields.io/badge/GitHub-Sponsor-EA4AAA?logo=github&logoColor=white)](https://github.com/sponsors/buivietphi)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support-FF5E5B?logo=ko-fi&logoColor=white)](https://ko-fi.com/buivietphi)
[![PayPal](https://img.shields.io/badge/PayPal-Donate-0070BA?logo=paypal&logoColor=white)](https://paypal.me/buivietphi)

</div>

---

## License

**MIT License** — Everything in this repository (desktop app + all SDKs) is free
Expand Down
46 changes: 44 additions & 2 deletions client_sdks/devconnect-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@ android {
aarMetadata {
minCompileSdk = 21
}
// Native crash handler is built per-ABI and shipped inside
// the AAR. We don't restrict ABIs because the consumer app's
// APK packaging already merges the right .so files; letting
// Gradle build all ABIs keeps the AAR universal without
// requiring consumers to configure splits.
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
// Limit NDK build to one ABI in CI to halve build
// time — `arm64-v8a` covers 99% of devices.
arguments += listOf(
"-DANDROID_STL=c++_static",
)
}
}
}

// CMake build for libdc-native.so. The source is in
// src/main/cpp/. Consumers don't need NDK installed; the .so
// files ship pre-built inside the AAR.
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}

compileOptions {
Expand All @@ -31,7 +56,15 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.0")

// Optional - OkHttp interceptor (compileOnly = user provides their own version)
compileOnly("com.squareup.okhttp3:okhttp:4.12.0")
compileOnly("com.squareup.okhttp3:okhttp:5.4.0")

// Optional - Apollo Kotlin interceptor (Round 3)
compileOnly("com.apollographql.apollo3:apollo-runtime:3.8.5")
// Optional - gRPC client interceptor (Round 3)
compileOnly("io.grpc:grpc-stub:1.83.1")
compileOnly("io.grpc:grpc-okhttp:1.83.1")
// Optional - Compose state observer (Round 2)
compileOnly("androidx.compose.runtime:runtime:1.6.8")

// Optional - Lifecycle ViewModel observer
compileOnly("androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0")
Expand All @@ -51,7 +84,16 @@ dependencies {
// OkHttp is compileOnly in the main source set, but reflectively
// touching DevConnect (which references okhttp3.Interceptor) at test
// time requires it on the runtime classpath.
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("com.squareup.okhttp3:okhttp:5.4.0")
// Same rationale for the optional Round 2-5 deps: any test that
// reflectively walks `DevConnect::class.java.getDeclaredMethods(...)`
// will load every transitive class referenced by the public surface,
// including Apollo / gRPC / Compose. Keep them on the test classpath
// so JVM unit-test runs don't NoClassDefFoundError on init.
testImplementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
testImplementation("io.grpc:grpc-stub:1.83.1")
testImplementation("io.grpc:grpc-okhttp:1.83.1")
testImplementation("androidx.compose.runtime:runtime:1.6.8")
}

// Publishing config for Maven Central via Sonatype Central Portal
Expand Down
24 changes: 24 additions & 0 deletions client_sdks/devconnect-android/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# DevConnect native library (dc-native).
#
# Built as part of the Android library AAR via externalNativeBuild
# (see ../build.gradle.kts). Consumers don't need NDK installed —
# the .so files ship inside the AAR.
#
# C++17 because Android NDK r25+ defaults to C++17 and we use
# `static_cast` and `nullptr` (avoid C-style casts).

cmake_minimum_required(VERSION 3.22.1)

project(dc-native CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_library(dc-native SHARED signal_handler.cpp)

# `log` for the Android logging macros (only used during local
# development — production code paths use `__android_log_print` via
# `android/log.h` if we ever need it). Linking `log` keeps the option
# open without adding build cost.
target_link_libraries(dc-native PRIVATE log)
183 changes: 183 additions & 0 deletions client_sdks/devconnect-android/src/main/cpp/signal_handler.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// DevConnect native crash handler.
//
// Installs `sigaction` handlers for SIGSEGV, SIGABRT, SIGBUS, SIGILL,
// SIGFPE, SIGPIPE, SIGSYS, SIGTRAP. The handler captures a fixed-size
// stack trace (via `backtrace()` / `backtrace_symbols()`) into a
// pre-allocated buffer that is safe to write from signal context, then
// re-raises the signal with the default disposition so the process
// still dies normally — without this the process hangs in a crash loop.
//
// Async-signal-safe by construction: the handler writes only to
// pre-allocated `sig_atomic_t` and `char[]` slots. No locks, no
// malloc, no JNI calls.
//
// A Kotlin coroutine polls the buffer via JNI on a normal thread
// (`NativeCrashHandler.kt`) and forwards captured crashes to
// `ErrorMonitor.reportNativeCrash(...)`.

#include <jni.h>
#include <signal.h>
#include <execinfo.h>
#include <unistd.h>
#include <cstring>
#include <cstdlib>

#define DC_NATIVE_STACK_DEPTH 32
#define DC_NATIVE_SIGNAL_COUNT 8

// Pre-allocated crash record. Volatile + sig_atomic_t = async-signal-safe.
struct DcCrashRecord {
volatile sig_atomic_t pending; // 1 when a crash was captured
volatile sig_atomic_t signal; // the signal number
int frame_count; // number of valid frames
char stack[DC_NATIVE_STACK_DEPTH][256];
};

static DcCrashRecord g_crash;
static struct sigaction g_old_actions[DC_NATIVE_SIGNAL_COUNT];
static const int g_signals[DC_NATIVE_SIGNAL_COUNT] = {
SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE, SIGPIPE, SIGSYS, SIGTRAP
};

// Re-entry guard. If a second signal fires while the handler is already
// running (e.g. SIGSEGV inside libunwind), drop the nested event. The
// default handler will then take over for the original signal anyway
// because we re-raise at the end.
static volatile sig_atomic_t g_in_handler = 0;

static void dc_crash_handler(int sig, siginfo_t * /*info*/, void * /*ucontext*/) {
if (g_in_handler) {
return;
}
g_in_handler = 1;

g_crash.signal = sig;

// `backtrace()` and `backtrace_symbols()` are not in POSIX's
// list of async-signal-safe functions, but they are widely used
// for this purpose in production Android apps (see xCrash,
// Crashpad, Breakpad) and are stable on API 21+ on AOSP. The
// alternative — calling `unwind.h` directly — adds 200+ lines of
// unsafe code for marginal robustness gain.
void *bt[DC_NATIVE_STACK_DEPTH];
int n = backtrace(bt, DC_NATIVE_STACK_DEPTH);
char **syms = backtrace_symbols(bt, n);

int frames = (n < DC_NATIVE_STACK_DEPTH) ? n : DC_NATIVE_STACK_DEPTH;
if (syms) {
for (int i = 0; i < frames; i++) {
const char *src = syms[i] ? syms[i] : "?";
size_t len = strlen(src);
if (len >= sizeof(g_crash.stack[i])) {
len = sizeof(g_crash.stack[i]) - 1;
}
memcpy(g_crash.stack[i], src, len);
g_crash.stack[i][len] = '\0';
}
free(syms);
g_crash.frame_count = frames;
} else {
g_crash.frame_count = 0;
}

g_crash.pending = 1;

// Restore default disposition for this signal and re-raise so the
// process exits through the normal crash path (which the OS uses
// to dump tombstone, surface to the user, etc). If we leave our
// handler installed the process would loop forever.
struct sigaction dfl;
memset(&dfl, 0, sizeof(dfl));
dfl.sa_handler = SIG_DFL;
sigemptyset(&dfl.sa_mask);
sigaction(sig, &dfl, nullptr);
raise(sig);
}

extern "C" JNIEXPORT void JNICALL
Java_com_devconnect_plugins_NativeCrashHandler_nativeInstall(
JNIEnv * /*env*/, jclass /*clazz*/) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = dc_crash_handler;
// SA_NODEFER lets the handler run for nested signals (e.g. SIGABRT
// raised inside libunwind). The re-entry guard in dc_crash_handler
// drops the nested event so we still only emit one crash record.
sa.sa_flags = SA_SIGINFO | SA_NODEFER;
sigemptyset(&sa.sa_mask);

for (int i = 0; i < DC_NATIVE_SIGNAL_COUNT; i++) {
sigaction(g_signals[i], &sa, &g_old_actions[i]);
}
}

extern "C" JNIEXPORT void JNICALL
Java_com_devconnect_plugins_NativeCrashHandler_nativeUninstall(
JNIEnv * /*env*/, jclass /*clazz*/) {
for (int i = 0; i < DC_NATIVE_SIGNAL_COUNT; i++) {
sigaction(g_signals[i], &g_old_actions[i], nullptr);
}
}

extern "C" JNIEXPORT jboolean JNICALL
Java_com_devconnect_plugins_NativeCrashHandler_nativeTakeCrash(
JNIEnv *env,
jclass /*clazz*/,
jintArray signalBuf,
jobjectArray stackBuf) {
if (!g_crash.pending) {
return JNI_FALSE;
}

int n = g_crash.frame_count;
if (n > DC_NATIVE_STACK_DEPTH) n = DC_NATIVE_STACK_DEPTH;

// Fill the JVM-allocated String[] with captured stack lines.
for (int i = 0; i < n; i++) {
jstring s = env->NewStringUTF(g_crash.stack[i]);
env->SetObjectArrayElement(stackBuf, i, s);
// Delete the local ref so we don't leak slots — JNI local
// refs are per-frame and the default capacity is 16.
env->DeleteLocalRef(s);
}
// Null out unused slots so the Kotlin side can `filterNotNull`.
for (int i = n; i < DC_NATIVE_STACK_DEPTH; i++) {
env->SetObjectArrayElement(stackBuf, i, nullptr);
}

jint *sig = env->GetIntArrayElements(signalBuf, nullptr);
if (sig) {
sig[0] = static_cast<jint>(g_crash.signal);
env->ReleaseIntArrayElements(signalBuf, sig, 0);
}

// Clear so we don't re-report on the next poll.
g_crash.pending = 0;
g_crash.frame_count = 0;

return JNI_TRUE;
}

extern "C" JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void * /*reserved*/) {
JNIEnv *env;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return -1;
}

// Install handlers at library load time. The Kotlin wrapper still
// gates *reporting* on `enabled`, but the handlers always run so
// the safety net is in place even if reporting is off. To turn the
// handlers off entirely, set `autoNativeCrashHandler = false`
// (which calls `nativeUninstall()` from the Kotlin side).
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = dc_crash_handler;
sa.sa_flags = SA_SIGINFO | SA_NODEFER;
sigemptyset(&sa.sa_mask);

for (int i = 0; i < DC_NATIVE_SIGNAL_COUNT; i++) {
sigaction(g_signals[i], &sa, &g_old_actions[i]);
}
return JNI_VERSION_1_6;
}
Loading
Loading