Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sigint-android

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.

Hardware-verified on a Pixel 7 (Android 17)

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.

Prerequisites

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.properties

JAVA_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.

Cloning

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

Building

./gradlew assembleDebug

Output: 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 librtlsdr433

Host-side unit tests (no device or hardware needed):

./gradlew testDebugUnitTest

Instrumented 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 connectedDebugAndroidTest

Note this uninstalls the app when it finishes, so reinstall before expecting the foreground service to be running again.

The RTL-SDR will not start

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.rtlsdr433andro

The 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.

Release builds

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 assembleRelease

Output: 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.

Native layer

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.

Two Kotlin classes, one bridge

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 are Java_eu_ebctech_rtlsdr433andro_rtlsdr_NativeBridge_*, and initNative() does a literal FindClass("eu/ebctech/rtlsdr433andro/rtlsdr/NativeBridge") before resolving the static methods nativeOutputLine(String) and nativeDeviceStat(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 wraps NativeBridge and smooths over two things: the native start() blocks for the entire scan (so it is run on a dedicated rtl433-sdr thread), and the native callbacks are process-global statics (so they are routed to the DetectionCallback registered 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).

Verifying the native layer with a connected RTL-SDR

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:

  1. Install on a phone with USB OTG support:

    ./gradlew installDebug
  2. Confirm the library loads. Launch the app. The placeholder screen reports librtlsdr433.so loaded on success, or the UnsatisfiedLinkError message on failure. Loading only proves the .so is present and its symbols resolve — it does not touch the radio.

  3. Watch the native logs. rtl_433's logging is routed to logcat:

    adb logcat -s RTL433 RTL433_JNI RTL433_STATE AndroidBridge Rtl433Bridge
  4. 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() returns true once the native loop reports itself running. Expect RTL433_STATE to move STARTING → STARTED in logcat.

  5. Expect JSON. Each decode arrives via onDetection as 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.

  6. Stop with Rtl433Bridge.stop(). RTL433_STATE should report STOPPED and the rtl433-sdr thread should exit.

If it does not work

  • UnsatisfiedLinkError on load — the ABI is missing from the APK. Only arm64-v8a and x86_64 are built (abiFilters); a 32-bit device will not work.
  • UnsatisfiedLinkError on a specific method — the NativeBridge package or class name was changed, or ProGuard renamed it.
  • INVALID fd=... in logcat — USB permission was never granted, so openDevice() returned null, or the connection was closed too early. The descriptor must stay open for the whole scan.
  • initNative() failed — FindClass could not locate NativeBridge, which again means the class name or the keep rule is wrong.

Meshtastic link

The app talks directly to the radio over BLE GATT. It does not require, and does not use, the Meshtastic Android app.

Why not 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.

The GATT protocol

  • Service 6ba1b218-15a8-461f-9fa8-5dcae273eafd
  • f75c76d2-129e-4dad-a1dd-7866124401e7 — ToRadio: write a ToRadio protobuf
  • 2c55e69e-4993-11ed-b878-0242ac120002 — FromRadio: read repeatedly until a read returns empty
  • ed9da18c-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 through drainLock. Without the second lock a FromNum notification races the handshake poller and one of them consumes the config_complete frame 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.

Verifying the mesh link

./gradlew connectedDebugAndroidTest

MeshtasticLinkInstrumentedTest 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

Message routing

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.

License

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages