driver_rtlsdr

pub package pub points pub likes CI License: GPL v2 or later

Android driver (Flutter plugin) for RTL-SDR dongles (RTL2832U chipset) over USB-OTG. Extracted from the rtl-sdr mobile app (sibling folder to this package) so that other Flutter software-defined-radio apps can build their own UI/UX on top of the same native core, instead of reimplementing USB + libusb + librtlsdr + DSP from scratch.

The native core (C, android/src/main/cpp/) is identical to the one in the source app — same pipeline: USB permission/open → raw IQ streaming → two-stage decimation → demodulation (WFM/NFM/AM, with stereo and RDS on WFM) → PCM to the speaker (Oboe), with WAV recording and spectrum readout for waterfall/visualization.

What this package provides

  • USB: dongle detection, permission flow (UsbState/UsbChannel, MethodChannel/EventChannel over DriverRtlsdrPlugin.kt).
  • Tuning: frequency, sample rate.
  • Demodulation: WFM (with stereo and RDS), NFM, AM, USB/LSB (DemodMode) — SSB via the phasing method (Hilbert transform on Q, matched delay on I), see android/src/main/cpp/dsp/demod_ssb.c.
  • WFM stereo: 19kHz pilot PLL, live on/off toggle (shimSetStereoEnabled), lock reported in ShimStats.stereoLocked.
  • RDS: PI/PTY/TP/TA/PS/RadioText (ShimRdsInfo, via shimGetRdsInfo), live on/off toggle (shimSetRdsEnabled).
  • Gain: automatic (AGC) or manual, list of gains supported by the tuner.
  • Squelch: NFM/AM (WFM doesn't use it — a commercial radio wouldn't have squelch).
  • Spectrum: dB snapshot of the whole captured band, ready to plot (shimGetSpectrumDb).
  • Recording: records the demodulated PCM (mono or stereo, whatever the session is producing) directly to a WAV file (shimStartRecording/ shimStopRecording), or the raw pre-decimation I/Q stream as a .cu8 file compatible with rtl_sdr/GNU Radio/gqrx (shimStartIqRecording/shimStopIqRecording) — independent of each other and of DemodMode. Either can also be written straight to a developer-chosen public folder (shimStartRecordingFd/ shimStartIqRecordingFd, taking an already-open file descriptor) — see DownloadsChannel for the public-Downloads-folder helper built on top of this, and "Recording to the public Downloads folder" below.
  • Sharing: hands a finished recording to Android's native share sheet (DownloadsChannel.shareFile) — works for both a MediaStore/Downloads recording and a plain-path one (resolved to a shareable URI through a bundled FileProvider).
  • Statistics: IQ rate, ring buffer overflow, RF/audio level (ShimStats, via shimGetStats).

What this package deliberately does NOT provide

  • UI: zero widgets. The consuming app builds the interface.
  • Foreground service: keeping the process alive in the background during streaming is a UX decision for each app — it isn't bundled here. A consuming app that needs this can implement its own (see StreamingService.kt in the rtl-sdr mobile app as a reference).
  • Where to save recordings: the driver offers the mechanism for two storage destinations — a plain absolute path (shimStartRecording, chosen by the app, typically via path_provider) or the public Downloads folder via MediaStore (DownloadsChannel.openDownloadsFd + shimStartRecordingFd) — but the choice of which, any custom subdirectory/file name, and any other destination entirely, is still up to the app.
  • Presets, automatic scanning, visual carousel/tuner: these are application logic built on top of this driver's API, not part of it. The rtl-sdr mobile app has reference implementations of all of this (lib/radio/scan_controller.dart, lib/widgets/spectrum_tuner.dart, etc.) that can be adapted.

Installation

dependencies:
  driver_rtlsdr:
    path: ../driver_rtlsdr # or a git/pub reference, if published

