From 8c38d6518bf9a62647df683256d0a00c50f7ea41 Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Thu, 28 May 2026 22:13:42 +0200 Subject: [PATCH 1/2] fix(android): create transport HybridObject on the JS thread (class loader) Runtime ClassNotFoundException for com.margelo.nitro.ecr17.HybridEcr17Transport on every command: createHybridObject's JNI FindClass resolves against the current thread's class loader, and the Nitro worker thread (attached via ThreadScope) has the SYSTEM class loader, which can't see app classes. Fix: run ensureInit() (the createHybridObject) in configure(), which executes on the JS thread where the app class loader is available. fbjni caches the resolved jclass globally, so subsequent method calls from worker threads succeed (they only need a JNIEnv, provided by the ThreadScope guards). Found by running the example app on a device. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/LESSON.md | 10 ++++++++++ package/cpp/Ecr17Client/HybridEcr17Client.cpp | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/docs/LESSON.md b/docs/LESSON.md index d351f7e..79f2e20 100644 --- a/docs/LESSON.md +++ b/docs/LESSON.md @@ -43,6 +43,16 @@ guarded by `#ifdef __ANDROID__` (no-op on iOS). We attach in `ensureConnected`, `runTransaction`, `runAckOnly` (the worker-thread paths that hit the transport). Only reproducible by RUNNING the app — not by the build. +- **`ClassNotFoundException` for the Kotlin HybridObject when created from a + worker thread.** After attaching a Nitro worker thread with `ThreadScope`, + `createHybridObject` does a JNI `FindClass` that resolves against the *thread's* + class loader — an attached worker thread gets the SYSTEM class loader (the error + shows `DexPathList[... /system/lib64 ...]`), which can't see app classes. Fix: + perform `ensureInit()` (the `createHybridObject`) on the **JS thread** (in + `configure()`), which has the app class loader; fbjni caches the resolved + `jclass` globally, so later method calls from worker threads work (they only need + a `JNIEnv`, supplied by the `ThreadScope` guards). Only reproducible by RUNNING + the app. (Alternative: `ThreadScope::WithClassLoader`.) - **Emit `DISCONNECTED` on a failed connect**, else listeners stay stuck on `CONNECTING`. `connect()` delegates to `ensureConnected()` which emits CONNECTING→(CONNECTED | DISCONNECTED on throw). diff --git a/package/cpp/Ecr17Client/HybridEcr17Client.cpp b/package/cpp/Ecr17Client/HybridEcr17Client.cpp index 8f867bb..74ab716 100644 --- a/package/cpp/Ecr17Client/HybridEcr17Client.cpp +++ b/package/cpp/Ecr17Client/HybridEcr17Client.cpp @@ -227,6 +227,13 @@ void HybridEcr17Client::configure(const Ecr17Config& config) { session_.reset(); adapter_.reset(); transport_.reset(); + // Create the transport HybridObject NOW, on this (JS) thread. createHybridObject + // does a JNI FindClass for the Kotlin transport, which resolves only against the + // app class loader — and the JS thread has it. Doing it lazily on a Nitro worker + // thread (attached via ThreadScope) would use the system class loader and throw + // ClassNotFoundException. fbjni caches the resolved jclass globally, so later + // method calls from worker threads work (they only need a JNIEnv, see the guards). + ensureInit(); } Ecr17Config HybridEcr17Client::configuration() { return config_; } From 6ccc30f76a43b33cff0e0faef6df06497584fc9a Mon Sep 17 00:00:00 2001 From: "lorenzo.padovani@padosoft.com" Date: Thu, 28 May 2026 22:18:20 +0200 Subject: [PATCH 2/2] docs: align LESSON + AGENTS with this session's Nitro/JNI learnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LESSON: fix now-stale notes (client include is HybridEcr17Client.hpp; createHybridObject downcast is dynamic_pointer_cast on the JS thread). - AGENTS: add a consolidated "Nitro Android native integration (JNI)" rule group (impl-header naming, dynamic_pointer_cast, react-native.config.js, ThreadScope, createHybridObject on the JS thread) — all reproducible only by running the app. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 18 ++++++++++++++++++ docs/LESSON.md | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e19cb1a..9630efd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,24 @@ still has open, valid reviewer comments. via `HybridObjectRegistry::createHybridObject(name)`. Kotlin HybridObjects get an auto-generated JNI bridge (no manual JNI); use `Promise.parallel{}` for blocking work, `ArrayBuffer.copy(ByteArray)/toByteArray()`. +- **Nitro Android native integration (JNI) — gotchas only reproducible by RUNNING + the app, not by the build** (full detail in docs/LESSON.md): + 1. The C++ impl file MUST be named after `implementationClassName` from + `nitro.json` (`HybridEcr17Client.{hpp,cpp}`) and its dir be in CMake + `include_directories` — the generated `*OnLoad.cpp` does a flat + `#include "HybridEcr17Client.hpp"`. + 2. Downcast `createHybridObject` results with `dynamic_pointer_cast` (HybridObject + is a *virtual* base); null-check. + 3. Autolinking + `.so` load needs `package/react-native.config.js` (declares + android/ios) so RN registers `Ecr17Package`, whose `companion init` runs + `System.loadLibrary`. + 4. Commands run on Nitro worker threads → attach to the JVM with fbjni + `ThreadScope` (`#ifdef __ANDROID__`) before any C++→Kotlin call, else "Unable + to retrieve jni environment". + 5. `createHybridObject` (JNI `FindClass`) must run on the **JS thread** (do it in + `configure()`), because attached worker threads use the system class loader → + `ClassNotFoundException`. fbjni caches the jclass so later worker-thread method + calls work. - Don't reuse a Nitro-generated struct name in our namespace (clash) — e.g. our parser DCC struct is `DccInfo`, not `CurrencyExchange`. - ECR17: status code is lowercase `'s'`; payment `'P'` = 167 bytes; progress diff --git a/docs/LESSON.md b/docs/LESSON.md index 79f2e20..3bf1359 100644 --- a/docs/LESSON.md +++ b/docs/LESSON.md @@ -87,7 +87,8 @@ - iOS `package/Ecr17.podspec` globs `cpp/**/*.{hpp,cpp}` — new C++ auto-included. - C++20 on both; Android NDK provides POSIX sockets in libc (no extra link lib). - Include convention: cross-unit includes are subdir-qualified from the `../cpp` - root, e.g. `#include "Lcr/Lcr.hpp"`, `#include "Ecr17Client/Ecr17Client.hpp"`. + root, e.g. `#include "Lcr/Lcr.hpp"`, `#include "Ecr17Client/HybridEcr17Client.hpp"` + (the client impl file is named after its Nitro class — see Build wiring). ## ECR17 protocol facts (from docs/) - Status command code is lowercase `'s'` (0x73). Payment `'P'` request = 167 bytes. @@ -161,7 +162,9 @@ - **Verified Nitro C++ APIs** (compiled into the APK, use as-is): - `#include `; `auto o = HybridObjectRegistry::createHybridObject("Ecr17Transport");` - `auto t = std::static_pointer_cast(o);` + `auto t = std::dynamic_pointer_cast(o);` (NOT + static_pointer_cast — HybridObject is a virtual base; null-check `t`). Call + `createHybridObject` on the JS thread (app class loader) — see Runtime (Android). - `#include `; `ArrayBuffer::copy(const std::vector&)`, `buf->data()` / `buf->size()`. - Transport spec uses `std::shared_ptr` (NOT std::vector) for send/onData.