driver_rtlsdr 0.2.1
driver_rtlsdr: ^0.2.1 copied to clipboard
Android driver for RTL-SDR (RTL2832U) dongles over USB-OTG: tuning, IQ streaming, WFM/NFM/AM/SSB demodulation with stereo and RDS, spectrum, recording and gain. Reusable native core (C, GPLv2).
driver_rtlsdr #
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/EventChanneloverDriverRtlsdrPlugin.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), seeandroid/src/main/cpp/dsp/demod_ssb.c. - WFM stereo: 19kHz pilot PLL, live on/off toggle
(
shimSetStereoEnabled), lock reported inShimStats.stereoLocked. - RDS: PI/PTY/TP/TA/PS/RadioText (
ShimRdsInfo, viashimGetRdsInfo), 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.cu8file compatible withrtl_sdr/GNU Radio/gqrx (shimStartIqRecording/shimStopIqRecording) — independent of each other and ofDemodMode. Either can also be written straight to a developer-chosen public folder (shimStartRecordingFd/shimStartIqRecordingFd, taking an already-open file descriptor) — seeDownloadsChannelfor 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 bundledFileProvider). - Statistics: IQ rate, ring buffer overflow, RF/audio level
(
ShimStats, viashimGetStats).
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.ktin thertl-sdr mobileapp 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 viapath_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 mobileapp 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 #
-
AndroidManifest.xmlof 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 themeta-datato 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_filterresolves todriver_rtlsdr's own resource (merged into the build by Gradle's resource merger — no need to copy anything).android.hardware.usb.hostis already declared by the plugin's manifest and is also merged automatically. -
minSdk = 26— required by the Oboe/AAudio low-latency path used internally for audio output. -
Lifecycle:
UsbState+UsbChannel(callrefreshConnectedDevices()when your screen starts — this covers the case where the dongle is already plugged in when the app opens) →requestPermission()→ listen for thedeviceReadyevent → from there,NativeBindings.shim*are free to use (shimSetFrequencyHz,shimSetDemodMode,shimStartStreaming, etc.). -
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 fromrtlsdr_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 thatlibnative_rtlsdr.sobuilds, 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 mobileapp, 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.mdfor 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 5DemodModevalues streaming with plausible RF/audio levels and no crash (confirmed via the nativedsp_thread_mainlog, not just the UI) → both a.wavand a.cu8recording 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.