Integrating into a new app

  1. AndroidManifest.xml of your app — add the auto-open intent filter for when the dongle is plugged in (optional, but it's what makes Android offer to open your app when the user connects the dongle) and point the meta-data to the VID/PID filter already included in this package:

    <activity ...>
        <intent-filter>
            <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
        </intent-filter>
        <meta-data
            android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
            android:resource="@xml/device_filter" />
    </activity>
    

    @xml/device_filter resolves to driver_rtlsdr's own resource (merged into the build by Gradle's resource merger — no need to copy anything). android.hardware.usb.host is already declared by the plugin's manifest and is also merged automatically.

  2. minSdk = 26 — required by the Oboe/AAudio low-latency path used internally for audio output.

  3. Lifecycle: UsbState + UsbChannel (call refreshConnectedDevices() when your screen starts — this covers the case where the dongle is already plugged in when the app opens) → requestPermission() → listen for the deviceReady event → from there, NativeBindings.shim* are free to use (shimSetFrequencyHz, shimSetDemodMode, shimStartStreaming, etc.).

  4. See example/ in this package for a minimal, fully working implementation (permission → tuning via slider → mode selection → start/stop streaming → live statistics, including stereo pilot lock).

Quick start: permission → tuning → streaming

import 'package:driver_rtlsdr/driver_rtlsdr.dart';
import 'package:flutter/material.dart';

class RadioScreen extends StatefulWidget {
  const RadioScreen({super.key});
  @override
  State<RadioScreen> createState() => _RadioScreenState();
}

class _RadioScreenState extends State<RadioScreen> {
  late final UsbState _usbState;
  late final UsbChannel _usbChannel;

  @override
  void initState() {
    super.initState();
    _usbState = UsbState();
    _usbChannel = UsbChannel(state: _usbState);
    // Covers the case where the dongle is already plugged in when this
    // screen opens (no USB_DEVICE_ATTACHED broadcast fires in that case).
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => _usbChannel.refreshConnectedDevices(),
    );
  }

  @override
  void dispose() {
    _usbChannel.dispose(); // stops listening; does NOT stop streaming — see below
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _usbState,
      builder: (context, _) => switch (_usbState.status) {
        UsbConnectionStatus.noDevice => const Text(
          'Connect an RTL-SDR dongle via a USB-OTG cable.',
        ),
        UsbConnectionStatus.attached ||
        UsbConnectionStatus.permissionDenied => FilledButton(
          onPressed: _usbChannel.requestPermission,
          child: const Text('Grant USB permission'),
        ),
        UsbConnectionStatus.permissionRequested => const CircularProgressIndicator(),
        UsbConnectionStatus.deviceReady => const _Tuner(),
      },
    );
  }
}

Once _usbState.status reaches UsbConnectionStatus.deviceReady, the native driver has the dongle open and NativeBindings is ready to use — no more plugin-level setup needed:

// Tune to 100.0 MHz and start streaming in WFM (commercial FM broadcast).
NativeBindings.shimSetFrequencyHz(100000000);
NativeBindings.shimSetDemodMode(DemodMode.wfm.nativeValue);
final status = NativeBindings.shimStartStreaming(); // 0 == success

// While streaming, poll stats periodically (e.g. Timer.periodic every
// 500ms) to drive a level meter / stereo indicator in your UI:
final statsPtr = pkg_ffi.calloc<ShimStats>();
if (NativeBindings.shimGetStats(statsPtr) == 0) {
  final rfLevelDbfs = statsPtr.ref.rfLevelDbfs;
  final audioLevelDbfs = statsPtr.ref.audioLevelDbfs;
  final stereoLocked = statsPtr.ref.stereoLocked != 0;
}
// Free statsPtr once, when the screen disposes — not on every poll.

NativeBindings.shimStopStreaming();
pkg_ffi.calloc.free(statsPtr);

shim* calls return 0 on success and a negative error code otherwise — always check the return value (see _applyFrequency/_startStreaming in example/lib/main.dart for the pattern used throughout the example app).

More usage examples

Demodulation modes — DemodMode.wfm / .nfm / .am / .usb / .lsb:

Mode Typical use Stereo/RDS Squelch
wfm (Wideband/Commercial FM) FM broadcast, e.g. 87.5–108.0 MHz Yes (stereo + RDS) No — commercial broadcast is always "open"
nfm (Narrowband FM) PMR/ham/two-way radio channels (12.5/25kHz spacing), e.g. VHF/UHF ham bands No Yes
am (AM) AM broadcast (~530kHz–1.7MHz), aviation (108–137MHz), shortwave No Yes
usb (Upper Sideband) HF ham radio above ~10MHz (by convention), most digital modes No Yes
lsb (Lower Sideband) HF ham radio below ~10MHz (by convention) No Yes

