Android remote sensor agent for the arall/sigint distributed SIGINT framework.
Current state: v0.4.0 development build. The Compose operator UI, native RTL-SDR scanner, native Ubertooth BLE-advertisement capture, persistent outbox, direct-BLE-GATT Meshtastic link, and foreground service are implemented. The latest field work added frequency sweeps, LoRa activity detection, carrier detection, and noise-floor jamming detection. See the commit history and the hardware checks below for the exact level of validation for each path.
| Path | Evidence |
|---|---|
| Native rtl_433 | IQ frames flowing (len=262144 n_samples=131072), measured noise floor, clean teardown |
| RTL-SDR over OTG | attach detected, permission granted, native scan loop runs |
| Ubertooth BLE advertisements over OTG | attach detected (0x1D50:0x6002), permission granted, native capture and 37/38/39 hopping |
| Both dongles at once | opened concurrently; notification RTL2838UHIDIR + Ubertooth One |
| Meshtastic BLE GATT | scan, connect, MTU, config handshake, node name resolved |
| OUI database | 39,180 entries loaded from the bundled asset on device |
A Realtek RTL8153 ethernet adapter attached at the same time was correctly
ignored — it shares vendor 0x0BDA with the RTL-SDR, so the matcher keys on
vendor and product.
Ubertooth limits. The app captures BLE advertising PDUs through its small native USB bridge and rotates across advertising channels 37, 38, and 39. An Ubertooth can listen to only one advertising channel at a time, so it is a presence/advertisement sensor rather than a lossless BLE recorder. It does not follow or decrypt BLE connections.
Targets minSdk 29 (Android 10) through targetSdk 35.
| Tool | Version | Why |
|---|---|---|
| JDK | 17+ | Gradle / AGP toolchain |
| Android SDK Platform | 35 | compileSdk / targetSdk |
| Android SDK Build-Tools | 35.0.0 | |
| Android NDK | 29.0.14206865 | required by the native submodule |
| CMake | 4.1.2 | required by the native submodule |
The NDK and CMake versions are not optional — they are the minimums stated by
app/src/main/cpp/rtlsdr433-native-gpl/README.md.
Install them with the SDK command-line tools:
sdkmanager "platforms;android-35" "build-tools;35.0.0" \
"ndk;29.0.14206865" "cmake;4.1.2"Then point the build at your SDK, either via ANDROID_HOME or local.properties:
echo "sdk.dir=$HOME/Android/Sdk" > local.propertiesJAVA_HOME must point at a full JDK, not a JRE. If Gradle fails with
does not provide the required capabilities: [JAVA_COMPILER], you have a JRE —
check with $JAVA_HOME/bin/javac -version.
The native sources are a git submodule, so a plain git clone is not enough:
git clone <repo-url> sigint-android
cd sigint-android
git submodule update --init --recursive./gradlew assembleDebugOutput: app/build/outputs/apk/debug/app-debug.apk, containing
lib/arm64-v8a/librtlsdr433.so and lib/x86_64/librtlsdr433.so.
Confirm the native library actually made it into the APK:
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep librtlsdr433Host-side unit tests (no device or hardware needed):
./gradlew testDebugUnitTestInstrumented tests (need a connected device; radio tests need hardware in range
or attached over OTG — they assume rather than fail when it is absent):
./gradlew connectedDebugAndroidTestNote this uninstalls the app when it finishes, so reinstall before expecting the foreground service to be running again.
SDR did not start within 5000ms — another app is probably holding the RTL-SDR.
An RTL-SDR can only be claimed by one process. Viewer apps register for
USB_DEVICE_ATTACHED and launch themselves when the dongle is plugged in, so
this happens without anyone deliberately opening one — the symptom is a scan
that appears to start and dies a second later, which looks like a hardware or
antenna fault and is not.
Confirm it by looking for a second process streaming from the dongle:
adb logcat | grep "IQ frame"Two different PIDs emitting IQ frame means something else has it. Close it:
adb shell am force-stop eu.ebctech.rtlsdr433androThe Ubertooth is a separate device, so bt and an RTL-SDR scanner do run at the
same time — if ism fails only while bt is running, this is still the cause
rather than a conflict between the two.
Versioning lives in app/build.gradle.kts (versionCode / versionName).
Agent.VERSION reads BuildConfig.VERSION_NAME, so the version the server sees
in HELLO is always the version of the APK — do not reintroduce a second
hand-maintained copy.
Signing is configured from an untracked keystore.properties at the repo root:
storeFile=keystore/sigint-release.jks
storePassword=<password>
keyAlias=sigint
keyPassword=<password>Create the keystore once:
keytool -genkeypair -v -keystore keystore/sigint-release.jks \
-alias sigint -keyalg RSA -keysize 4096 -validity 10950./gradlew assembleReleaseOutput: app/build/outputs/apk/release/app-release.apk.
Keep the keystore and its password. Android identifies an app by its signing
certificate, so a later build signed with a different key will not install over
an existing one — the only route is uninstalling first, which wipes the agent id
and its adoption. Neither the keystore nor keystore.properties is tracked: a
signing key in the repository is a signing key everyone has. Without them the
release build still runs, it just emits an unsigned APK.
minifyEnabled is deliberately off. The app reaches native code through JNI and
its database through generated Room code, both of which shrinking has a habit of
breaking in ways that only appear on a field device.
The APK carries native code for arm64-v8a and x86_64 only. That covers
current handsets and emulators; a 32-bit armeabi-v7a device needs that ABI
added to abiFilters and a rebuild.
app/src/main/cpp/rtlsdr433-native-gpl is a submodule of
ebc81/rtlsdr433-native-gpl
(GPL-2.0): rtl_433 v25.12, the rtl-sdr driver, and libusb 1.0.23, all patched
for Android behind #ifdef __EBCANDROID__. It declares its own rtlsdr433 shared
target; app/src/main/cpp/CMakeLists.txt only sets the Android-wide flags and
pulls it in with add_subdirectory.
Because the app links GPL-2.0 code, distributing the APK carries GPL obligations.
The native library hard-codes the class it calls back into, so the JNI surface cannot live where you would want it:
eu.ebctech.rtlsdr433andro.rtlsdr.NativeBridge— the raw JNI surface. Its package and class name are fixed by the submodule: the exported symbols areJava_eu_ebctech_rtlsdr433andro_rtlsdr_NativeBridge_*, andinitNative()does a literalFindClass("eu/ebctech/rtlsdr433andro/rtlsdr/NativeBridge")before resolving the static methodsnativeOutputLine(String)andnativeDeviceStat(int)on it. Renaming any of that breaks linking at runtime, not at compile time, so a ProGuard keep rule guards it too.com.sigint.android.sdr.Rtl433Bridge— what the rest of the app uses. It wrapsNativeBridgeand smooths over two things: the nativestart()blocks for the entire scan (so it is run on a dedicatedrtl433-sdrthread), and the native callbacks are process-global statics (so they are routed to theDetectionCallbackregistered for the current scan).
Detection callbacks arrive on the native SDR thread, not the main thread.
The gain parameter also differs between the two: Rtl433Bridge.start() takes
tenths of a dB (400 = 40.0 dB, -1 = auto) per the project spec, and converts
it to the dB string rtl_433 expects ("" meaning auto).
The JNI bridge cannot be tested on the host — the .so is an Android/ARM binary,
and a real USB file descriptor is required. Verify on a device:
-
Install on a phone with USB OTG support:
./gradlew installDebug
-
Confirm the library loads. Launch the app. The placeholder screen reports
librtlsdr433.so loadedon success, or theUnsatisfiedLinkErrormessage on failure. Loading only proves the.sois present and its symbols resolve — it does not touch the radio. -
Watch the native logs. rtl_433's logging is routed to logcat:
adb logcat -s RTL433 RTL433_JNI RTL433_STATE AndroidBridge Rtl433Bridge
-
Drive a real scan. USB attach, permission, and fd extraction land in a later session, so for now do it from a scratch call site:
val manager = getSystemService(Context.USB_SERVICE) as UsbManager val device = manager.deviceList.values.first { it.vendorId == 0x0BDA } val connection = manager.openDevice(device) // requires USB permission first Rtl433Bridge.start( usbFd = connection.fileDescriptor, frequencyHz = 433_920_000L, gainTenths = 400, callback = object : Rtl433Bridge.DetectionCallback { override fun onDetection(json: String) = Log.i("sigint", json) override fun onError(message: String) = Log.e("sigint", message) }, )
start()returnstrueonce the native loop reports itself running. ExpectRTL433_STATEto moveSTARTING→STARTEDin logcat. -
Expect JSON. Each decode arrives via
onDetectionas one rtl_433 JSON object, e.g.:{"time":"2026-01-01 12:00:00","model":"Nexus-TH","id":30,"channel":1,"temperature_C":21.4,"humidity":48}With no 433 MHz traffic nearby you will see no detections — that is normal and not a failure. A cheap way to generate some is any 433 MHz remote, weather station, or TPMS sensor.
-
Stop with
Rtl433Bridge.stop().RTL433_STATEshould reportSTOPPEDand thertl433-sdrthread should exit.
UnsatisfiedLinkErroron load — the ABI is missing from the APK. Onlyarm64-v8aandx86_64are built (abiFilters); a 32-bit device will not work.UnsatisfiedLinkErroron a specific method — theNativeBridgepackage or class name was changed, or ProGuard renamed it.INVALID fd=...in logcat — USB permission was never granted, soopenDevice()returned null, or the connection was closed too early. The descriptor must stay open for the whole scan.initNative() failed—FindClasscould not locateNativeBridge, which again means the class name or the keep rule is wrong.
The app talks directly to the radio over BLE GATT. It does not require, and does not use, the Meshtastic Android app.
There is no published Meshtastic Android SDK — com.geeksville.mesh:mesh-android
exists on no repository, and neither does anything under org.meshtastic. The
app's AIDL IPC was evaluated on real hardware and rejected, because it is not a
stable contract:
| Meshtastic app | AIDL descriptor | Methods | DataPacket |
|---|---|---|---|
| 2.7.0 | com.geeksville.mesh.IMeshService |
38 | 10 fields |
| 2.7.14 | org.meshtastic.core.service.IMeshService |
43 | 21 fields, okio.ByteString |
| 2.8.0+ | removed | — | — |
Tested against 2.7.14 on a Pixel 7: binding succeeded, then every transaction
failed with SecurityException: Binder invocation to an incorrect interface.
Since AIDL assigns transaction codes by declaration order, the five methods added
in 2.7.14 also shifted every code after position 26. Building on that surface
would mean version-pinning the Meshtastic app on every field device — and it is
gone entirely in the current release.
- Service
6ba1b218-15a8-461f-9fa8-5dcae273eafd f75c76d2-129e-4dad-a1dd-7866124401e7— ToRadio: write aToRadioprotobuf2c55e69e-4993-11ed-b878-0242ac120002— FromRadio: read repeatedly until a read returns emptyed9da18c-a800-4f66-a670-aa7547e34453— FromNum: notifies that data is waiting
Connect sequence: GATT connect → request MTU 512 (the default 23-byte ATT MTU
leaves 20 usable bytes, far below a mesh packet) → discover services → subscribe
to FromNum → write ToRadio{want_config_id: nonce} → drain FromRadio until
config_complete_id matches the nonce.
Protobufs come from the meshtastic/protobufs submodule at app/src/main/proto,
compiled with protoc javalite.
Two things worth knowing if you touch this code:
- Only one GATT operation may be outstanding at a time, so every exchange is
serialised through
gattLock, and whole drain passes throughdrainLock. Without the second lock a FromNum notification races the handshake poller and one of them consumes theconfig_completeframe the other is waiting for — which presents as a handshake that simply times out. - The node must already be bonded. Meshtastic BLE uses a fixed PIN (readable
over serial with
meshtastic --get bluetooth), and the pairing dialog cannot be driven programmatically.
./gradlew connectedDebugAndroidTestMeshtasticLinkInstrumentedTest covers protobuf round-tripping, the BLE scan
filter, and a full connect + config handshake. Tests needing a radio in range
assume rather than fail, so the suite still passes on an emulator. It grants its
own BLE permissions — each instrumented run reinstalls the APK and drops any
grants made by hand with adb shell pm grant.
Verified on a Pixel 7 (Android 17) against RAK4631 firmware 2.7.15:
found node CF:52:B2:21:D0:44 bond=12
config handshake complete
connect=true node=acr0i61 isConnected=true
Which path a message takes is a correctness matter, not a style choice:
| Message | Path | Why |
|---|---|---|
| DET, STAT, LOG | Outbox (retried) | Carry a seq the server ACKs |
| HELLO, ACK, RES | Direct to mesh | Never ACKed — queueing them would retry forever |
The seq embedded in a DET/STAT/LOG wire string is the same number as its outbox
row's primary key. Outbox.enqueue(priority) { seq -> ... } allocates the seq and
hands it to the encoder for exactly that reason; if the two ever diverged, an
incoming ACK would clear the wrong row or none at all.
This project is licensed under the GNU General Public License, version 2 or
later; see LICENSE. The APK includes GPL-2.0 native code from the
rtlsdr433-native-gpl submodule and a Meshtastic protobuf submodule. libusb in
the native layer is LGPL-2.1. The complete corresponding source, including both
submodules, is available from this repository after git submodule update --init --recursive.