Build a Local DNS Filter for Android

Intercept and filter DNS queries on Android using Rust packet parsing and a local VPN.

Introduction

30 Second Summary

Every app on your phone silently sends DNS queries to resolve domain names into IP addresses. Many of those queries go to ad networks, trackers, and analytics services you never agreed to contact.

In this project, you will build a local DNS content filter that runs entirely on your Android phone. You'll intercept every DNS query at the packet level using Rust, block unwanted domains, and display the results in a real-time Flutter dashboard.

What You'll Build

A VPN-based Android app that intercepts DNS traffic through a TUN interface, parses raw IP packets in Rust, and filters domains against a configurable blocklist.

By the end of this project, you'll have:

  • A Flutter app with a VPN toggle and live DNS query log that shows every domain your phone contacts.
  • A Kotlin VpnService that creates a virtual network interface and passes the file descriptor to your Rust engine.
  • A Rust packet engine that parses IPv4 and UDP headers byte-by-byte, decodes DNS queries, and makes allow/block routing decisions.
  • Secret Mission: Add a domain statistics screen showing the top 10 most queried and most blocked domains.

Are there any prerequisites?

This is an advanced project. You should be comfortable with basic Flutter (widgets, setState) and basic Rust (functions, structs, byte manipulation).

You also need a working Flutter + Android Studio environment and a physical Android phone with USB debugging enabled. Emulators do not support the VpnService API reliably.

Before We Start

To build a DNS content filter, you need three languages working together: Dart for the UI, Kotlin for Android platform code, and Rust for the packet engine. Flutter Rust Bridge connects all three with minimal glue code.

In this step, you will install the tooling that makes this multi-language project possible and verify everything compiles on your physical device before writing any project code.

In this step, get ready to:
  • Install flutter_rust_bridge_codegen and the Android NDK.
  • Create a new Flutter Rust Bridge project scaffold.
  • Verify the template app builds and runs on your physical Android device.
Install flutter_rust_bridge_codegen

Flutter Rust Bridge uses a code generator to create the glue between Dart and Rust. You install this generator as a global Cargo binary.

  • Open your terminal.
  • Install the code generator by running this command:
cargo install flutter_rust_bridge_codegen

This takes a few minutes as Cargo compiles the binary from source.

  • Once the install finishes, confirm it worked by running:
flutter_rust_bridge_codegen --version

✔️ I see version 2.10.0 or higher

You are good to go. Move on to the next substep.

ⓧ I see an older version

You have an older version installed from a previous project. Upgrade it by running:

cargo install flutter_rust_bridge_codegen --force

The --force flag overwrites the existing binary with the latest version from crates.io.

ⓧ Command not found

Your Cargo bin directory is not on your PATH. Check that ~/.cargo/bin is included in your shell profile.

  • Open your shell config file (~/.zshrc on macOS or ~/.bashrc on Linux).
  • Add this line if it is missing:
export PATH="$HOME/.cargo/bin:$PATH"
  • Restart your terminal window and try cargo install flutter_rust_bridge_codegen again.
Install Android NDK (side by side) via SDK Manager

Flutter Rust Bridge compiles Rust code to native Android libraries using the Android NDK. This project requires NDK version 27.0.12077973 specifically.

  • Open Android Studio.
  • Click Tools in the top menu bar.
  • Click SDK Manager.
  • Click the SDK Tools tab.
  • Check the Show Package Details checkbox in the bottom-right corner.
  • Expand NDK (Side by side) in the list.
  • Check the box next to version 27.0.12077973.
  • Click OK.
  • A dialog tells you how much space the package needs. Click OK again to confirm.
  • When the installation completes, click Finish.

Why this specific NDK version?

Flutter Rust Bridge v2 uses CargoKit to cross-compile Rust for Android. CargoKit requires NDK 27.0.12077973 to match its internal toolchain configuration. Using a different NDK version can cause cryptic linker errors during the build.

Create the project scaffold and verify the build

With the tooling in place, you can now scaffold a complete Flutter + Rust project in a single command.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project by running this command:
flutter_rust_bridge_codegen create dns_filter

This generates a standard Flutter app with a Rust crate wired in. You will see output as it creates directories and runs flutter pub get.

  • Move into the new project folder by running:
cd dns_filter

What did that command create?

The project structure is a standard Flutter app plus a standard Rust crate plus generated glue code. Your Flutter code lives near lib/main.dart and your Rust code lives near rust/src/api/simple.rs.

Now you need to tell the project which NDK version to use.

  • Open android/app/build.gradle.kts in a text editor.
  • Find the android { block.
  • Add this line inside the android block (for example, below namespace):
ndkVersion = "27.0.12077973"

What does this line do?

This pins the project to NDK 27.0.12077973 so CargoKit can find the correct cross-compilation toolchain. Without it, Gradle may pick a different NDK version and the Rust build fails.

  • Save the file.
  • Connect your physical Android device via USB. Make sure USB debugging is enabled.
  • Run the template app on your device by running:
flutter run

The first build takes several minutes because Cargo compiles the Rust crate for the arm64-v8a target. Subsequent builds are much faster.

You should see the default Flutter Rust Bridge demo app launch on your phone with a counter that increments when you tap the button.

Build failing with NDK errors?

Make sure ndkVersion = "27.0.12077973" is inside the android { } block (not the defaultConfig block).

Confirm the NDK is actually installed by reopening Tools > SDK Manager > SDK Tools and verifying 27.0.12077973 shows as installed.

If you see a linker error mentioning libgcc, ensure you are on the correct NDK version and not a newer one that removed libgcc.

help me fix NDK build errors in my Flutter Rust Bridge project

Your toolchain is ready. Next up, you will configure the Android VpnService that creates the virtual network interface for intercepting DNS traffic.

Set Up the Flutter Rust Bridge Project

Your DNS content filter needs three languages working together: Dart for the UI, Kotlin for Android's VpnService, and Rust for high-performance packet parsing.

Flutter Rust Bridge (FRB) connects these pieces by generating the glue code between Dart and Rust automatically. In this step, you'll explore the project structure the scaffold created, customize the app entry point, and confirm the code generation pipeline works end to end.

In this step, get ready to:
  • Explore the FRB project directory structure.
  • Customize the Dart entry point for the DNS filter app.
  • Verify the code generation pipeline runs successfully.
Explore the project structure

The scaffold you created earlier generated a multi-language project with a specific layout. Understanding this layout is essential because you'll add files to each of these directories throughout the project.

  • In your code editor, expand the top-level folders of your dns_filter project.
  • Locate these three key directories:
  • lib/ contains your Dart/Flutter code (UI and app logic).
  • rust/src/api/ contains your Rust source files that FRB exposes to Dart.
  • android/ contains Android-specific code (Kotlin, manifest, Gradle config).

What is this project structure?

Flutter Rust Bridge v2 organizes your project so each language has its own home. The rust/src/api/ folder is special. Any public function you define in a Rust file inside this folder is automatically picked up by the code generator and made callable from Dart.

The generated glue code lives in lib/src/rust/ and rust/src/frb_generated.rs. You never edit these files directly.

Examine the Rust initialization function

The scaffold already includes a Rust file that sets up the FRB runtime. This function runs once when your app starts and must remain in place for the bridge to work.

  • Open rust/src/api/simple.rs in your editor.
  • Confirm it contains the following code:
use flutter_rust_bridge::frb;

#[flutter_rust_bridge::frb(sync, init)]
pub fn init_app() {
    flutter_rust_bridge::setup_default_user_utils();
}

What does this code do?

The #[flutter_rust_bridge::frb(sync, init)] attribute tells FRB two things: this function is synchronous (sync), and it should run automatically during initialization (init).

The setup_default_user_utils() call configures Rust-side logging and panic handling so errors surface properly in Flutter's debug console.

Customize the Dart entry point

The scaffold's default main.dart contains a demo app. You'll replace it with a minimal entry point for the DNS filter, keeping the critical RustLib.init() call that connects Dart to Rust.

  • Open lib/main.dart in your editor.
  • Replace the entire file contents with this code:
import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      home: Scaffold(body: Center(child: Text('DNS Filter'))),
    );
  }
}

What does this code do?

WidgetsFlutterBinding.ensureInitialized() is required before any async work in main(). It sets up Flutter's binding layer so platform channels and native code can communicate.

RustLib.init() loads the compiled Rust library and runs your init_app() function. Without this call, any Rust function you invoke from Dart would crash.

The DnsFilterApp widget is a placeholder. You'll build the real UI with a VPN toggle and DNS query log in a later step.

  • Save lib/main.dart.

Seeing a red squiggly under the import?

If your editor shows an error on the import 'src/rust/frb_generated.dart' line, the generated Dart files might be stale. Run the code generation command in the next substep to fix this.

help me fix an import error in my Flutter Rust Bridge main.dart file

Verify the code generation pipeline

Every time you change a Rust file in rust/src/api/, you need to regenerate the Dart bindings. This ensures the Dart side knows about any new or changed Rust functions.

  • Run the code generator from your project root by running this command:
flutter_rust_bridge_codegen generate
  • Wait for the command to complete. You should see output indicating the code generation succeeded without errors.

What does this command do?

flutter_rust_bridge_codegen generate scans every Rust file in rust/src/api/, finds all public functions and types, then generates matching Dart wrappers in lib/src/rust/. It also produces C/FFI glue so the compiled Rust library can be loaded at runtime.

  • Now deploy the app to your connected Android device by running:
flutter run
  • Wait for the build to finish. You should see a blank screen with the text "DNS Filter" centered on your phone.

Build failed with NDK errors?

If you see errors mentioning NDK version mismatch, open android/app/build.gradle.kts and confirm that ndkVersion is set to 27.0.12077973. This is the NDK version required by Flutter Rust Bridge's build system.

help me fix an NDK version mismatch error in my Flutter Rust Bridge Android build

✔️ Awesome, I've got everything!

Great. Double check you've saved lib/main.dart and that the app is running on your phone showing "DNS Filter" text.

ⓧ I'd like to double check the full code

Here is the complete code for both files at this point:

import 'package:flutter/material.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      home: Scaffold(body: Center(child: Text('DNS Filter'))),
    );
  }
}
use flutter_rust_bridge::frb;

#[flutter_rust_bridge::frb(sync, init)]
pub fn init_app() {
    flutter_rust_bridge::setup_default_user_utils();
}

Your project skeleton is ready. The Dart-to-Rust bridge is working, and you have a clean slate to start building the VPN service layer next.

Configure Android VpnService in Kotlin

Your Flutter app needs a way to intercept DNS traffic at the network level. On Android, the only way to do this without root access is through VpnService. This Android framework class creates a virtual network interface (called a TUN) that captures packets before they leave the device.

In this step, you'll write the Kotlin platform code that creates this TUN interface and passes its file descriptor back to Flutter. The file descriptor is what lets Rust read and write raw network packets directly.

In this step, get ready to:
  • Declare VPN permissions and register the service in AndroidManifest.xml.
  • Create a VpnService class that builds a TUN interface for DNS interception.
  • Set up a Platform Channel so Flutter can start and stop the VPN.
Add VPN permissions and service declaration to AndroidManifest.xml

Android requires explicit permission declarations for networking and VPN access. You also need to register your VpnService subclass in the manifest so the system knows it exists.

  • In Android Studio, expand the project tree on the left and navigate to android/app/src/main/AndroidManifest.xml.
  • Open the file and replace its entire contents with the following:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

    <application
        android:label="dns_filter"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
                android:name="io.flutter.embedding.android.NormalTheme"
                android:resource="@style/NormalTheme"/>
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <service
            android:name=".DnsFilterVpnService"
            android:permission="android.permission.BIND_VPN_SERVICE"
            android:exported="false">
            <intent-filter>
                <action android:name="android.net.VpnService"/>
            </intent-filter>
        </service>

        <meta-data
            android:name="flutterEmbedding"
            android:value="2"/>
    </application>
</manifest>

What does this manifest do?

  • INTERNET permission lets the app make outbound network requests (for forwarding DNS queries to upstream servers).
  • FOREGROUND_SERVICE permission allows the VPN service to keep running while the app is in the background.
  • The <service> block registers DnsFilterVpnService with the required BIND_VPN_SERVICE permission. This tells Android it is a legitimate VPN service that can create virtual network interfaces.
  • The android.net.VpnService intent-filter lets the system find and manage this service.
  • Save AndroidManifest.xml.

Seeing red squiggles under DnsFilterVpnService?

That's expected. You haven't created the DnsFilterVpnService class yet. The error disappears after the next substep.

Help me troubleshoot manifest errors

Create the Kotlin VpnService class that builds a TUN interface

The VpnService class is where Android actually creates the virtual network interface. You configure it with a private IP address and tell Android to route DNS queries through it.

  • In Android Studio, navigate to android/app/src/main/kotlin/com/example/dns_filter/.
  • Right-click the dns_filter folder, select New then Kotlin Class/File.
  • Name it VpnService and select File as the type.
  • Replace the file contents with the following code:
package com.example.dns_filter

import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor

class DnsFilterVpnService : VpnService() {
    private var vpnInterface: ParcelFileDescriptor? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        if (intent?.action == "STOP") {
            stopVpn()
            return START_NOT_STICKY
        }
        startVpn()
        return START_STICKY
    }

    private fun startVpn() {
        // Configure the TUN interface for DNS-only interception
        val builder = Builder()
        builder.setSession("DNS Filter")
        builder.addAddress("10.0.0.2", 32)
        builder.addRoute("10.0.0.2", 32)
        builder.addDnsServer("10.0.0.2")
        builder.setMtu(1500)
        builder.setBlocking(false)

        // Create the interface and pass the fd back to Flutter
        vpnInterface = builder.establish()
        vpnInterface?.let {
            val fd = it.fd
            // Invoke the callback stored by MainActivity
            MainActivity.vpnFdCallback?.invoke(fd)
        }
    }

    private fun stopVpn() {
        vpnInterface?.close()
        vpnInterface = null
        stopSelf()
    }

    override fun onDestroy() {
        stopVpn()
        super.onDestroy()
    }
}

What does this VPN service do?

  • addAddress("10.0.0.2", 32) assigns a private IP to the virtual interface.
  • addRoute("10.0.0.2", 32) combined with addDnsServer("10.0.0.2") makes Android send all DNS queries to the TUN interface. Regular internet traffic bypasses it entirely.
  • setBlocking(false) makes the file descriptor non-blocking so the Rust packet loop can poll without freezing.
  • builder.establish() returns a ParcelFileDescriptor. The integer .fd is what Rust will use to read outgoing DNS packets and write back responses.

The callback pattern (MainActivity.vpnFdCallback) passes the file descriptor back to Flutter through the MainActivity. You'll write that next.

Why DNS-only routing instead of full VPN?

A full VPN proxy would require reimplementing a userspace TCP/IP stack with connection tracking and retransmission. DNS-only routing avoids that complexity while still teaching all the packet-parsing concepts.

The trick is the route configuration. By only routing traffic destined for the virtual DNS address (10.0.0.2) through the TUN interface, all other traffic flows normally. Your phone's web browsing stays fast.

  • Save VpnService.kt.

Unresolved reference to MainActivity?

The MainActivity.vpnFdCallback reference will show an error until you create the updated MainActivity.kt in the next substep.

Help me fix VpnService.kt errors

Set up a Flutter Platform Channel to start and stop the VPN

Flutter cannot call Android framework APIs directly. A Platform Channel bridges this gap by letting Dart send messages to Kotlin and receive responses back asynchronously.

  • Open android/app/src/main/kotlin/com/example/dns_filter/MainActivity.kt in Android Studio.
  • Replace the entire file contents with the following code:
package com.example.dns_filter

import android.app.Activity
import android.content.Intent
import android.net.VpnService
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.dns_filter/vpn"
    private val VPN_REQUEST_CODE = 1

    companion object {
        // Static callback so VpnService can pass the fd back
        var vpnFdCallback: ((Int) -> Unit)? = null
    }

    private var pendingResult: MethodChannel.Result? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "startVpn" -> {
                        pendingResult = result
                        // Check if user has already granted VPN permission
                        val intent = VpnService.prepare(this)
                        if (intent != null) {
                            // User needs to approve VPN permission
                            startActivityForResult(intent, VPN_REQUEST_CODE)
                        } else {
                            // Already approved, launch directly
                            launchVpnService(result)
                        }
                    }
                    "stopVpn" -> {
                        val intent = Intent(this, DnsFilterVpnService::class.java)
                        intent.action = "STOP"
                        startService(intent)
                        result.success(null)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == VPN_REQUEST_CODE) {
            if (resultCode == Activity.RESULT_OK) {
                pendingResult?.let { launchVpnService(it) }
            } else {
                pendingResult?.error("VPN_DENIED", "User denied VPN permission", null)
            }
            pendingResult = null
        }
    }

    private fun launchVpnService(result: MethodChannel.Result) {
        // Store a callback that the VpnService will invoke with the fd
        vpnFdCallback = { fd ->
            runOnUiThread {
                result.success(fd)
            }
            vpnFdCallback = null
        }
        val intent = Intent(this, DnsFilterVpnService::class.java)
        startService(intent)
    }
}

How does the Platform Channel flow work?

  • Flutter calls invokeMethod('startVpn') on the MethodChannel named com.example.dns_filter/vpn.
  • Kotlin calls VpnService.prepare(this). If the user hasn't approved VPN access yet, Android shows a permission dialog. If already approved, it returns null.
  • Once approved, launchVpnService() starts DnsFilterVpnService and sets up a callback to capture the TUN file descriptor.
  • The VPN service creates the interface, invokes the callback with the fd integer, and result.success(fd) sends it back to Dart.
  • Save MainActivity.kt.
  • Build the project by clicking Build then Make Project in the Android Studio menu bar (or press Ctrl+F9).

The project should compile without errors. All three files reference each other correctly now.

Build fails with unresolved references?

Check that the package name com.example.dns_filter matches at the top of all three Kotlin files. Also verify that VpnService.kt is in the same directory as MainActivity.kt.

Help me fix my Android build errors

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>

    <application
        android:label="dns_filter"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">

        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
                android:name="io.flutter.embedding.android.NormalTheme"
                android:resource="@style/NormalTheme"/>
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <service
            android:name=".DnsFilterVpnService"
            android:permission="android.permission.BIND_VPN_SERVICE"
            android:exported="false">
            <intent-filter>
                <action android:name="android.net.VpnService"/>
            </intent-filter>
        </service>

        <meta-data
            android:name="flutterEmbedding"
            android:value="2"/>
    </application>
</manifest>

Here is VpnService.kt:

package com.example.dns_filter

import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor

class DnsFilterVpnService : VpnService() {
    private var vpnInterface: ParcelFileDescriptor? = null

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        if (intent?.action == "STOP") {
            stopVpn()
            return START_NOT_STICKY
        }
        startVpn()
        return START_STICKY
    }

    private fun startVpn() {
        val builder = Builder()
        builder.setSession("DNS Filter")
        builder.addAddress("10.0.0.2", 32)
        builder.addRoute("10.0.0.2", 32)
        builder.addDnsServer("10.0.0.2")
        builder.setMtu(1500)
        builder.setBlocking(false)

        vpnInterface = builder.establish()
        vpnInterface?.let {
            val fd = it.fd
            MainActivity.vpnFdCallback?.invoke(fd)
        }
    }

    private fun stopVpn() {
        vpnInterface?.close()
        vpnInterface = null
        stopSelf()
    }

    override fun onDestroy() {
        stopVpn()
        super.onDestroy()
    }
}

Here is MainActivity.kt:

package com.example.dns_filter

import android.app.Activity
import android.content.Intent
import android.net.VpnService
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.dns_filter/vpn"
    private val VPN_REQUEST_CODE = 1

    companion object {
        var vpnFdCallback: ((Int) -> Unit)? = null
    }

    private var pendingResult: MethodChannel.Result? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "startVpn" -> {
                        pendingResult = result
                        val intent = VpnService.prepare(this)
                        if (intent != null) {
                            startActivityForResult(intent, VPN_REQUEST_CODE)
                        } else {
                            launchVpnService(result)
                        }
                    }
                    "stopVpn" -> {
                        val intent = Intent(this, DnsFilterVpnService::class.java)
                        intent.action = "STOP"
                        startService(intent)
                        result.success(null)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == VPN_REQUEST_CODE) {
            if (resultCode == Activity.RESULT_OK) {
                pendingResult?.let { launchVpnService(it) }
            } else {
                pendingResult?.error("VPN_DENIED", "User denied VPN permission", null)
            }
            pendingResult = null
        }
    }

    private fun launchVpnService(result: MethodChannel.Result) {
        vpnFdCallback = { fd ->
            runOnUiThread {
                result.success(fd)
            }
            vpnFdCallback = null
        }
        val intent = Intent(this, DnsFilterVpnService::class.java)
        startService(intent)
    }
}

Your Android platform layer is wired up. Next, you'll connect this TUN file descriptor to Rust so the packet engine can start reading raw network data.

Wire the TUN File Descriptor to Rust

Your Kotlin VPN service is now creating a TUN interface and passing the file descriptor back to Flutter via a Platform Channel. The next challenge is getting that raw fd into Rust, where the real packet processing will happen.

In this step, you will build the Rust packet engine that accepts the TUN file descriptor, reads raw packets off the virtual interface in a loop, and streams events back to Dart in real time using Flutter Rust Bridge's StreamSink.

In this step, get ready to:
  • Create a Rust packet engine that reads from the TUN file descriptor.
  • Connect Dart to Rust via Flutter Rust Bridge to pass the fd and receive events as a Stream.
  • Test the full pipeline from VPN start through to Rust reading packets.
Create the Rust packet engine

The packet engine is a Rust struct that owns a background thread reading raw bytes from the TUN file descriptor. It needs a way to start, stop, and send events back to Dart.

  • Create a new file at rust/src/api/packet_engine.rs in Android Studio.
  • Add the following imports, the DnsEvent struct, and the PacketEngine skeleton with its constructor and stop method:
use flutter_rust_bridge::frb;
use std::io::{Read, Write};
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex};

// Event sent from Rust to Dart for each DNS query
#[derive(Clone, Debug)]
pub struct DnsEvent {
    pub domain: String,
    pub blocked: bool,
    pub timestamp_ms: i64,
}

// Opaque struct hidden from Dart's direct access
#[frb(opaque)]
pub struct PacketEngine {
    running: Arc<Mutex<bool>>,
}

impl PacketEngine {
    // Sync constructor callable from Dart without await
    #[frb(sync)]
    pub fn new() -> PacketEngine {
        PacketEngine {
            running: Arc::new(Mutex::new(false)),
        }
    }

    // Sync stop method to halt the packet loop
    #[frb(sync)]
    pub fn stop(&self) {
        *self.running.lock().unwrap() = false;
    }
}

What does this code do?

DnsEvent is the struct that Rust will send to Dart for every DNS query it processes. Flutter Rust Bridge automatically generates the corresponding Dart class.

PacketEngine is marked #[frb(opaque)] which means Dart holds an opaque reference to it. The running field is an Arc<Mutex<bool>> so the background thread and the main thread can share the stop signal safely.

Both new() and stop() are #[frb(sync)] so Dart can call them without await.

Now add the start_filtering method inside the impl PacketEngine block, between the new() function and the stop() function:

    pub fn start_filtering(
        &self,
        tun_fd: i32,
        sink: flutter_rust_bridge::StreamSink<DnsEvent>,
    ) {
        let running = self.running.clone();
        *running.lock().unwrap() = true;

        std::thread::spawn(move || {
            // Wrap the raw fd in a File for reading
            let mut file = unsafe { std::fs::File::from_raw_fd(tun_fd) };
            let mut buf = [0u8; 4096];

            while *running.lock().unwrap() {
                let n = match file.read(&mut buf) {
                    Ok(n) if n > 0 => n,
                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                        // TUN fd is non-blocking, so sleep briefly and retry
                        std::thread::sleep(std::time::Duration::from_millis(10));
                        continue;
                    }
                    _ => continue,
                };
                // Packet received, further processing TBD
            }

            // Don't close the fd - it's managed by Kotlin
            std::mem::forget(file);
        });
    }

What does start_filtering do?

This method spawns a background thread that reads raw IP packets from the TUN file descriptor in a loop.

  • unsafe { std::fs::File::from_raw_fd(tun_fd) } wraps the integer fd into a Rust File so you can call .read() on it.
  • The WouldBlock branch handles the fact that Android sets the TUN fd to non-blocking mode.
  • std::mem::forget(file) prevents Rust from closing the file descriptor when the thread exits. Kotlin's VpnService owns the fd lifecycle.
  • The sink parameter is a StreamSink that FRB translates into a Dart Stream on the Flutter side. You will use it in the next step to push parsed DNS events.
  • Save rust/src/api/packet_engine.rs.

Seeing a compilation error about unused imports?

The Write import and sink parameter will show warnings until later steps use them. You can ignore the warnings for now.

help me fix Rust compilation warnings in my packet_engine.rs

✔️ Awesome, I've got everything!

Great. Make sure you have saved packet_engine.rs before moving on.

ⓧ I'd like to double check the full code

use flutter_rust_bridge::frb;
use std::io::{Read, Write};
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex};

#[derive(Clone, Debug)]
pub struct DnsEvent {
    pub domain: String,
    pub blocked: bool,
    pub timestamp_ms: i64,
}

#[frb(opaque)]
pub struct PacketEngine {
    running: Arc<Mutex<bool>>,
}

impl PacketEngine {
    #[frb(sync)]
    pub fn new() -> PacketEngine {
        PacketEngine {
            running: Arc::new(Mutex::new(false)),
        }
    }

    pub fn start_filtering(
        &self,
        tun_fd: i32,
        sink: flutter_rust_bridge::StreamSink<DnsEvent>,
    ) {
        let running = self.running.clone();
        *running.lock().unwrap() = true;

        std::thread::spawn(move || {
            let mut file = unsafe { std::fs::File::from_raw_fd(tun_fd) };
            let mut buf = [0u8; 4096];

            while *running.lock().unwrap() {
                let n = match file.read(&mut buf) {
                    Ok(n) if n > 0 => n,
                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                        std::thread::sleep(std::time::Duration::from_millis(10));
                        continue;
                    }
                    _ => continue,
                };
                // Packet received, further processing TBD
            }

            std::mem::forget(file);
        });
    }

    #[frb(sync)]
    pub fn stop(&self) {
        *self.running.lock().unwrap() = false;
    }
}
Connect Dart to Rust via Flutter Rust Bridge

With the Rust side ready, you need to regenerate the Dart bindings and update lib/main.dart to use the new PacketEngine class.

  • Regenerate the FRB bindings by running this command in your project root terminal:
flutter_rust_bridge_codegen generate

What does this command do?

flutter_rust_bridge_codegen generate scans all .rs files inside rust/src/api/ and generates corresponding Dart classes in lib/src/rust/api/. It creates packet_engine.dart with DnsEvent and PacketEngine classes that mirror your Rust structs.

  • Confirm you see lib/src/rust/api/packet_engine.dart in your project tree.

Codegen failed or produced errors?

Make sure your packet_engine.rs file compiles without errors. The codegen parses your Rust code before generating Dart bindings. If you have a syntax error in the Rust file, the codegen will fail.

help me fix flutter_rust_bridge_codegen generate errors

Now replace the contents of lib/main.dart with the following code that uses the generated Dart bindings:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/packet_engine.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  // Platform channel matching the Kotlin side
  static const platform = MethodChannel('com.example.dns_filter/vpn');

  bool _vpnActive = false;
  PacketEngine? _engine;
  final List<DnsEvent> _events = [];

  Future<void> _toggleVpn() async {
    if (_vpnActive) {
      // Stop the Rust engine first, then the VPN service
      _engine?.stop();
      await platform.invokeMethod('stopVpn');
      setState(() => _vpnActive = false);
    } else {
      try {
        // Start VPN and receive the TUN file descriptor
        final fd = await platform.invokeMethod<int>('startVpn');
        if (fd != null) {
          // Create engine and start filtering with the fd
          _engine = PacketEngine.new_();
          _engine!.startFiltering(
            tunFd: fd,
            sink: _onDnsEvent,
          );
          setState(() => _vpnActive = true);
        }
      } on PlatformException catch (e) {
        debugPrint('VPN start failed: ${e.message}');
      }
    }
  }

  void _onDnsEvent(DnsEvent event) {
    setState(() {
      _events.insert(0, event);
      if (_events.length > 100) _events.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DNS Content Filter'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(child: Text(_vpnActive ? 'VPN Active - reading packets' : 'VPN Inactive')),
    );
  }
}

What's happening in the Dart code?

  • RustLib.init() initializes the Flutter Rust Bridge runtime before the app starts.
  • PacketEngine.new_() calls the Rust PacketEngine::new() constructor. The trailing underscore is how FRB exposes Rust's new (a reserved word in Dart).
  • startFiltering(tunFd: fd, sink: _onDnsEvent) passes the TUN fd to Rust and provides a callback that receives streamed DnsEvent objects.
  • _onDnsEvent inserts events at the top of the list, capping at 100 entries to prevent unbounded memory growth.
  • Save lib/main.dart.

✔️ Awesome, I've got everything!

Great. Double check you have saved both packet_engine.rs and main.dart.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/packet_engine.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  static const platform = MethodChannel('com.example.dns_filter/vpn');

  bool _vpnActive = false;
  PacketEngine? _engine;
  final List<DnsEvent> _events = [];

  Future<void> _toggleVpn() async {
    if (_vpnActive) {
      _engine?.stop();
      await platform.invokeMethod('stopVpn');
      setState(() => _vpnActive = false);
    } else {
      try {
        final fd = await platform.invokeMethod<int>('startVpn');
        if (fd != null) {
          _engine = PacketEngine.new_();
          _engine!.startFiltering(
            tunFd: fd,
            sink: _onDnsEvent,
          );
          setState(() => _vpnActive = true);
        }
      } on PlatformException catch (e) {
        debugPrint('VPN start failed: ${e.message}');
      }
    }
  }

  void _onDnsEvent(DnsEvent event) {
    setState(() {
      _events.insert(0, event);
      if (_events.length > 100) _events.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DNS Content Filter'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(child: Text(_vpnActive ? 'VPN Active - reading packets' : 'VPN Inactive')),
    );
  }
}
Test the pipeline end to end

Time to confirm that the full chain works: Flutter starts the VPN, Kotlin creates the TUN interface, and Rust begins reading packets from it.

  • Make sure your physical Android device is connected via USB with USB debugging enabled.
  • Run the app on your device by pressing the green play button in Android Studio, or by running:
flutter run
  • Once the app launches, tap anywhere on the screen text to confirm the UI renders.
  • Android will show a VPN connection request dialog when you first start the filter. Tap OK to grant VPN permission.
  • The screen text should change from VPN Inactive to VPN Active - reading packets.
  • Open the Logcat panel in Android Studio (View > Tool Windows > Logcat).
  • Confirm there are no crash logs or Rust panics in the output. The app should remain stable with the VPN running.

Why no DNS events yet?

The start_filtering loop is reading raw bytes but not yet parsing or emitting DnsEvent objects. The comment // Packet received, further processing TBD is where the next step will add IPv4 and UDP header parsing.

For now, a stable app with VPN Active - reading packets on screen confirms the full fd pipeline is working.

App crashes when tapping the VPN toggle?

  • Check that the MethodChannel name in main.dart matches the Kotlin side exactly: com.example.dns_filter/vpn.
  • Make sure you ran flutter_rust_bridge_codegen generate after creating packet_engine.rs. Without the generated Dart file, the import will fail.
  • Verify your device is running Android 5.0+ (API 21+). VpnService requires at least API level 14, but the FRB native libraries need API 21+.

my app crashes when I toggle the VPN switch in my DNS filter project

Your full pipeline is live. The TUN file descriptor flows from Kotlin through Dart into Rust, and Rust is reading raw packets off the wire. Next up, you will parse those raw bytes into proper IPv4 and UDP headers to identify DNS queries.

Parse IP and UDP Headers in Rust

Your packet engine can read raw bytes from the TUN interface, but those bytes are meaningless until you decode their structure. Every packet that arrives follows a strict layout defined by the IPv4 and UDP protocols.

In this step, you will parse these raw bytes to extract the fields that matter for DNS filtering: the protocol type, the source and destination IP addresses, and the destination port. By the end, your engine will identify DNS packets and pass everything else through untouched.

In this step, get ready to:
  • Parse IPv4 headers to extract protocol, source IP, and destination IP.
  • Parse UDP headers to identify DNS traffic on port 53.
  • Verify parsing works by running the app on your device.
Implement IPv4 header parsing

An IPv4 packet starts with a fixed header of at least 20 bytes. The fields you care about are packed into specific byte offsets within that header.

What is an IPv4 header?

Every IP packet begins with a header containing routing metadata. The first byte holds two fields: the version (upper 4 bits, always 4 for IPv4) and the Internet Header Length or IHL (lower 4 bits, measured in 32-bit words).

Byte 9 contains the protocol number (17 means UDP, 6 means TCP). Bytes 12-15 are the source IP address, and bytes 16-19 are the destination IP address.

Before you can use these offsets in your packet loop, you need a helper function to handle non-blocking reads from the TUN file descriptor. This keeps the loop clean and separates read logic from parsing logic.

  • In packet_engine.rs, scroll to the very bottom of the file, below the closing } of the impl PacketEngine block.
  • Add this helper function by pasting the following code:
fn read_with_timeout(file: &mut std::fs::File, buf: &mut [u8]) -> Option<usize> {
    match file.read(buf) {
        Ok(n) => Some(n),
        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
            std::thread::sleep(std::time::Duration::from_millis(10));
            None
        }
        Err(_) => None,
    }
}

What does this function do?

  • The TUN file descriptor is non-blocking, meaning read() returns immediately with a WouldBlock error when no packets are available.
  • This helper returns Some(n) when bytes are read successfully.
  • It returns None when there is nothing to read, sleeping for 10ms to avoid busy-spinning the CPU.
  • Save packet_engine.rs.

Getting a compile error about read_with_timeout?

Make sure the function is placed outside the impl PacketEngine block (after its closing brace), not inside it. It is a standalone function, not a method on the struct.

help me fix a compile error after adding read_with_timeout to packet_engine.rs

Now you will replace the packet loop body inside start_filtering to use this helper and add IPv4 header parsing.

  • In packet_engine.rs, find the while *running.lock().unwrap() loop inside start_filtering.
  • Find this existing loop body:
            while *running.lock().unwrap() {
                let n = match file.read(&mut buf) {
                    Ok(n) if n > 0 => n,
                    Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                        std::thread::sleep(std::time::Duration::from_millis(10));
                        continue;
                    }
                    _ => continue,
                };
                // Packet received, further processing TBD
            }
  • Replace the entire while loop (including its braces) with this new version that parses IPv4 headers:
            while *running.lock().unwrap() {
                let n = match read_with_timeout(&mut file, &mut buf) {
                    Some(n) if n > 0 => n,
                    _ => continue,
                };

                let packet = &buf[..n];

                // Parse IPv4 header
                if packet.len() < 20 || (packet[0] >> 4) != 4 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let ihl = (packet[0] & 0x0F) as usize * 4;
                let protocol = packet[9];
                let src_ip = &packet[12..16];
                let dst_ip = &packet[16..20];

                // Only process UDP (protocol 17)
                if protocol != 17 || packet.len() < ihl + 8 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let udp_header = &packet[ihl..];
                let dst_port = u16::from_be_bytes([udp_header[2], udp_header[3]]);

                // Only process DNS (port 53)
                if dst_port != 53 {
                    let _ = file.write_all(packet);
                    continue;
                }

                // DNS packet confirmed - further processing TBD
            }

What does this code do?

  • read_with_timeout(&mut file, &mut buf) reads raw bytes from the TUN interface using the helper you just added.
  • (packet[0] >> 4) != 4 checks that the first nibble is version 4, confirming this is an IPv4 packet.
  • (packet[0] & 0x0F) as usize * 4 extracts the IHL (Internet Header Length) from the lower nibble and converts it from 32-bit words to bytes.
  • packet[9] reads the protocol field. Value 17 means UDP.
  • packet[12..16] and packet[16..20] are the 4-byte source and destination IP addresses.
  • &packet[ihl..] slices the packet at the byte offset where the UDP header begins (right after the variable-length IP header).
  • u16::from_be_bytes([udp_header[2], udp_header[3]]) reads the destination port as a big-endian 16-bit integer from bytes 2-3 of the UDP header.
  • Non-IPv4, non-UDP, and non-DNS packets are written back to the TUN unchanged so they can continue through normal routing.
  • Save packet_engine.rs.

Getting a compile error about write_all?

Check that use std::io::{Read, Write}; is still at the top of the file. The write_all calls require the Write trait in scope.

Also make sure you replaced the entire old while loop. Leaving remnants of the old file.read(&mut buf) call alongside the new read_with_timeout call will cause conflicts.

help me fix a compile error in my packet_engine.rs after replacing the while loop with IPv4 and UDP parsing

Update the UI and verify parsing on your device

To confirm the parsing pipeline works without crashes, you will update the status text in your Flutter UI and run the app on your phone.

  • In lib/main.dart, find this line in the build method:
body: Center(child: Text(_vpnActive ? 'VPN Active - reading packets' : 'VPN Inactive')),
  • Replace 'VPN Active - reading packets' with 'VPN Active - parsing IP/UDP headers', so the line now looks like this:
body: Center(child: Text(_vpnActive ? 'VPN Active - parsing IP/UDP headers' : 'VPN Inactive')),
  • Save main.dart.
  • Run the app on your physical device by clicking the green play button in Android Studio (or pressing Shift+F10).
  • Once the app loads, tap the VPN toggle to start filtering.
  • Accept the VPN permission dialog if prompted.

You should see the status text change to "VPN Active - parsing IP/UDP headers". The app running without crashing confirms your Rust code is correctly parsing packet headers and writing non-DNS traffic back to the TUN.

App crashes after toggling the VPN?

Open the Logcat panel in Android Studio (View > Tool Windows > Logcat) and filter for "RUST" or "panic" to find the error message.

Common causes: a missing read_with_timeout function (the loop calls it but it does not exist), or a slice index out of bounds (you skipped the packet.len() < ihl + 8 length check before accessing UDP bytes).

help me debug a crash in my DNS filter app after adding IPv4 and UDP header parsing in Rust

✔️ Awesome, I've got everything!

Great. Double check you have saved both packet_engine.rs and main.dart.

ⓧ I'd like to double check the full code

Here is the full packet_engine.rs at this point:

use flutter_rust_bridge::frb;
use std::io::{Read, Write};
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex};

#[derive(Clone, Debug)]
pub struct DnsEvent {
    pub domain: String,
    pub blocked: bool,
    pub timestamp_ms: i64,
}

#[frb(opaque)]
pub struct PacketEngine {
    running: Arc<Mutex<bool>>,
}

impl PacketEngine {
    #[frb(sync)]
    pub fn new() -> PacketEngine {
        PacketEngine {
            running: Arc::new(Mutex::new(false)),
        }
    }

    pub fn start_filtering(
        &self,
        tun_fd: i32,
        sink: flutter_rust_bridge::StreamSink<DnsEvent>,
    ) {
        let running = self.running.clone();
        *running.lock().unwrap() = true;

        std::thread::spawn(move || {
            let mut file = unsafe { std::fs::File::from_raw_fd(tun_fd) };
            let mut buf = [0u8; 4096];

            while *running.lock().unwrap() {
                let n = match read_with_timeout(&mut file, &mut buf) {
                    Some(n) if n > 0 => n,
                    _ => continue,
                };

                let packet = &buf[..n];

                // Parse IPv4 header
                if packet.len() < 20 || (packet[0] >> 4) != 4 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let ihl = (packet[0] & 0x0F) as usize * 4;
                let protocol = packet[9];
                let src_ip = &packet[12..16];
                let dst_ip = &packet[16..20];

                // Only process UDP (protocol 17)
                if protocol != 17 || packet.len() < ihl + 8 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let udp_header = &packet[ihl..];
                let dst_port = u16::from_be_bytes([udp_header[2], udp_header[3]]);

                // Only process DNS (port 53)
                if dst_port != 53 {
                    let _ = file.write_all(packet);
                    continue;
                }

                // DNS packet confirmed - further processing TBD
            }

            std::mem::forget(file);
        });
    }

    #[frb(sync)]
    pub fn stop(&self) {
        *self.running.lock().unwrap() = false;
    }
}

fn read_with_timeout(file: &mut std::fs::File, buf: &mut [u8]) -> Option<usize> {
    match file.read(buf) {
        Ok(n) => Some(n),
        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
            std::thread::sleep(std::time::Duration::from_millis(10));
            None
        }
        Err(_) => None,
    }
}

And here is the updated main.dart:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/packet_engine.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  static const platform = MethodChannel('com.example.dns_filter/vpn');

  bool _vpnActive = false;
  PacketEngine? _engine;
  final List<DnsEvent> _events = [];

  Future<void> _toggleVpn() async {
    if (_vpnActive) {
      _engine?.stop();
      await platform.invokeMethod('stopVpn');
      setState(() => _vpnActive = false);
    } else {
      try {
        final fd = await platform.invokeMethod<int>('startVpn');
        if (fd != null) {
          _engine = PacketEngine.new_();
          _engine!.startFiltering(
            tunFd: fd,
            sink: _onDnsEvent,
          );
          setState(() => _vpnActive = true);
        }
      } on PlatformException catch (e) {
        debugPrint('VPN start failed: ${e.message}');
      }
    }
  }

  void _onDnsEvent(DnsEvent event) {
    setState(() {
      _events.insert(0, event);
      if (_events.length > 100) _events.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DNS Content Filter'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(child: Text(_vpnActive ? 'VPN Active - parsing IP/UDP headers' : 'VPN Inactive')),
    );
  }
}

Your packet engine now understands the structure of every packet flowing through the VPN. Next up, you will decode the DNS query payload itself to extract the domain name being requested and decide whether to block or allow it.

Parse DNS Queries and Implement Filtering

Your packet engine can identify DNS traffic, but right now it hits a dead end at the // DNS packet confirmed - further processing TBD comment. The next challenge is reading the actual domain name out of the DNS wire format and deciding whether to allow or block the query.

In this step, you will parse the DNS question section byte-by-byte, check extracted domains against a blocklist, and craft full response packets to either block or forward each query.

In this step, get ready to:
  • Parse DNS domain names from the wire format using label-length encoding.
  • Check queried domains against a hardcoded blocklist of ad and tracker domains.
  • Craft DNS responses for blocked domains and forward allowed queries to upstream DNS.
Parse DNS domain names and define the blocklist

DNS encodes domain names using a label-length format. Instead of dots, each label is preceded by a single byte indicating its length, and the name terminates with a zero byte.

What is label-length encoding?

In the DNS wire format, www.google.com becomes the byte sequence 3 w w w 6 g o o g l e 3 c o m 0. Each number is a length byte telling you how many characters follow. The final 0 marks the end of the name.

The DNS header is always 12 bytes, so the question section (where the domain name lives) starts at byte offset 12 of the DNS payload.

  • In rust/src/api/packet_engine.rs, find the imports at the top of the file.
  • Replace them with the following updated imports and add the BLOCKLIST constant below them:
use flutter_rust_bridge::frb;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::UdpSocket;
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex};

// Blocklist of domains to filter
const BLOCKLIST: &[&str] = &[
    "ads.example.com",
    "tracker.example.com",
    "analytics.doubleclick.net",
    "ad.doubleclick.net",
    "pagead2.googlesyndication.com",
    "graph.facebook.com",
];

What does this code do?

  • std::collections::HashMap is imported for future statistics tracking.
  • std::net::UdpSocket lets you send DNS queries to the upstream resolver (Google DNS at 8.8.8.8).
  • BLOCKLIST is a compile-time array of domain strings. Any DNS query matching one of these domains will be blocked.
  • Now scroll down past the read_with_timeout function at the bottom of the file.
  • Add the parse_dns_domain function below it:
fn parse_dns_domain(data: &[u8]) -> Option<String> {
    let mut domain = String::new();
    let mut pos = 0;

    loop {
        if pos >= data.len() {
            return None;
        }
        let len = data[pos] as usize;
        if len == 0 {
            break;
        }
        if pos + 1 + len > data.len() {
            return None;
        }
        if !domain.is_empty() {
            domain.push('.');
        }
        domain.push_str(
            std::str::from_utf8(&data[pos + 1..pos + 1 + len]).ok()?,
        );
        pos += 1 + len;
    }

    Some(domain.to_lowercase())
}

What does this code do?

This function walks through the label-length encoded domain name byte by byte.

  • It reads a length byte at the current position. If it is 0, the name is complete.
  • Otherwise, it reads that many bytes as a UTF-8 label and appends it to the domain string with a dot separator.
  • The final result is lowercased for case-insensitive matching, as DNS comparisons are case-insensitive per RFC 1035.
  • Add the is_blocked and current_time_ms helper functions below parse_dns_domain:
fn is_blocked(domain: &str) -> bool {
    BLOCKLIST.iter().any(|blocked| {
        domain == *blocked || domain.ends_with(&format!(".", blocked))
    })
}

fn current_time_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as i64
}

What does this code do?

  • is_blocked checks whether the queried domain exactly matches a blocklist entry or is a subdomain of one. For example, sub.ads.example.com matches because it ends with .ads.example.com.
  • current_time_ms returns the current Unix timestamp in milliseconds, used to timestamp each DnsEvent sent to the Flutter UI.
  • Save packet_engine.rs.

You won't see a change yet. The next substep adds the response-crafting functions that use these helpers.

Seeing a compiler error?

  • Make sure the BLOCKLIST constant is placed between the imports and the DnsEvent struct definition.
  • Check that you did not accidentally delete the existing use flutter_rust_bridge::frb; import.

Why is my Rust code not compiling?

Craft DNS response packets

When the filter blocks a domain, it needs to reply with a valid DNS response so the requesting app stops waiting. You will craft an NXDOMAIN response ("this domain does not exist") and also build the IP/UDP wrapper that makes it look like a reply from the DNS server.

  • In packet_engine.rs, add the craft_blocked_response function below current_time_ms:
fn craft_blocked_response(original_packet: &[u8], ihl: usize) -> Vec<u8> {
    let dns_payload = &original_packet[ihl + 8..];
    let transaction_id = &dns_payload[0..2];

    // Find the end of the question section
    let mut qname_end = 12;
    while qname_end < dns_payload.len() && dns_payload[qname_end] != 0 {
        qname_end += 1 + dns_payload[qname_end] as usize;
    }
    qname_end += 1; // null terminator
    qname_end += 4; // QTYPE + QCLASS

    let question_section = &dns_payload[12..qname_end];

    // Build DNS response with NXDOMAIN rcode
    let mut dns_response = Vec::new();
    dns_response.extend_from_slice(transaction_id);
    dns_response.extend_from_slice(&[0x81, 0x83]); // Response, NXDOMAIN
    dns_response.extend_from_slice(&[0x00, 0x01]); // 1 question
    dns_response.extend_from_slice(&[0x00, 0x00]); // 0 answers
    dns_response.extend_from_slice(&[0x00, 0x00]); // 0 authority
    dns_response.extend_from_slice(&[0x00, 0x00]); // 0 additional
    dns_response.extend_from_slice(question_section);

    build_response_packet(original_packet, ihl, &dns_response)
}

What does this code do?

This function builds a fake DNS response that tells the app the domain does not exist.

  • It reuses the original query's transaction ID so the requesting app matches it to its pending query.
  • The flags 0x81, 0x83 set the QR bit (response), recursion available, and RCODE 3 (NXDOMAIN).
  • The original question section is echoed back, which is required by the DNS protocol.
  • Add the forward_dns_query and craft_forward_response functions below craft_blocked_response:
fn forward_dns_query(dns_payload: &[u8]) -> Option<Vec<u8>> {
    let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
    socket
        .set_read_timeout(Some(std::time::Duration::from_secs(3)))
        .ok()?;
    socket.send_to(dns_payload, "8.8.8.8:53").ok()?;

    let mut buf = [0u8; 4096];
    let (n, _) = socket.recv_from(&mut buf).ok()?;
    Some(buf[..n].to_vec())
}

fn craft_forward_response(original_packet: &[u8], ihl: usize, dns_response: &[u8]) -> Vec<u8> {
    build_response_packet(original_packet, ihl, dns_response)
}

What does this code do?

  • forward_dns_query opens a fresh UDP socket, sends the raw DNS payload to Google's public DNS at 8.8.8.8:53, and waits up to 3 seconds for a response.
  • craft_forward_response wraps the upstream DNS answer in a proper IP+UDP response packet using the same build_response_packet helper.
  • Add the build_response_packet and ip_checksum functions below craft_forward_response:
fn build_response_packet(original_packet: &[u8], ihl: usize, dns_response: &[u8]) -> Vec<u8> {
    let udp_len = 8 + dns_response.len();
    let total_len = ihl + udp_len;

    let mut response = vec![0u8; total_len];

    // Copy IP header and swap src/dst addresses
    response[..ihl].copy_from_slice(&original_packet[..ihl]);
    response[12..16].copy_from_slice(&original_packet[16..20]);
    response[16..20].copy_from_slice(&original_packet[12..16]);
    // Update total length
    response[2] = (total_len >> 8) as u8;
    response[3] = total_len as u8;
    // Recalculate IP checksum
    response[10] = 0;
    response[11] = 0;
    let checksum = ip_checksum(&response[..ihl]);
    response[10] = (checksum >> 8) as u8;
    response[11] = (checksum & 0xFF) as u8;

    // Build UDP header with swapped ports
    let src_port = &original_packet[ihl + 2..ihl + 4];
    let dst_port = &original_packet[ihl..ihl + 2];
    response[ihl..ihl + 2].copy_from_slice(src_port);
    response[ihl + 2..ihl + 4].copy_from_slice(dst_port);
    response[ihl + 4] = (udp_len >> 8) as u8;
    response[ihl + 5] = udp_len as u8;
    response[ihl + 6] = 0; // UDP checksum optional for IPv4
    response[ihl + 7] = 0;

    // Attach DNS payload
    response[ihl + 8..].copy_from_slice(dns_response);

    response
}

fn ip_checksum(header: &[u8]) -> u16 {
    let mut sum: u32 = 0;
    let mut i = 0;
    while i < header.len() {
        if i == 10 {
            i += 2; // skip checksum field
            continue;
        }
        let word = if i + 1 < header.len() {
            ((header[i] as u32) << 8) | (header[i + 1] as u32)
        } else {
            (header[i] as u32) << 8
        };
        sum += word;
        i += 2;
    }
    while (sum >> 16) != 0 {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }
    !sum as u16
}

What does this code do?

build_response_packet assembles a complete IPv4+UDP packet from a DNS response payload. It copies the original IP header, swaps source and destination addresses (so the response goes back to the querying app), swaps the UDP ports, updates the IP total length field, and recalculates the IP header checksum.

ip_checksum implements the standard one's complement checksum required by IPv4 headers. It sums all 16-bit words (skipping the checksum field itself) and folds the carry bits back in.

  • Save packet_engine.rs.

You won't see a change yet. The next substep wires these functions into the main packet loop.

Wire the filtering logic into the packet loop

All the helper functions are ready. Now you need to replace the placeholder comment in start_filtering with the actual DNS parsing, event emission, and routing logic.

  • In packet_engine.rs, find this line inside the start_filtering method's loop:
                // DNS packet confirmed - further processing TBD
  • Replace that comment with the following DNS parsing and routing logic:
                let dns_payload = &packet[ihl + 8..];
                if dns_payload.len() < 12 {
                    continue;
                }

                let domain = match parse_dns_domain(&dns_payload[12..]) {
                    Some(d) => d,
                    None => continue,
                };

                let blocked = is_blocked(&domain);

                let event = DnsEvent {
                    domain: domain.clone(),
                    blocked,
                    timestamp_ms: current_time_ms(),
                };
                let _ = sink.add(event);

                if blocked {
                    let response = craft_blocked_response(packet, ihl);
                    let _ = file.write_all(&response);
                } else {
                    if let Some(response) = forward_dns_query(dns_payload) {
                        let full_response = craft_forward_response(packet, ihl, &response);
                        let _ = file.write_all(&full_response);
                    }
                }

What does this code do?

  • It extracts the DNS payload (everything after the UDP header at offset ihl + 8) and validates it has at least 12 bytes (the DNS header size).
  • parse_dns_domain reads the domain from byte 12 onward (the start of the question section).
  • A DnsEvent is emitted via the StreamSink for every query, telling Flutter whether it was blocked.
  • Blocked domains get an NXDOMAIN response written directly to the TUN. Allowed domains are forwarded to 8.8.8.8 and the real answer is relayed back.
  • Also in the start_filtering method, find and delete these two unused variable lines that remain from the previous step:
                let src_ip = &packet[12..16];
                let dst_ip = &packet[16..20];
  • Delete those two lines. They are no longer needed since the response builder reads addresses directly from the original packet.
  • In lib/main.dart, find this line in the build method:
      body: Center(child: Text(_vpnActive ? 'VPN Active - parsing IP/UDP headers' : 'VPN Inactive')),
  • Replace it with this updated status text:
      body: Center(child: Text(_vpnActive ? 'DNS filtering active' : 'VPN Inactive')),
  • Save both files.
  • In the terminal, navigate to your rust/ directory and verify the Rust code compiles by running:
cargo check
  • You should see a successful compilation with no errors. You may see unused import warnings for HashMap. That is fine for now.

Seeing compilation errors?

  • If you see an error about src_ip or dst_ip, make sure you deleted both of those lines from the start_filtering loop.
  • If parse_dns_domain or is_blocked are "not found", check that they are defined outside the impl PacketEngine block (they are standalone functions, not methods).
  • If UdpSocket is unresolved, confirm the use std::net::UdpSocket; import is present at the top.

Why is my packet_engine.rs not compiling after adding the filtering logic?

  • Run the app on your physical device by clicking the green play button in Android Studio.
  • Toggle the VPN switch on. Open your phone's browser and visit any website.
  • You should see the status text change to DNS filtering active. DNS queries are now being intercepted, parsed, and either blocked or forwarded.

✔️ Awesome, I've got everything!

Great. Double check you have saved packet_engine.rs and main.dart.

ⓧ I'd like to double check the full code

Here is the complete rust/src/api/packet_engine.rs at this point:

use flutter_rust_bridge::frb;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::UdpSocket;
use std::os::unix::io::FromRawFd;
use std::sync::{Arc, Mutex};

// Blocklist of domains to filter
const BLOCKLIST: &[&str] = &[
    "ads.example.com",
    "tracker.example.com",
    "analytics.doubleclick.net",
    "ad.doubleclick.net",
    "pagead2.googlesyndication.com",
    "graph.facebook.com",
];

#[derive(Clone, Debug)]
pub struct DnsEvent {
    pub domain: String,
    pub blocked: bool,
    pub timestamp_ms: i64,
}

#[frb(opaque)]
pub struct PacketEngine {
    running: Arc<Mutex<bool>>,
}

impl PacketEngine {
    #[frb(sync)]
    pub fn new() -> PacketEngine {
        PacketEngine {
            running: Arc::new(Mutex::new(false)),
        }
    }

    pub fn start_filtering(
        &self,
        tun_fd: i32,
        sink: flutter_rust_bridge::StreamSink<DnsEvent>,
    ) {
        let running = self.running.clone();
        *running.lock().unwrap() = true;

        std::thread::spawn(move || {
            let mut file = unsafe { std::fs::File::from_raw_fd(tun_fd) };
            let mut buf = [0u8; 4096];

            while *running.lock().unwrap() {
                let n = match read_with_timeout(&mut file, &mut buf) {
                    Some(n) if n > 0 => n,
                    _ => continue,
                };

                let packet = &buf[..n];

                if packet.len() < 20 || (packet[0] >> 4) != 4 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let ihl = (packet[0] & 0x0F) as usize * 4;
                let protocol = packet[9];

                if protocol != 17 || packet.len() < ihl + 8 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let udp_header = &packet[ihl..];
                let dst_port = u16::from_be_bytes([udp_header[2], udp_header[3]]);

                if dst_port != 53 {
                    let _ = file.write_all(packet);
                    continue;
                }

                let dns_payload = &packet[ihl + 8..];
                if dns_payload.len() < 12 {
                    continue;
                }

                let domain = match parse_dns_domain(&dns_payload[12..]) {
                    Some(d) => d,
                    None => continue,
                };

                let blocked = is_blocked(&domain);

                let event = DnsEvent {
                    domain: domain.clone(),
                    blocked,
                    timestamp_ms: current_time_ms(),
                };
                let _ = sink.add(event);

                if blocked {
                    let response = craft_blocked_response(packet, ihl);
                    let _ = file.write_all(&response);
                } else {
                    if let Some(response) = forward_dns_query(dns_payload) {
                        let full_response = craft_forward_response(packet, ihl, &response);
                        let _ = file.write_all(&full_response);
                    }
                }
            }

            std::mem::forget(file);
        });
    }

    #[frb(sync)]
    pub fn stop(&self) {
        *self.running.lock().unwrap() = false;
    }
}

fn read_with_timeout(file: &mut std::fs::File, buf: &mut [u8]) -> Option<usize> {
    match file.read(buf) {
        Ok(n) => Some(n),
        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
            std::thread::sleep(std::time::Duration::from_millis(10));
            None
        }
        Err(_) => None,
    }
}

fn parse_dns_domain(data: &[u8]) -> Option<String> {
    let mut domain = String::new();
    let mut pos = 0;

    loop {
        if pos >= data.len() {
            return None;
        }
        let len = data[pos] as usize;
        if len == 0 {
            break;
        }
        if pos + 1 + len > data.len() {
            return None;
        }
        if !domain.is_empty() {
            domain.push('.');
        }
        domain.push_str(
            std::str::from_utf8(&data[pos + 1..pos + 1 + len]).ok()?,
        );
        pos += 1 + len;
    }

    Some(domain.to_lowercase())
}

fn is_blocked(domain: &str) -> bool {
    BLOCKLIST.iter().any(|blocked| {
        domain == *blocked || domain.ends_with(&format!(".", blocked))
    })
}

fn craft_blocked_response(original_packet: &[u8], ihl: usize) -> Vec<u8> {
    let dns_payload = &original_packet[ihl + 8..];
    let transaction_id = &dns_payload[0..2];

    let mut qname_end = 12;
    while qname_end < dns_payload.len() && dns_payload[qname_end] != 0 {
        qname_end += 1 + dns_payload[qname_end] as usize;
    }
    qname_end += 1;
    qname_end += 4;

    let question_section = &dns_payload[12..qname_end];

    let mut dns_response = Vec::new();
    dns_response.extend_from_slice(transaction_id);
    dns_response.extend_from_slice(&[0x81, 0x83]);
    dns_response.extend_from_slice(&[0x00, 0x01]);
    dns_response.extend_from_slice(&[0x00, 0x00]);
    dns_response.extend_from_slice(&[0x00, 0x00]);
    dns_response.extend_from_slice(&[0x00, 0x00]);
    dns_response.extend_from_slice(question_section);

    build_response_packet(original_packet, ihl, &dns_response)
}

fn forward_dns_query(dns_payload: &[u8]) -> Option<Vec<u8>> {
    let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
    socket
        .set_read_timeout(Some(std::time::Duration::from_secs(3)))
        .ok()?;
    socket.send_to(dns_payload, "8.8.8.8:53").ok()?;

    let mut buf = [0u8; 4096];
    let (n, _) = socket.recv_from(&mut buf).ok()?;
    Some(buf[..n].to_vec())
}

fn craft_forward_response(original_packet: &[u8], ihl: usize, dns_response: &[u8]) -> Vec<u8> {
    build_response_packet(original_packet, ihl, dns_response)
}

fn build_response_packet(original_packet: &[u8], ihl: usize, dns_response: &[u8]) -> Vec<u8> {
    let udp_len = 8 + dns_response.len();
    let total_len = ihl + udp_len;

    let mut response = vec![0u8; total_len];

    response[..ihl].copy_from_slice(&original_packet[..ihl]);
    response[12..16].copy_from_slice(&original_packet[16..20]);
    response[16..20].copy_from_slice(&original_packet[12..16]);
    response[2] = (total_len >> 8) as u8;
    response[3] = total_len as u8;
    response[10] = 0;
    response[11] = 0;
    let checksum = ip_checksum(&response[..ihl]);
    response[10] = (checksum >> 8) as u8;
    response[11] = (checksum & 0xFF) as u8;

    let src_port = &original_packet[ihl + 2..ihl + 4];
    let dst_port = &original_packet[ihl..ihl + 2];
    response[ihl..ihl + 2].copy_from_slice(src_port);
    response[ihl + 2..ihl + 4].copy_from_slice(dst_port);
    response[ihl + 4] = (udp_len >> 8) as u8;
    response[ihl + 5] = udp_len as u8;
    response[ihl + 6] = 0;
    response[ihl + 7] = 0;

    response[ihl + 8..].copy_from_slice(dns_response);

    response
}

fn ip_checksum(header: &[u8]) -> u16 {
    let mut sum: u32 = 0;
    let mut i = 0;
    while i < header.len() {
        if i == 10 {
            i += 2;
            continue;
        }
        let word = if i + 1 < header.len() {
            ((header[i] as u32) << 8) | (header[i + 1] as u32)
        } else {
            (header[i] as u32) << 8
        };
        sum += word;
        i += 2;
    }
    while (sum >> 16) != 0 {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }
    !sum as u16
}

fn current_time_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as i64
}

And the updated lib/main.dart:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/packet_engine.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  static const platform = MethodChannel('com.example.dns_filter/vpn');

  bool _vpnActive = false;
  PacketEngine? _engine;
  final List<DnsEvent> _events = [];

  Future<void> _toggleVpn() async {
    if (_vpnActive) {
      _engine?.stop();
      await platform.invokeMethod('stopVpn');
      setState(() => _vpnActive = false);
    } else {
      try {
        final fd = await platform.invokeMethod<int>('startVpn');
        if (fd != null) {
          _engine = PacketEngine.new_();
          _engine!.startFiltering(
            tunFd: fd,
            sink: _onDnsEvent,
          );
          setState(() => _vpnActive = true);
        }
      } on PlatformException catch (e) {
        debugPrint('VPN start failed: ${e.message}');
      }
    }
  }

  void _onDnsEvent(DnsEvent event) {
    setState(() {
      _events.insert(0, event);
      if (_events.length > 100) _events.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DNS Content Filter'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(child: Text(_vpnActive ? 'DNS filtering active' : 'VPN Inactive')),
    );
  }
}

Your Rust packet engine is now a fully functional DNS content filter. Next up, you will build the Flutter UI to show live query results and let users manage the blocklist.

Build the Flutter UI

Your Rust packet engine can parse DNS traffic, make blocking decisions, and emit events. The missing piece is a way for you to actually see what's happening on your phone.

In this step, you'll replace the placeholder UI in main.dart with a real-time dashboard. A toggle switch starts and stops the VPN filter. Below it, a scrolling list shows every DNS query your phone makes, colour-coded by whether it was blocked or allowed.

In this step, get ready to:
  • Add a toggle switch that starts and stops the VPN service.
  • Display a live scrolling log of DNS queries with blocked/allowed status.
Add the VPN toggle switch

The current build method renders a single line of text. You'll replace it with a Column layout containing a toggle row at the top and a divider separating it from the query log below.

  • In lib/main.dart, scroll to the build method inside _HomePageState.
  • Find this line:
      body: Center(child: Text(_vpnActive ? 'DNS filtering active' : 'VPN Inactive')),
  • Replace that entire body: line with the following Column layout:
      body: Column(
        children: [
          // Toggle row: shows filter status and on/off switch
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  _vpnActive ? 'Filter Active' : 'Filter Inactive',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                Switch(
                  value: _vpnActive,
                  onChanged: (_) => _toggleVpn(),
                ),
              ],
            ),
          ),
          const Divider(),
        ],
      ),

What does this code do?

The Column stacks its children vertically. The first child is a Padding widget wrapping a Row that spreads its children to opposite ends.

  • The Text widget displays "Filter Active" or "Filter Inactive" depending on the _vpnActive state.
  • The Switch calls _toggleVpn() whenever the user taps it, starting or stopping the VPN service.
  • The Divider() draws a horizontal line separating the toggle from the query log you'll add next.
  • Save lib/main.dart.
  • Hot-reload the app on your phone. You should see a row at the top with "Filter Inactive" on the left and a toggle switch on the right, followed by a thin horizontal divider.

Don't see the toggle row?

  • Make sure you deleted the entire original body: Center(...) line. Having both the old and new body will cause a compile error.
  • Check that the closing brackets match. The Column's children: [...] must end with ], and the Column itself with ),.

Help me fix my Column layout in Flutter

Display the live DNS query log

The toggle controls the VPN, but you need a way to see DNS queries as they flow through. An Expanded widget fills the remaining vertical space with a scrolling list of DnsEvent items streamed from Rust.

  • In lib/main.dart, find the const Divider(), line you just added inside the children array.
  • Add the following Expanded widget below the Divider, before the closing ], of children:
          Expanded(
            child: _events.isEmpty
                // Empty state when no DNS queries have been intercepted yet
                ? const Center(child: Text('No DNS queries yet'))
                // Live scrolling list of DNS events
                : ListView.builder(
                    itemCount: _events.length,
                    itemBuilder: (context, index) {
                      final event = _events[index];
                      return ListTile(
                        // Red block icon for blocked, green check for allowed
                        leading: Icon(
                          event.blocked ? Icons.block : Icons.check_circle,
                          color: event.blocked ? Colors.red : Colors.green,
                        ),
                        // Domain name with strikethrough if blocked
                        title: Text(
                          event.domain,
                          style: TextStyle(
                            decoration: event.blocked
                                ? TextDecoration.lineThrough
                                : null,
                          ),
                        ),
                        // Timestamp showing HH:MM:SS
                        subtitle: Text(
                          DateTime.fromMillisecondsSinceEpoch(event.timestampMs)
                              .toLocal()
                              .toString()
                              .substring(11, 19),
                        ),
                      );
                    },
                  ),
          ),

What does this code do?

The Expanded widget tells Flutter to give this child all remaining vertical space after the toggle row and divider.

  • When _events is empty, a centered placeholder message appears.
  • Once events arrive from Rust, a ListView.builder renders each DnsEvent as a ListTile with a coloured icon, the domain name, and a timestamp.
  • Blocked domains get a red Icons.block icon and a strikethrough on the domain text. Allowed domains get a green Icons.check_circle.
  • The timestamp extracts characters 11 through 19 from the datetime string, giving you just the HH:MM:SS portion.
  • Save lib/main.dart and hot-reload on your phone.
  • You should see the toggle row at the top, the divider, and centered text reading "No DNS queries yet" in the remaining space.
  • Tap the switch to activate the filter. Android will prompt you to allow the VPN connection. After accepting, the text should change to "Filter Active" and DNS events will start appearing in the list as your phone resolves domain names.

List stays empty after enabling the filter?

  • Open your phone's browser and navigate to any website. This forces a DNS query that should appear in the log.
  • Check the Android Studio logcat for errors from Rust. Look for messages containing "VPN start failed" or file descriptor errors.
  • Confirm your Kotlin VpnService is calling MainActivity.vpnFdCallback?.invoke(fd) after the TUN interface is established.

Help me debug why DNS events aren't showing in my Flutter list

✔️ Awesome, I've got everything!

Great. Double check you've saved lib/main.dart and that the filter is working on your phone.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart file after this step:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'src/rust/api/packet_engine.dart';
import 'src/rust/frb_generated.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await RustLib.init();
  runApp(const DnsFilterApp());
}

class DnsFilterApp extends StatelessWidget {
  const DnsFilterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DNS Filter',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  static const platform = MethodChannel('com.example.dns_filter/vpn');

  bool _vpnActive = false;
  PacketEngine? _engine;
  final List<DnsEvent> _events = [];

  Future<void> _toggleVpn() async {
    if (_vpnActive) {
      _engine?.stop();
      await platform.invokeMethod('stopVpn');
      setState(() => _vpnActive = false);
    } else {
      try {
        final fd = await platform.invokeMethod<int>('startVpn');
        if (fd != null) {
          _engine = PacketEngine.new_();
          _engine!.startFiltering(
            tunFd: fd,
            sink: _onDnsEvent,
          );
          setState(() => _vpnActive = true);
        }
      } on PlatformException catch (e) {
        debugPrint('VPN start failed: ${e.message}');
      }
    }
  }

  void _onDnsEvent(DnsEvent event) {
    setState(() {
      _events.insert(0, event);
      if (_events.length > 100) _events.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DNS Content Filter'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  _vpnActive ? 'Filter Active' : 'Filter Inactive',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                Switch(
                  value: _vpnActive,
                  onChanged: (_) => _toggleVpn(),
                ),
              ],
            ),
          ),
          const Divider(),
          Expanded(
            child: _events.isEmpty
                ? const Center(child: Text('No DNS queries yet'))
                : ListView.builder(
                    itemCount: _events.length,
                    itemBuilder: (context, index) {
                      final event = _events[index];
                      return ListTile(
                        leading: Icon(
                          event.blocked ? Icons.block : Icons.check_circle,
                          color: event.blocked ? Colors.red : Colors.green,
                        ),
                        title: Text(
                          event.domain,
                          style: TextStyle(
                            decoration: event.blocked
                                ? TextDecoration.lineThrough
                                : null,
                          ),
                        ),
                        subtitle: Text(
                          DateTime.fromMillisecondsSinceEpoch(event.timestampMs)
                              .toLocal()
                              .toString()
                              .substring(11, 19),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

Your DNS content filter is complete. You've built a full pipeline from raw IP packets in Rust, through a Kotlin VPN service, all the way up to a live Flutter dashboard. Every DNS query your phone makes is now visible and filterable in real time.

Secret mission

Add a Domain Statistics Screen

Your DNS filter is working, but you have no visibility into patterns. Which domains get queried the most? How effective is your blocklist? In this secret mission, add a statistics screen that tracks query frequency and block rates in real time.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your machine and your Android device, so there are no ongoing cloud costs.

Resources you used:

  • The dns_filter Flutter project folder on your Desktop.
  • Android NDK (Side by side) 27.0.12077973 installed via Android Studio SDK Manager.
  • The flutter_rust_bridge_codegen Cargo tool.
  • The DNS Filter app installed on your physical Android device.
  • The local VPN service (if still active on your phone).

Keep everything running

No action needed. Choose this if you're still actively building on the DNS filter or want to experiment with the blocklist and statistics features.

  • Your project folder stays on your Desktop, ready to open in Android Studio.
  • The Android NDK remains available for any future Flutter + Rust projects.
  • Toggle the VPN off in the app when you're not actively testing to avoid interfering with your phone's DNS resolution.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay intact so you can resume later.

  • In the DNS Filter app on your phone, toggle the VPN switch off to stop the filtering service.
  • In Android Studio, disconnect your USB device and close the project.
  • Your project folder and all installed tools remain on your machine for next time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes the project files, the app from your device, and optionally the tools you installed.

  • On your Android phone, open Settings, then Apps. Find dns_filter and tap Uninstall. This removes the app and stops any VPN service.
  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/dns_filter

Windows

rmdir /s /q %USERPROFILE%\Desktop\dns_filter
  • Uninstall flutter_rust_bridge_codegen by running:
cargo uninstall flutter_rust_bridge_codegen
  • To remove the Android NDK, open Android Studio and go to Settings, then Languages & Frameworks, then Android SDK. Select the SDK Tools tab. Uncheck NDK (Side by side) and click Apply to uninstall it.

Nice Work!

Nice Work!

You just built a real DNS content filter from scratch. Your Android phone is now running a local VPN that intercepts every DNS query, parses raw IP packets in Rust, and makes real-time routing decisions to block unwanted domains.

Here's what you learned:

  • Created a local VPN using Android's VpnService that intercepts all DNS traffic without root access, parsing raw IPv4 and UDP packet headers byte-by-byte in Rust.
  • Decoded the DNS wire protocol to extract queried domain names and implemented a routing decision engine that blocks unwanted domains with crafted NXDOMAIN responses.
  • Wired Kotlin, Dart, and Rust across three languages using Flutter Rust Bridge and Platform Channels, building a live Flutter UI that shows real-time network activity.
  • Secret Mission: Added a domain statistics screen tracking the top 10 most queried and most blocked domains with per-session history.

Ready to quiz yourself?