USB/LSB use the phasing method (a Hilbert transform on Q, matched by a plain delay on I — see android/src/main/cpp/dsp/demod_ssb.c) so the unwanted sideband is actually rejected, not just silently mixed in; a synthetic-signal check for this lives in tool/native_tests/test_demod_ssb.c (no hardware or emulator needed — see that file's header for how to build and run it).

The tuner itself isn't restricted to these ranges — shimSetFrequencyHz accepts whatever the RTL2832U/tuner chip can physically reach (roughly 24MHz–1.7GHz depending on the tuner, e.g. R820T). The ranges above are just what each demodulation scheme is designed to decode correctly.

Switching modes requires stopping and restarting streaming — the DSP thread reads the mode once, at shimStartStreaming(), not on every block:

NativeBindings.shimStopStreaming();
NativeBindings.shimSetDemodMode(DemodMode.nfm.nativeValue);
NativeBindings.shimStartStreaming();

(Compare with stereo/RDS/squelch/gain below, all of which apply live — no restart needed.)

Gain — automatic (AGC) or manual, in tenths of a dB:

// Automatic:
NativeBindings.shimSetGainMode(1);

// Manual — read the tuner's supported gain steps first (librtlsdr's own
// convention: tenths of a dB, e.g. 40 == 4.0 dB), then pick one:
final gains = pkg_ffi.calloc<ffi.Int32>(32);
final count = NativeBindings.shimGetGainList(gains, 32);
NativeBindings.shimSetGainMode(0);
if (count > 0) NativeBindings.shimSetGainTenthDb(gains[0]);
pkg_ffi.calloc.free(gains);

Squelch — everything except WFM (DemodMode.supportsSquelch; WFM is commercial broadcast and never squelches):

NativeBindings.shimSetSquelchThresholdDb(-30.0);

RDS — WFM only, applied live (no restart needed):

NativeBindings.shimSetRdsEnabled(1);

final rdsPtr = pkg_ffi.calloc<ShimRdsInfo>();
if (NativeBindings.shimGetRdsInfo(rdsPtr) == 0 && rdsPtr.ref.syncLocked != 0) {
  final stationName = _decodeAscii(rdsPtr.ref.ps); // up to 8 chars
  final radiotext = _decodeAscii(rdsPtr.ref.radiotext); // up to 64 chars
}
pkg_ffi.calloc.free(rdsPtr);

ps/radiotext are fixed-size, null-terminated byte arrays (ShimRdsInfo mirrors the native struct 1:1) — decode them with a small helper:

String _decodeAscii(ffi.Array<ffi.Uint8> arr) {
  final bytes = <int>[];
  for (var i = 0; i < arr.length && arr[i] != 0; i++) {
    bytes.add(arr[i]);
  }
  return String.fromCharCodes(bytes);
}

Spectrum — snapshot of the whole captured band, for a waterfall/plot:

const numBins = 512;
final binsPtr = pkg_ffi.calloc<ffi.Float>(numBins);
if (NativeBindings.shimGetSpectrumDb(binsPtr, numBins) == 0) {
  // bins[0] = lower edge of the band, bins[last] = upper edge.
  final bins = List.generate(numBins, (i) => binsPtr[i]);
}
pkg_ffi.calloc.free(binsPtr);

Recording the demodulated audio to a WAV file:

final dir = await getApplicationDocumentsDirectory(); // package:path_provider
final path = '${dir.path}/capture.wav';
final pathPtr = path.toNativeUtf8(); // package:ffi
NativeBindings.shimStartRecording(pathPtr);
pkg_ffi.calloc.free(pathPtr);

// ... later, while still streaming:
NativeBindings.shimStopRecording();

Recording the raw I/Q stream (pre-decimation, before any demodulation — the exact bytes the dongle sent, independent of DemodMode; can run at the same time as the WAV recording above, they tap different points in the pipeline):

final dir = await getApplicationDocumentsDirectory();
final path = '${dir.path}/capture.cu8';
final pathPtr = path.toNativeUtf8();
NativeBindings.shimStartIqRecording(pathPtr);
pkg_ffi.calloc.free(pathPtr);

// ... later, while still streaming:
NativeBindings.shimStopIqRecording();

The file is raw interleaved 8-bit unsigned I/Q (I,Q,I,Q..., no header) — the same .cu8 convention rtl_sdr/GNU Radio/gqrx use for raw captures, so it opens directly in those tools (e.g. for offline analysis of a signal this driver doesn't demodulate). ShimStats.iqRecordingBytesWritten reports progress the same way recordingBytesWritten does for the WAV recording.

Recording to the public Downloads folder (visible to the user in the Files app and to other apps, unlike the app-private paths above) — via Android's MediaStore.Downloads, API 29+ only, no storage permission needed:

final downloads = DownloadsChannel();
int? fd;
try {
  fd = await downloads.openDownloadsFd(
    fileName: 'capture.wav',
    mimeType: 'audio/wav',
    subdirectory: 'MyApp', // -> Downloads/MyApp/capture.wav
  );
} on PlatformException {
  // API < 29, or MediaStore insert failed — fall back to a plain path
  // (getApplicationDocumentsDirectory/getExternalStorageDirectory + shimStartRecording).
}

if (fd != null) {
  NativeBindings.shimStartRecordingFd(fd);

  // ... later, while still streaming:
  NativeBindings.shimStopRecording(); // same stop call as the path-based recording
  final contentUri = await downloads.finishDownloadsFd(fd); // clears IS_PENDING
  if (contentUri != null) {
    await downloads.shareFile(uri: contentUri, mimeType: 'audio/wav');
  }
}

shimStartIqRecordingFd is the fd-based counterpart of shimStartIqRecording, for the same Downloads workflow with a .cu8 capture. A plain-path recording can be shared the same way, without ever calling openDownloadsFd/finishDownloadsFd — pass path: instead of uri: to shareFile, which resolves it to a shareable URI through the bundled FileProvider. DownloadsChannel throws PlatformException (package:flutter/services.dart) on failure, so catch that rather than Exception.

All snippets above assume:

import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'package:driver_rtlsdr/driver_rtlsdr.dart';

Tests

  • test/ — pure Dart unit tests, run on the host (no Android or dongle needed): DemodMode (native values, round-trip, squelch) and the byte size of the FFI structs (ShimStats/ShimRdsInfo) against the expected layout computed from rtlsdr_shim.h — catches the most common mistake when evolving the native API (forgetting to mirror a new field on both sides). Run with: flutter test.
  • example/integration_test/ — runs on a real Android device/emulator; confirms that libnative_rtlsdr.so builds, links, and loads on that specific ABI, and that a real FFI call works — without needing a dongle physically connected. Run with: cd example && flutter test integration_test.
  • Validation against real hardware: this package's WFM/NFM/AM/stereo/ RDS core is byte-for-byte the same as the rtl-sdr mobile app, which was tested live against a real RTL2838U dongle (USB permission, tuning, streaming, mode switching, stereo pilot lock, RDS sync/decoding against a real station, recording, scanning) — see ../rtl-sdr mobile/docs/how-it-was-built.md for the full results of that validation. This package's example app specifically (including SSB and raw I/Q recording, both added after that validation) has since been run end-to-end against a real RTL2838U dongle too: USB permission → device open → all 5 DemodMode values streaming with plausible RF/audio levels and no crash (confirmed via the native dsp_thread_main log, not just the UI) → both a .wav and a .cu8 recording pulled off the device and inspected (correct WAV header/size fixup; raw I/Q byte mean ≈127.5, matching the expected ADC offset-binary center). Not yet confirmed by ear that the demodulated SSB audio is intelligible against a real SSB voice transmission (that needs an actual HF signal, not the VHF band this was tested on).

License

GPLv2, or (at your option) any later version — see LICENSE. This driver links librtlsdr (GPLv2-or-later), which requires that any app using it be distributed under the GPL. libusb (LGPL-2.1) and KissFFT (BSD-3-Clause) are vendored under android/src/main/cpp/vendor/; Oboe (Apache-2.0) is a Gradle/Prefab dependency. See LICENSE for the full breakdown.

Architecture / how the native driver works

/docs is a component-by-component reference for this package specifically — every native C/C++ file under android/src/main/cpp/, every Dart file under lib/src/, and the Kotlin USB bridge, with a signal-pipeline diagram tying them together. Start there for anything about how the plugin itself is built.

For the deeper DSP design/validation story (stereo/RDS decoding, automatic scanning, recording, the visual tuner), see how-it-was-built.md (and its translation como-foi-construido.md) in this package's sibling app, from which the native core was extracted — note those links only resolve inside that monorepo checkout, not from a standalone clone of this package.

Contributing

Contributions are welcome! See CONTRIBUTING.md for how to set up your environment, coding conventions, and the PR process.

Libraries

driver_rtlsdr
Android driver for RTL-SDR (RTL2832U) dongles via USB-OTG.